Add FlexFEC benchmark tooling

This commit is contained in:
David Chen
2026-06-07 19:54:49 -07:00
parent ec606596fe
commit 8ef4cb89e5
14 changed files with 1596 additions and 18 deletions
+2
View File
@@ -171,6 +171,7 @@ func (b *Buffer) Bind(params webrtc.RTPParameters, codec webrtc.RTPCodecCapabili
func (b *Buffer) Write(pkt []byte) (int, error) {
if b.impair != nil {
if b.impair.dropInbound() {
b.logImpairedDrop(pkt)
// Packet "lost" on the simulated 5G uplink; report success so pion's read
// loop is unaffected.
return len(pkt), nil
@@ -188,6 +189,7 @@ func (b *Buffer) writeNow(pkt []byte) (n int, err error) {
if err != nil {
return
}
b.logImpairedMarker(&rtpPacket)
b.Lock()
if b.BufferBase.IsClosed() {
+168 -7
View File
@@ -22,6 +22,12 @@ import (
"time"
"github.com/pion/rtcp"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
"github.com/livekit/livekit-server/pkg/sfu/packettrailer"
"github.com/livekit/mediatransportutil/pkg/codec"
"github.com/livekit/protocol/codecs/mime"
)
// Debug-only simulated publisher ("robot") uplink impairment for the local FlexFEC test
@@ -46,12 +52,18 @@ import (
//
// LK_PUB_LOSS fractional loss on the publisher->SFU path, e.g. 0.03 (3%)
// LK_PUB_DELAY_MS one-way publisher<->SFU delay in ms (inbound media + outbound NACK)
// LK_PUB_BURST_MS optional burst-on window; loss only applies inside this window
// LK_PUB_GAP_MS optional burst-off window after LK_PUB_BURST_MS
// LK_PUB_DROP_LOG set to 1 to log dropped RTP packets and received marker frame IDs
//
// (These are the robot-link knobs. The operator/subscriber-side knobs live in the sfu
// package: LK_DOWNLINK_LOSS, LK_DOWNLINK_DELAY_MS, LK_UPLINK_DELAY_MS.)
const (
envPubLoss = "LK_PUB_LOSS"
envPubDelayMs = "LK_PUB_DELAY_MS"
envPubBurstMs = "LK_PUB_BURST_MS"
envPubGapMs = "LK_PUB_GAP_MS"
envPubDropLog = "LK_PUB_DROP_LOG"
impairQueueDepth = 8192
)
@@ -69,8 +81,12 @@ type delayedFeedback struct {
}
type uplinkImpair struct {
loss float64
delay time.Duration
loss float64
delay time.Duration
burst time.Duration
gap time.Duration
started time.Time
logDrops bool
rngMu sync.Mutex
rng *rand.Rand
@@ -91,13 +107,20 @@ func getUplinkImpair() *uplinkImpair {
globalImpairOnce.Do(func() {
loss := parseEnvFloat(envPubLoss)
delay := time.Duration(parseEnvInt(envPubDelayMs)) * time.Millisecond
if loss <= 0 && delay <= 0 {
burst := time.Duration(parseEnvInt(envPubBurstMs)) * time.Millisecond
gap := time.Duration(parseEnvInt(envPubGapMs)) * time.Millisecond
logDrops := parseEnvBool(envPubDropLog)
if loss <= 0 && delay <= 0 && !logDrops {
return
}
imp := &uplinkImpair{
loss: loss,
delay: delay,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
loss: loss,
delay: delay,
burst: burst,
gap: gap,
started: time.Now(),
logDrops: logDrops,
rng: rand.New(rand.NewSource(time.Now().UnixNano())),
}
if delay > 0 {
imp.inQueue = make(chan delayedInbound, impairQueueDepth)
@@ -115,7 +138,14 @@ func getUplinkImpair() *uplinkImpair {
func (b *Buffer) initUplinkImpair() {
b.impair = getUplinkImpair()
if b.impair != nil && b.logger != nil {
b.logger.Infow("uplink impairment enabled (debug)", "lossFraction", b.impair.loss, "delay", b.impair.delay)
b.logger.Infow(
"uplink impairment enabled (debug)",
"lossFraction", b.impair.loss,
"delay", b.impair.delay,
"burst", b.impair.burst,
"gap", b.impair.gap,
"logDrops", b.impair.logDrops,
)
}
}
@@ -123,12 +153,29 @@ func (u *uplinkImpair) dropInbound() bool {
if u.loss <= 0 {
return false
}
if !u.inLossWindow(time.Now()) {
return false
}
u.rngMu.Lock()
r := u.rng.Float64()
u.rngMu.Unlock()
return r < u.loss
}
func (u *uplinkImpair) inLossWindow(now time.Time) bool {
if u.burst <= 0 {
return true
}
if u.gap <= 0 {
return true
}
cycle := u.burst + u.gap
if cycle <= 0 {
return true
}
return now.Sub(u.started)%cycle < u.burst
}
// delayInbound returns true when the packet was deferred onto the shared delay line (caller
// should return immediately); false when no delay is configured and the caller should
// proceed inline.
@@ -202,3 +249,117 @@ func parseEnvInt(key string) int {
}
return v
}
func parseEnvBool(key string) bool {
v := os.Getenv(key)
return v == "1" || v == "true" || v == "TRUE" || v == "yes" || v == "YES"
}
func (u *uplinkImpair) shouldLogDrops() bool {
return u != nil && u.logDrops
}
func (b *Buffer) logImpairedDrop(pkt []byte) {
if b.impair == nil || !b.impair.shouldLogDrops() || b.logger == nil {
return
}
var rtpPacket rtp.Packet
if err := rtpPacket.Unmarshal(pkt); err != nil {
b.logger.Infow("uplink impairment dropped invalid RTP packet", "err", err)
return
}
fields := []any{
"stream", b.impairedStreamKind(),
"ssrc", rtpPacket.SSRC,
"sequenceNumber", rtpPacket.SequenceNumber,
"rtpTimestamp", rtpPacket.Timestamp,
"payloadType", rtpPacket.PayloadType,
"marker", rtpPacket.Marker,
"frameTypeHint", b.frameTypeHint(&rtpPacket),
}
if metadata, ok := packettrailer.ParseTrailer(rtpPacket.Payload, rtpPacket.Marker); ok {
fields = append(fields,
"hasFrameID", metadata.HasFrameID,
"frameID", metadata.FrameID,
"hasUserTimestamp", metadata.HasTimestampUs,
"userTimestampUs", metadata.TimestampUs,
)
}
b.logger.Infow("uplink impairment dropped RTP packet", fields...)
}
func (b *Buffer) logImpairedMarker(pkt *rtp.Packet) {
if b.impair == nil || !b.impair.shouldLogDrops() || b.logger == nil || !pkt.Marker {
return
}
if b.primaryBufferForRTX != nil || b.primaryBufferForFEC != nil {
return
}
metadata, ok := packettrailer.ParseTrailer(pkt.Payload, pkt.Marker)
if !ok || !metadata.HasFrameID {
return
}
b.logger.Infow(
"uplink impairment received frame marker",
"ssrc", pkt.SSRC,
"sequenceNumber", pkt.SequenceNumber,
"rtpTimestamp", pkt.Timestamp,
"frameID", metadata.FrameID,
"hasUserTimestamp", metadata.HasTimestampUs,
"userTimestampUs", metadata.TimestampUs,
"frameTypeHint", b.frameTypeHint(pkt),
)
}
func (b *Buffer) impairedStreamKind() string {
switch {
case b.primaryBufferForRTX != nil:
return "rtx"
case b.primaryBufferForFEC != nil:
return "fec"
default:
return "media"
}
}
func (b *Buffer) frameTypeHint(pkt *rtp.Packet) string {
if b.codecType != webrtc.RTPCodecTypeVideo || b.impairedStreamKind() != "media" {
return "unknown"
}
switch b.mime {
case mime.MimeTypeH264:
if codec.IsH264KeyFrame(pkt.Payload) {
return "I"
}
return "P"
case mime.MimeTypeH265:
if codec.IsH265KeyFrame(pkt.Payload) {
return "I"
}
return "P"
case mime.MimeTypeAV1:
if codec.IsAV1KeyFrame(pkt.Payload) {
return "I"
}
return "P"
case mime.MimeTypeVP8:
var vp8Packet codec.VP8
if err := vp8Packet.Unmarshal(pkt.Payload); err == nil && vp8Packet.IsKeyFrame {
return "I"
}
return "P"
case mime.MimeTypeVP9:
if codec.IsVP9KeyFrame(nil, pkt.Payload) {
return "I"
}
return "P"
default:
return "unknown"
}
}
+53
View File
@@ -0,0 +1,53 @@
package buffer
import (
"testing"
"time"
)
func TestUplinkImpairLossWindow(t *testing.T) {
started := time.Unix(100, 0)
impair := &uplinkImpair{
burst: 100 * time.Millisecond,
gap: 200 * time.Millisecond,
started: started,
}
tests := []struct {
name string
at time.Duration
want bool
}{
{name: "start in burst", at: 0, want: true},
{name: "inside burst", at: 99 * time.Millisecond, want: true},
{name: "gap begins", at: 100 * time.Millisecond, want: false},
{name: "inside gap", at: 250 * time.Millisecond, want: false},
{name: "next burst", at: 300 * time.Millisecond, want: true},
{name: "next gap", at: 450 * time.Millisecond, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := impair.inLossWindow(started.Add(tt.at)); got != tt.want {
t.Fatalf("inLossWindow() = %v, want %v", got, tt.want)
}
})
}
}
func TestUplinkImpairLossWindowWithoutBurst(t *testing.T) {
impair := &uplinkImpair{started: time.Unix(100, 0)}
if !impair.inLossWindow(time.Unix(200, 0)) {
t.Fatal("inLossWindow() = false, want true without burst config")
}
}
func TestUplinkImpairLossWindowWithoutGap(t *testing.T) {
started := time.Unix(100, 0)
impair := &uplinkImpair{burst: 100 * time.Millisecond, started: started}
if !impair.inLossWindow(started.Add(time.Hour)) {
t.Fatal("inLossWindow() = false, want true when burst is set without gap")
}
}
+57
View File
@@ -14,14 +14,26 @@
package packettrailer
import "encoding/binary"
var Magic = [4]byte{'L', 'K', 'T', 'S'}
const (
xorByte = 0xFF
envelopeSize = 5 // 1B trailer_len + 4B magic
tagTimestampUs = 0x01
tagFrameID = 0x02
)
type Metadata struct {
TimestampUs uint64
HasTimestampUs bool
FrameID uint32
HasFrameID bool
}
// StripTrailer returns the number of bytes to strip from the end of an RTP
// payload if it contains an LKTS trailer. The trailer is located by checking
// for the "LKTS" magic suffix and then reading the XORed trailer_len byte
@@ -44,3 +56,48 @@ func StripTrailer(payload []byte, marker bool) int {
return trailerLen
}
func ParseTrailer(payload []byte, marker bool) (Metadata, bool) {
strip := StripTrailer(payload, marker)
if strip == 0 {
return Metadata{}, false
}
var metadata Metadata
tlvEnd := len(payload) - envelopeSize
for i := len(payload) - strip; i < tlvEnd; {
if i+2 > tlvEnd {
return metadata, true
}
tag := payload[i] ^ xorByte
length := int(payload[i+1] ^ xorByte)
i += 2
if length < 0 || i+length > tlvEnd {
return metadata, true
}
switch tag {
case tagTimestampUs:
if length == 8 {
var buf [8]byte
for j := range buf {
buf[j] = payload[i+j] ^ xorByte
}
metadata.TimestampUs = binary.BigEndian.Uint64(buf[:])
metadata.HasTimestampUs = true
}
case tagFrameID:
if length == 4 {
var buf [4]byte
for j := range buf {
buf[j] = payload[i+j] ^ xorByte
}
metadata.FrameID = binary.BigEndian.Uint32(buf[:])
metadata.HasFrameID = true
}
}
i += length
}
return metadata, true
}
+31 -6
View File
@@ -19,11 +19,6 @@ import (
"testing"
)
const (
tagTimestampUs = 0x01
tagFrameID = 0x02
)
// appendTLV appends a single XORed TLV element to dst.
func appendTLV(dst []byte, tag byte, value []byte) []byte {
dst = append(dst, tag^xorByte, byte(len(value))^xorByte)
@@ -78,7 +73,7 @@ func makeTimestampOnlyTrailer(timestampUs int64) []byte {
}
func TestStripTrailer(t *testing.T) {
fullTrailerSize := 21 // (1+1+8) + (1+1+4) + 5
fullTrailerSize := 21 // (1+1+8) + (1+1+4) + 5
tsOnlyTrailerSize := 15 // (1+1+8) + 5
tests := []struct {
@@ -180,3 +175,33 @@ func TestStripTrailer(t *testing.T) {
})
}
}
func TestParseTrailer(t *testing.T) {
payload := makePayloadWithTrailer(20, 1700000000000000, 42)
metadata, ok := ParseTrailer(payload, true)
if !ok {
t.Fatal("ParseTrailer() did not find trailer")
}
if !metadata.HasTimestampUs || metadata.TimestampUs != 1700000000000000 {
t.Fatalf("timestamp = (%v, %d), want (true, 1700000000000000)", metadata.HasTimestampUs, metadata.TimestampUs)
}
if !metadata.HasFrameID || metadata.FrameID != 42 {
t.Fatalf("frame id = (%v, %d), want (true, 42)", metadata.HasFrameID, metadata.FrameID)
}
metadata, ok = ParseTrailer(makeTimestampOnlyTrailer(99), true)
if !ok {
t.Fatal("ParseTrailer() did not find timestamp-only trailer")
}
if !metadata.HasTimestampUs || metadata.TimestampUs != 99 {
t.Fatalf("timestamp = (%v, %d), want (true, 99)", metadata.HasTimestampUs, metadata.TimestampUs)
}
if metadata.HasFrameID {
t.Fatalf("frame id present = %v, want false", metadata.HasFrameID)
}
if _, ok := ParseTrailer(payload, false); ok {
t.Fatal("ParseTrailer() found trailer without RTP marker")
}
}
+115
View File
@@ -30,10 +30,14 @@ to UDP `7882` (see `flexfec-local.yaml`) so shaping can target it by port.
| File | Purpose |
| -------------------- | ---------------------------------------------------- |
| `flexfec-local.yaml` | SFU config: flexfec on (pub+sub), fixed loopback port |
| `flexfec-linux.yaml` | SFU config for Linux netns/veth uplink tests |
| `run-sfu.sh` | Build + run the SFU (`--dev`, devkey/secret) |
| `netem.sh` | Start/stop/inspect packet-loss shaping on udp/7882 |
| `netns.sh` | Linux robot netns + veth shaping for uplink tests |
| `run-leg-linux.sh` | Run one Linux benchmark leg and print log summaries |
| `run-publisher.sh` | Run the publisher with `--flex-fec` |
| `run-subscriber.sh` | Run the subscriber, surface `Video FEC ...` logs |
| `sweep-linux.sh` | Linux multi-case FlexFEC-vs-RTX benchmark sweep |
## Workflow
@@ -127,6 +131,117 @@ With FEC ON you expect fewer `freezes`/`frames_dropped`, lower net `packets_lost
meaningful `fec_recv`; with FEC OFF the same loss leans entirely on `rtx_recovered` and
(because of the delay) typically shows more freezes and dropped frames.
## Full Linux benchmark sweep
For a full comparison on a Linux box, use:
```bash
scripts/flexfec/sweep-linux.sh
```
The sweep builds this SFU plus the `local_video` publisher/subscriber, generates one SFU
config per case, runs every case for `DURATION=45` seconds by default, and writes raw logs
plus a summary CSV:
```text
scripts/flexfec/results/<timestamp>/
summary.csv
<leg>_<profile>_<fec-config>/
sfu.yaml
sfu.log
publisher.log
subscriber.log
netem.log
```
Default legs:
| Leg | Topology | Impairment | Compared signal |
| -------- | --------------------------------------- | ----------------------------------- | --------------- |
| Uplink | publisher in `robot` netns -> SFU | `netns.sh` burst loss on robot veth | SFU `packetsRecovered` |
| Downlink | SFU -> subscriber on loopback/root netns | `netem.sh burst down` on UDP/7882 | subscriber FEC accepted payload + quality/latency lines |
Default FEC matrix:
| Label | Meaning |
| --------- | -------------------------- |
| `rtx` | FEC disabled, RTX baseline |
| `fec5x1` | 5 media packets + 1 repair |
| `fec8x1` | 8 media packets + 1 repair |
| `fec10x2` | 10 media packets + 2 repairs |
Default burst profiles:
| Label | Loss burst |
| ------------ | ---------------------------------------------------- |
| `fade-short` | 35% loss for 250ms every 1750ms, 40ms one-way delay |
| `fade-long` | 70% loss for 750ms every 5000ms, 60ms one-way delay |
Useful overrides:
```bash
RUST_DIR=~/workspace/rust-sdks5 \
OUT_DIR=/tmp/flexfec-results \
DURATION=60 \
SCENARIOS="uplink downlink" \
FEC_CONFIGS="rtx:off:0:0 fec5x1:on:5:1 fec10x2:on:10:2" \
LOSS_PROFILES="fade:35:250:1750:40:10:70 handoff:80:750:5000:60:20:85" \
scripts/flexfec/sweep-linux.sh
```
`LOSS_PROFILES` format:
```text
label:loss_pct:burst_ms:gap_ms:one_way_delay_ms:jitter_ms:loss_correlation_pct
```
For downlink loopback shaping, only `loss_pct`, `burst_ms`, `gap_ms`, and
`one_way_delay_ms` are used. For uplink netns shaping, `jitter_ms` and
`loss_correlation_pct` are also passed to `tc netem` during each burst.
## Focused remote-SFU publish-leg sweep
For tele-op tests where this computer runs the publisher/subscriber and `DC-Linux`
runs only the SFU, use:
```bash
scripts/flexfec/sweep-remote-publish.sh
```
This keeps the real network RTT between this machine and `DC-Linux`; the SFU only
injects publisher-side packet loss and does not add latency. Subscriber/downlink FEC is
disabled in the generated SFU config, but subscriber quality and frame-latency logs are
still captured for every case.
Default matrix:
| Axis | Values |
| ---- | ------ |
| Loss percent | `0.1 0.5 1 2 5` |
| Burst duration | `100 200 500` ms |
| Burst gap | `2000` ms |
| FEC configs | `rtx`, `fec6x1`, `fec8x1`, `fec5x1` |
Useful overrides:
```bash
REMOTE=dc@DC-Linux \
SFU_HOST=DC-Linux \
DURATION=90 \
LOSS_PCTS="0.1 0.5 1 2 5" \
BURST_MS="100 200 500" \
BURST_GAP_MS=2000 \
FEC_CONFIGS="rtx:off:0:0 fec6x1:on:6:1 fec8x1:on:8:1" \
scripts/flexfec/sweep-remote-publish.sh
```
Publisher packet-trailer metadata is always enabled with `--attach-timestamp` and
`--attach-frame-id`. The SFU can log publish-side dropped RTP packets and received marker
packets when `DROP_LOG=1` (the default for this sweep), including RTP timestamp, frame ID
when present on marker packets, and a best-effort `frameTypeHint` (`I`/`P`/`unknown`).
Each case also writes `dropped-packets.log` and `frame-markers.log` extracted from the SFU
log for easier frame/timestamp correlation.
## Notes & caveats
- **Loopback shaping (macOS):** `netem.sh` loads its dummynet rules into a dedicated
Regular → Executable
View File
+3
View File
@@ -0,0 +1,3 @@
*
!.gitignore
!*.md
@@ -0,0 +1,186 @@
# FlexFEC Benchmark Report - 2026-06-06
## Summary
FlexFEC is functionally active on both tested legs:
- Publisher to SFU: the SFU enabled its FlexFEC decoder and recovered media packets from publisher-side repair packets.
- SFU to subscriber: the subscriber received FlexFEC repair packets and accepted recovered payloads.
The clearest quality win in this run was on the SFU-to-subscriber leg. Under the long fade profile, RTX-only delivery averaged 1694.3 ms capture-to-decode latency, while FlexFEC reduced that to 295.0 ms with `fec5x1` and 298.1 ms with `fec10x2`.
The publisher-to-SFU leg also proved correction, but the single-run quality result was more configuration-sensitive. `fec8x1` was the best uplink long-fade result in this run: 146 SFU-recovered packets, 21 subscriber-reported lost packets vs. 195 for RTX-only, and 141.2 ms capture-to-decode average vs. 154.1 ms for RTX-only.
## Environment
- Host: `DC-Linux`
- OS: Ubuntu 24.04.2 LTS
- Kernel: `Linux dc-linux 6.11.0-25-generic`
- Go: `/home/dc/go1.26/bin/go`, `go1.26.4 linux/amd64`
- SFU source: synced from local branch `dc/exp/flexfec` at `ec606596` plus local benchmark script changes
- Remote SFU workspace: `/home/dc/workspace/livekit-flexfec-bench`
- Local video binaries: `/home/dc/workspace/rust-sdks5/target/debug/publisher` and `subscriber`
## Validation
Passed locally:
```bash
go test ./pkg/config ./pkg/sfu/flexfec ./pkg/sfu/buffer ./pkg/rtc
bash -n scripts/flexfec/sweep-linux.sh scripts/flexfec/netns.sh scripts/flexfec/netem.sh scripts/flexfec/run-leg-linux.sh scripts/flexfec/run-publisher.sh scripts/flexfec/run-sfu.sh scripts/flexfec/run-subscriber.sh
git diff --check
```
Passed on `DC-Linux` after syncing:
```bash
/home/dc/go1.26/bin/go test ./pkg/config ./pkg/sfu/flexfec ./pkg/sfu/buffer ./pkg/rtc
bash -n scripts/flexfec/sweep-linux.sh scripts/flexfec/netns.sh scripts/flexfec/netem.sh scripts/flexfec/run-leg-linux.sh
```
## Methodology
The sweep ran each case for 45 seconds after publisher and subscriber startup.
FEC matrix:
| Label | Meaning |
|---|---|
| `rtx` | FEC disabled, RTX baseline |
| `fec5x1` | 5 media packets, 1 repair packet |
| `fec8x1` | 8 media packets, 1 repair packet |
| `fec10x2` | 10 media packets, 2 repair packets |
Loss profiles:
| Profile | Loss | Burst | Gap | One-way delay | Jitter | Correlation |
|---|---:|---:|---:|---:|---:|---:|
| `fade-short` | 35% | 250 ms | 1750 ms | 40 ms | 10 ms | 70% |
| `fade-long` | 70% | 750 ms | 5000 ms | 60 ms | 20 ms | 85% |
Legs:
| Leg | Topology | Impairment | Primary correction signal |
|---|---|---|---|
| Publisher to SFU | publisher in `robot` netns -> SFU | `tc netem` on robot veth | SFU `packetsRecovered` |
| SFU to subscriber | SFU -> subscriber on loopback/root netns | `tc netem` on SFU UDP/7882 downlink | Subscriber accepted FEC recovery payloads |
Raw artifacts:
- Downlink/full sweep: `scripts/flexfec/results/flexfec-full-20260606-120329/`
- Corrected uplink sweep: `scripts/flexfec/results/flexfec-uplink-rerun-20260606-124334/`
- Superseded uplink sweep with launcher failure: `scripts/flexfec/results/flexfec-uplink-20260606-122520/`
The first uplink sweep was not used for conclusions because publisher namespace startup failed in FEC cases. The sweep scripts were fixed to invoke helper scripts via `bash`, then the uplink-only sweep was rerun successfully.
## Publisher to SFU Results
Subscriber FEC counters are expected to stay zero in this leg because the downlink is clean and subscriber-side FlexFEC is disabled. The relevant correction signal is SFU `packetsRecovered`.
### Uplink `fade-short`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | Capture-to-decode avg | Delta vs RTX | Capture-to-decode max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 1515 | 8 | 40 | 0 | 98.4 ms | 0.0 ms | 99.7 ms |
| `fec5x1` | 212 | 1134 | 12 | 507 | 25 | 102.9 ms | +4.5 ms | 104.9 ms |
| `fec8x1` | 118 | 1361 | 8 | 123 | 4 | 105.1 ms | +6.7 ms | 231.0 ms |
| `fec10x2` | 50 | 1485 | 10 | 88 | 0 | 103.3 ms | +4.9 ms | 209.5 ms |
Interpretation:
- All FEC configurations except RTX produced SFU packet recovery.
- RTX-only had the best short-fade quality metrics in this single run.
- `fec8x1` was the least disruptive FEC option in this profile: same freeze count as RTX and only 4 lost packets, while still proving 118 recovered packets at the SFU.
### Uplink `fade-long`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | Capture-to-decode avg | Delta vs RTX | Capture-to-decode max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 1224 | 9 | 239 | 195 | 154.1 ms | 0.0 ms | 156.2 ms |
| `fec5x1` | 32 | 1148 | 9 | 261 | 105 | 395.7 ms | +241.6 ms | 4141.8 ms |
| `fec8x1` | 146 | 1295 | 9 | 213 | 21 | 141.2 ms | -12.9 ms | 142.7 ms |
| `fec10x2` | 0 | 991 | 13 | 262 | 31 | 386.0 ms | +231.9 ms | 4139.4 ms |
Interpretation:
- `fec8x1` was the best uplink long-fade configuration in this run.
- `fec8x1` recovered 146 packets at the SFU, decoded more frames than RTX, reduced reported lost packets from 195 to 21, and reduced average capture-to-decode latency by 12.9 ms.
- `fec5x1` and `fec10x2` showed large latency spikes in this run, despite lower reported lost packets than RTX. That makes them poor uplink choices until repeated runs show otherwise.
- `fec10x2` enabled the decoder, but its last SFU recovery counter was zero in this case. That needs repeat testing; burst alignment can dominate a single 45-second run.
## SFU to Subscriber Results
Here the correction signal is subscriber-side accepted recovery payloads. The `fec_recv` column comes from the last video quality log line and can be slightly lower than accepted total when the accepted counter logged later.
### Downlink `fade-short`
| Config | Subscriber FEC accepted | FEC recv | Decoded | Freezes | NACK | Lost | RTX recovered | Capture-to-decode avg | Delta vs RTX | Capture-to-decode max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 0 | 1425 | 14 | 82 | 57 | 57 | 131.1 ms | 0.0 ms | 270.0 ms |
| `fec5x1` | 325 | 315 | 1392 | 15 | 81 | 63 | 50 | 113.0 ms | -18.1 ms | 254.9 ms |
| `fec8x1` | 203 | 203 | 1450 | 15 | 80 | 60 | 52 | 108.6 ms | -22.5 ms | 255.4 ms |
| `fec10x2` | 327 | 327 | 1452 | 13 | 90 | 69 | 59 | 118.5 ms | -12.6 ms | 270.5 ms |
Interpretation:
- All FEC configurations generated accepted recovery payloads at the subscriber.
- Average capture-to-decode latency improved by 12.6 to 22.5 ms vs. RTX-only.
- Freeze counts were broadly similar in this short profile, so latency and accepted recovery are the stronger signals.
- `fec8x1` had the lowest average latency; `fec10x2` decoded the most frames and had the fewest freezes.
### Downlink `fade-long`
| Config | Subscriber FEC accepted | FEC recv | Decoded | Freezes | NACK | Lost | RTX recovered | Capture-to-decode avg | Delta vs RTX | Capture-to-decode max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 0 | 839 | 10 | 284 | 123 | 105 | 1694.3 ms | 0.0 ms | 3262.7 ms |
| `fec5x1` | 307 | 295 | 632 | 12 | 309 | 118 | 101 | 295.0 ms | -1399.3 ms | 3260.3 ms |
| `fec8x1` | 198 | 187 | 843 | 16 | 313 | 125 | 105 | 527.0 ms | -1167.3 ms | 3754.8 ms |
| `fec10x2` | 312 | 300 | 747 | 11 | 307 | 130 | 112 | 298.1 ms | -1396.2 ms | 3259.6 ms |
Interpretation:
- Downlink FlexFEC strongly reduced average decode latency under long fades.
- `fec5x1` and `fec10x2` performed similarly on latency: 295.0 ms and 298.1 ms vs. 1694.3 ms for RTX-only.
- `fec8x1` decoded the most frames, but had worse latency and more freezes than the other FEC profiles.
- The high max latency values show there are still isolated stalls; the average latency improvement is still large.
## Conclusions
1. FlexFEC correction is proven in both directions.
- Publisher to SFU: SFU logs show the FlexFEC decoder enabled and `packetsRecovered` increasing.
- SFU to subscriber: subscriber logs show FEC received and recovery payloads accepted.
2. SFU-to-subscriber FEC is clearly useful in this burst-loss setup.
- It substantially reduced average capture-to-decode latency, especially for long fades.
- `fec5x1` looks like the best initial downlink default because it matched `fec10x2` latency with lower nominal repair overhead.
3. Publisher-to-SFU FEC is useful but needs more repetitions before choosing a default.
- `fec8x1` was the best uplink long-fade configuration in this run.
- `fec5x1` recovered the most short-fade packets but hurt subscriber quality metrics in that case.
- `fec10x2` was inconsistent on uplink and should not be chosen from this single run.
4. Freeze count alone is not a reliable success metric here.
- Some FEC cases recovered packets and reduced latency without reducing freezes.
- Report `packetsRecovered`, accepted recovery payloads, latency, lost packets, NACK/PLI, and decoded frames together.
## Recommended Next Sweep
Run 3 to 5 repetitions per case and compare medians/percentiles. The current single-run results are enough to validate functionality, but burst timing makes per-config ranking noisy.
Recommended next matrix:
```bash
DURATION=120 \
SCENARIOS="uplink downlink" \
FEC_CONFIGS="rtx:off:0:0 fec5x1:on:5:1 fec6x1:on:6:1 fec8x1:on:8:1 fec10x1:on:10:1 fec10x2:on:10:2" \
LOSS_PROFILES="fade-short:35:250:1750:40:10:70 fade-long:70:750:5000:60:20:85 handoff:85:1200:7000:80:30:90" \
scripts/flexfec/sweep-linux.sh
```
Add report fields for:
- FEC bytes/packets sent to compute overhead.
- P50/P95/P99 capture-to-decode latency from all timing windows, not just the last one.
- SFU ingress loss before and after recovery.
- Subscriber RTP jitter and jitter-buffer delay.
- CPU usage for SFU and publisher/subscriber.
@@ -0,0 +1,180 @@
# FlexFEC Tele-Op Benchmark Report - 2026-06-06
## Summary
Ran a tele-op-oriented benchmark suite on `DC-Linux`:
- Main path: publisher/robot uplink over cellular-like loss into the SFU.
- Control path: SFU-to-subscriber downlink over clean/mild landline-like loss.
The result matches the tele-op premise:
- Uplink FEC is useful under moderate to severe cellular fades, but the right block size depends on the fade shape.
- Downlink FEC showed no practical quality benefit under clean/mild landline conditions; it only adds repair traffic.
Best uplink candidates from this single run:
- `fec6x1` for moderate `cell-edge` loss.
- `fec5x1` for severe long handoff where latency matters most.
- `fec8x1` as a conservative general candidate: it behaved well in `cell-edge`, `handoff-long`, and `bursty-uplink`, but was not always the lowest-latency choice.
Avoid `fec10x2` as a default for tele-op uplink. It recovered packets, but it caused clear latency/quality regressions in `bursty-uplink`.
## Validation
Passed locally:
```bash
go test ./pkg/config ./pkg/sfu/flexfec ./pkg/sfu/buffer ./pkg/rtc
bash -n scripts/flexfec/sweep-linux.sh scripts/flexfec/netns.sh scripts/flexfec/netem.sh scripts/flexfec/run-leg-linux.sh scripts/flexfec/run-publisher.sh scripts/flexfec/run-sfu.sh scripts/flexfec/run-subscriber.sh
```
Passed on `DC-Linux` after syncing:
```bash
/home/dc/go1.26/bin/go test ./pkg/config ./pkg/sfu/flexfec ./pkg/sfu/buffer ./pkg/rtc
bash -n scripts/flexfec/sweep-linux.sh scripts/flexfec/netns.sh scripts/flexfec/netem.sh scripts/flexfec/run-leg-linux.sh
```
No launcher failures were found in the copied log artifacts.
## Raw Artifacts
- Uplink cellular sweep: `scripts/flexfec/results/flexfec-teleop-uplink-20260606-135721/`
- Downlink landline control: `scripts/flexfec/results/flexfec-teleop-downlink-control-20260606-151840/`
## Uplink Sweep
Command shape:
```bash
DURATION=120 \
SCENARIOS=uplink \
FEC_CONFIGS="rtx:off:0:0 fec5x1:on:5:1 fec6x1:on:6:1 fec8x1:on:8:1 fec10x1:on:10:1 fec10x2:on:10:2" \
LOSS_PROFILES="clean-control:0:100:2000:20:2:0 urban-5g:3:100:2000:35:8:50 cell-edge:20:250:3000:60:20:75 handoff-short:60:500:8000:80:30:85 handoff-long:85:1200:12000:100:40:90 bursty-uplink:35:300:2500:70:25:85" \
scripts/flexfec/sweep-linux.sh
```
Primary correction signal: SFU `packetsRecovered`.
### `clean-control`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | PLI | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 3645 | 2 | 2 | 0 | 0 | 72.8 ms | 0.0 ms | 73.6 ms |
| `fec5x1` | 0 | 3706 | 2 | 3 | 0 | 0 | 71.0 ms | -1.8 ms | 72.0 ms |
| `fec6x1` | 0 | 3736 | 2 | 2 | 0 | 0 | 71.9 ms | -0.9 ms | 73.0 ms |
| `fec8x1` | 0 | 3676 | 2 | 2 | 0 | 0 | 72.4 ms | -0.4 ms | 73.5 ms |
| `fec10x1` | 498 | 3585 | 3 | 818 | 37 | 1 | 71.9 ms | -0.9 ms | 72.9 ms |
| `fec10x2` | 0 | 3676 | 2 | 5 | 0 | 0 | 71.9 ms | -0.9 ms | 72.7 ms |
Note: `fec10x1` is anomalous here: a no-loss case produced SFU recovery, high NACK, and lost packets. Treat that row as suspect and rerun before drawing conclusions from it.
### `urban-5g`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | PLI | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 3736 | 2 | 14 | 0 | 0 | 86.9 ms | 0.0 ms | 87.8 ms |
| `fec5x1` | 0 | 3647 | 2 | 10 | 0 | 0 | 87.8 ms | +0.9 ms | 88.5 ms |
| `fec6x1` | 0 | 3676 | 2 | 11 | 0 | 0 | 88.6 ms | +1.7 ms | 89.5 ms |
| `fec8x1` | 20 | 3646 | 2 | 29 | 0 | 0 | 87.8 ms | +0.9 ms | 94.5 ms |
| `fec10x1` | 101 | 3705 | 2 | 34 | 0 | 0 | 87.8 ms | +0.9 ms | 92.8 ms |
| `fec10x2` | 101 | 3706 | 2 | 47 | 0 | 0 | 88.8 ms | +1.9 ms | 90.0 ms |
Interpretation: RTX/no-FEC is already fine under mild cellular loss. FEC adds little benefit here.
### `cell-edge`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | PLI | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 3608 | 5 | 446 | 0 | 0 | 137.6 ms | 0.0 ms | 138.8 ms |
| `fec5x1` | 145 | 3674 | 5 | 506 | 0 | 0 | 137.1 ms | -0.5 ms | 159.7 ms |
| `fec6x1` | 77 | 3692 | 4 | 443 | 0 | 0 | 134.1 ms | -3.5 ms | 138.8 ms |
| `fec8x1` | 77 | 3734 | 4 | 474 | 0 | 0 | 137.8 ms | +0.2 ms | 147.9 ms |
| `fec10x1` | 0 | 3674 | 4 | 456 | 0 | 0 | 135.0 ms | -2.6 ms | 143.1 ms |
| `fec10x2` | 189 | 3674 | 4 | 568 | 0 | 0 | 137.2 ms | -0.4 ms | 161.5 ms |
Interpretation: `fec6x1` was the best-balanced result: lowest latency, fewer freezes than RTX, and similar NACK load.
### `handoff-short`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | PLI | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 3465 | 15 | 889 | 7 | 2 | 222.5 ms | 0.0 ms | 789.2 ms |
| `fec5x1` | 185 | 3551 | 16 | 880 | 8 | 1 | 201.4 ms | -21.1 ms | 205.6 ms |
| `fec6x1` | 125 | 3500 | 18 | 828 | 2 | 1 | 192.4 ms | -30.1 ms | 927.9 ms |
| `fec8x1` | 206 | 3655 | 16 | 832 | 0 | 0 | 231.6 ms | +9.1 ms | 403.5 ms |
| `fec10x1` | 25 | 3734 | 17 | 866 | 0 | 0 | 242.4 ms | +19.9 ms | 630.5 ms |
| `fec10x2` | 181 | 3617 | 16 | 856 | 2 | 1 | 227.3 ms | +4.8 ms | 571.6 ms |
Interpretation: `fec6x1` had the best average latency, but its max latency and freeze count were worse. `fec5x1` is safer for short handoffs if max-latency stability matters more than the lowest average.
### `handoff-long`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | PLI | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 3373 | 12 | 915 | 3 | 2 | 245.3 ms | 0.0 ms | 261.1 ms |
| `fec5x1` | 454 | 3268 | 14 | 847 | 0 | 0 | 171.2 ms | -74.1 ms | 214.0 ms |
| `fec6x1` | 503 | 3426 | 15 | 954 | 1 | 1 | 219.6 ms | -25.7 ms | 238.5 ms |
| `fec8x1` | 446 | 3549 | 12 | 1105 | 35 | 1 | 202.2 ms | -43.1 ms | 231.6 ms |
| `fec10x1` | 500 | 2963 | 21 | 823 | 1 | 1 | 1120.8 ms | +875.5 ms | 1197.1 ms |
| `fec10x2` | 491 | 3207 | 16 | 922 | 8 | 1 | 223.8 ms | -21.5 ms | 234.6 ms |
Interpretation: `fec5x1` clearly lowered latency under severe long fades. `fec8x1` decoded the most frames and matched RTX freeze count, but reported more lost packets. `fec10x1` is a bad fit here.
### `bursty-uplink`
| Config | SFU recovered | Decoded | Freezes | NACK | Lost | PLI | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 3726 | 20 | 661 | 0 | 0 | 153.5 ms | 0.0 ms | 157.5 ms |
| `fec5x1` | 20 | 3704 | 21 | 650 | 0 | 0 | 154.1 ms | +0.6 ms | 164.3 ms |
| `fec6x1` | 122 | 3668 | 16 | 732 | 8 | 1 | 160.0 ms | +6.5 ms | 169.7 ms |
| `fec8x1` | 143 | 3668 | 17 | 775 | 6 | 1 | 154.0 ms | +0.5 ms | 159.3 ms |
| `fec10x1` | 120 | 3608 | 20 | 736 | 3 | 1 | 157.4 ms | +3.9 ms | 158.9 ms |
| `fec10x2` | 290 | 3199 | 18 | 1091 | 27 | 5 | 254.6 ms | +101.1 ms | 664.0 ms |
Interpretation: `fec8x1` was the best FEC tradeoff here: nearly RTX latency with fewer freezes, but still more NACK and some lost packets. `fec10x2` is too heavy/unstable for this profile.
## Downlink Landline Control
Command shape:
```bash
DURATION=60 \
SCENARIOS=downlink \
FEC_CONFIGS="rtx:off:0:0 fec8x1:on:8:1 fec10x2:on:10:2" \
LOSS_PROFILES="landline-clean:0:100:2000:5:1:0 landline-mild:1:50:2000:10:2:0" \
scripts/flexfec/sweep-linux.sh
```
Primary correction signal: subscriber accepted recovery payloads. Under these landline profiles, accepted payloads prove FEC is active, not that it is needed.
### `landline-clean`
| Config | Subscriber FEC accepted | FEC recv | Decoded | Freezes | NACK | Lost | RTX recovered | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 0 | 1906 | 2 | 0 | 0 | 0 | 56.8 ms | 0.0 ms | 57.6 ms |
| `fec8x1` | 267 | 267 | 1907 | 2 | 0 | 0 | 0 | 56.8 ms | 0.0 ms | 57.7 ms |
| `fec10x2` | 426 | 408 | 1816 | 2 | 0 | 0 | 0 | 55.9 ms | -0.9 ms | 56.8 ms |
### `landline-mild`
| Config | Subscriber FEC accepted | FEC recv | Decoded | Freezes | NACK | Lost | RTX recovered | Capture-to-decode avg | Delta vs RTX | Max |
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|
| `rtx` | 0 | 0 | 1906 | 2 | 0 | 0 | 0 | 62.8 ms | 0.0 ms | 63.7 ms |
| `fec8x1` | 267 | 259 | 1847 | 2 | 0 | 0 | 0 | 62.9 ms | +0.1 ms | 63.6 ms |
| `fec10x2` | 426 | 408 | 1817 | 2 | 0 | 0 | 0 | 62.9 ms | +0.1 ms | 63.7 ms |
Interpretation: downlink FEC provided no visible quality benefit in clean or mild landline conditions. Keep subscriber-side/downlink FEC disabled for the tele-op default unless the operator path is known to be lossy.
## Recommendation
For a tele-op default where only the robot uplink is cellular:
1. Enable FEC on publisher-to-SFU only.
2. Leave SFU-to-subscriber FEC disabled by default.
3. Start field testing with `fec6x1` and `fec8x1`.
4. Add `fec5x1` as an aggressive mode for known handoff/fade zones.
5. Do not default to `fec10x2`; it is too costly under correlated burst loss in this run.
The next benchmark improvement should be repeated runs with median/P95/P99 latency extraction from all timing windows. This run is enough to choose candidates, but not enough to lock a production default from a single sample per case.
Regular → Executable
+4 -4
View File
@@ -67,9 +67,9 @@ if [[ "$USE_NETNS" == "1" ]]; then
NETEM_LOSS="${NETEM_LOSS:-0}"
NETEM_OWD="${NETEM_OWD:-0}"
if [[ "$NETEM_LOSS" != "0" || "$NETEM_OWD" != "0" ]]; then
"$LK_DIR/scripts/flexfec/netns.sh" shape "$NETEM_LOSS" "$NETEM_OWD" "${NETEM_JITTER:-0}" "${NETEM_CORR:-0}"
bash "$LK_DIR/scripts/flexfec/netns.sh" shape "$NETEM_LOSS" "$NETEM_OWD" "${NETEM_JITTER:-0}" "${NETEM_CORR:-0}"
else
"$LK_DIR/scripts/flexfec/netns.sh" clear
bash "$LK_DIR/scripts/flexfec/netns.sh" clear
fi
fi
@@ -94,13 +94,13 @@ PUB="$RUST_DIR/target/debug/publisher"
SUB="$RUST_DIR/target/debug/subscriber"
# --- Publisher (robot) ---
PUB_ARGS=(--flex-fec --room-name "$ROOM" --identity flexfec-pub --test-pattern --animate-test-pattern --attach-timestamp --attach-frame-id)
PUB_ARGS=(--flex-fec --room-name "$ROOM" --identity flexfec-pub --test-pattern 1 --attach-timestamp --attach-frame-id)
# shellcheck disable=SC2206
[[ -n "$PUB_EXTRA" ]] && PUB_ARGS+=($PUB_EXTRA)
if [[ "$USE_NETNS" == "1" ]]; then
# netns.sh sudoes ip/tc internally; do NOT wrap it in sudo (the script isn't allowlisted).
"$LK_DIR/scripts/flexfec/netns.sh" exec env \
bash "$LK_DIR/scripts/flexfec/netns.sh" exec env \
LIVEKIT_URL="$LIVEKIT_URL" LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret RUST_LOG="$RUST_LOG" \
"$PUB" "${PUB_ARGS[@]}" > "$PUB_LOG" 2>&1 &
else
+2 -1
View File
@@ -42,7 +42,8 @@ PUB_IDENTITY="${PUB_IDENTITY:-flexfec-pub}"
PUB_ARGS=(--flex-fec --room-name "$ROOM" --identity "$PUB_IDENTITY")
if [[ "${TEST_PATTERN:-1}" == "1" ]]; then
PUB_ARGS+=(--test-pattern)
# TEST_PATTERN_ANIMATE=1 -> animated (scrolling) pattern for a realistic motion bitrate.
PUB_ARGS+=(--test-pattern "${TEST_PATTERN_ANIMATE:-0}")
fi
if [[ "${ATTACH_META:-1}" == "1" ]]; then
PUB_ARGS+=(--attach-timestamp --attach-frame-id)
+456
View File
@@ -0,0 +1,456 @@
#!/usr/bin/env bash
#
# Run a Linux FlexFEC-vs-RTX benchmark sweep.
#
# The sweep runs two independent legs:
# - uplink: publisher/robot -> SFU, shaped through the robot netns veth.
# - downlink: SFU -> subscriber/operator, shaped on loopback by SFU UDP source port.
#
# Each case writes raw logs and appends one row to summary.csv. Defaults are intentionally
# small enough to run overnight-ish while still covering RTX-only, low-latency FEC, and
# stronger FEC under short fade and longer handoff-style burst loss.
#
# Run from this repo on Linux:
# scripts/flexfec/sweep-linux.sh
#
# Useful overrides:
# RUST_DIR=~/workspace/rust-sdks5
# GO_BIN=~/go1.26/bin/go
# OUT_DIR=/tmp/flexfec-results
# DURATION=60
# SCENARIOS="uplink downlink"
# FEC_CONFIGS="rtx:off:0:0 fec5x1:on:5:1 fec10x2:on:10:2"
# LOSS_PROFILES="fade:35:250:1750:40:10:70 handoff:80:750:5000:60:20:85"
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
LK_DIR="${LK_DIR:-$REPO_ROOT}"
RUST_DIR="${RUST_DIR:-$REPO_ROOT/../rust-sdks5}"
SFU_BIN="${SFU_BIN:-/tmp/flexfec-livekit-server}"
OUT_DIR="${OUT_DIR:-$SCRIPT_DIR/results/$(date +%Y%m%d-%H%M%S)}"
DURATION="${DURATION:-45}"
SFU_BOOT="${SFU_BOOT:-3}"
PUB_BOOT="${PUB_BOOT:-4}"
SUB_BOOT="${SUB_BOOT:-5}"
SCENARIOS="${SCENARIOS:-uplink downlink}"
FEC_CONFIGS="${FEC_CONFIGS:-rtx:off:0:0 fec5x1:on:5:1 fec8x1:on:8:1 fec10x2:on:10:2}"
LOSS_PROFILES="${LOSS_PROFILES:-fade-short:35:250:1750:40:10:70 fade-long:70:750:5000:60:20:85}"
ROOM_PREFIX="${ROOM_PREFIX:-flexfec-bench}"
RUST_LOG="${RUST_LOG:-info}"
PAYLOAD_TYPE="${PAYLOAD_TYPE:-49}"
GO_BIN="${GO_BIN:-go}"
REBUILD_RUST="${REBUILD_RUST:-0}"
DRY_RUN="${DRY_RUN:-0}"
PUB_BIN="$RUST_DIR/target/debug/publisher"
SUB_BIN="$RUST_DIR/target/debug/subscriber"
SUMMARY_CSV="$OUT_DIR/summary.csv"
declare -a CASE_PIDS=()
IMPAIR_PID=""
NETNS_ACTIVE=0
CURRENT_LEG=""
if [[ "$(uname -s)" != "Linux" ]]; then
echo "error: sweep-linux.sh must run on Linux" >&2
exit 1
fi
if [[ "$(id -u)" == "0" ]]; then
SUDO=()
elif [[ -n "${SUDO_ASKPASS:-}" ]]; then
SUDO=(sudo -A)
else
SUDO=(sudo)
fi
log() {
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
die() {
echo "error: $*" >&2
exit 1
}
safe_name() {
printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '_'
}
ms_to_seconds() {
awk "BEGIN { printf \"%.3f\", $1 / 1000 }"
}
csv_quote() {
local s="${1:-}"
s="${s//\"/\"\"}"
printf '"%s"' "$s"
}
run_netns() {
"${SUDO[@]}" bash "$SCRIPT_DIR/netns.sh" "$@"
}
run_netem() {
"${SUDO[@]}" bash "$SCRIPT_DIR/netem.sh" "$@"
}
case_contains_scenario() {
local want="$1"
for scenario in $SCENARIOS; do
[[ "$scenario" == "$want" ]] && return 0
done
return 1
}
prepare_sudo() {
if [[ "${#SUDO[@]}" -gt 0 ]]; then
log "checking sudo access for tc/ip netem setup"
"${SUDO[@]}" -v
fi
}
build_artifacts() {
[[ -d "$RUST_DIR" ]] || die "rust-sdks checkout not found at $RUST_DIR (set RUST_DIR)"
log "building SFU: $SFU_BIN"
(cd "$LK_DIR" && "$GO_BIN" build -o "$SFU_BIN" ./cmd/server)
if [[ "$REBUILD_RUST" == "1" || ! -x "$PUB_BIN" || ! -x "$SUB_BIN" ]]; then
log "building local_video publisher/subscriber in $RUST_DIR"
(cd "$RUST_DIR" && cargo build -p local_video --features desktop --bin publisher --bin subscriber)
else
log "using existing local_video binaries in $RUST_DIR/target/debug"
fi
}
write_summary_header() {
mkdir -p "$OUT_DIR"
cat >"$SUMMARY_CSV" <<'CSV'
timestamp,leg,profile,fec_label,fec_enabled,num_media_packets,num_fec_packets,loss_pct,burst_ms,gap_ms,one_way_delay_ms,jitter_ms,loss_correlation_pct,sfu_packets_recovered,subscriber_fec_accepted_total,video_quality_last,frame_latency_last,case_dir
CSV
}
write_config() {
local leg="$1"
local fec_enabled="$2"
local num_media="$3"
local num_fec="$4"
local config="$5"
local publisher=false
local subscriber=false
local bind_addresses=""
local includes=""
if [[ "$leg" == "uplink" ]]; then
publisher="$fec_enabled"
bind_addresses=$'bind_addresses:\n - 10.200.0.1\n - 127.0.0.1'
includes=$' - 10.200.0.0/24\n - 127.0.0.0/8'
else
subscriber="$fec_enabled"
bind_addresses=$'bind_addresses:\n - 127.0.0.1'
includes=$' - 127.0.0.0/8'
fi
if [[ "$fec_enabled" == "false" ]]; then
num_media=5
num_fec=1
fi
cat >"$config" <<EOF
port: 7880
$bind_addresses
rtc:
udp_port: 7882
tcp_port: 7881
use_external_ip: false
enable_loopback_candidate: true
ips:
includes:
$includes
flexfec:
subscriber: $subscriber
publisher: $publisher
payload_type: $PAYLOAD_TYPE
num_media_packets: $num_media
num_fec_packets: $num_fec
logging:
level: debug
json: false
EOF
}
stop_impairment() {
set +e
if [[ -n "${IMPAIR_PID:-}" ]]; then
kill "$IMPAIR_PID" 2>/dev/null
for _ in {1..20}; do
kill -0 "$IMPAIR_PID" 2>/dev/null || break
sleep 0.1
done
kill -9 "$IMPAIR_PID" 2>/dev/null
wait "$IMPAIR_PID" 2>/dev/null
IMPAIR_PID=""
fi
case "$CURRENT_LEG" in
uplink) run_netns clear >/dev/null 2>&1 ;;
downlink) run_netem stop >/dev/null 2>&1 ;;
esac
set -e
}
stop_case_processes() {
set +e
if [[ "${#CASE_PIDS[@]}" -gt 0 ]]; then
kill "${CASE_PIDS[@]}" 2>/dev/null
sleep 1
kill -9 "${CASE_PIDS[@]}" 2>/dev/null
wait "${CASE_PIDS[@]}" 2>/dev/null
fi
CASE_PIDS=()
pkill -f "$SFU_BIN" 2>/dev/null
pkill -f "$PUB_BIN" 2>/dev/null
pkill -f "$SUB_BIN" 2>/dev/null
set -e
}
cleanup_all() {
set +e
stop_impairment
set +e
stop_case_processes
set +e
if [[ "$NETNS_ACTIVE" == "1" ]]; then
run_netns down >/dev/null 2>&1
fi
}
start_uplink_burst() {
local case_dir="$1"
local loss="$2"
local burst_ms="$3"
local gap_ms="$4"
local owd_ms="$5"
local jitter_ms="$6"
local corr_pct="$7"
local burst_s gap_s
burst_s="$(ms_to_seconds "$burst_ms")"
gap_s="$(ms_to_seconds "$gap_ms")"
(
trap 'exit 0' INT TERM
while true; do
run_netns shape "$loss" "$owd_ms" "$jitter_ms" "$corr_pct"
sleep "$burst_s"
run_netns shape 0 "$owd_ms" "$jitter_ms" 0
sleep "$gap_s"
done
) >"$case_dir/netem.log" 2>&1 &
IMPAIR_PID="$!"
}
start_downlink_burst() {
local case_dir="$1"
local loss="$2"
local burst_ms="$3"
local gap_ms="$4"
local owd_ms="$5"
run_netem burst "$loss" "$burst_ms" "$gap_ms" down "$owd_ms" >"$case_dir/netem.log" 2>&1 &
IMPAIR_PID="$!"
}
start_impairment() {
local leg="$1"
local case_dir="$2"
local loss="$3"
local burst_ms="$4"
local gap_ms="$5"
local owd_ms="$6"
local jitter_ms="$7"
local corr_pct="$8"
CURRENT_LEG="$leg"
if [[ "$leg" == "uplink" ]]; then
start_uplink_burst "$case_dir" "$loss" "$burst_ms" "$gap_ms" "$owd_ms" "$jitter_ms" "$corr_pct"
else
start_downlink_burst "$case_dir" "$loss" "$burst_ms" "$gap_ms" "$owd_ms"
fi
}
last_line() {
local pattern="$1"
local file="$2"
grep -iE "$pattern" "$file" 2>/dev/null | tail -1 || true
}
extract_key_number() {
local key="$1"
local line="$2"
printf '%s' "$line" | sed -nE "s/.*${key}\"?[:= ]+([0-9]+).*/\1/p"
}
summarize_case() {
local leg="$1"
local profile="$2"
local fec_label="$3"
local fec_enabled="$4"
local num_media="$5"
local num_fec="$6"
local loss="$7"
local burst_ms="$8"
local gap_ms="$9"
local owd_ms="${10}"
local jitter_ms="${11}"
local corr_pct="${12}"
local case_dir="${13}"
local sfu_log="$case_dir/sfu.log"
local sub_log="$case_dir/subscriber.log"
local sfu_recovery_line video_quality frame_latency accepted_line recovered accepted
sfu_recovery_line="$(last_line 'flexfec recovery stats' "$sfu_log")"
video_quality="$(last_line 'Video quality' "$sub_log")"
frame_latency="$(last_line 'frame latency' "$sub_log")"
accepted_line="$(last_line 'accepted_recovery_payload_total|accepted_total|Video FEC recovery payload accepted' "$sub_log")"
recovered="$(extract_key_number 'packetsRecovered' "$sfu_recovery_line")"
accepted="$(extract_key_number 'accepted_recovery_payload_total' "$accepted_line")"
if [[ -z "$accepted" ]]; then
accepted="$(extract_key_number 'accepted_total' "$accepted_line")"
fi
{
printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,' \
"$(date -Iseconds)" "$leg" "$profile" "$fec_label" "$fec_enabled" \
"$num_media" "$num_fec" "$loss" "$burst_ms" "$gap_ms" "$owd_ms" "$jitter_ms" "$corr_pct" \
"${recovered:-0}" "${accepted:-0}"
csv_quote "$video_quality"
printf ','
csv_quote "$frame_latency"
printf ','
csv_quote "$case_dir"
printf '\n'
} >>"$SUMMARY_CSV"
}
run_case() {
local leg="$1"
local config_spec="$2"
local profile_spec="$3"
local fec_label fec_mode num_media num_fec
local profile loss burst_ms gap_ms owd_ms jitter_ms corr_pct
IFS=: read -r fec_label fec_mode num_media num_fec <<<"$config_spec"
IFS=: read -r profile loss burst_ms gap_ms owd_ms jitter_ms corr_pct <<<"$profile_spec"
local fec_enabled=false
[[ "$fec_mode" == "on" ]] && fec_enabled=true
local case_name case_dir config sfu_host room
case_name="$(safe_name "${leg}_${profile}_${fec_label}")"
case_dir="$OUT_DIR/$case_name"
config="$case_dir/sfu.yaml"
room="${ROOM_PREFIX}-${case_name}"
mkdir -p "$case_dir"
write_config "$leg" "$fec_enabled" "$num_media" "$num_fec" "$config"
if [[ "$leg" == "uplink" ]]; then
sfu_host="10.200.0.1"
run_netns clear >/dev/null 2>&1 || true
else
sfu_host="127.0.0.1"
run_netem stop >/dev/null 2>&1 || true
fi
log "case: leg=$leg profile=$profile fec=$fec_label loss=${loss}% burst=${burst_ms}ms gap=${gap_ms}ms owd=${owd_ms}ms"
if [[ "$DRY_RUN" == "1" ]]; then
return
fi
stop_case_processes
CASE_PIDS=()
IMPAIR_PID=""
CURRENT_LEG=""
(
cd "$LK_DIR"
"$SFU_BIN" --dev --config "$config"
) >"$case_dir/sfu.log" 2>&1 &
CASE_PIDS+=("$!")
sleep "$SFU_BOOT"
local url="ws://$sfu_host:7880"
local pub_args=(--flex-fec --room-name "$room" --identity "${case_name}-pub" --test-pattern 1 --attach-timestamp --attach-frame-id)
if [[ "$leg" == "uplink" ]]; then
run_netns exec env \
LIVEKIT_URL="$url" LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret RUST_LOG="$RUST_LOG" \
"$PUB_BIN" "${pub_args[@]}" >"$case_dir/publisher.log" 2>&1 &
else
LIVEKIT_URL="$url" LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret RUST_LOG="$RUST_LOG" \
"$PUB_BIN" "${pub_args[@]}" >"$case_dir/publisher.log" 2>&1 &
fi
CASE_PIDS+=("$!")
sleep "$PUB_BOOT"
local sub_display sub_xauth
sub_display="${DISPLAY:-${SUB_DISPLAY:-:1}}"
sub_xauth="${XAUTHORITY:-${SUB_XAUTHORITY:-/run/user/$(id -u)/gdm/Xauthority}}"
DISPLAY="$sub_display" XAUTHORITY="$sub_xauth" \
LIVEKIT_URL="$url" LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret RUST_LOG="$RUST_LOG" \
"$SUB_BIN" --room-name "$room" --identity "${case_name}-sub" >"$case_dir/subscriber.log" 2>&1 &
CASE_PIDS+=("$!")
sleep "$SUB_BOOT"
start_impairment "$leg" "$case_dir" "$loss" "$burst_ms" "$gap_ms" "$owd_ms" "$jitter_ms" "$corr_pct"
sleep "$DURATION"
stop_impairment
stop_case_processes
summarize_case "$leg" "$profile" "$fec_label" "$fec_enabled" "$num_media" "$num_fec" \
"$loss" "$burst_ms" "$gap_ms" "$owd_ms" "$jitter_ms" "$corr_pct" "$case_dir"
}
main() {
prepare_sudo
build_artifacts
write_summary_header
if case_contains_scenario uplink; then
log "setting up robot netns for uplink benchmarks"
run_netns up
NETNS_ACTIVE=1
fi
log "results: $OUT_DIR"
for scenario in $SCENARIOS; do
case "$scenario" in
uplink | downlink) ;;
*) die "unknown scenario '$scenario' (use uplink/downlink)" ;;
esac
for profile_spec in $LOSS_PROFILES; do
for config_spec in $FEC_CONFIGS; do
run_case "$scenario" "$config_spec" "$profile_spec"
done
done
done
cleanup_all
log "summary: $SUMMARY_CSV"
}
trap cleanup_all EXIT INT TERM
main "$@"
+339
View File
@@ -0,0 +1,339 @@
#!/usr/bin/env bash
#
# Focused tele-op publish-leg benchmark:
# - this computer runs local_video publisher + subscriber
# - DC-Linux runs the SFU
# - the SFU applies publish-side packet loss only, with no added delay
#
# This models a robot publishing over an already-real public/LAN path where the link RTT is
# real and should not be inflated by the test harness. Subscriber/downlink metrics are still
# collected, but FlexFEC generation toward the subscriber is disabled by default.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REMOTE="${REMOTE:-dc@DC-Linux}"
REMOTE_LK_DIR="${REMOTE_LK_DIR:-/home/dc/workspace/livekit-flexfec-bench}"
REMOTE_GO_BIN="${REMOTE_GO_BIN:-/home/dc/go1.26/bin/go}"
REMOTE_SFU_BIN="${REMOTE_SFU_BIN:-/tmp/flexfec-livekit-server}"
REMOTE_WORK_DIR="${REMOTE_WORK_DIR:-/tmp/flexfec-remote-publish}"
REMOTE_BIND_ADDRESSES="${REMOTE_BIND_ADDRESSES:-10.104.0.88 100.69.171.8 127.0.0.1}"
REMOTE_ICE_INCLUDES="${REMOTE_ICE_INCLUDES:-10.104.0.0/16 100.64.0.0/10 127.0.0.0/8}"
SSH_OPTS="${SSH_OPTS:--o ControlPath=/tmp/dc-linux-ctrl}"
RUST_DIR="${RUST_DIR:-$REPO_ROOT/../rust-sdks5}"
PUB_BIN="${PUB_BIN:-$RUST_DIR/target/debug/publisher}"
SUB_BIN="${SUB_BIN:-$RUST_DIR/target/debug/subscriber}"
SFU_HOST="${SFU_HOST:-DC-Linux}"
LIVEKIT_URL="${LIVEKIT_URL:-ws://$SFU_HOST:7880}"
OUT_DIR="${OUT_DIR:-$SCRIPT_DIR/results/remote-publish-$(date +%Y%m%d-%H%M%S)}"
SUMMARY_CSV="$OUT_DIR/summary.csv"
DURATION="${DURATION:-60}"
SFU_BOOT="${SFU_BOOT:-3}"
SUB_BOOT="${SUB_BOOT:-4}"
PUB_BOOT="${PUB_BOOT:-4}"
ROOM_PREFIX="${ROOM_PREFIX:-teleop-publish}"
RUST_LOG="${RUST_LOG:-info}"
PAYLOAD_TYPE="${PAYLOAD_TYPE:-49}"
DROP_LOG="${DROP_LOG:-1}"
REBUILD_RUST="${REBUILD_RUST:-0}"
DRY_RUN="${DRY_RUN:-0}"
FEC_CONFIGS="${FEC_CONFIGS:-rtx:off:0:0 fec6x1:on:6:1 fec8x1:on:8:1 fec5x1:on:5:1}"
LOSS_PCTS="${LOSS_PCTS:-0.1 0.5 1 2 5}"
BURST_MS="${BURST_MS:-100 200 500}"
BURST_GAP_MS="${BURST_GAP_MS:-2000}"
PUB_EXTRA="${PUB_EXTRA:---codec vp8 --encoder software}"
SUB_EXTRA="${SUB_EXTRA:-}"
declare -a LOCAL_PIDS=()
REMOTE_SFU_PID=""
REMOTE_CASE_DIR=""
log() {
printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"
}
die() {
echo "error: $*" >&2
exit 1
}
ssh_remote() {
# shellcheck disable=SC2086
ssh $SSH_OPTS "$REMOTE" "$@"
}
rsync_from_remote() {
local src="$1"
local dst="$2"
# shellcheck disable=SC2086
rsync -az -e "ssh $SSH_OPTS" "$REMOTE:$src" "$dst"
}
safe_name() {
printf '%s' "$1" | tr -c 'A-Za-z0-9_.=-' '_'
}
csv_quote() {
local s="${1:-}"
s="${s//\"/\"\"}"
printf '"%s"' "$s"
}
shell_quote() {
printf '%q' "$1"
}
loss_fraction() {
awk "BEGIN { printf \"%.6f\", $1 / 100 }"
}
write_summary_header() {
mkdir -p "$OUT_DIR"
cat >"$SUMMARY_CSV" <<'CSV'
timestamp,profile,fec_label,fec_enabled,num_media_packets,num_fec_packets,loss_pct,burst_ms,gap_ms,sfu_packets_recovered,sfu_drop_count,sfu_marker_count,subscriber_fec_accepted_total,video_quality_last,frame_latency_last,case_dir
CSV
}
write_remote_config() {
local fec_enabled="$1"
local num_media="$2"
local num_fec="$3"
local remote_config="$4"
local bind_addresses_yaml=""
local ice_includes_yaml=""
if [[ "$fec_enabled" == "false" ]]; then
num_media=5
num_fec=1
fi
local bind_address
for bind_address in $REMOTE_BIND_ADDRESSES; do
bind_addresses_yaml+=" - $bind_address"$'\n'
done
local ice_include
for ice_include in $REMOTE_ICE_INCLUDES; do
ice_includes_yaml+=" - $ice_include"$'\n'
done
ssh_remote "cat > '$remote_config' <<'EOF'
port: 7880
bind_addresses:
$bind_addresses_yaml
rtc:
udp_port: 7882
tcp_port: 7881
use_external_ip: false
enable_loopback_candidate: true
ips:
includes:
$ice_includes_yaml
flexfec:
subscriber: false
publisher: $fec_enabled
payload_type: $PAYLOAD_TYPE
num_media_packets: $num_media
num_fec_packets: $num_fec
logging:
level: debug
json: false
EOF"
}
stop_local_processes() {
set +e
if [[ "${#LOCAL_PIDS[@]}" -gt 0 ]]; then
kill "${LOCAL_PIDS[@]}" 2>/dev/null
sleep 1
kill -9 "${LOCAL_PIDS[@]}" 2>/dev/null
wait "${LOCAL_PIDS[@]}" 2>/dev/null
fi
LOCAL_PIDS=()
set -e
}
stop_remote_sfu() {
set +e
if [[ -n "${REMOTE_SFU_PID:-}" ]]; then
ssh_remote "kill '$REMOTE_SFU_PID' 2>/dev/null || true; sleep 1; kill -9 '$REMOTE_SFU_PID' 2>/dev/null || true; wait '$REMOTE_SFU_PID' 2>/dev/null || true" >/dev/null 2>&1
REMOTE_SFU_PID=""
fi
ssh_remote "pkill -f '$REMOTE_SFU_BIN' 2>/dev/null || true" >/dev/null 2>&1
set -e
}
cleanup_all() {
set +e
stop_local_processes
stop_remote_sfu
}
last_line() {
local pattern="$1"
local file="$2"
grep -iE "$pattern" "$file" 2>/dev/null | tail -1 || true
}
extract_key_number() {
local key="$1"
local line="$2"
printf '%s' "$line" | sed -nE "s/.*${key}\"?[:= ]+([0-9]+).*/\1/p"
}
summarize_case() {
local profile="$1"
local fec_label="$2"
local fec_enabled="$3"
local num_media="$4"
local num_fec="$5"
local loss="$6"
local burst_ms="$7"
local gap_ms="$8"
local case_dir="$9"
local sfu_log="$case_dir/sfu.log"
local sub_log="$case_dir/subscriber.log"
local sfu_recovery_line video_quality frame_latency accepted_line recovered accepted drops markers
sfu_recovery_line="$(last_line 'flexfec recovery stats' "$sfu_log")"
video_quality="$(last_line 'Video quality' "$sub_log")"
frame_latency="$(last_line 'frame latency' "$sub_log")"
accepted_line="$(last_line 'accepted_recovery_payload_total|accepted_total|Video FEC recovery payload accepted' "$sub_log")"
recovered="$(extract_key_number 'packetsRecovered' "$sfu_recovery_line")"
accepted="$(extract_key_number 'accepted_recovery_payload_total' "$accepted_line")"
if [[ -z "$accepted" ]]; then
accepted="$(extract_key_number 'accepted_total' "$accepted_line")"
fi
drops="$(grep -c 'uplink impairment dropped RTP packet' "$sfu_log" 2>/dev/null || true)"
markers="$(grep -c 'uplink impairment received frame marker' "$sfu_log" 2>/dev/null || true)"
{
printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,' \
"$(date -Iseconds)" "$profile" "$fec_label" "$fec_enabled" "$num_media" "$num_fec" \
"$loss" "$burst_ms" "$gap_ms" "${recovered:-0}" "${drops:-0}" "${markers:-0}" "${accepted:-0}"
csv_quote "$video_quality"
printf ','
csv_quote "$frame_latency"
printf ','
csv_quote "$case_dir"
printf '\n'
} >>"$SUMMARY_CSV"
}
build_artifacts() {
[[ -d "$RUST_DIR" ]] || die "rust-sdks checkout not found at $RUST_DIR (set RUST_DIR)"
log "building remote SFU: $REMOTE:$REMOTE_SFU_BIN"
ssh_remote "cd '$REMOTE_LK_DIR' && '$REMOTE_GO_BIN' build -o '$REMOTE_SFU_BIN' ./cmd/server"
if [[ "$REBUILD_RUST" == "1" || ! -x "$PUB_BIN" || ! -x "$SUB_BIN" ]]; then
log "building local_video publisher/subscriber in $RUST_DIR"
(cd "$RUST_DIR" && cargo build -p local_video --features desktop --bin publisher --bin subscriber)
else
log "using existing local_video binaries in $RUST_DIR/target/debug"
fi
}
make_profiles() {
for loss in $LOSS_PCTS; do
for burst in $BURST_MS; do
printf 'loss%s_b%s:%s:%s:%s\n' \
"$(printf '%s' "$loss" | tr '.' 'p')" "$burst" "$loss" "$burst" "$BURST_GAP_MS"
done
done
}
run_case() {
local config_spec="$1"
local profile_spec="$2"
local fec_label fec_mode num_media num_fec profile loss burst_ms gap_ms
IFS=: read -r fec_label fec_mode num_media num_fec <<<"$config_spec"
IFS=: read -r profile loss burst_ms gap_ms <<<"$profile_spec"
local fec_enabled=false
[[ "$fec_mode" == "on" ]] && fec_enabled=true
local case_name case_dir remote_case config room pub_args
case_name="$(safe_name "${profile}_${fec_label}")"
case_dir="$OUT_DIR/$case_name"
remote_case="$REMOTE_WORK_DIR/$case_name"
config="$remote_case/sfu.yaml"
room="${ROOM_PREFIX}-${case_name}"
mkdir -p "$case_dir"
log "case: profile=$profile fec=$fec_label loss=${loss}% burst=${burst_ms}ms gap=${gap_ms}ms url=$LIVEKIT_URL"
if [[ "$DRY_RUN" == "1" ]]; then
return
fi
stop_local_processes
stop_remote_sfu
LOCAL_PIDS=()
ssh_remote "mkdir -p '$remote_case'"
write_remote_config "$fec_enabled" "$num_media" "$num_fec" "$config"
local loss_frac
loss_frac="$(loss_fraction "$loss")"
local remote_start_cmd
remote_start_cmd="cd $(shell_quote "$REMOTE_LK_DIR") || exit; env LK_PUB_LOSS=$(shell_quote "$loss_frac") LK_PUB_DELAY_MS=0 LK_PUB_BURST_MS=$(shell_quote "$burst_ms") LK_PUB_GAP_MS=$(shell_quote "$gap_ms") LK_PUB_DROP_LOG=$(shell_quote "$DROP_LOG") $(shell_quote "$REMOTE_SFU_BIN") --dev --config $(shell_quote "$config") > $(shell_quote "$remote_case/sfu.log") 2>&1 < /dev/null & pid=\$!; disown \"\$pid\"; echo \"\$pid\""
REMOTE_SFU_PID="$(
ssh_remote "bash -lc $(shell_quote "$remote_start_cmd")"
)"
sleep "$SFU_BOOT"
LIVEKIT_URL="$LIVEKIT_URL" LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret RUST_LOG="$RUST_LOG" \
"$SUB_BIN" --room-name "$room" --identity "${case_name}-sub" $SUB_EXTRA >"$case_dir/subscriber.log" 2>&1 &
LOCAL_PIDS+=("$!")
sleep "$SUB_BOOT"
pub_args=(--room-name "$room" --identity "${case_name}-pub" --test-pattern 1 --attach-timestamp --attach-frame-id)
if [[ "$fec_enabled" == "true" ]]; then
pub_args=(--flex-fec "${pub_args[@]}")
fi
LIVEKIT_URL="$LIVEKIT_URL" LIVEKIT_API_KEY=devkey LIVEKIT_API_SECRET=secret RUST_LOG="$RUST_LOG" \
"$PUB_BIN" "${pub_args[@]}" $PUB_EXTRA >"$case_dir/publisher.log" 2>&1 &
LOCAL_PIDS+=("$!")
sleep "$PUB_BOOT"
sleep "$DURATION"
stop_local_processes
stop_remote_sfu
rsync_from_remote "$remote_case/" "$case_dir/"
grep 'uplink impairment dropped RTP packet' "$case_dir/sfu.log" >"$case_dir/dropped-packets.log" 2>/dev/null || true
grep 'uplink impairment received frame marker' "$case_dir/sfu.log" >"$case_dir/frame-markers.log" 2>/dev/null || true
summarize_case "$profile" "$fec_label" "$fec_enabled" "$num_media" "$num_fec" "$loss" "$burst_ms" "$gap_ms" "$case_dir"
}
main() {
build_artifacts
write_summary_header
log "results: $OUT_DIR"
for profile_spec in $(make_profiles); do
for config_spec in $FEC_CONFIGS; do
run_case "$config_spec" "$profile_spec"
done
done
cleanup_all
log "summary: $SUMMARY_CSV"
}
trap cleanup_all EXIT INT TERM
main "$@"