Files
livekit/pkg/sfu/flexfec/encoder.go
David Chen 7b02897ce2 Align downstream FEC grouping with frame boundaries
Generate repairs at each frame marker, splitting large frames into blocks of at most 48 packets. Match upstream Q8 repair-count rounding and minimum-one protection, and lazily reuse packet buffers.

Reserve measured RTP repair overhead during layer allocation so small frames have enough headroom. Cover sparse frames, large-frame tails, buffer reuse, real Pion wire recovery, and live preset changes.
2026-09-12 14:24:34 -07:00

249 lines
8.2 KiB
Go

// 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 flexfec
import (
"math/rand/v2"
"sync"
"time"
pionflexfec "github.com/pion/interceptor/pkg/flexfec"
"github.com/pion/rtp"
"go.uber.org/atomic"
"go.uber.org/zap/zapcore"
"github.com/livekit/protocol/utils/mono"
)
const (
// DefaultProtectionPercent is the repair-to-media packet ratio when unspecified.
DefaultProtectionPercent uint32 = 0
MaxProtectionPercent uint32 = 100
// MaxMediaPacketsPerGroup bounds memory and work for large frames. Like
// libwebrtc, use at most 48 media packets in a protection block.
MaxMediaPacketsPerGroup = 48
maxEncoderGroupAge = 200 * time.Millisecond
overheadUpdateInterval = time.Second
// Reserve all three FEC masks and up to 16 bytes of outer RTP extensions
// (AST/TWCC). A 48-packet group can require the third mask.
maxEncoderMediaPacketSize = maxMediaPacketSize - pionflexfec.BaseFec03HeaderSize - 12 - 16
)
// EncoderState preserves the repair sequence when a sender SSRC is reused.
// Partial media groups are never carried over to a new binding.
type EncoderState struct {
SSRC uint32
NextSequenceNumber uint16
}
func (s EncoderState) MarshalLogObject(e zapcore.ObjectEncoder) error {
e.AddUint32("SSRC", s.SSRC)
e.AddUint16("NextSequenceNumber", s.NextSequenceNumber)
return nil
}
type encoderMediaGroup struct {
packets [MaxMediaPacketsPerGroup]rtp.Packet
storage [MaxMediaPacketsPerGroup]*[maxEncoderMediaPacketSize]byte
}
// Encoder batches final, outgoing video packets for Pion's FlexFEC-03 encoder.
// It owns reusable wire storage: the caller may recycle headers, extensions and
// payloads as soon as Encode returns. It does not delay media or start workers.
// Methods are safe to call from concurrent pass-through pacer writes.
type Encoder struct {
mu sync.Mutex
encoder *pionflexfec.FlexEncoder03
media *encoderMediaGroup
count int
startedAt int64
sequenceNumber uint16
ssrc uint32
closed bool
onSent func(packets int, bytes int)
protectionPercent uint32
protectionFactor uint32
overheadPercent atomic.Uint32
overheadUpdatedAt int64
sentMediaBytes uint64
sentRepairBytes uint64
}
func NewEncoder(payloadType uint8, ssrc uint32, onSent func(packets int, bytes int)) *Encoder {
return &Encoder{
encoder: pionflexfec.NewFlexEncoder03(payloadType, ssrc),
sequenceNumber: uint16(rand.Uint32()),
ssrc: ssrc,
onSent: onSent,
protectionPercent: DefaultProtectionPercent,
}
}
func (e *Encoder) GetState() EncoderState {
e.mu.Lock()
defer e.mu.Unlock()
return EncoderState{SSRC: e.ssrc, NextSequenceNumber: e.sequenceNumber}
}
// SeedState must be called before forwarding starts. Only reuse sequencing for
// the same repair SSRC; a new SSRC keeps its randomized initial sequence number.
func (e *Encoder) SeedState(state EncoderState) {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed || state.SSRC == 0 || state.SSRC != e.ssrc {
return
}
e.sequenceNumber = state.NextSequenceNumber
e.count = 0
}
// Encode returns independently owned repairs at the end of a video frame (the
// RTP marker), splitting large frames at MaxMediaPacketsPerGroup. Timestamp
// changes discard an incomplete previous frame. Gaps, codec switches and stale
// groups also reset the block; Pion requires consecutive sequence numbers.
// Media is never delayed. No timer or worker is needed for sparse frames.
// Call after the media write, including any interceptor header modifications.
func (e *Encoder) Encode(header *rtp.Header, payload []byte) []rtp.Packet {
e.mu.Lock()
defer e.mu.Unlock()
if e.closed || e.protectionPercent == 0 {
return nil
}
now := mono.UnixNano()
size := header.MarshalSize() + len(payload)
e.sentMediaBytes += uint64(size)
if header.Padding || len(payload) == 0 || size > maxEncoderMediaPacketSize {
e.count = 0
return nil
}
if e.media == nil {
// Like the upstream recovery buffer, allocate packet storage only once
// it is needed. Negotiated tracks with the default "none" stay small.
e.media = &encoderMediaGroup{}
}
if e.count != 0 {
previous := &e.media.packets[e.count-1]
if header.SequenceNumber != previous.SequenceNumber+1 ||
header.SSRC != previous.SSRC || header.PayloadType != previous.PayloadType ||
header.Timestamp != previous.Timestamp ||
now-e.startedAt > int64(maxEncoderGroupAge) {
e.count = 0
}
}
if e.count == 0 {
e.startedAt = now
}
if e.media.storage[e.count] == nil {
// Retain only the packet buffers this stream has actually needed.
e.media.storage[e.count] = new([maxEncoderMediaPacketSize]byte)
}
raw := e.media.storage[e.count][:size]
n, err := header.MarshalTo(raw)
if err != nil {
e.count = 0
return nil
}
copy(raw[n:], payload)
if err = e.media.packets[e.count].Unmarshal(raw); err != nil {
e.count = 0
return nil
}
e.count++
if !header.Marker && e.count < MaxMediaPacketsPerGroup {
return nil
}
count := e.count
e.count = 0
// Match libwebrtc's ForwardErrorCorrection::NumFecPackets: round the Q8
// protection factor, with at least one repair for every protected block.
// In particular, a one-packet frame is protected immediately at every preset.
numRepair := max(uint32(1), (uint32(count)*e.protectionFactor+128)>>8)
repair := e.encoder.EncodeFec(e.media.packets[:count], numRepair)
for i := range repair {
// Pion's encoder uses a constant timestamp and a fixed initial sequence number.
// Use this stream's media clock and a randomized, continuous repair sequence.
repair[i].Timestamp = header.Timestamp
repair[i].SequenceNumber = e.sequenceNumber
e.sequenceNumber++
}
return repair
}
// SetProtectionPercent updates the packet ratio without changing the repair
// SSRC or sequence number. A new value discards partial groups and measurements.
// Values above 100 are capped; zero disables encoding before any packet copies.
func (e *Encoder) SetProtectionPercent(percent uint32) {
percent = min(percent, MaxProtectionPercent)
e.mu.Lock()
if e.protectionPercent != percent {
e.protectionPercent = percent
// Publish-track presets use the same conversion to libwebrtc's Q8 rate.
e.protectionFactor = percent * 255 / 100
e.count = 0
e.overheadPercent.Store(percent)
e.overheadUpdatedAt = 0
e.sentMediaBytes = 0
e.sentRepairBytes = 0
}
e.mu.Unlock()
}
// OverheadPercent reserves at least the configured rate, using measured RTP
// bytes when frame rounding or repair headers increase the actual overhead.
// Reads by the allocator do not contend with encoding.
func (e *Encoder) OverheadPercent() uint32 {
return e.overheadPercent.Load()
}
// RecordSent accounts successful repairs, including headers for the allocator
// and payload bytes for the upstream-compatible telemetry callback. Refresh the
// estimate on the first repair and at most once per second thereafter.
// Callbacks run outside the encoder lock.
func (e *Encoder) RecordSent(packets int, payloadBytes int, rtpBytes int) {
if packets == 0 {
return
}
e.mu.Lock()
if !e.closed && e.protectionPercent != 0 {
e.sentRepairBytes += uint64(rtpBytes)
now := mono.UnixNano()
if e.sentMediaBytes != 0 && (e.overheadUpdatedAt == 0 || now-e.overheadUpdatedAt >= int64(overheadUpdateInterval)) {
percent := uint32((e.sentRepairBytes*100 + e.sentMediaBytes - 1) / e.sentMediaBytes)
e.overheadPercent.Store(max(e.protectionPercent, percent))
e.overheadUpdatedAt = now
e.sentMediaBytes = 0
e.sentRepairBytes = 0
}
}
e.mu.Unlock()
if e.onSent != nil {
e.onSent(packets, payloadBytes)
}
}
// Close prevents queued packets from generating repairs after unbind/close.
func (e *Encoder) Close() {
e.mu.Lock()
e.closed = true
e.count = 0
e.media = nil
e.encoder = nil
e.mu.Unlock()
}