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
+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"