add flexFEC publisher to SFU support & test scripts

This commit is contained in:
David Chen
2026-06-10 14:34:06 -07:00
parent 816d37281d
commit afd7a59c58
22 changed files with 3098 additions and 8 deletions
+3
View File
@@ -120,6 +120,9 @@ rtc:
# tcp_fallback_rtt_threshold: 0
# # migrate an established-but-lossy UDP connection to ICE/TCP or TURN/TLS. requires tcp_fallback_rtt_threshold > 0, default false
# allow_udp_unstable_fallback: false
# # accept FlexFEC-03 from publishers and use it to recover lost packets without
# # waiting for an RTT (useful for high-loss links, e.g. cellular). default false
# enable_flexfec: false
# # number of packets to buffer in the SFU for video, defaults to 500
# packet_buffer_size_video: 500
# # number of packets to buffer in the SFU for audio, defaults to 200
+3
View File
@@ -151,6 +151,9 @@ type RTCConfig struct {
// enable rtp stream restart detection for published tracks
EnableRTPStreamRestartDetection bool `yaml:"enable_rtp_stream_restart_detection,omitempty"`
// accept FlexFEC-03 from publishers and use it to recover lost packets
EnableFlexFEC bool `yaml:"enable_flexfec,omitempty"`
}
type TURNServer struct {
+12 -4
View File
@@ -37,6 +37,8 @@ type WebRTCConfig struct {
Receiver ReceiverConfig
Publisher DirectionConfig
Subscriber DirectionConfig
enableFlexFEC bool
}
type ReceiverConfig struct {
@@ -57,6 +59,8 @@ type RTCPFeedbackConfig struct {
type DirectionConfig struct {
RTPHeaderExtension RTPHeaderExtensionConfig
RTCPFeedback RTCPFeedbackConfig
// accept FlexFEC-03 in negotiation (publisher/receive direction only)
EnableFlexFEC bool
}
func NewWebRTCConfig(conf *config.Config) (*WebRTCConfig, error) {
@@ -80,19 +84,23 @@ func NewWebRTCConfig(conf *config.Config) (*WebRTCConfig, error) {
rtcConf.PacketBufferSizeAudio = rtcConf.PacketBufferSize
}
return &WebRTCConfig{
c := &WebRTCConfig{
WebRTCConfig: *webRTCConfig,
Receiver: ReceiverConfig{
PacketBufferSizeVideo: rtcConf.PacketBufferSizeVideo,
PacketBufferSizeAudio: rtcConf.PacketBufferSizeAudio,
},
Publisher: getPublisherConfig(false),
Subscriber: getSubscriberConfig(rtcConf.CongestionControl.UseSendSideBWEInterceptor || rtcConf.CongestionControl.UseSendSideBWE),
}, nil
Publisher: getPublisherConfig(false),
Subscriber: getSubscriberConfig(rtcConf.CongestionControl.UseSendSideBWEInterceptor || rtcConf.CongestionControl.UseSendSideBWE),
enableFlexFEC: rtcConf.EnableFlexFEC,
}
c.Publisher.EnableFlexFEC = c.enableFlexFEC
return c, nil
}
func (c *WebRTCConfig) UpdatePublisherConfig(consolidated bool) {
c.Publisher = getPublisherConfig(consolidated)
c.Publisher.EnableFlexFEC = c.enableFlexFEC
}
func (c *WebRTCConfig) UpdateSubscriberConfig(ccConf config.CongestionControlConfig) {
+22 -2
View File
@@ -25,7 +25,21 @@ import (
"github.com/livekit/protocol/livekit"
)
func registerCodecs(me *webrtc.MediaEngine, codecs []*livekit.Codec, rtcpFeedback RTCPFeedbackConfig, filterOutH264HighProfile bool) error {
// flexFEC03CodecParameters describes the FlexFEC-03 codec as sent by libwebrtc.
// It is not part of livekit/protocol codecs - it is a repair mechanism negotiated
// alongside a video codec (a=ssrc-group:FEC-FR), not a publishable codec.
// The payload type is a local preference only, pion matches by capability.
var flexFEC03CodecParameters = webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeFlexFEC03,
ClockRate: 90000,
SDPFmtpLine: "repair-window=10000000",
},
PayloadType: 49,
}
func registerCodecs(me *webrtc.MediaEngine, codecs []*livekit.Codec, config DirectionConfig, filterOutH264HighProfile bool) error {
rtcpFeedback := config.RTCPFeedback
// audio codecs
if IsCodecEnabled(codecs, protoCodecs.OpusCodecParameters.RTPCodecCapability) {
cp := protoCodecs.OpusCodecParameters
@@ -83,6 +97,12 @@ func registerCodecs(me *webrtc.MediaEngine, codecs []*livekit.Codec, rtcpFeedbac
return err
}
}
if config.EnableFlexFEC {
if err := me.RegisterCodec(flexFEC03CodecParameters, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
}
return nil
}
@@ -104,7 +124,7 @@ func registerHeaderExtensions(me *webrtc.MediaEngine, rtpHeaderExtension RTPHead
func createMediaEngine(codecs []*livekit.Codec, config DirectionConfig, filterOutH264HighProfile bool) (*webrtc.MediaEngine, error) {
me := &webrtc.MediaEngine{}
if err := registerCodecs(me, codecs, config.RTCPFeedback, filterOutH264HighProfile); err != nil {
if err := registerCodecs(me, codecs, config, filterOutH264HighProfile); err != nil {
return nil, err
}
+12
View File
@@ -2267,6 +2267,18 @@ func (p *ParticipantImpl) onMediaTrack(rtcTrack *webrtc.TrackRemote, rtpReceiver
"parameters", rtpReceiver.GetParameters(),
)
// FlexFEC repair flows are consumed by the primary stream's buffer and must
// not surface as media tracks. pion does not fire tracks for declared FEC
// SSRCs, this is a safety net. The mime package does not know flexfec.
if strings.EqualFold(codec.MimeType, webrtc.MimeTypeFlexFEC03) || strings.EqualFold(codec.MimeType, webrtc.MimeTypeFlexFEC) {
p.pubLogger.Infow(
"ignoring flexfec track",
"trackID", rtcTrack.ID(),
"ssrc", rtcTrack.SSRC(),
)
return
}
var track sfu.TrackRemote = sfu.NewTrackRemoteFromSdp(rtcTrack, codec)
publishedTrack, isNewTrack, isReceiverAdded, sdpRids := p.mediaTrackReceived(track, rtpReceiver)
if publishedTrack == nil {
+64
View File
@@ -1661,6 +1661,14 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo
t.params.Config.BufferFactory.SetRTXPair(repair, base, "")
}
}
fecPairs := flexFECPairsFromSDP(parsed, t.params.Logger)
if len(fecPairs) > 0 {
t.params.Logger.Debugw("flexfec pairs found from sdp", "ssrcs", fecPairs)
for fec, base := range fecPairs {
t.params.Config.BufferFactory.SetFECPair(fec, base)
}
}
return nil
}
@@ -2899,6 +2907,14 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o
}
}
fecPairs := flexFECPairsFromSDP(parsed, t.params.Logger)
if len(fecPairs) > 0 {
t.params.Logger.Debugw("flexfec pairs found from sdp", "ssrcs", fecPairs)
for fec, base := range fecPairs {
t.params.Config.BufferFactory.SetFECPair(fec, base)
}
}
if t.currentOfferIceCredential == "" || offerRestartICE {
t.currentOfferIceCredential = iceCredential
}
@@ -3173,6 +3189,15 @@ func (t *PCTransport) restrictReceiverCodecsToPublishList() {
if len(filtered) == 0 {
continue
}
if t.params.DirectionConfig.EnableFlexFEC && tr.Kind() == webrtc.RTPCodecTypeVideo {
// flexfec is a repair mechanism negotiated alongside a video codec,
// it is never part of the publish codec list
for _, c := range receiver.GetParameters().Codecs {
if strings.EqualFold(c.MimeType, webrtc.MimeTypeFlexFEC03) {
filtered = append(filtered, c)
}
}
}
if err := tr.SetCodecPreferences(filtered); err != nil {
t.params.Logger.Warnw("failed to set recv codec preferences", err, "mid", tr.Mid())
}
@@ -3270,6 +3295,45 @@ func nonSimulcastRTXRepairsFromSDP(s *sdp.SessionDescription, logger logger.Logg
return rtxRepairFlows
}
// flexFECPairsFromSDP extracts FlexFEC repair flows (a=ssrc-group:FEC-FR <mediaSSRC> <fecSSRC>)
// from non-simulcast media sections, returning a map of FEC SSRC -> media SSRC.
// libwebrtc only protects a single stream per FEC-FR group, simulcast (rid) sections are skipped.
func flexFECPairsFromSDP(s *sdp.SessionDescription, logger logger.Logger) map[uint32]uint32 {
fecFlows := map[uint32]uint32{}
for _, media := range s.MediaDescriptions {
var ridFound bool
fecPairs := make(map[uint32]uint32)
findFEC:
for _, attr := range media.Attributes {
switch attr.Key {
case "rid":
ridFound = true
break findFEC
case sdp.AttrKeySSRCGroup:
split := strings.Split(attr.Value, " ")
if split[0] == sdp.SemanticTokenForwardErrorCorrectionFramework && len(split) == 3 {
baseSsrc, err := strconv.ParseUint(split[1], 10, 32)
if err != nil {
logger.Warnw("Failed to parse SSRC", err, "ssrc", split[1])
continue
}
fecSsrc, err := strconv.ParseUint(split[2], 10, 32)
if err != nil {
logger.Warnw("Failed to parse SSRC", err, "ssrc", split[2])
continue
}
fecPairs[uint32(fecSsrc)] = uint32(baseSsrc)
}
}
}
if !ridFound {
maps.Copy(fecFlows, fecPairs)
}
}
return fecFlows
}
// ----------------------
type iceCandidatePairStatsEncoder struct {
+118 -2
View File
@@ -28,9 +28,11 @@ import (
"github.com/livekit/livekit-server/pkg/rtc/transport"
"github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes"
"github.com/livekit/livekit-server/pkg/sfu/buffer"
"github.com/livekit/livekit-server/pkg/testutils"
"github.com/livekit/protocol/codecs/mime"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
)
func TestMissingAnswerDuringICERestart(t *testing.T) {
@@ -610,7 +612,7 @@ func TestConfigureAudioTransceiver(t *testing.T) {
} {
t.Run(fmt.Sprintf("nack=%v,stereo=%v", testcase.nack, testcase.stereo), func(t *testing.T) {
var me webrtc.MediaEngine
registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}, false)
registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, DirectionConfig{RTCPFeedback: RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}}, false)
pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(&me)).NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer pc.Close()
@@ -732,7 +734,7 @@ func TestSinglePCAnswerStripsSubscribeOnlyCodecsFromRecvSide(t *testing.T) {
defer server.Close()
var clientME webrtc.MediaEngine
require.NoError(t, registerCodecs(&clientME, subscribeCodecs, RTCPFeedbackConfig{}, false))
require.NoError(t, registerCodecs(&clientME, subscribeCodecs, DirectionConfig{}, false))
client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer client.Close()
@@ -777,3 +779,117 @@ func TestSinglePCAnswerStripsSubscribeOnlyCodecsFromRecvSide(t *testing.T) {
"answer must not advertise H.264 in recv-side m-section: %s", a.Value)
}
}
// TestFlexFECPairsFromSDP verifies extraction of FEC-FR repair flows and that
// simulcast (rid) sections are skipped
func TestFlexFECPairsFromSDP(t *testing.T) {
offer := `v=0
o=- 8423650423 2 IN IP4 127.0.0.1
s=-
t=0 0
m=video 9 UDP/TLS/RTP/SAVPF 96 49
a=rtpmap:96 VP8/90000
a=rtpmap:49 flexfec-03/90000
a=fmtp:49 repair-window=10000000
a=ssrc-group:FEC-FR 1234 5678
a=ssrc:1234 cname:test
a=ssrc:5678 cname:test
`
parsed := &sdp.SessionDescription{}
require.NoError(t, parsed.Unmarshal([]byte(offer)))
pairs := flexFECPairsFromSDP(parsed, logger.GetLogger())
require.Equal(t, map[uint32]uint32{5678: 1234}, pairs)
// rid section must be skipped
offerWithRid := offer + "a=rid:hi send\n"
parsed = &sdp.SessionDescription{}
require.NoError(t, parsed.Unmarshal([]byte(offerWithRid)))
require.Empty(t, flexFECPairsFromSDP(parsed, logger.GetLogger()))
}
// TestFlexFECAnswer verifies the answer accepts flexfec-03 from an offer with a
// FEC-FR group only when FlexFEC is enabled for the direction
func TestFlexFECAnswer(t *testing.T) {
publishCodecs := []*livekit.Codec{
{Mime: mime.MimeTypeVP8.String()},
}
for _, enabled := range []bool{true, false} {
t.Run(fmt.Sprintf("enabled=%v", enabled), func(t *testing.T) {
cfg := &WebRTCConfig{}
cfg.SetBufferFactory(buffer.NewFactoryOfBufferFactory(500, 200).CreateBufferFactory())
handler := &transportfakes.FakeHandler{}
server, err := NewPCTransport(TransportParams{
Config: cfg,
EnabledPublishCodecs: publishCodecs,
DirectionConfig: DirectionConfig{EnableFlexFEC: enabled},
Handler: handler,
})
require.NoError(t, err)
defer server.Close()
var clientME webrtc.MediaEngine
require.NoError(t, registerCodecs(&clientME, publishCodecs, DirectionConfig{}, false))
client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{})
require.NoError(t, err)
defer client.Close()
_, err = client.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{
Direction: webrtc.RTPTransceiverDirectionSendonly,
})
require.NoError(t, err)
offer, err := client.CreateOffer(nil)
require.NoError(t, err)
require.NoError(t, client.SetLocalDescription(offer))
// munge the offer the way a libwebrtc publisher with FlexFEC
// enabled generates it: flexfec-03 codec + FEC-FR ssrc group
mungedSDP := client.LocalDescription().SDP
mediaSSRC := ""
for _, line := range strings.Split(mungedSDP, "\n") {
if strings.HasPrefix(line, "a=ssrc:") {
mediaSSRC = strings.Split(strings.TrimPrefix(line, "a=ssrc:"), " ")[0]
break
}
}
require.NotEmpty(t, mediaSSRC, "offer missing media ssrc")
lines := strings.Split(strings.TrimRight(mungedSDP, "\r\n"), "\n")
for i, line := range lines {
if strings.HasPrefix(line, "m=video ") {
lines[i] = strings.TrimRight(line, "\r") + " 49"
}
}
lines = append(lines,
"a=rtpmap:49 flexfec-03/90000",
"a=fmtp:49 repair-window=10000000",
fmt.Sprintf("a=ssrc-group:FEC-FR %s 99999", mediaSSRC),
"a=ssrc:99999 cname:fec-test",
)
munged := webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: strings.Join(lines, "\n") + "\n",
}
var answer atomic.Pointer[webrtc.SessionDescription]
handler.OnAnswerCalls(func(sd webrtc.SessionDescription, _ uint32, _ map[string]string) error {
answer.Store(&sd)
return nil
})
require.NoError(t, server.HandleRemoteDescription(munged, 1))
require.Eventually(t, func() bool {
return answer.Load() != nil
}, 5*time.Second, 10*time.Millisecond, "server did not produce answer")
if enabled {
require.Contains(t, answer.Load().SDP, "flexfec-03/90000", "answer must accept flexfec-03")
require.Contains(t, answer.Load().SDP, "a=rtpmap:49 flexfec-03/90000", "answer must use the offered payload type")
} else {
require.NotContains(t, answer.Load().SDP, "flexfec", "answer must not accept flexfec when disabled")
}
})
}
}
+181
View File
@@ -18,6 +18,9 @@ import (
"encoding/binary"
"errors"
"io"
"math/rand/v2"
"os"
"strconv"
"github.com/pion/rtcp"
"github.com/pion/rtp"
@@ -35,12 +38,30 @@ const (
InitPacketBufferSizeVideo = 300
InitPacketBufferSizeAudio = 70
flexFECStatsLogInterval = int64(30 * 1e9) // 30s
)
var (
errInvalidCodec = errors.New("invalid codec")
)
// debugRxDropPercent enables receive-side fault injection for testing loss
// recovery (NACK/RTX, FlexFEC) without OS-level traffic shaping. Packets are
// dropped before any processing, like wire loss. Test harnesses only, see
// scripts/fec/README.md.
var debugRxDropPercent = func() float64 {
v := os.Getenv("LIVEKIT_DEBUG_RX_DROP_PCT")
if v == "" {
return 0
}
pct, err := strconv.ParseFloat(v, 64)
if err != nil || pct <= 0 {
return 0
}
return pct
}()
var _ BufferProvider = (*Buffer)(nil)
type pendingPacket struct {
@@ -72,6 +93,12 @@ type Buffer struct {
primaryBufferForRTX *Buffer
rtxPktBuf []byte
primaryBufferForFEC *Buffer
fecDecoder *flexFECDecoder
fecInjecting bool
lastFECStatsLogAt int64
lastFECStatsLogged FECStreamStats
}
func NewBuffer(ssrc uint32, maxVideoPkts, maxAudioPkts int) *Buffer {
@@ -145,6 +172,10 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) {
return
}
if debugRxDropPercent > 0 && rand.Float64()*100 < debugRxDropPercent {
return len(pkt), nil
}
b.Lock()
if b.BufferBase.IsClosed() {
b.Unlock()
@@ -179,6 +210,19 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) {
return
}
// handle FlexFEC packet
if pb := b.primaryBufferForFEC; pb != nil {
b.Unlock()
// skip padding only packets
if rtpPacket.Padding && len(rtpPacket.Payload) == 0 {
return
}
pb.writeFEC(&rtpPacket, now)
return
}
if !b.isBound {
packet := make([]byte, len(pkt))
copy(packet, pkt)
@@ -239,6 +283,26 @@ func (b *Buffer) NotifyRTX(ssrc uint32, repairSSRC uint32, rsid string) {
}
}
func (b *Buffer) SetPrimaryBufferForFEC(primaryBuffer *Buffer) {
b.Lock()
b.primaryBufferForFEC = primaryBuffer
pkts := b.pPackets
b.pPackets = nil
b.Unlock()
for _, pp := range pkts {
var rtpPacket rtp.Packet
err := rtpPacket.Unmarshal(pp.packet)
if err != nil {
continue
}
if rtpPacket.Padding && len(rtpPacket.Payload) == 0 {
continue
}
primaryBuffer.writeFEC(&rtpPacket, pp.arrivalTime)
}
}
func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) {
b.Lock()
defer b.Unlock()
@@ -274,6 +338,107 @@ func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) {
b.calc(b.rtxPktBuf[:n], &repairedPkt, arrivalTime, false, true)
}
// writeFEC processes a packet of this buffer's FlexFEC repair flow, recovering
// and injecting lost media packets when possible
func (b *Buffer) writeFEC(fecPkt *rtp.Packet, arrivalTime int64) {
b.Lock()
defer b.Unlock()
if !b.isBound {
return
}
// FEC packets carry transport-wide sequence numbers and count toward the
// publisher's send side bandwidth estimate. The FEC stream is not bound by
// pion, so it is reported on the primary's responder, the streams share a
// media section and with it the extension IDs.
if b.twcc != nil && b.twccExtID != 0 {
if ext := fecPkt.GetExtension(b.twccExtID); ext != nil {
b.twcc.Push(fecPkt.SSRC, binary.BigEndian.Uint16(ext[0:2]), arrivalTime, fecPkt.Marker)
}
}
// the payload type is learned from negotiated parameters at bind time, when
// unknown the FEC-FR pairing from the SDP and the header validation of the
// decoder are relied upon instead
if b.fecPayloadType != 0 && fecPkt.PayloadType != b.fecPayloadType {
b.logger.Debugw("unexpected fec payload type", "expected", b.fecPayloadType, "actual", fecPkt.PayloadType)
return
}
if b.fecDecoder == nil {
b.logger.Infow("first flexfec packet received", "fecSSRC", fecPkt.SSRC, "payloadType", fecPkt.PayloadType)
b.fecDecoder = newFlexFECDecoder(flexFECDecoderParams{
Logger: b.logger,
SSRC: b.BufferBase.SSRC(),
GetPacket: b.BufferBase.getWirePacketLocked,
ExtHighestSN: b.BufferBase.extHighestWireSNLocked,
WindowSize: b.BufferBase.bucketCapacityLocked,
})
}
b.injectRecoveredLocked(b.fecDecoder.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, arrivalTime), arrivalTime)
b.maybeLogFECStatsLocked(arrivalTime)
}
// injectRecoveredLocked feeds FEC recovered packets through the regular packet
// path. An injected packet can complete other buffered FEC packets, those
// chained recoveries are processed in the same loop.
func (b *Buffer) injectRecoveredLocked(recovered [][]byte, arrivalTime int64) {
if len(recovered) == 0 || b.fecInjecting {
return
}
b.fecInjecting = true
for len(recovered) > 0 {
raw := recovered[0]
recovered = recovered[1:]
var rtpPacket rtp.Packet
if err := rtpPacket.Unmarshal(raw); err != nil {
continue
}
if rtpPacket.Padding && len(rtpPacket.Payload) == 0 {
// a padding only packet would have been dropped on arrival as well
b.logger.Debugw("discarding recovered padding only packet", "sn", rtpPacket.SequenceNumber)
continue
}
wireSN := rtpPacket.SequenceNumber
b.calc(raw, &rtpPacket, arrivalTime, false, true)
if b.fecDecoder.HasPending() {
recovered = append(recovered, b.fecDecoder.OnMediaPacket(wireSN)...)
}
}
b.fecInjecting = false
}
func (b *Buffer) maybeLogFECStatsLocked(now int64) {
if b.fecDecoder == nil || now-b.lastFECStatsLogAt < flexFECStatsLogInterval {
return
}
stats := b.fecDecoder.Stats()
if stats == b.lastFECStatsLogged {
return
}
b.lastFECStatsLogAt = now
b.lastFECStatsLogged = stats
b.logger.Debugw("flexfec stats", "stats", stats.String())
}
// GetFECStreamStats returns FlexFEC counters for this buffer's repair flow,
// false if no FlexFEC packet has been received
func (b *Buffer) GetFECStreamStats() (FECStreamStats, bool) {
b.RLock()
defer b.RUnlock()
if b.fecDecoder == nil {
return FECStreamStats{}, false
}
return b.fecDecoder.Stats(), true
}
func (b *Buffer) Read(buff []byte) (n int, err error) {
b.Lock()
for {
@@ -303,6 +468,10 @@ func (b *Buffer) Close() error {
return err
}
if fecStats, ok := b.GetFECStreamStats(); ok {
b.logger.Infow("flexfec final stats", "stats", fecStats.String())
}
if stats != nil {
if cb := b.getOnFinalRtpStats(); cb != nil {
cb(stats)
@@ -349,6 +518,13 @@ func (b *Buffer) sendPLI() {
}
func (b *Buffer) calc(rawPkt []byte, rtpPacket *rtp.Packet, arrivalTime int64, isBuffered bool, isRTX bool) []rtcp.Packet {
// HandleIncomingPacketLocked rewrites the sequence number for bucket storage,
// rtpPacket is nil for packets queued before bind, no FEC state exists then
var wireSN uint16
if rtpPacket != nil {
wireSN = rtpPacket.SequenceNumber
}
b.BufferBase.HandleIncomingPacketLocked(
rawPkt,
rtpPacket,
@@ -359,6 +535,11 @@ func (b *Buffer) calc(rawPkt []byte, rtpPacket *rtp.Packet, arrivalTime int64, i
0,
)
// an arriving packet can complete buffered FEC packets waiting on it
if rtpPacket != nil && b.fecDecoder != nil && !b.fecInjecting && b.fecDecoder.HasPending() {
b.injectRecoveredLocked(b.fecDecoder.OnMediaPacket(wireSN), arrivalTime)
}
return b.getRTCPPackets(arrivalTime)
}
+35
View File
@@ -168,6 +168,7 @@ type BufferBase struct {
rtpParameters webrtc.RTPParameters
payloadType uint8
rtxPayloadType uint8
fecPayloadType uint8
snRangeMap *utils.RangeMap[uint64, uint64]
@@ -312,6 +313,14 @@ func (b *BufferBase) BindLocked(rtpParameters webrtc.RTPParameters, codec webrtc
}
}
// find FlexFEC payload type, the mime package does not know flexfec-03
for _, codec := range rtpParameters.Codecs {
if strings.EqualFold(codec.MimeType, webrtc.MimeTypeFlexFEC03) {
b.fecPayloadType = uint8(codec.PayloadType)
break
}
}
for _, ext := range rtpParameters.HeaderExtensions {
switch ext.URI {
case dd.ExtensionURI:
@@ -695,6 +704,32 @@ func (b *BufferBase) NotifyRead() {
b.readCond.Broadcast()
}
// getWirePacketLocked fetches the raw packet stored for the given extended wire
// sequence number, mapping it through the padding-only-drop sequence number adjustment.
func (b *BufferBase) getWirePacketLocked(buf []byte, wireESN uint64) (int, error) {
snAdjustment, err := b.snRangeMap.GetValue(wireESN)
if err != nil {
return 0, err
}
return b.bucket.GetPacket(buf, wireESN-snAdjustment)
}
// extHighestWireSNLocked returns the extended highest sequence number as seen on the
// wire, i.e. before the padding-only-drop adjustment applied for bucket storage.
func (b *BufferBase) extHighestWireSNLocked() (uint64, bool) {
if b.rtpStats == nil || !b.rtpStats.IsActive() {
return 0, false
}
return b.rtpStats.ExtendedHighestSequenceNumber(), true
}
func (b *BufferBase) bucketCapacityLocked() int {
if b.bucket == nil {
return 0
}
return b.bucket.Capacity()
}
func (b *BufferBase) HandleIncomingPacket(
rawPkt []byte,
rtpPacket *rtp.Packet,
+30
View File
@@ -40,6 +40,7 @@ func (f *FactoryOfBufferFactory) CreateBufferFactory() *Factory {
rtpBuffers: make(map[uint32]*Buffer),
rtcpReaders: make(map[uint32]*RTCPReader),
rtxPair: make(map[uint32]uint32),
fecPair: make(map[uint32]uint32),
}
}
@@ -50,6 +51,7 @@ type Factory struct {
rtpBuffers map[uint32]*Buffer
rtcpReaders map[uint32]*RTCPReader
rtxPair map[uint32]uint32 // repair -> base
fecPair map[uint32]uint32 // fec -> base
}
func (f *Factory) GetOrNew(packetType packetio.BufferPacketType, ssrc uint32) io.ReadWriteCloser {
@@ -89,10 +91,26 @@ func (f *Factory) GetOrNew(packetType packetio.BufferPacketType, ssrc uint32) io
break
}
}
for fec, base := range f.fecPair {
if fec == ssrc {
baseBuffer, ok := f.rtpBuffers[base]
if ok {
buffer.SetPrimaryBufferForFEC(baseBuffer)
}
break
} else if base == ssrc {
fecBuffer, ok := f.rtpBuffers[fec]
if ok {
fecBuffer.SetPrimaryBufferForFEC(buffer)
}
break
}
}
buffer.OnClose(func() {
f.Lock()
delete(f.rtpBuffers, ssrc)
delete(f.rtxPair, ssrc)
delete(f.fecPair, ssrc)
f.Unlock()
})
return buffer
@@ -132,3 +150,15 @@ func (f *Factory) SetRTXPair(repair, base uint32, rsid string) {
}
}
}
func (f *Factory) SetFECPair(fec, base uint32) {
f.Lock()
fecBuffer, baseBuffer := f.rtpBuffers[fec], f.rtpBuffers[base]
if fecBuffer == nil || baseBuffer == nil {
f.fecPair[fec] = base
}
f.Unlock()
if fecBuffer != nil && baseBuffer != nil {
fecBuffer.SetPrimaryBufferForFEC(baseBuffer)
}
}
+516
View File
@@ -0,0 +1,516 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package buffer
import (
"encoding/binary"
"errors"
"fmt"
"github.com/pion/rtp"
"github.com/livekit/mediatransportutil/pkg/bucket"
"github.com/livekit/protocol/logger"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
)
// FlexFEC-03 (draft-ietf-payload-flexible-fec-scheme-03) decoder as sent by libwebrtc.
//
// A FlexFEC packet is an RTP packet on its own SSRC (negotiated via
// a=ssrc-group:FEC-FR <mediaSSRC> <fecSSRC>) whose payload is:
//
// 0 1 2 3
// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// |R|F| P|X| CC |M| PT recovery | length recovery |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | TS recovery |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | SSRCCount | reserved |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | SSRC_i |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | SN base_i |k| Mask [0-14] |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// |k| Mask [15-45] (optional) |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// |k| |
// +-+ Mask [46-108] (optional) |
// | |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
// | ... repair payload (XOR of protected) ... |
// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
//
// The first 10 bytes are the XOR of the protected packets' first 8 header
// bytes with their 16-bit (length - 12) in place of the sequence number,
// the repair payload is the XOR of the protected packets' bytes [12:],
// zero padded to the longest protected packet. A missing packet is recovered
// by XORing the FEC bit string and repair payload with all other protected
// packets, which therefore requires every other protected packet.
var (
errFECPacketTruncated = errors.New("flexfec packet truncated")
errFECRetransmissionBit = errors.New("flexfec retransmission bit set not supported")
errFECInflexibleMask = errors.New("flexfec inflexible generator matrix not supported")
errFECMultipleSSRC = errors.New("flexfec multiple ssrc protection not supported")
errFECLastMaskKBitNotSet = errors.New("flexfec k-bit of last optional mask not set")
errFECEmptyMask = errors.New("flexfec empty packet mask")
errFECRepairPayloadTooSmall = errors.New("flexfec repair payload smaller than recovered length")
)
const (
// FlexFEC-03 masks cover at most 109 packets from SN base
flexFECMaxCoverage = 109
defaultMaxPendingFEC = 64
)
// FECStreamStats are cumulative counters for one media stream's FlexFEC repair flow
type FECStreamStats struct {
PacketsReceived uint32
PacketsInvalid uint32
RecoveryAttempts uint32
PacketsRecovered uint32
RecoveryFailed uint32
PacketsUnused uint32
PacketsDiscardedOld uint32
}
func (f FECStreamStats) String() string {
return fmt.Sprintf(
"received: %d, invalid: %d, recoveryAttempts: %d, recovered: %d, recoveryFailed: %d, unused: %d, discardedOld: %d",
f.PacketsReceived, f.PacketsInvalid, f.RecoveryAttempts, f.PacketsRecovered, f.RecoveryFailed, f.PacketsUnused, f.PacketsDiscardedOld,
)
}
type flexFECHeader struct {
protectedSSRC uint32
snBase uint16
// offsets from snBase of protected packets, ascending
offsets []uint16
// offset of the repair payload within the FEC RTP payload
payloadOffset int
}
// parseFlexFEC03Header parses the FlexFEC-03 header out of a FEC RTP payload
func parseFlexFEC03Header(payload []byte) (flexFECHeader, error) {
if len(payload) < 20 {
return flexFECHeader{}, fmt.Errorf("%w: length %d", errFECPacketTruncated, len(payload))
}
if payload[0]&0x80 != 0 {
return flexFECHeader{}, errFECRetransmissionBit
}
if payload[0]&0x40 != 0 {
return flexFECHeader{}, errFECInflexibleMask
}
if ssrcCount := payload[8]; ssrcCount != 1 {
return flexFECHeader{}, fmt.Errorf("%w: count %d", errFECMultipleSSRC, ssrcCount)
}
h := flexFECHeader{
protectedSSRC: binary.BigEndian.Uint32(payload[12:]),
snBase: binary.BigEndian.Uint16(payload[16:]),
}
appendMask := func(mask uint64, bitCount uint16, baseOffset uint16) {
for i := uint16(0); i < bitCount; i++ {
if (mask>>(bitCount-1-i))&1 == 1 {
h.offsets = append(h.offsets, baseOffset+i)
}
}
}
mask0 := binary.BigEndian.Uint16(payload[18:]) & 0x7fff
appendMask(uint64(mask0), 15, 0)
if payload[18]&0x80 != 0 {
// k-bit 0 set, mask is 15 bits
h.payloadOffset = 20
} else {
if len(payload) < 24 {
return flexFECHeader{}, fmt.Errorf("%w: length %d", errFECPacketTruncated, len(payload))
}
appendMask(uint64(binary.BigEndian.Uint32(payload[20:])&0x7fffffff), 31, 15)
if payload[20]&0x80 != 0 {
// k-bit 1 set, masks are 15 + 31 bits
h.payloadOffset = 24
} else {
// k-bit 2 must be set, masks are 15 + 31 + 63 bits
if len(payload) < 32 {
return flexFECHeader{}, fmt.Errorf("%w: length %d", errFECPacketTruncated, len(payload))
}
if payload[24]&0x80 == 0 {
return flexFECHeader{}, errFECLastMaskKBitNotSet
}
appendMask(binary.BigEndian.Uint64(payload[24:])&0x7fffffffffffffff, 63, 46)
h.payloadOffset = 32
}
}
if len(h.offsets) == 0 {
return flexFECHeader{}, errFECEmptyMask
}
return h, nil
}
type pendingFECPacket struct {
fecSN uint16
header flexFECHeader
payload []byte // copied FEC RTP payload
arrivalTime int64
// extended wire sequence numbers of protected packets, ascending,
// resolved on first evaluation with an active sequence number reference
protected []uint64
// set once an evaluation saw no not-yet-arrived protected packets, after
// which only arrivals of covered packets can change the outcome
rangeSettled bool
}
type flexFECDecoderParams struct {
Logger logger.Logger
// SSRC of the protected media stream
SSRC uint32
// GetPacket fetches the stored raw packet for an extended wire sequence number
GetPacket func(buf []byte, wireESN uint64) (int, error)
// ExtHighestSN returns the extended highest wire sequence number received, false if none yet
ExtHighestSN func() (uint64, bool)
// WindowSize returns the number of packets retrievable via GetPacket
WindowSize func() int
// MaxPendingFEC bounds the number of buffered FEC packets, 0 for default
MaxPendingFEC int
}
// flexFECDecoder recovers lost media packets from a FlexFEC-03 repair flow.
// Received media packets are not duplicated here, presence and XOR inputs
// are sourced from the primary buffer's packet bucket via GetPacket.
// All methods must be called holding the primary buffer's lock.
type flexFECDecoder struct {
params flexFECDecoderParams
pending []*pendingFECPacket
stats FECStreamStats
pktBuf []byte // scratch for GetPacket
payloadAcc []byte // scratch for repair payload XOR accumulation
// packets recovered within the current call, recovery of the same
// packet by an overlapping FEC is deferred until it is injected
justRecovered map[uint64]struct{}
}
func newFlexFECDecoder(params flexFECDecoderParams) *flexFECDecoder {
if params.MaxPendingFEC == 0 {
params.MaxPendingFEC = defaultMaxPendingFEC
}
return &flexFECDecoder{
params: params,
pktBuf: make([]byte, bucket.RTPMaxPktSize),
payloadAcc: make([]byte, bucket.RTPMaxPktSize),
justRecovered: make(map[uint64]struct{}, 4),
}
}
func (d *flexFECDecoder) HasPending() bool {
return len(d.pending) > 0
}
func (d *flexFECDecoder) Stats() FECStreamStats {
return d.stats
}
// AddFEC processes one received FlexFEC packet and returns raw recovered media
// packets, if any. The payload is copied and may be reused by the caller.
func (d *flexFECDecoder) AddFEC(fecSN uint16, payload []byte, arrivalTime int64) [][]byte {
d.stats.PacketsReceived++
prometheus.IncrementFEC(prometheus.FECStateReceived, 1)
for _, p := range d.pending {
if p.fecSN == fecSN {
// duplicate of a buffered FEC packet
return nil
}
}
header, err := parseFlexFEC03Header(payload)
if err == nil && header.protectedSSRC != d.params.SSRC {
err = fmt.Errorf("flexfec protecting unexpected ssrc, expected %d, got %d", d.params.SSRC, header.protectedSSRC)
}
if err != nil {
d.stats.PacketsInvalid++
prometheus.IncrementFEC(prometheus.FECStateInvalid, 1)
d.params.Logger.Debugw("dropping invalid flexfec packet", "error", err, "fecSN", fecSN)
return nil
}
defer clear(d.justRecovered)
recoveredPackets := d.evictStale()
if len(d.pending) >= d.params.MaxPendingFEC {
d.pending = d.pending[1:]
d.stats.PacketsDiscardedOld++
prometheus.IncrementFEC(prometheus.FECStateDiscardedOld, 1)
}
p := &pendingFECPacket{
fecSN: fecSN,
header: header,
payload: append([]byte(nil), payload...),
arrivalTime: arrivalTime,
}
d.pending = append(d.pending, p)
if recovered := d.evaluate(p); recovered != nil {
recoveredPackets = append(recoveredPackets, recovered)
}
return recoveredPackets
}
// OnMediaPacket re-evaluates buffered FEC packets covering the given wire
// sequence number and returns raw recovered media packets, if any.
// Callers should check HasPending first to keep the common path cheap.
func (d *flexFECDecoder) OnMediaPacket(wireSN uint16) [][]byte {
if len(d.pending) == 0 {
return nil
}
ref, ok := d.params.ExtHighestSN()
if !ok {
return nil
}
esn := unwrapNearESN(wireSN, ref)
var recoveredPackets [][]byte
defer clear(d.justRecovered)
for _, p := range append([]*pendingFECPacket(nil), d.pending...) {
if !p.affectedBy(esn) {
continue
}
if recovered := d.evaluate(p); recovered != nil {
recoveredPackets = append(recoveredPackets, recovered)
}
}
return recoveredPackets
}
// affectedBy reports whether the arrival of the given packet can change the
// outcome of this FEC packet, either because it is protected by it or because
// it moves a previously not-yet-arrived part of the protected range into the
// loss-detectable past
func (p *pendingFECPacket) affectedBy(esn uint64) bool {
if p.protected == nil {
// not resolved yet, conservatively assume affected
return true
}
if !p.rangeSettled && esn > p.protected[len(p.protected)-1] {
return true
}
for _, protectedESN := range p.protected {
if protectedESN == esn {
return true
}
}
return false
}
// evictStale drops buffered FEC packets whose protected range has fallen out
// of the packet window, giving each a final evaluation first. Returns any
// packets recovered by those final evaluations.
func (d *flexFECDecoder) evictStale() [][]byte {
ref, ok := d.params.ExtHighestSN()
if !ok {
return nil
}
var recoveredPackets [][]byte
window := uint64(d.params.WindowSize())
for _, p := range append([]*pendingFECPacket(nil), d.pending...) {
if p.protected == nil {
continue
}
if ref > p.protected[0]+window {
// oldest protected packet is leaving the window, final attempt;
// evaluate evicts the packet on any conclusive outcome
if recovered := d.evaluate(p); recovered != nil {
recoveredPackets = append(recoveredPackets, recovered)
}
d.remove(p, prometheus.FECStateRecoveryFailed)
}
}
return recoveredPackets
}
// remove drops a pending FEC packet, counting the disposition if it is still buffered
func (d *flexFECDecoder) remove(p *pendingFECPacket, state prometheus.FECState) {
for i, pp := range d.pending {
if pp != p {
continue
}
d.pending = append(d.pending[:i], d.pending[i+1:]...)
switch state {
case prometheus.FECStateUnused:
d.stats.PacketsUnused++
prometheus.IncrementFEC(state, 1)
case prometheus.FECStateRecoveryFailed:
d.stats.RecoveryFailed++
prometheus.IncrementFEC(state, 1)
case prometheus.FECStateDiscardedOld:
d.stats.PacketsDiscardedOld++
prometheus.IncrementFEC(state, 1)
}
return
}
}
// evaluate classifies the protected packets of a buffered FEC packet and runs
// XOR recovery when exactly one is missing, returning the recovered raw packet.
// Conclusive outcomes (recovered, unused, unrecoverable) evict the FEC packet.
func (d *flexFECDecoder) evaluate(p *pendingFECPacket) []byte {
ref, ok := d.params.ExtHighestSN()
if !ok {
// no media received yet, keep pending
return nil
}
if p.protected == nil {
p.protected = make([]uint64, 0, len(p.header.offsets))
for _, offset := range p.header.offsets {
p.protected = append(p.protected, unwrapNearESN(p.header.snBase+offset, ref))
}
}
var (
missing int
missingESN uint64
hdrAcc [12]byte
repair = p.payload[p.header.payloadOffset:]
payloadAccSize = len(repair)
)
if payloadAccSize > len(d.payloadAcc) {
payloadAccSize = len(d.payloadAcc)
}
copy(hdrAcc[:10], p.payload[:10])
copy(d.payloadAcc[:payloadAccSize], repair[:payloadAccSize])
for _, esn := range p.protected {
if _, isJustRecovered := d.justRecovered[esn]; isJustRecovered {
// recovered by an overlapping FEC in this call but not yet in the
// bucket, defer to the re-evaluation triggered by its injection
return nil
}
n, err := d.params.GetPacket(d.pktBuf, esn)
switch {
case err == nil:
if n < 12 {
// not a parseable RTP packet, cannot use as XOR input
d.remove(p, prometheus.FECStateRecoveryFailed)
return nil
}
raw := d.pktBuf[:n]
hdrAcc[0] ^= raw[0]
hdrAcc[1] ^= raw[1]
lengthRecovery := uint16(n - 12)
hdrAcc[2] ^= byte(lengthRecovery >> 8)
hdrAcc[3] ^= byte(lengthRecovery)
for i := 4; i < 8; i++ {
hdrAcc[i] ^= raw[i]
}
for i := 12; i < n && i-12 < payloadAccSize; i++ {
d.payloadAcc[i-12] ^= raw[i]
}
case errors.Is(err, bucket.ErrPacketSizeInvalid):
// in-window hole, the packet was lost
missing++
missingESN = esn
if missing > 1 {
// not recoverable yet, keep pending
return nil
}
case errors.Is(err, bucket.ErrPacketTooNew):
// not arrived yet, keep pending and re-evaluate as the stream advances
return nil
default:
// packet aged out of the window or was excluded (e.g. a received
// padding-only packet that is not stored), never recoverable
d.remove(p, prometheus.FECStateRecoveryFailed)
return nil
}
}
// the classification completed without not-yet-arrived packets
p.rangeSettled = true
if missing == 0 {
d.remove(p, prometheus.FECStateUnused)
return nil
}
recovered, err := d.finishRecovery(p, hdrAcc, d.payloadAcc[:payloadAccSize], missingESN)
d.stats.RecoveryAttempts++
prometheus.IncrementFEC(prometheus.FECStateRecoveryAttempt, 1)
if err != nil {
d.params.Logger.Debugw("flexfec recovery failed", "error", err, "fecSN", p.fecSN, "missingESN", missingESN)
d.remove(p, prometheus.FECStateRecoveryFailed)
return nil
}
d.stats.PacketsRecovered++
prometheus.IncrementFEC(prometheus.FECStateRecovered, 1)
d.justRecovered[missingESN] = struct{}{}
// the FEC packet did its job, no eviction disposition to count
d.remove(p, "")
return recovered
}
// finishRecovery turns the XOR accumulators into the recovered raw packet
func (d *flexFECDecoder) finishRecovery(p *pendingFECPacket, hdrAcc [12]byte, payloadAcc []byte, missingESN uint64) ([]byte, error) {
// force RTP version 2
hdrAcc[0] = (hdrAcc[0] | 0x80) & 0xbf
recoveredLen := int(binary.BigEndian.Uint16(hdrAcc[2:4]))
if recoveredLen > len(payloadAcc) {
return nil, fmt.Errorf("%w: recovered %d, repair %d", errFECRepairPayloadTooSmall, recoveredLen, len(payloadAcc))
}
binary.BigEndian.PutUint16(hdrAcc[2:4], uint16(missingESN))
binary.BigEndian.PutUint32(hdrAcc[8:12], d.params.SSRC)
recovered := make([]byte, 12+recoveredLen)
copy(recovered, hdrAcc[:])
copy(recovered[12:], payloadAcc[:recoveredLen])
var pkt rtp.Packet
if err := pkt.Unmarshal(recovered); err != nil {
return nil, fmt.Errorf("recovered packet does not unmarshal: %w", err)
}
return recovered, nil
}
// unwrapNearESN expands a 16-bit wire sequence number to the extended sequence
// number closest to the reference
func unwrapNearESN(sn uint16, ref uint64) uint64 {
candidate := (ref &^ uint64(0xffff)) | uint64(sn)
if candidate > ref {
if candidate-ref > 0x8000 && candidate >= (1<<16) {
candidate -= 1 << 16
}
} else if ref-candidate > 0x8000 {
candidate += 1 << 16
}
return candidate
}
+591
View File
@@ -0,0 +1,591 @@
// Copyright 2026 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package buffer
import (
"fmt"
"testing"
pionflexfec "github.com/pion/interceptor/pkg/flexfec"
"github.com/pion/rtp"
"github.com/pion/transport/v4/packetio"
"github.com/pion/webrtc/v4"
"github.com/stretchr/testify/require"
"github.com/livekit/mediatransportutil/pkg/bucket"
"github.com/livekit/mediatransportutil/pkg/twcc"
"github.com/livekit/protocol/logger"
)
const (
testFECMediaSSRC = uint32(0x12345678)
testFECSSRC = uint32(0x23456789)
testFECPT = uint8(49)
)
// fecPacketStore mimics the primary buffer's packet bucket keyed by extended
// wire sequence number
type fecPacketStore struct {
packets map[uint64][]byte
highest uint64
hasAny bool
window uint64
}
func newFECPacketStore(window int) *fecPacketStore {
return &fecPacketStore{
packets: make(map[uint64][]byte),
window: uint64(window),
}
}
func (s *fecPacketStore) add(esn uint64, raw []byte) {
s.packets[esn] = raw
if !s.hasAny || esn > s.highest {
s.highest = esn
}
s.hasAny = true
}
func (s *fecPacketStore) getPacket(buf []byte, esn uint64) (int, error) {
if !s.hasAny || esn > s.highest {
return 0, bucket.ErrPacketTooNew
}
if s.highest-esn >= s.window {
return 0, bucket.ErrPacketTooOld
}
raw, ok := s.packets[esn]
if !ok {
return 0, bucket.ErrPacketSizeInvalid
}
return copy(buf, raw), nil
}
func (s *fecPacketStore) decoderParams() flexFECDecoderParams {
return flexFECDecoderParams{
Logger: logger.GetLogger(),
SSRC: testFECMediaSSRC,
GetPacket: s.getPacket,
ExtHighestSN: func() (uint64, bool) { return s.highest, s.hasAny },
WindowSize: func() int { return int(s.window) },
}
}
// makeFECMediaPackets builds count consecutive media packets starting at baseSN
// with varying payload sizes, header extensions and marker bits
func makeFECMediaPackets(t *testing.T, baseSN uint16, count int) ([]rtp.Packet, [][]byte) {
t.Helper()
packets := make([]rtp.Packet, 0, count)
raws := make([][]byte, 0, count)
for i := 0; i < count; i++ {
pkt := rtp.Packet{
Header: rtp.Header{
Version: 2,
PayloadType: 96,
SequenceNumber: baseSN + uint16(i),
Timestamp: 0x1000 + uint32(i/3)*3000,
SSRC: testFECMediaSSRC,
Marker: i%3 == 2,
},
Payload: make([]byte, 20+(i*37)%600),
}
for j := range pkt.Payload {
pkt.Payload[j] = byte(i + j)
}
if i%3 == 0 {
require.NoError(t, pkt.Header.SetExtension(3, []byte{byte(i), byte(i >> 8)}))
}
raw, err := pkt.Marshal()
require.NoError(t, err)
packets = append(packets, pkt)
raws = append(raws, raw)
}
return packets, raws
}
func encodeFECPackets(t *testing.T, media []rtp.Packet, numFEC uint32) []rtp.Packet {
t.Helper()
fecPackets := pionflexfec.NewFlexEncoder03(testFECPT, testFECSSRC).EncodeFec(media, numFEC)
require.NotEmpty(t, fecPackets)
return fecPackets
}
func TestParseFlexFEC03Header(t *testing.T) {
buildPayload := func(k0, k1, k2 bool, size int) []byte {
payload := make([]byte, size)
payload[8] = 1 // SSRCCount
// protected SSRC
payload[12], payload[13], payload[14], payload[15] = 0x12, 0x34, 0x56, 0x78
// SN base
payload[16], payload[17] = 0x10, 0x01
if k0 {
payload[18] |= 0x80
}
if k1 && size > 20 {
payload[20] |= 0x80
}
if k2 && size > 24 {
payload[24] |= 0x80
}
return payload
}
t.Run("mask sizes", func(t *testing.T) {
// k-bit 0 set, 15 bit mask, offsets 0 and 14
payload := buildPayload(true, false, false, 30)
payload[18] |= 0x40 // offset 0
payload[19] |= 0x01 // offset 14
h, err := parseFlexFEC03Header(payload)
require.NoError(t, err)
require.Equal(t, uint32(0x12345678), h.protectedSSRC)
require.Equal(t, uint16(0x1001), h.snBase)
require.Equal(t, []uint16{0, 14}, h.offsets)
require.Equal(t, 20, h.payloadOffset)
// k-bit 1 set, 15+31 bit masks, offsets 15 and 45
payload = buildPayload(false, true, false, 40)
payload[20] |= 0x40 // offset 15
payload[23] |= 0x01 // offset 45
h, err = parseFlexFEC03Header(payload)
require.NoError(t, err)
require.Equal(t, []uint16{15, 45}, h.offsets)
require.Equal(t, 24, h.payloadOffset)
// k-bit 2 set, 15+31+63 bit masks, offsets 46 and 108
payload = buildPayload(false, false, true, 40)
payload[24] |= 0x40 // offset 46
payload[31] |= 0x01 // offset 108
h, err = parseFlexFEC03Header(payload)
require.NoError(t, err)
require.Equal(t, []uint16{46, 108}, h.offsets)
require.Equal(t, 32, h.payloadOffset)
})
t.Run("rejects", func(t *testing.T) {
// truncated
_, err := parseFlexFEC03Header(make([]byte, 19))
require.ErrorIs(t, err, errFECPacketTruncated)
// truncated with k-bit 0 unset
_, err = parseFlexFEC03Header(buildPayload(false, true, false, 20))
require.ErrorIs(t, err, errFECPacketTruncated)
// truncated with k-bits 0 and 1 unset
_, err = parseFlexFEC03Header(buildPayload(false, false, true, 24))
require.ErrorIs(t, err, errFECPacketTruncated)
// retransmission bit
payload := buildPayload(true, false, false, 30)
payload[0] |= 0x80
_, err = parseFlexFEC03Header(payload)
require.ErrorIs(t, err, errFECRetransmissionBit)
// inflexible mask bit
payload = buildPayload(true, false, false, 30)
payload[0] |= 0x40
_, err = parseFlexFEC03Header(payload)
require.ErrorIs(t, err, errFECInflexibleMask)
// multiple SSRC
payload = buildPayload(true, false, false, 30)
payload[8] = 2
_, err = parseFlexFEC03Header(payload)
require.ErrorIs(t, err, errFECMultipleSSRC)
// k-bit of last mask not set
payload = buildPayload(false, false, false, 40)
_, err = parseFlexFEC03Header(payload)
require.ErrorIs(t, err, errFECLastMaskKBitNotSet)
// empty mask
payload = buildPayload(true, false, false, 30)
_, err = parseFlexFEC03Header(payload)
require.ErrorIs(t, err, errFECEmptyMask)
})
}
func TestFlexFECDecoderRoundTrip(t *testing.T) {
for _, blockSize := range []int{10, 30, 100} {
for _, dropIdx := range []int{0, blockSize / 2, blockSize - 1} {
t.Run(fmt.Sprintf("block=%d,drop=%d", blockSize, dropIdx), func(t *testing.T) {
media, raws := makeFECMediaPackets(t, 100, blockSize)
fecPkt := encodeFECPackets(t, media, 1)[0]
store := newFECPacketStore(1000)
for i, raw := range raws {
if i != dropIdx {
store.add(uint64(media[i].SequenceNumber), raw)
}
}
d := newFlexFECDecoder(store.decoderParams())
recovered := d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0)
if dropIdx == blockSize-1 {
// the dropped packet is past the highest received one, it
// is not detectable as lost until the stream advances
require.Empty(t, recovered)
next, nextRaws := makeFECMediaPackets(t, 100+uint16(blockSize), 1)
store.add(uint64(next[0].SequenceNumber), nextRaws[0])
recovered = d.OnMediaPacket(next[0].SequenceNumber)
}
require.Len(t, recovered, 1)
require.Equal(t, raws[dropIdx], recovered[0])
stats := d.Stats()
require.Equal(t, uint32(1), stats.PacketsReceived)
require.Equal(t, uint32(1), stats.RecoveryAttempts)
require.Equal(t, uint32(1), stats.PacketsRecovered)
require.Equal(t, uint32(0), stats.RecoveryFailed)
require.False(t, d.HasPending())
})
}
}
}
func TestFlexFECDecoderNoLoss(t *testing.T) {
media, raws := makeFECMediaPackets(t, 5000, 12)
fecPkt := encodeFECPackets(t, media, 1)[0]
store := newFECPacketStore(1000)
for i, raw := range raws {
store.add(uint64(media[i].SequenceNumber), raw)
}
d := newFlexFECDecoder(store.decoderParams())
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0))
stats := d.Stats()
require.Equal(t, uint32(1), stats.PacketsReceived)
require.Equal(t, uint32(1), stats.PacketsUnused)
require.Equal(t, uint32(0), stats.RecoveryAttempts)
require.False(t, d.HasPending())
}
func TestFlexFECDecoderFECBeforeMedia(t *testing.T) {
media, raws := makeFECMediaPackets(t, 300, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
store := newFECPacketStore(1000)
d := newFlexFECDecoder(store.decoderParams())
// FEC arrives before any media
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0))
require.True(t, d.HasPending())
// media arrives, packet 4 is lost
var recovered [][]byte
for i, raw := range raws {
if i == 4 {
continue
}
store.add(uint64(media[i].SequenceNumber), raw)
recovered = append(recovered, d.OnMediaPacket(media[i].SequenceNumber)...)
}
require.Len(t, recovered, 1)
require.Equal(t, raws[4], recovered[0])
require.False(t, d.HasPending())
}
func TestFlexFECDecoderTwoMissing(t *testing.T) {
media, raws := makeFECMediaPackets(t, 700, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
store := newFECPacketStore(1000)
for i, raw := range raws {
if i == 2 || i == 6 {
continue
}
store.add(uint64(media[i].SequenceNumber), raw)
}
d := newFlexFECDecoder(store.decoderParams())
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0))
require.True(t, d.HasPending())
require.Equal(t, uint32(0), d.Stats().RecoveryAttempts)
// packet 2 arrives late, e.g. via RTX, packet 6 becomes recoverable
store.add(uint64(media[2].SequenceNumber), raws[2])
recovered := d.OnMediaPacket(media[2].SequenceNumber)
require.Len(t, recovered, 1)
require.Equal(t, raws[6], recovered[0])
require.False(t, d.HasPending())
}
func TestFlexFECDecoderDuplicateFEC(t *testing.T) {
media, raws := makeFECMediaPackets(t, 900, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
store := newFECPacketStore(1000)
for i, raw := range raws {
if i == 3 || i == 5 {
continue
}
store.add(uint64(media[i].SequenceNumber), raw)
}
d := newFlexFECDecoder(store.decoderParams())
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0))
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0))
stats := d.Stats()
require.Equal(t, uint32(2), stats.PacketsReceived)
require.Equal(t, 1, len(d.pending), "duplicate must not be buffered twice")
}
func TestFlexFECDecoderInvalid(t *testing.T) {
store := newFECPacketStore(1000)
d := newFlexFECDecoder(store.decoderParams())
// garbage payload
require.Empty(t, d.AddFEC(1, make([]byte, 10), 0))
// valid header protecting another SSRC
media, _ := makeFECMediaPackets(t, 100, 5)
for i := range media {
media[i].SSRC = testFECMediaSSRC + 1
}
fecPkt := encodeFECPackets(t, media, 1)[0]
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0))
stats := d.Stats()
require.Equal(t, uint32(2), stats.PacketsReceived)
require.Equal(t, uint32(2), stats.PacketsInvalid)
require.False(t, d.HasPending())
}
func TestFlexFECDecoderWraparound(t *testing.T) {
// media packets crossing the 16-bit sequence number wrap
media, raws := makeFECMediaPackets(t, 65530, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
const cycle = uint64(7) << 16
esn := func(i int) uint64 {
// 65530..65535 are in the cycle, 0..3 in the next
if media[i].SequenceNumber >= 65530 {
return cycle | uint64(media[i].SequenceNumber)
}
return cycle + (1 << 16) + uint64(media[i].SequenceNumber)
}
for _, dropIdx := range []int{2, 8} { // one drop on each side of the wrap
t.Run(fmt.Sprintf("drop=%d", dropIdx), func(t *testing.T) {
store := newFECPacketStore(1000)
for i, raw := range raws {
if i != dropIdx {
store.add(esn(i), raw)
}
}
d := newFlexFECDecoder(store.decoderParams())
recovered := d.AddFEC(fecPkt.SequenceNumber, fecPkt.Payload, 0)
require.Len(t, recovered, 1)
require.Equal(t, raws[dropIdx], recovered[0])
})
}
}
func TestFlexFECDecoderOverlappingChain(t *testing.T) {
// two FEC packets with overlapping coverage, recovering a packet via the
// first enables the second once the recovered packet is fed back
media, raws := makeFECMediaPackets(t, 2000, 12)
fecA := encodeFECPackets(t, media[:8], 1)[0] // protects 0..7
fecB := encodeFECPackets(t, media[4:], 1)[0] // protects 4..11
store := newFECPacketStore(1000)
for i, raw := range raws {
if i == 6 || i == 10 {
continue
}
store.add(uint64(media[i].SequenceNumber), raw)
}
d := newFlexFECDecoder(store.decoderParams())
// distinct FEC stream sequence numbers, each encoder starts at the same one
// B cannot recover, 6 and 10 missing
require.Empty(t, d.AddFEC(1, fecB.Payload, 0))
// A recovers 6
recovered := d.AddFEC(2, fecA.Payload, 0)
require.Len(t, recovered, 1)
require.Equal(t, raws[6], recovered[0])
// the buffer injects the recovered packet and notifies the decoder,
// which lets B recover 10
store.add(uint64(media[6].SequenceNumber), raws[6])
recovered = d.OnMediaPacket(media[6].SequenceNumber)
require.Len(t, recovered, 1)
require.Equal(t, raws[10], recovered[0])
require.False(t, d.HasPending())
require.Equal(t, uint32(2), d.Stats().PacketsRecovered)
}
func TestFlexFECDecoderStaleEviction(t *testing.T) {
media, raws := makeFECMediaPackets(t, 100, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
store := newFECPacketStore(50)
for i, raw := range raws {
if i == 2 || i == 6 {
continue
}
store.add(uint64(media[i].SequenceNumber), raw)
}
d := newFlexFECDecoder(store.decoderParams())
require.Empty(t, d.AddFEC(1, fecPkt.Payload, 0))
require.True(t, d.HasPending())
// the stream advances beyond the window, next FEC packet triggers a sweep
next, nextRaws := makeFECMediaPackets(t, 300, 10)
for i, raw := range nextRaws {
store.add(uint64(next[i].SequenceNumber), raw)
}
nextFEC := encodeFECPackets(t, next, 1)[0]
require.Empty(t, d.AddFEC(2, nextFEC.Payload, 0))
stats := d.Stats()
require.Equal(t, uint32(1), stats.RecoveryFailed, "stale FEC with missing packets evicted as failed")
require.Equal(t, uint32(1), stats.PacketsUnused, "fresh FEC with no losses evicted as unused")
require.False(t, d.HasPending())
}
// TestBufferFlexFECIntegration exercises the full path, a FEC packet written to
// the paired FEC buffer recovers a lost media packet into the primary's bucket
func TestBufferFlexFECIntegration(t *testing.T) {
flexfecCodec := webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeFlexFEC03,
ClockRate: 90000,
SDPFmtpLine: "repair-window=10000000",
},
PayloadType: 49,
}
media, raws := makeFECMediaPackets(t, 100, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
fecRaw, err := fecPkt.Marshal()
require.NoError(t, err)
for _, pairFirst := range []bool{true, false} {
t.Run(fmt.Sprintf("pairBeforeFECBuffer=%v", pairFirst), func(t *testing.T) {
factory := NewFactoryOfBufferFactory(500, 200).CreateBufferFactory()
primary := factory.GetOrNew(packetio.RTPBufferPacket, testFECMediaSSRC).(*Buffer)
require.NoError(t, primary.Bind(webrtc.RTPParameters{
Codecs: []webrtc.RTPCodecParameters{vp8Codec, flexfecCodec},
}, vp8Codec.RTPCodecCapability, 0))
require.Equal(t, uint8(49), primary.fecPayloadType, "fec payload type learned at bind")
twccResponder := twcc.NewTransportWideCCResponder()
primary.SetTWCCAndExtID(twccResponder, 5)
// media packets arrive, packet 4 is lost
for i, raw := range raws {
if i == 4 {
continue
}
_, err := primary.Write(raw)
require.NoError(t, err)
}
if pairFirst {
factory.SetFECPair(testFECSSRC, testFECMediaSSRC)
}
fecBuffer := factory.GetOrNew(packetio.RTPBufferPacket, testFECSSRC).(*Buffer)
if !pairFirst {
factory.SetFECPair(testFECSSRC, testFECMediaSSRC)
}
_, err = fecBuffer.Write(fecRaw)
require.NoError(t, err)
// the recovered packet must be byte equal in the primary's bucket
primary.Lock()
ref, ok := primary.extHighestWireSNLocked()
require.True(t, ok)
buf := make([]byte, bucket.RTPMaxPktSize)
n, getErr := primary.getWirePacketLocked(buf, unwrapNearESN(media[4].SequenceNumber, ref))
primary.Unlock()
require.NoError(t, getErr)
require.Equal(t, raws[4], buf[:n])
stats, hasStats := primary.GetFECStreamStats()
require.True(t, hasStats)
require.Equal(t, uint32(1), stats.PacketsReceived)
require.Equal(t, uint32(1), stats.RecoveryAttempts)
require.Equal(t, uint32(1), stats.PacketsRecovered)
})
}
}
// TestBufferFlexFECBeforePairing verifies FEC packets queued in the unbound FEC
// buffer are processed once the pairing is established
func TestBufferFlexFECBeforePairing(t *testing.T) {
media, raws := makeFECMediaPackets(t, 100, 10)
fecPkt := encodeFECPackets(t, media, 1)[0]
fecRaw, err := fecPkt.Marshal()
require.NoError(t, err)
factory := NewFactoryOfBufferFactory(500, 200).CreateBufferFactory()
primary := factory.GetOrNew(packetio.RTPBufferPacket, testFECMediaSSRC).(*Buffer)
require.NoError(t, primary.Bind(webrtc.RTPParameters{
Codecs: []webrtc.RTPCodecParameters{vp8Codec},
}, vp8Codec.RTPCodecCapability, 0))
require.Equal(t, uint8(0), primary.fecPayloadType, "no flexfec codec negotiated")
// FEC packet arrives before the pairing is known, queued in the FEC buffer
fecBuffer := factory.GetOrNew(packetio.RTPBufferPacket, testFECSSRC).(*Buffer)
_, err = fecBuffer.Write(fecRaw)
require.NoError(t, err)
for i, raw := range raws {
if i == 4 {
continue
}
_, err := primary.Write(raw)
require.NoError(t, err)
}
// pairing flushes the queued FEC packet into the primary
factory.SetFECPair(testFECSSRC, testFECMediaSSRC)
stats, hasStats := primary.GetFECStreamStats()
require.True(t, hasStats)
require.Equal(t, uint32(1), stats.PacketsReceived)
require.Equal(t, uint32(1), stats.PacketsRecovered)
}
func TestFlexFECDecoderPendingOverflow(t *testing.T) {
store := newFECPacketStore(100000)
params := store.decoderParams()
params.MaxPendingFEC = 4
d := newFlexFECDecoder(params)
// no media received, FEC packets pile up
for i := 0; i < 6; i++ {
media, _ := makeFECMediaPackets(t, uint16(1000+i*20), 5)
fecPkt := encodeFECPackets(t, media, 1)[0]
require.Empty(t, d.AddFEC(fecPkt.SequenceNumber+uint16(i), fecPkt.Payload, 0))
}
require.Equal(t, 4, len(d.pending))
require.Equal(t, uint32(2), d.Stats().PacketsDiscardedOld)
}
+30
View File
@@ -35,6 +35,19 @@ const (
TransmissionRetransmit TransmissionType = "retransmit"
)
// FECState describes the disposition of incoming FlexFEC packets and recovery outcomes
type FECState string
const (
FECStateReceived FECState = "received" // valid FlexFEC packet received
FECStateInvalid FECState = "invalid" // FlexFEC packet failed to parse or was unsupported
FECStateRecoveryAttempt FECState = "recovery_attempt" // XOR recovery executed
FECStateRecovered FECState = "recovered" // recovered media packet injected
FECStateRecoveryFailed FECState = "recovery_failed" // FEC packet discarded with protected packets still missing
FECStateUnused FECState = "unused" // FEC packet discarded with all protected packets received
FECStateDiscardedOld FECState = "discarded_old" // FEC packet discarded due to age/overflow before evaluation
)
var (
bytesIn atomic.Uint64
bytesOut atomic.Uint64
@@ -57,6 +70,7 @@ var (
promPacketTotal *prometheus.CounterVec
promPacketBytes *prometheus.CounterVec
promRTCPLabels = []string{"direction", "country"}
promFlexFECTotal *prometheus.CounterVec
promStreamLabels = []string{"direction", "source", "type", "country"}
promNackTotal *prometheus.CounterVec
promPliTotal *prometheus.CounterVec
@@ -105,6 +119,12 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) {
Name: "total",
ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()},
}, promRTCPLabels)
promFlexFECTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: livekitNamespace,
Subsystem: "flexfec_packet",
Name: "total",
ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()},
}, []string{"state"})
promPacketLossTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: livekitNamespace,
Subsystem: "packet_loss",
@@ -195,6 +215,7 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) {
prometheus.MustRegister(promNackTotal)
prometheus.MustRegister(promPliTotal)
prometheus.MustRegister(promFirTotal)
prometheus.MustRegister(promFlexFECTotal)
prometheus.MustRegister(promPacketLossTotal)
prometheus.MustRegister(promPacketLoss)
prometheus.MustRegister(promPacketOutOfOrderTotal)
@@ -208,6 +229,15 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) {
prometheus.MustRegister(promForwardLatencyHist)
}
// IncrementFEC counts incoming FlexFEC packet dispositions and recovery outcomes.
// Safe to call before Init (e.g. from unit tests), increments are dropped in that case.
func IncrementFEC(state FECState, count uint32) {
if promFlexFECTotal == nil || count == 0 {
return
}
promFlexFECTotal.WithLabelValues(string(state)).Add(float64(count))
}
func IncrementPackets(country string, direction Direction, count uint64, retransmit bool) {
var transmission TransmissionType
if retransmit {
+2
View File
@@ -0,0 +1,2 @@
# FEC test run artifacts (logs, CSVs, prom samples, reports, built binaries)
out/
+135
View File
@@ -0,0 +1,135 @@
# FlexFEC test harness (publisher → SFU)
Validates that FlexFEC-03 sent by a publisher recovers lost packets at the SFU
under cellular-like loss (continuous base loss plus periodic bursts), the kind
of uplink a robot on LTE/5G sees. The harness runs everything on loopback:
```
publisher (rust-sdks local_video, --test-pattern --flex-fec)
│ UDP → 127.0.0.1:7882 ← traffic shaper drops packets here
livekit-server (enable_flexfec: true, prometheus on :6789)
subscriber (rust-sdks local_video, --headless --log-frames)
```
The publisher attaches a wall-clock timestamp and a frame id to every frame
via the packet trailer feature; the subscriber logs them per received frame.
That gives ground truth for end-to-end frame latency and frame loss, while
the SFU's prometheus counters (`livekit_flexfec_packet_total{state=...}`,
`livekit_nack_total`, `livekit_packet_loss_total`) show what FEC did.
## Requirements
- `go`, `cargo`, `curl`, `python3` with `matplotlib` (`pip3 install matplotlib`)
- a `rust-sdks` checkout with the local_video example
(default `../rust-sdks` relative to this repo, override with `RUST_SDKS_DIR`)
- `sudo` for traffic shaping:
- **macOS**: dummynet (`dnctl` + `pfctl`). If dummynet is unavailable on
your macOS build, run with `--no-shaping` and use Network Link Conditioner
manually, or test on Linux.
- **Linux**: `tc` with the `netem` qdisc (`iproute2`).
## Usage
```bash
# A/B comparison: baseline (NACK only) vs FlexFEC, 2 minutes each
./run_fec_test.sh --mode ab --duration 120
# single FEC run with a harsher profile
./run_fec_test.sh --mode fec --duration 60 --base-loss 0.05 --burst-loss 0.4
# sanity check without shaping (expect ~0 loss, ~0 recoveries)
./run_fec_test.sh --mode fec --duration 30 --no-shaping
# no sudo available: drop 4% of received packets inside the SFU instead of
# OS shaping (uniform loss only, also useful for CI)
./run_fec_test.sh --mode ab --duration 60 --debug-drop 4
```
Outputs land in `scripts/fec/out/<timestamp>/`:
- `fec_report.png` — stacked time series: per-frame latency with frame-gap
markers and burst shading, FEC received/recovered/failed rates, NACK and
packet-loss rates, delivered fps
- `summary.txt` — per-run table (frames lost, latency percentiles, FEC
counters, NACK totals) and the A/B comparison
- per run (`baseline/`, `fec/`): `server.log`, `publisher.log`,
`subscriber.log`, `frames.csv`, `prom.tsv`, `events.csv`, `meta.env`
## Loss profile
Defaults simulate a robot uplink over cellular: 2% continuous loss
(Gilbert-Elliott on Linux for realistic correlation, uniform on macOS) with a
3 s burst of 25% loss every 15 s. Tune via `--base-loss`, `--burst-loss`,
`--burst-every`, `--burst-len`. The shaper matches **UDP destined to port
7882** on loopback, which is the publisher→SFU media leg; the subscriber's
upstream RTCP shares that port and is shaped too, which is acceptable for A/B
comparisons since both runs see identical conditions.
The publisher defaults to a fixed 30% FEC protection rate with the bursty
mask (`--fec-rate`, `--fec-mask-type`); pass `--fec-rate 0` to let libwebrtc
adapt the rate to its loss estimate instead.
`--debug-drop PCT` is an unprivileged alternative to OS shaping: the SFU
drops PCT% of received packets before processing (uniform, all SSRCs,
enabled via the `LIVEKIT_DEBUG_RX_DROP_PCT` env var). Use the OS shapers for
the cellular burst profile, this knob for quick checks and CI. Note that with
uniform loss and a fixed protection rate, blocks losing two or more packets
are not FEC-recoverable and fall back to NACK/RTX, so expect partial
recovery; bursty loss with the bursty mask is the scenario FlexFEC targets.
## What to expect
- Sanity run (no shaping): `state="received"` grows, `recovered` ≈ 0, no
frame gaps.
- FEC run under bursts: `recovered` spikes inside burst windows, the
subscriber sees few or no frame-id gaps, latency stays near baseline.
- Baseline under the same bursts: frame gaps and latency spikes during
bursts (NACK/RTX needs a round trip per loss; FEC repairs immediately).
- Publisher bitrate should not collapse in the FEC run — FEC packets carry
transport-wide CC sequence numbers and the SFU reports them, so the
publisher's bandwidth estimate stays intact.
## Parameter sweep
`sweep_fec.sh` runs `run_fec_test.sh` across a matrix of loss levels and FEC
configurations (one baseline plus one FEC run per `(rate, mask)` at each loss
level), builds the binaries once, then `aggregate_fec.py` combines all cells
into a single comparison: `sweep_report.png` (frame loss, p99 latency, FEC
recovery rate, and recovered-packet count, each vs loss level, baseline vs
every FEC config), `sweep_summary.csv`, and a markdown table.
```bash
# uniform-loss sweep, no sudo: 4 loss levels x 2 FEC rates x 1 mask
./sweep_fec.sh --loss-mode debug --loss-list "2 5 10 15" --fec-rate-list "20 50"
# cellular-burst sweep: vary burst intensity, compare the two masks at 30%
sudo -v && ./sweep_fec.sh --loss-mode shaped --loss-list "0.15 0.30 0.50" \
--fec-rate-list "30" --mask-list "random bursty" --duration 90
# re-aggregate an existing sweep without re-running it
python3 aggregate_fec.py --sweep out/sweep_<ts>
```
Runtime ≈ cells × (~20s setup + `--duration`). Each cell is a directory under
the sweep output; `manifest.tsv` maps cell → parameters. In `debug` mode
`--loss-list` is packet-drop percentages; in `shaped` mode it is the burst
loss fraction (base loss and cadence fixed by `--base-loss`/`--burst-every`/
`--burst-len`). Note the caveat above: uniform `debug` loss with a fixed FEC
rate only recovers blocks that lose a single packet, so `shaped` bursts with
the `bursty` mask show FlexFEC at its best.
## Direct script use
```bash
sudo ./shape_macos.sh start --port 7882 --base-loss 0.02 --burst-loss 0.25 \
--burst-every 15 --burst-len 3 --events /tmp/events.csv
sudo ./shape_macos.sh stop # force cleanup (also: shape_linux.sh)
./prom_poll.sh 6789 /tmp/prom.tsv
# single run / A/B pair
python3 plot_fec.py --run out/<ts>/baseline --run out/<ts>/fec --out out/<ts>
```
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""Aggregate a FlexFEC parameter sweep into a single comparison report.
Reads the cells produced by sweep_fec.sh (a manifest.tsv plus one run directory
per cell) and emits:
sweep_report.png - loss/latency/recovery vs loss level, baseline vs each config
sweep_summary.csv - one row per cell with the key metrics
a markdown table on stdout
Usage:
aggregate_fec.py --sweep <sweep_dir>
"""
import argparse
import csv
import os
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
# Run lives in plot_fec.py next to this script
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from plot_fec import Run # noqa: E402
def read_manifest(sweep_dir):
rows = []
with open(os.path.join(sweep_dir, "manifest.tsv")) as f:
for row in csv.DictReader(f, delimiter="\t"):
rows.append(row)
return rows
def collect(sweep_dir):
records = []
for entry in read_manifest(sweep_dir):
run_dir = os.path.join(sweep_dir, entry["leaf"])
if not os.path.isdir(run_dir):
print(f"warning: missing run dir {run_dir}", file=sys.stderr)
continue
run = Run(run_dir)
if not run.frames:
print(f"warning: no frames for {entry['leaf']}, skipping", file=sys.stderr)
continue
s = run.summary()
try:
loss = float(entry["loss"])
except ValueError:
loss = 0.0
expected = s["frames_expected"] or 1
rec = {
"leaf": entry["leaf"],
"mode": entry["mode"],
"loss": loss,
"loss_label": entry["loss"],
"fec_rate": entry["fec_rate"],
"fec_mask": entry["fec_mask"],
"config": "baseline"
if entry["mode"] == "baseline"
else f"FEC r{entry['fec_rate']}/{entry['fec_mask']}",
"frame_loss_pct": 100.0 * s["frames_lost"] / expected,
"recovery_rate_pct": (100.0 * s["fec_recovered"] / s["fec_received"]) if s["fec_received"] else 0.0,
"debug_drop": run.meta.get("DEBUG_DROP", "0"),
**s,
}
records.append(rec)
return records
def x_unit(records):
if records and records[0]["debug_drop"] not in ("0", ""):
return "injected packet drop (%)"
return "burst loss fraction"
def write_csv(records, path):
cols = [
"config", "mode", "loss_label", "fec_rate", "fec_mask",
"frames_received", "frames_expected", "frames_lost", "frame_loss_pct",
"latency_p50_ms", "latency_p95_ms", "latency_p99_ms",
"fec_received", "fec_recovery_attempts", "fec_recovered",
"fec_recovery_failed", "recovery_rate_pct", "nack_total",
]
with open(path, "w", newline="") as f:
w = csv.writer(f)
w.writerow(cols)
for r in sorted(records, key=lambda r: (r["loss"], r["config"])):
w.writerow([_fmt(r.get(c)) for c in cols])
def _fmt(v):
if isinstance(v, float):
return f"{v:.2f}"
return v
def series_by_config(records):
"""config label -> sorted [(loss, record)]"""
by_cfg = {}
for r in records:
by_cfg.setdefault(r["config"], []).append(r)
for cfg in by_cfg:
by_cfg[cfg].sort(key=lambda r: r["loss"])
return by_cfg
def plot(records, out_path):
by_cfg = series_by_config(records)
unit = x_unit(records)
# baseline first (gray), FEC configs in a color cycle
order = sorted(by_cfg, key=lambda c: (c != "baseline", c))
cmap = plt.get_cmap("viridis")
fec_cfgs = [c for c in order if c != "baseline"]
colors = {"baseline": "#888888"}
for i, c in enumerate(fec_cfgs):
colors[c] = cmap(0.15 + 0.7 * (i / max(1, len(fec_cfgs) - 1)))
fig, axes = plt.subplots(2, 2, figsize=(15, 11))
ax_loss, ax_lat, ax_rec, ax_recn = axes.flat
def line(ax, key, cfgs):
for cfg in cfgs:
pts = by_cfg[cfg]
xs = [r["loss"] for r in pts]
ys = [r[key] for r in pts]
ax.plot(xs, ys, marker="o", label=cfg, color=colors[cfg],
lw=2 if cfg != "baseline" else 1.5,
ls="-" if cfg != "baseline" else "--")
line(ax_loss, "frame_loss_pct", order)
ax_loss.set_title("Frame loss vs loss level")
ax_loss.set_ylabel("frames lost (%)")
ax_loss.set_xlabel(unit)
ax_loss.legend(fontsize=8)
ax_loss.grid(alpha=0.3)
line(ax_lat, "latency_p99_ms", order)
ax_lat.set_title("Tail latency (p99) vs loss level")
ax_lat.set_ylabel("capture→receive p99 (ms)")
ax_lat.set_xlabel(unit)
ax_lat.legend(fontsize=8)
ax_lat.grid(alpha=0.3)
line(ax_rec, "recovery_rate_pct", fec_cfgs)
ax_rec.set_title("FEC recovery rate (recovered / FEC packets received)")
ax_rec.set_ylabel("recovery rate (%)")
ax_rec.set_xlabel(unit)
ax_rec.legend(fontsize=8)
ax_rec.grid(alpha=0.3)
line(ax_recn, "fec_recovered", fec_cfgs)
ax_recn.set_title("Packets recovered by FEC")
ax_recn.set_ylabel("recovered packets")
ax_recn.set_xlabel(unit)
ax_recn.legend(fontsize=8)
ax_recn.grid(alpha=0.3)
fig.suptitle("FlexFEC parameter sweep — baseline (no FEC) vs FEC configs", fontsize=13)
fig.tight_layout(rect=(0, 0, 1, 0.97))
fig.savefig(out_path, dpi=130)
def print_table(records):
cols = [
("config", "config", "{}"),
("loss_label", "loss", "{}"),
("frames_lost", "lost", "{:.0f}"),
("frame_loss_pct", "lost%", "{:.1f}"),
("latency_p95_ms", "p95ms", "{:.1f}"),
("latency_p99_ms", "p99ms", "{:.1f}"),
("fec_received", "fecRx", "{:.0f}"),
("fec_recovered", "recov", "{:.0f}"),
("recovery_rate_pct", "recov%", "{:.1f}"),
("nack_total", "nacks", "{:.0f}"),
]
widths = [max(len(h), 8) for _, h, _ in cols]
print("| " + " | ".join(h.ljust(w) for (_, h, _), w in zip(cols, widths)) + " |")
print("|" + "|".join("-" * (w + 2) for w in widths) + "|")
for r in sorted(records, key=lambda r: (r["loss"], r["config"] != "baseline", r["config"])):
cells = []
for (key, _, fmt), w in zip(cols, widths):
v = r.get(key)
cells.append((fmt.format(v) if v is not None else "-").ljust(w))
print("| " + " | ".join(cells) + " |")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--sweep", required=True, help="sweep output directory (contains manifest.tsv)")
args = parser.parse_args()
records = collect(args.sweep)
if not records:
print("no usable cells found", file=sys.stderr)
sys.exit(1)
csv_path = os.path.join(args.sweep, "sweep_summary.csv")
png_path = os.path.join(args.sweep, "sweep_report.png")
write_csv(records, csv_path)
plot(records, png_path)
print(f"report: {png_path}")
print(f"summary: {csv_path}")
print()
print_table(records)
if __name__ == "__main__":
main()
+373
View File
@@ -0,0 +1,373 @@
#!/usr/bin/env python3
"""Render time-series plots and a summary for FlexFEC test harness runs.
Inputs are run directories produced by run_fec_test.sh, each containing:
frames.csv - per received frame: recv_wall_us,frame_id,user_timestamp_us,width,height
prom.tsv - 1s prometheus samples: wall_us<TAB>metric{labels}<TAB>value
events.csv - shaper events: wall_us,event (burst_on / burst_off / ...)
meta.env - run parameters incl. T0_US/T1_US measurement window
Usage:
plot_fec.py --run <dir> [--run <dir2>] --out <dir>
With two runs the first is treated as the baseline and the second as the FEC
run, both are overlaid and a comparison table is printed.
"""
import argparse
import csv
import os
import re
import statistics
import sys
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt # noqa: E402
FLEXFEC_METRIC = "livekit_flexfec_packet_total"
METRIC_RE = re.compile(r"^(?P<name>[a-zA-Z0-9_]+)(?:\{(?P<labels>.*)\})?$")
class Run:
def __init__(self, path):
self.path = path
self.name = os.path.basename(os.path.normpath(path))
self.meta = self._read_meta()
self.t0 = int(self.meta.get("T0_US", 0))
self.t1 = int(self.meta.get("T1_US", 1 << 62))
self.frames = self._read_frames()
self.prom = self._read_prom()
self.bursts = self._read_bursts()
def _read_meta(self):
meta = {}
path = os.path.join(self.path, "meta.env")
if os.path.exists(path):
with open(path) as f:
for line in f:
if "=" in line:
k, v = line.strip().split("=", 1)
meta[k] = v
return meta
def _in_window(self, ts):
return self.t0 <= ts <= self.t1
def rel(self, ts):
return (ts - self.t0) / 1e6
def _read_frames(self):
frames = []
path = os.path.join(self.path, "frames.csv")
if not os.path.exists(path):
return frames
with open(path) as f:
for row in csv.DictReader(f):
try:
recv = int(row["recv_wall_us"])
except (KeyError, ValueError):
continue
if not self._in_window(recv):
continue
frames.append(
{
"recv": recv,
"frame_id": int(row["frame_id"]) if row.get("frame_id") else None,
"user_ts": int(row["user_timestamp_us"]) if row.get("user_timestamp_us") else None,
}
)
frames.sort(key=lambda fr: fr["recv"])
return frames
def _read_prom(self):
"""metric family -> label key -> [(wall_us, value)]"""
series = {}
path = os.path.join(self.path, "prom.tsv")
if not os.path.exists(path):
return series
with open(path) as f:
for line in f:
parts = line.rstrip("\n").split("\t")
if len(parts) != 3:
continue
ts, metric, value = parts
m = METRIC_RE.match(metric)
if not m:
continue
try:
ts, value = int(ts), float(value)
except ValueError:
continue
family = m.group("name")
labels = m.group("labels") or ""
series.setdefault(family, {}).setdefault(labels, []).append((ts, value))
return series
def _read_bursts(self):
"""[(start_rel_s, end_rel_s)] of shaper burst windows"""
bursts, start = [], None
path = os.path.join(self.path, "events.csv")
if not os.path.exists(path):
return bursts
with open(path) as f:
for line in f:
parts = line.strip().split(",", 1)
if len(parts) != 2:
continue
ts, event = int(parts[0]), parts[1]
if event == "burst_on":
start = self.rel(ts)
elif event == "burst_off" and start is not None:
bursts.append((start, self.rel(ts)))
start = None
if start is not None:
bursts.append((start, self.rel(self.t1 if self.t1 < (1 << 62) else start)))
return bursts
# ---------- derived series ----------
def latency_series(self):
xs, ys = [], []
for fr in self.frames:
if fr["user_ts"]:
xs.append(self.rel(fr["recv"]))
ys.append((fr["recv"] - fr["user_ts"]) / 1e3)
return xs, ys
def frame_gaps(self):
"""[(rel_s, missing_count)] where frame ids skipped between consecutive frames"""
gaps, prev = [], None
for fr in self.frames:
fid = fr["frame_id"]
if fid is None:
continue
if prev is not None and fid > prev + 1:
gaps.append((self.rel(fr["recv"]), fid - prev - 1))
prev = fid
return gaps
def fps_series(self):
buckets = {}
for fr in self.frames:
buckets[int(self.rel(fr["recv"]))] = buckets.get(int(self.rel(fr["recv"])), 0) + 1
xs = sorted(buckets)
return xs, [buckets[x] for x in xs]
def counter_rate(self, family, label_filter=None):
"""summed per-second rate across label sets of a counter family"""
fam = self.prom.get(family, {})
per_ts = {}
for labels, samples in fam.items():
if label_filter and label_filter not in labels:
continue
samples = [s for s in samples if self._in_window(s[0])]
for (t_a, v_a), (t_b, v_b) in zip(samples, samples[1:]):
dt = (t_b - t_a) / 1e6
if dt <= 0:
continue
key = int(self.rel(t_b))
per_ts[key] = per_ts.get(key, 0.0) + max(0.0, v_b - v_a) / dt
xs = sorted(per_ts)
return xs, [per_ts[x] for x in xs]
def counter_total(self, family, label_filter=None):
"""delta of a summed counter family over the measurement window"""
total = 0.0
for labels, samples in self.prom.get(family, {}).items():
if label_filter and label_filter not in labels:
continue
samples = [s for s in samples if self._in_window(s[0])]
if len(samples) >= 2:
total += samples[-1][1] - samples[0][1]
return total
def flexfec_state_rate(self, state):
return self.counter_rate(FLEXFEC_METRIC, f'state="{state}"')
def flexfec_state_total(self, state):
return self.counter_total(FLEXFEC_METRIC, f'state="{state}"')
# ---------- summary ----------
def summary(self):
_, lat = self.latency_series()
gaps = self.frame_gaps()
frame_ids = [fr["frame_id"] for fr in self.frames if fr["frame_id"] is not None]
expected = (max(frame_ids) - min(frame_ids) + 1) if frame_ids else 0
lost = sum(n for _, n in gaps)
def pct(p):
if not lat:
return float("nan")
data = sorted(lat)
return data[min(len(data) - 1, int(len(data) * p))]
return {
"frames_received": len(self.frames),
"frames_expected": expected,
"frames_lost": lost,
"gap_events": len(gaps),
"latency_mean_ms": statistics.fmean(lat) if lat else float("nan"),
"latency_p50_ms": pct(0.50),
"latency_p95_ms": pct(0.95),
"latency_p99_ms": pct(0.99),
"fec_received": self.flexfec_state_total("received"),
"fec_recovery_attempts": self.flexfec_state_total("recovery_attempt"),
"fec_recovered": self.flexfec_state_total("recovered"),
"fec_recovery_failed": self.flexfec_state_total("recovery_failed"),
"fec_unused": self.flexfec_state_total("unused"),
"fec_invalid": self.flexfec_state_total("invalid"),
"nack_total": self.counter_total("livekit_nack_total"),
"packet_loss_total": self.counter_total("livekit_packet_loss_total"),
}
def shade_bursts(ax, bursts):
for start, end in bursts:
ax.axvspan(start, end, color="red", alpha=0.08, lw=0)
def plot(runs, out_dir):
colors = {"baseline": "#888888", "fec": "#1f77b4"}
fig, axes = plt.subplots(4, 1, figsize=(14, 16), sharex=True)
ax_lat, ax_fec, ax_loss, ax_fps = axes
bursts = runs[-1].bursts
for run in runs:
color = colors.get(run.name, None)
xs, ys = run.latency_series()
ax_lat.plot(xs, ys, ".", markersize=2.5, label=f"{run.name} latency", color=color, alpha=0.7)
for x, n in run.frame_gaps():
ax_lat.axvline(x, color=color or "red", alpha=0.5, lw=min(0.5 + n * 0.3, 3))
shade_bursts(ax_lat, bursts)
ax_lat.set_ylabel("capture→receive latency (ms)")
ax_lat.set_title("Frame latency (vertical lines: frame-id gaps = lost frames, red shading: loss bursts)")
ax_lat.legend(loc="upper right", fontsize=8)
ax_lat.grid(alpha=0.3)
fec_runs = [r for r in runs if r.flexfec_state_total("received") > 0] or runs[-1:]
for run in fec_runs:
for state, style in [
("received", dict(color="#1f77b4", lw=1)),
("recovered", dict(color="#2ca02c", lw=1.8)),
("recovery_failed", dict(color="#d62728", lw=1.2)),
("unused", dict(color="#9467bd", lw=0.8, alpha=0.6)),
]:
xs, ys = run.flexfec_state_rate(state)
ax_fec.plot(xs, ys, label=f"{run.name} {state}/s", **style)
shade_bursts(ax_fec, bursts)
ax_fec.set_ylabel("FEC packets/s")
ax_fec.set_title("FlexFEC at the SFU")
ax_fec.legend(loc="upper right", fontsize=8)
ax_fec.grid(alpha=0.3)
for run in runs:
color = colors.get(run.name, None)
xs, ys = run.counter_rate("livekit_nack_total")
ax_loss.plot(xs, ys, label=f"{run.name} nack/s", color=color, lw=1.2)
xs, ys = run.counter_rate("livekit_packet_loss_total")
ax_loss.plot(xs, ys, label=f"{run.name} packet_loss/s", color=color, lw=1.2, ls="--", alpha=0.7)
shade_bursts(ax_loss, bursts)
ax_loss.set_ylabel("packets/s")
ax_loss.set_title("NACK and reported packet loss")
ax_loss.legend(loc="upper right", fontsize=8)
ax_loss.grid(alpha=0.3)
for run in runs:
color = colors.get(run.name, None)
xs, ys = run.fps_series()
ax_fps.plot(xs, ys, label=f"{run.name} fps", color=color, lw=1.2)
shade_bursts(ax_fps, bursts)
ax_fps.set_ylabel("frames/s")
ax_fps.set_xlabel("seconds since measurement start")
ax_fps.set_title("Delivered frame rate at subscriber")
ax_fps.legend(loc="lower right", fontsize=8)
ax_fps.grid(alpha=0.3)
meta = runs[-1].meta
if meta.get("DEBUG_DROP", "0") not in ("0", ""):
profile = "uniform {}% loss injected at SFU receive".format(meta["DEBUG_DROP"])
elif meta.get("SHAPING") == "0":
profile = "no loss"
else:
profile = "base loss {} / burst {} for {}s every {}s".format(
meta.get("BASE_LOSS", "?"),
meta.get("BURST_LOSS", "?"),
meta.get("BURST_LEN", "?"),
meta.get("BURST_EVERY", "?"),
)
fig.suptitle(
"FlexFEC publisher→SFU recovery — {}, codec {}".format(profile, meta.get("CODEC", "?")),
fontsize=12,
)
fig.tight_layout(rect=(0, 0, 1, 0.985))
out_path = os.path.join(out_dir, "fec_report.png")
fig.savefig(out_path, dpi=130)
return out_path
def print_summary(runs):
summaries = [(run.name, run.summary()) for run in runs]
keys = [
("frames_received", "{:.0f}"),
("frames_expected", "{:.0f}"),
("frames_lost", "{:.0f}"),
("gap_events", "{:.0f}"),
("latency_mean_ms", "{:.1f}"),
("latency_p50_ms", "{:.1f}"),
("latency_p95_ms", "{:.1f}"),
("latency_p99_ms", "{:.1f}"),
("fec_received", "{:.0f}"),
("fec_recovery_attempts", "{:.0f}"),
("fec_recovered", "{:.0f}"),
("fec_recovery_failed", "{:.0f}"),
("fec_unused", "{:.0f}"),
("fec_invalid", "{:.0f}"),
("nack_total", "{:.0f}"),
("packet_loss_total", "{:.0f}"),
]
name_w = 24
header = "metric".ljust(name_w) + "".join(name.rjust(16) for name, _ in summaries)
print(header)
print("-" * len(header))
for key, fmt in keys:
row = key.ljust(name_w)
for _, summary in summaries:
row += fmt.format(summary[key]).rjust(16)
print(row)
if len(summaries) == 2:
base, fec = summaries[0][1], summaries[1][1]
print()
if fec["frames_lost"] < base["frames_lost"]:
print(
"frames lost reduced {} -> {} with FlexFEC".format(
int(base["frames_lost"]), int(fec["frames_lost"])
)
)
if fec["fec_recovered"] > 0:
print("SFU recovered {} packets via FlexFEC".format(int(fec["fec_recovered"])))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run", action="append", required=True, help="run directory (repeatable)")
parser.add_argument("--out", required=True, help="output directory for the report")
args = parser.parse_args()
runs = [Run(path) for path in args.run]
for run in runs:
if not run.frames:
print(f"warning: no frames recorded for {run.path}", file=sys.stderr)
out_path = plot(runs, args.out)
print(f"report: {out_path}")
print()
print_summary(runs)
if __name__ == "__main__":
main()
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Polls the SFU's prometheus endpoint once per second and appends the FEC and
# packet counters relevant to the FlexFEC test harness as tab-separated rows:
# wall_us<TAB>metric{labels}<TAB>value
#
# Usage: ./prom_poll.sh <prometheus_port> <output_file>
set -u
PORT="${1:?prometheus port required}"
OUT="${2:?output file required}"
now_us() { python3 -c 'import time; print(int(time.time() * 1e6))'; }
while true; do
TS=$(now_us)
curl -s --max-time 2 "http://127.0.0.1:${PORT}/metrics" | \
awk -v ts="$TS" '/^livekit_(flexfec_packet|nack|packet|packet_loss|packet_out_of_order)_total/ {
value = $NF
metric = $0
sub(/ [^ ]*$/, "", metric)
print ts "\t" metric "\t" value
}' >> "$OUT"
sleep 1
done
+344
View File
@@ -0,0 +1,344 @@
#!/usr/bin/env bash
# End-to-end FlexFEC test harness: publisher (rust-sdks local_video) -> SFU,
# with traffic shaping on the publisher->SFU leg simulating a cellular uplink
# (continuous base loss + periodic loss bursts), collecting frame metadata at
# the subscriber and FEC/NACK/loss counters from the SFU, then plotting time
# series and printing a summary.
#
# Usage:
# ./run_fec_test.sh --mode {fec|baseline|ab} [options]
#
# Modes:
# fec single run with --flex-fec on the publisher
# baseline single run without FlexFEC (NACK/RTX recovery only)
# ab baseline run followed by a fec run with the same loss profile,
# producing a comparison report
#
# Options (defaults in brackets):
# --duration S measurement duration per run after media starts [120]
# --out DIR output directory [scripts/fec/out/<timestamp>]
# --base-loss F continuous loss fraction [0.02]
# --burst-loss F loss fraction inside bursts [0.1]
# --burst-every S seconds between burst starts [15]
# --burst-len S burst duration in seconds [1]
# --fec-rate N publisher FEC protection rate percent, 0 = adaptive [30]
# --fec-mask-type T random|bursty [bursty]
# --codec C video codec [h264]
# --width/--height/--fps test pattern format [1280/720/30]
# --no-shaping skip traffic shaping (sanity run)
# --debug-drop PCT drop PCT% of received packets inside the SFU instead of
# OS traffic shaping (uniform loss, no sudo required;
# implies --no-shaping)
# --server-bin PATH use this prebuilt livekit-server instead of building
# --skip-build skip go/cargo builds (requires --server-bin and
# pre-built example binaries); used by sweep_fec.sh
#
# Requires: go, cargo, python3 with matplotlib, curl, and sudo (for shaping).
# The rust-sdks checkout is located via RUST_SDKS_DIR [../rust-sdks].
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RUST_SDKS_DIR="${RUST_SDKS_DIR:-$(cd "$REPO_ROOT/.." && pwd)/rust-sdks}"
MODE=""
DURATION=120
OUT_DIR=""
BASE_LOSS=0.02
BURST_LOSS=0.1
BURST_EVERY=15
BURST_LEN=1
FEC_RATE=30
FEC_MASK_TYPE="bursty"
CODEC="h264"
WIDTH=1280
HEIGHT=720
FPS=30
SHAPING=1
DEBUG_DROP=0
SERVER_BIN=""
SKIP_BUILD=0
SIGNAL_PORT=7880
MEDIA_PORT=7882
PROM_PORT=6789
API_KEY="devkey"
API_SECRET="fec-test-secret-fec-test-secret-00"
ROOM_NAME="fec-test"
log() { echo "[fec-test] $*"; }
die() { echo "[fec-test] ERROR: $*" >&2; exit 1; }
now_us() { python3 -c 'import time; print(int(time.time() * 1e6))'; }
while [ $# -gt 0 ]; do
case "$1" in
--mode) MODE="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
--base-loss) BASE_LOSS="$2"; shift 2 ;;
--burst-loss) BURST_LOSS="$2"; shift 2 ;;
--burst-every) BURST_EVERY="$2"; shift 2 ;;
--burst-len) BURST_LEN="$2"; shift 2 ;;
--fec-rate) FEC_RATE="$2"; shift 2 ;;
--fec-mask-type) FEC_MASK_TYPE="$2"; shift 2 ;;
--codec) CODEC="$2"; shift 2 ;;
--width) WIDTH="$2"; shift 2 ;;
--height) HEIGHT="$2"; shift 2 ;;
--fps) FPS="$2"; shift 2 ;;
--no-shaping) SHAPING=0; shift ;;
--debug-drop) DEBUG_DROP="$2"; SHAPING=0; shift 2 ;;
--server-bin) SERVER_BIN="$2"; shift 2 ;;
--skip-build) SKIP_BUILD=1; shift ;;
-h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
case "$MODE" in
fec|baseline|ab) ;;
*) die "--mode must be fec, baseline or ab" ;;
esac
case "$(uname -s)" in
Darwin) SHAPER="$SCRIPT_DIR/shape_macos.sh"; LOOPBACK_IF="lo0" ;;
Linux) SHAPER="$SCRIPT_DIR/shape_linux.sh"; LOOPBACK_IF="lo" ;;
*) die "unsupported platform $(uname -s)" ;;
esac
# ---------- preflight ----------
command -v go >/dev/null || die "go not found"
command -v cargo >/dev/null || die "cargo not found"
command -v curl >/dev/null || die "curl not found"
python3 -c 'import matplotlib' 2>/dev/null || die "python3 with matplotlib required (pip3 install matplotlib)"
[ -d "$RUST_SDKS_DIR/examples/local_video" ] || die "rust-sdks not found at $RUST_SDKS_DIR (set RUST_SDKS_DIR)"
if [ "$SHAPING" = "1" ]; then
log "shaping requires sudo, validating credentials..."
sudo -v || die "sudo required for traffic shaping (or pass --no-shaping)"
# keep the sudo timestamp alive for long runs
( while true; do sudo -n true 2>/dev/null; sleep 60; done ) &
SUDO_KEEPALIVE_PID=$!
fi
if [ -z "$OUT_DIR" ]; then
OUT_DIR="$SCRIPT_DIR/out/$(date +%Y%m%d_%H%M%S)"
fi
mkdir -p "$OUT_DIR"
log "output directory: $OUT_DIR"
# ---------- builds ----------
if [ "$SKIP_BUILD" = "1" ]; then
[ -n "$SERVER_BIN" ] || die "--skip-build requires --server-bin"
[ -x "$SERVER_BIN" ] || die "server binary not found at $SERVER_BIN"
log "skipping builds, using prebuilt $SERVER_BIN"
else
SERVER_BIN="${SERVER_BIN:-$OUT_DIR/livekit-server}"
log "building livekit-server..."
(cd "$REPO_ROOT" && go build -o "$SERVER_BIN" ./cmd/server) || die "server build failed"
log "building local_video examples (release)..."
(cd "$RUST_SDKS_DIR" && cargo build --release -p local_video -F desktop --bin publisher --bin subscriber) \
|| die "example build failed"
fi
PUBLISHER_BIN="$RUST_SDKS_DIR/target/release/publisher"
SUBSCRIBER_BIN="$RUST_SDKS_DIR/target/release/subscriber"
[ -x "$PUBLISHER_BIN" ] || die "publisher binary not found, run once without --skip-build first"
[ -x "$SUBSCRIBER_BIN" ] || die "subscriber binary not found, run once without --skip-build first"
# ---------- process management ----------
SERVER_PID=""
SUBSCRIBER_PID=""
PUBLISHER_PID=""
PROM_POLL_PID=""
SHAPER_PID=""
stop_pid() {
local pid="$1" sig="${2:-TERM}"
if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then
kill -"$sig" "$pid" 2>/dev/null
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$pid" 2>/dev/null || return 0
sleep 0.5
done
kill -KILL "$pid" 2>/dev/null
fi
}
stop_shaper() {
if [ -n "$SHAPER_PID" ] && kill -0 "$SHAPER_PID" 2>/dev/null; then
sudo kill -TERM "$SHAPER_PID" 2>/dev/null
sleep 2
fi
SHAPER_PID=""
sudo "$SHAPER" stop >/dev/null 2>&1 || true
}
cleanup() {
trap - EXIT INT TERM
log "cleaning up processes"
[ "$SHAPING" = "1" ] && stop_shaper
stop_pid "$PROM_POLL_PID"
stop_pid "$PUBLISHER_PID" INT
stop_pid "$SUBSCRIBER_PID" INT
stop_pid "$SERVER_PID"
[ -n "${SUDO_KEEPALIVE_PID:-}" ] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null
exit "${1:-1}"
}
trap 'cleanup 1' INT TERM
trap 'cleanup $?' EXIT
# ---------- single run ----------
run_one() {
local mode="$1"
local run_dir="$OUT_DIR/$mode"
mkdir -p "$run_dir"
log "=== $mode run: ${DURATION}s ==="
# media is pinned to the loopback interface: it keeps every packet on the
# shaped path and, on macOS, avoids the application firewall silently
# dropping inbound UDP for unsigned freshly-built binaries
cat > "$run_dir/server.yaml" <<EOF
port: $SIGNAL_PORT
bind_addresses:
- 127.0.0.1
rtc:
udp_port: $MEDIA_PORT
use_external_ip: false
enable_loopback_candidate: true
interfaces:
includes:
- $LOOPBACK_IF
enable_flexfec: true
prometheus:
port: $PROM_PORT
keys:
$API_KEY: $API_SECRET
room:
auto_create: true
logging:
level: info
EOF
log "starting livekit-server"
LIVEKIT_DEBUG_RX_DROP_PCT="$DEBUG_DROP" \
"$SERVER_BIN" --config "$run_dir/server.yaml" > "$run_dir/server.log" 2>&1 &
SERVER_PID=$!
for i in $(seq 1 40); do
curl -s -o /dev/null --max-time 1 "http://127.0.0.1:$SIGNAL_PORT" && break
kill -0 "$SERVER_PID" 2>/dev/null || die "server exited early, see $run_dir/server.log"
[ "$i" = "40" ] && die "server did not become reachable"
sleep 0.5
done
log "server is up (pid $SERVER_PID)"
local conn_args="--url ws://127.0.0.1:$SIGNAL_PORT --api-key $API_KEY --api-secret $API_SECRET --room-name $ROOM_NAME"
log "starting subscriber (headless)"
RUST_LOG=info "$SUBSCRIBER_BIN" $conn_args \
--identity fec-sub --headless --log-frames "$run_dir/frames.csv" \
> "$run_dir/subscriber.log" 2>&1 &
SUBSCRIBER_PID=$!
sleep 2
local fec_args=""
if [ "$mode" = "fec" ]; then
fec_args="--flex-fec --fec-mask-type $FEC_MASK_TYPE"
if [ "$FEC_RATE" != "0" ]; then
fec_args="$fec_args --fec-protection-rate $FEC_RATE"
fi
fi
log "starting publisher (test pattern ${WIDTH}x${HEIGHT}@${FPS} $CODEC${fec_args:+,$fec_args})"
RUST_LOG=info "$PUBLISHER_BIN" $conn_args \
--identity fec-pub --test-pattern \
--width "$WIDTH" --height "$HEIGHT" --fps "$FPS" --codec "$CODEC" \
--attach-timestamp --attach-frame-id $fec_args \
> "$run_dir/publisher.log" 2>&1 &
PUBLISHER_PID=$!
log "waiting for media to flow..."
for i in $(seq 1 120); do
if [ -f "$run_dir/frames.csv" ] && [ "$(wc -l < "$run_dir/frames.csv")" -gt 30 ]; then
break
fi
kill -0 "$PUBLISHER_PID" 2>/dev/null || die "publisher exited early, see $run_dir/publisher.log"
kill -0 "$SUBSCRIBER_PID" 2>/dev/null || die "subscriber exited early, see $run_dir/subscriber.log"
[ "$i" = "120" ] && die "no frames received after 60s"
sleep 0.5
done
local t0
t0=$(now_us)
log "media flowing, starting measurement (t0=$t0)"
cat > "$run_dir/meta.env" <<EOF
MODE=$mode
T0_US=$t0
DURATION_S=$DURATION
BASE_LOSS=$BASE_LOSS
BURST_LOSS=$BURST_LOSS
BURST_EVERY=$BURST_EVERY
BURST_LEN=$BURST_LEN
SHAPING=$SHAPING
DEBUG_DROP=$DEBUG_DROP
FEC_RATE=$FEC_RATE
FEC_MASK_TYPE=$FEC_MASK_TYPE
CODEC=$CODEC
EOF
"$SCRIPT_DIR/prom_poll.sh" "$PROM_PORT" "$run_dir/prom.tsv" &
PROM_POLL_PID=$!
if [ "$SHAPING" = "1" ]; then
sudo "$SHAPER" start --port "$MEDIA_PORT" \
--base-loss "$BASE_LOSS" --burst-loss "$BURST_LOSS" \
--burst-every "$BURST_EVERY" --burst-len "$BURST_LEN" \
--events "$run_dir/events.csv" \
> "$run_dir/shaper.log" 2>&1 &
SHAPER_PID=$!
sleep 1
kill -0 "$SHAPER_PID" 2>/dev/null || die "shaper failed to start, see $run_dir/shaper.log"
fi
sleep "$DURATION"
log "measurement done, stopping"
[ "$SHAPING" = "1" ] && stop_shaper
echo "T1_US=$(now_us)" >> "$run_dir/meta.env"
stop_pid "$PROM_POLL_PID"; PROM_POLL_PID=""
stop_pid "$PUBLISHER_PID" INT; PUBLISHER_PID=""
stop_pid "$SUBSCRIBER_PID" INT; SUBSCRIBER_PID=""
# the final flexfec stats log line is emitted when the publisher's buffers close
sleep 2
grep -h "flexfec" "$run_dir/server.log" | tail -5 || true
stop_pid "$SERVER_PID"; SERVER_PID=""
sleep 1
}
# ---------- runs + report ----------
case "$MODE" in
fec) run_one fec ;;
baseline) run_one baseline ;;
ab)
run_one baseline
sleep 3
run_one fec
;;
esac
log "generating report"
if [ "$MODE" = "ab" ]; then
python3 "$SCRIPT_DIR/plot_fec.py" --run "$OUT_DIR/baseline" --run "$OUT_DIR/fec" --out "$OUT_DIR" \
| tee "$OUT_DIR/summary.txt"
else
python3 "$SCRIPT_DIR/plot_fec.py" --run "$OUT_DIR/$MODE" --out "$OUT_DIR" \
| tee "$OUT_DIR/summary.txt"
fi
log "done. results in $OUT_DIR"
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Traffic shaper for the FlexFEC test harness (Linux, tc/netem).
#
# Applies packet loss to UDP traffic destined to the SFU's pinned media port on
# loopback, simulating a robot uplink over cellular: continuous low base loss
# (Gilbert-Elliott model for realistic loss correlation) plus periodic
# high-loss bursts. Burst on/off transitions are appended to an events file
# (wall-clock microseconds) so plots can shade the burst windows.
#
# Usage:
# sudo ./shape_linux.sh start --port 7882 --base-loss 0.02 \
# --burst-loss 0.25 --burst-every 15 --burst-len 3 --events events.csv
# sudo ./shape_linux.sh stop
#
# `start` runs in the foreground until terminated, cleaning up on exit.
# `stop` force-cleans shaping state from a previous run.
set -u
DEV="lo"
PORT=7882
BASE_LOSS=0.02
BURST_LOSS=0.25
BURST_EVERY=15
BURST_LEN=3
EVENTS_FILE=""
log() { echo "[shape_linux] $*" >&2; }
now_us() { python3 -c 'import time; print(int(time.time() * 1e6))'; }
record_event() {
if [ -n "$EVENTS_FILE" ]; then
echo "$(now_us),$1" >> "$EVENTS_FILE"
fi
}
pct() { python3 -c "print($1 * 100)"; }
apply_base_loss() {
# Gilbert-Elliott: p = chance of entering the bad state, r = chance of
# leaving it. p derived from the target average loss with r fixed at 30%
# gives short correlated loss runs typical for radio links.
local p
p=$(pct "$BASE_LOSS")
tc qdisc change dev $DEV parent 1:4 handle 40: netem loss gemodel "${p}%" 30%
}
cleanup() {
trap - EXIT INT TERM
log "cleaning up"
tc qdisc del dev $DEV root 2>/dev/null
record_event "shaper_stopped"
log "done"
}
start() {
trap cleanup EXIT INT TERM
# 4-band prio qdisc: default TOS mapping never selects band 4, so only the
# filtered SFU-bound UDP flow passes through the netem child
tc qdisc add dev $DEV root handle 1: prio bands 4 priomap 1 2 2 2 1 2 0 0 1 1 1 1 1 1 1 1 || {
log "failed to add root qdisc (already shaped? try '$0 stop')"
exit 1
}
tc qdisc add dev $DEV parent 1:4 handle 40: netem loss gemodel "$(pct "$BASE_LOSS")%" 30%
tc filter add dev $DEV parent 1: protocol ip prio 1 u32 \
match ip protocol 17 0xff \
match ip dport "$PORT" 0xffff \
flowid 1:4
log "shaping active: udp dport $PORT, base loss $BASE_LOSS (gemodel), burst $BURST_LOSS for ${BURST_LEN}s every ${BURST_EVERY}s"
record_event "shaper_started base=$BASE_LOSS burst=$BURST_LOSS"
# periodic burst loop
while true; do
sleep "$BURST_EVERY"
tc qdisc change dev $DEV parent 1:4 handle 40: netem loss "$(pct "$BURST_LOSS")%"
record_event "burst_on"
log "burst on ($BURST_LOSS)"
sleep "$BURST_LEN"
apply_base_loss
record_event "burst_off"
log "burst off ($BASE_LOSS)"
done
}
CMD="${1:-}"
shift || true
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--base-loss) BASE_LOSS="$2"; shift 2 ;;
--burst-loss) BURST_LOSS="$2"; shift 2 ;;
--burst-every) BURST_EVERY="$2"; shift 2 ;;
--burst-len) BURST_LEN="$2"; shift 2 ;;
--events) EVENTS_FILE="$2"; shift 2 ;;
*) log "unknown argument: $1"; exit 1 ;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
log "ERROR: must run as root (sudo)"
exit 1
fi
case "$CMD" in
start) start ;;
stop) cleanup ;;
*) echo "usage: $0 {start|stop} [--port N] [--base-loss F] [--burst-loss F] [--burst-every S] [--burst-len S] [--events FILE]" >&2; exit 1 ;;
esac
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Traffic shaper for the FlexFEC test harness (macOS, dummynet via dnctl/pfctl).
#
# Applies packet loss to UDP traffic destined to the SFU's pinned media port on
# loopback, simulating a robot uplink over cellular: continuous low base loss
# plus periodic high-loss bursts. Burst on/off transitions are appended to an
# events file (wall-clock microseconds) so plots can shade the burst windows.
#
# Usage:
# sudo ./shape_macos.sh start --port 7882 --base-loss 0.02 \
# --burst-loss 0.25 --burst-every 15 --burst-len 3 --events events.csv
# sudo ./shape_macos.sh stop
#
# `start` runs in the foreground until terminated, cleaning up on exit.
# `stop` force-cleans shaping state from a previous run.
set -u
ANCHOR="livekit_fec"
PIPE=1
STATE_DIR="${TMPDIR:-/tmp}/livekit_fec_shaper"
PORT=7882
BASE_LOSS=0.02
BURST_LOSS=0.25
BURST_EVERY=15
BURST_LEN=3
EVENTS_FILE=""
log() { echo "[shape_macos] $*" >&2; }
now_us() { python3 -c 'import time; print(int(time.time() * 1e6))'; }
record_event() {
if [ -n "$EVENTS_FILE" ]; then
echo "$(now_us),$1" >> "$EVENTS_FILE"
fi
}
cleanup() {
trap - EXIT INT TERM
log "cleaning up"
pfctl -a "$ANCHOR" -F all 2>/dev/null
# restore the system ruleset, dropping our anchor attachment
pfctl -f /etc/pf.conf 2>/dev/null
dnctl -q flush 2>/dev/null
if [ -f "$STATE_DIR/pf_token" ]; then
pfctl -X "$(cat "$STATE_DIR/pf_token")" 2>/dev/null
rm -f "$STATE_DIR/pf_token"
fi
record_event "shaper_stopped"
log "done"
}
start() {
mkdir -p "$STATE_DIR"
if ! command -v dnctl >/dev/null; then
log "ERROR: dnctl not found. dummynet is unavailable on this system,"
log "consider Network Link Conditioner or running the test on Linux."
exit 1
fi
trap cleanup EXIT INT TERM
# configure the dummynet pipe with the base loss
dnctl pipe $PIPE config plr "$BASE_LOSS" || { log "dnctl failed"; exit 1; }
# enable pf, keeping the reference token for clean disable
local token
token=$(pfctl -E 2>&1 | awk '/Token/ {print $NF}')
if [ -n "$token" ]; then
echo "$token" > "$STATE_DIR/pf_token"
fi
# attach our dummynet anchor on top of the system ruleset
pfctl -q -f - <<EOF
include "/etc/pf.conf"
dummynet-anchor "$ANCHOR"
anchor "$ANCHOR"
EOF
# shape UDP packets addressed to the SFU media port (publisher -> SFU leg)
echo "dummynet in quick proto udp from any to any port $PORT pipe $PIPE" | \
pfctl -q -a "$ANCHOR" -f -
log "shaping active: udp dport $PORT, base loss $BASE_LOSS, burst $BURST_LOSS for ${BURST_LEN}s every ${BURST_EVERY}s"
record_event "shaper_started base=$BASE_LOSS burst=$BURST_LOSS"
# periodic burst loop
while true; do
sleep "$BURST_EVERY"
dnctl pipe $PIPE config plr "$BURST_LOSS"
record_event "burst_on"
log "burst on ($BURST_LOSS)"
sleep "$BURST_LEN"
dnctl pipe $PIPE config plr "$BASE_LOSS"
record_event "burst_off"
log "burst off ($BASE_LOSS)"
done
}
CMD="${1:-}"
shift || true
while [ $# -gt 0 ]; do
case "$1" in
--port) PORT="$2"; shift 2 ;;
--base-loss) BASE_LOSS="$2"; shift 2 ;;
--burst-loss) BURST_LOSS="$2"; shift 2 ;;
--burst-every) BURST_EVERY="$2"; shift 2 ;;
--burst-len) BURST_LEN="$2"; shift 2 ;;
--events) EVENTS_FILE="$2"; shift 2 ;;
*) log "unknown argument: $1"; exit 1 ;;
esac
done
if [ "$(id -u)" -ne 0 ]; then
log "ERROR: must run as root (sudo)"
exit 1
fi
case "$CMD" in
start) start ;;
stop) cleanup ;;
*) echo "usage: $0 {start|stop} [--port N] [--base-loss F] [--burst-loss F] [--burst-every S] [--burst-len S] [--events FILE]" >&2; exit 1 ;;
esac
+152
View File
@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Parameter sweep over the FlexFEC harness: runs run_fec_test.sh across a matrix
# of loss levels and FEC configurations, then aggregates all cells into a single
# comparison report (sweep_report.png + sweep_summary.csv + a markdown table).
#
# At each loss level it runs one baseline (no FEC) plus one FEC run per
# (rate, mask) combination, so every FEC point has a same-loss baseline to
# compare against. The server and example binaries are built once and reused.
#
# Usage:
# ./sweep_fec.sh [options]
#
# Options (defaults in brackets):
# --loss-mode {debug|shaped} loss mechanism [debug]
# debug = uniform packet drop inside the SFU, no sudo, --loss-list is %
# shaped = OS traffic shaping bursts (needs sudo), --loss-list is the
# burst loss fraction, base loss/cadence fixed by --base-loss etc.
# --loss-list "L1 L2 .." loss levels to sweep [debug: "2 5 10 15"]
# --fec-rate-list "R1 .." publisher FEC protection rates (percent) ["20 30 50"]
# --mask-list "M1 .." FEC mask types: random and/or bursty ["bursty"]
# --duration S measurement seconds per cell [60]
# --codec C video codec [h264]
# --base-loss F shaped mode: continuous base loss [0.01]
# --burst-every S shaped mode: seconds between bursts [15]
# --burst-len S shaped mode: burst duration [3]
# --out DIR output directory [scripts/fec/out/sweep_<timestamp>]
#
# Example matrices:
# # uniform-loss sweep (no sudo), 4 loss levels x 2 rates x 1 mask = 12 cells
# ./sweep_fec.sh --loss-list "2 5 10 15" --fec-rate-list "20 50"
#
# # shaped cellular-burst sweep, vary burst intensity and compare masks
# sudo -v && ./sweep_fec.sh --loss-mode shaped --loss-list "0.15 0.30 0.50" \
# --fec-rate-list "30" --mask-list "random bursty"
#
# Runtime ~= cells * (~20s setup + duration). The example above is ~12 * 80s.
set -u
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
RUST_SDKS_DIR="${RUST_SDKS_DIR:-$(cd "$REPO_ROOT/.." && pwd)/rust-sdks}"
RUNNER="$SCRIPT_DIR/run_fec_test.sh"
LOSS_MODE="debug"
LOSS_LIST=""
FEC_RATE_LIST="20 30 50"
MASK_LIST="bursty"
DURATION=60
CODEC="h264"
BASE_LOSS=0.01
BURST_EVERY=15
BURST_LEN=3
OUT_DIR=""
log() { echo "[sweep] $*"; }
die() { echo "[sweep] ERROR: $*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--loss-mode) LOSS_MODE="$2"; shift 2 ;;
--loss-list) LOSS_LIST="$2"; shift 2 ;;
--fec-rate-list) FEC_RATE_LIST="$2"; shift 2 ;;
--mask-list) MASK_LIST="$2"; shift 2 ;;
--duration) DURATION="$2"; shift 2 ;;
--codec) CODEC="$2"; shift 2 ;;
--base-loss) BASE_LOSS="$2"; shift 2 ;;
--burst-every) BURST_EVERY="$2"; shift 2 ;;
--burst-len) BURST_LEN="$2"; shift 2 ;;
--out) OUT_DIR="$2"; shift 2 ;;
-h|--help) sed -n '2,38p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "unknown argument: $1" ;;
esac
done
case "$LOSS_MODE" in
debug) : "${LOSS_LIST:=2 5 10 15}" ;;
shaped) : "${LOSS_LIST:=0.15 0.30 0.50}" ;;
*) die "--loss-mode must be debug or shaped" ;;
esac
command -v go >/dev/null || die "go not found"
command -v cargo >/dev/null || die "cargo not found"
python3 -c 'import matplotlib' 2>/dev/null || die "python3 with matplotlib required"
[ -d "$RUST_SDKS_DIR/examples/local_video" ] || die "rust-sdks not found at $RUST_SDKS_DIR"
if [ "$LOSS_MODE" = "shaped" ]; then
sudo -v || die "shaped mode needs sudo (or use --loss-mode debug)"
( while true; do sudo -n true 2>/dev/null; sleep 60; done ) &
SUDO_KEEPALIVE_PID=$!
trap '[ -n "${SUDO_KEEPALIVE_PID:-}" ] && kill "$SUDO_KEEPALIVE_PID" 2>/dev/null' EXIT
fi
if [ -z "$OUT_DIR" ]; then
OUT_DIR="$SCRIPT_DIR/out/sweep_$(date +%Y%m%d_%H%M%S)"
fi
mkdir -p "$OUT_DIR"
log "output directory: $OUT_DIR"
# build once, reuse across all cells
SERVER_BIN="$OUT_DIR/livekit-server"
log "building livekit-server (once)..."
(cd "$REPO_ROOT" && go build -o "$SERVER_BIN" ./cmd/server) || die "server build failed"
log "building local_video examples (once)..."
(cd "$RUST_SDKS_DIR" && cargo build --release -p local_video -F desktop --bin publisher --bin subscriber) \
|| die "example build failed"
MANIFEST="$OUT_DIR/manifest.tsv"
printf 'leaf\tmode\tloss\tfec_rate\tfec_mask\n' > "$MANIFEST"
run_cell() {
# run_cell <mode> <cell_name> <extra args...>
local mode="$1" cell="$2"; shift 2
log "cell: $cell"
"$RUNNER" --mode "$mode" --skip-build --server-bin "$SERVER_BIN" \
--duration "$DURATION" --codec "$CODEC" --out "$OUT_DIR/$cell" "$@" \
> "$OUT_DIR/$cell.log" 2>&1 || { log "WARNING: cell $cell failed, see $OUT_DIR/$cell.log"; return 1; }
}
loss_args() {
local loss="$1"
if [ "$LOSS_MODE" = "debug" ]; then
echo "--debug-drop $loss"
else
echo "--base-loss $BASE_LOSS --burst-loss $loss --burst-every $BURST_EVERY --burst-len $BURST_LEN"
fi
}
CELL_COUNT=0
for loss in $LOSS_LIST; do
la=$(loss_args "$loss")
base_cell="cell_loss${loss}_baseline"
if run_cell baseline "$base_cell" $la; then
printf '%s\t%s\t%s\t%s\t%s\n' "$base_cell/baseline" baseline "$loss" "-" "-" >> "$MANIFEST"
fi
CELL_COUNT=$((CELL_COUNT + 1))
for rate in $FEC_RATE_LIST; do
for mask in $MASK_LIST; do
fec_cell="cell_loss${loss}_rate${rate}_${mask}"
if run_cell fec "$fec_cell" --fec-rate "$rate" --fec-mask-type "$mask" $la; then
printf '%s\t%s\t%s\t%s\t%s\n' "$fec_cell/fec" fec "$loss" "$rate" "$mask" >> "$MANIFEST"
fi
CELL_COUNT=$((CELL_COUNT + 1))
done
done
done
log "ran $CELL_COUNT cells, aggregating"
python3 "$SCRIPT_DIR/aggregate_fec.py" --sweep "$OUT_DIR" | tee "$OUT_DIR/sweep_summary.txt"
log "done. report in $OUT_DIR/sweep_report.png"