From b10cc84314d13341e6916e0ba84a2830fdef4878 Mon Sep 17 00:00:00 2001 From: Thomas Drapier Date: Sat, 10 Oct 2026 16:33:32 +0100 Subject: [PATCH 1/7] chore(quest): claim quest/m1/tstd/slot-layout Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor From 1dc8b6bfb1a177967063bcae1333281061e4bec6 Mon Sep 17 00:00:00 2001 From: Thomas Drapier Date: Sat, 10 Oct 2026 17:32:53 +0100 Subject: [PATCH 2/7] fix(moq-mux): interleave each TS slot so no PID overruns its transport buffer Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor --- .github/workflows/interop.yml | 8 + quest/m1/tstd/README.md | 1 - quest/m1/tstd/slot-layout.md | 36 ---- rs/moq-mux/src/container/ts/export.rs | 7 +- rs/moq-mux/src/container/ts/schedule.rs | 210 ++++++++++++++++++------ test/ts/README.md | 14 +- test/ts/run.sh | 37 ++++- 7 files changed, 211 insertions(+), 102 deletions(-) delete mode 100644 quest/m1/tstd/slot-layout.md diff --git a/.github/workflows/interop.yml b/.github/workflows/interop.yml index 832c647d2b..d7e51fbec1 100644 --- a/.github/workflows/interop.yml +++ b/.github/workflows/interop.yml @@ -157,6 +157,14 @@ jobs: env: MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs + # The same video beside four audio PIDs in a multiplex faster than its Rx: each slot + # has to interleave the video with the rest, or its transport buffer overflows. + - name: TS multiplex above the video's Rx + run: nix develop --command just test ts --headroom + shell: bash -leo pipefail {0} + env: + MOQ_TEST_RUNS: ${{ runner.temp }}/moq-test-runs + # The same round-trip over an open-GOP clip: every leading picture after a # recovery point must come back, in decode order. - name: TS open GOP diff --git a/quest/m1/tstd/README.md b/quest/m1/tstd/README.md index ebf098f624..a748afd30c 100644 --- a/quest/m1/tstd/README.md +++ b/quest/m1/tstd/README.md @@ -37,7 +37,6 @@ nightly. - [Send-ahead within the delay](/quest/m1/tstd/send-ahead.md) - total lag is `--delay`, send-ahead included, with a 1 s default - [Mux-rate hold](/quest/m1/tstd/mux-rate-hold.md) - import publishes its catalog once the mux rate is measured, so export is constant-rate from the start -- [Slot layout](/quest/m1/tstd/slot-layout.md) - each slot interleaves its PIDs evenly, so video stays within its transport buffer when the multiplex runs above its Rx ## Related diff --git a/quest/m1/tstd/slot-layout.md b/quest/m1/tstd/slot-layout.md deleted file mode 100644 index 6b72e14d75..0000000000 --- a/quest/m1/tstd/slot-layout.md +++ /dev/null @@ -1,36 +0,0 @@ -# [S] TS export interleaves each slot so no PID overruns its transport buffer - -## Goal - -When the multiplex runs faster than a video PID's Rx, `moq export ts` still -keeps that PID's 512-byte transport buffer (ISO 13818-1 2.4.2.3) from -overflowing. The schedule already holds every PID to Rx per slot; the layout -within the slot has to spread the packets as evenly as that budget assumes. - -## Plan - -Decided (2026-10-10), importing #5142. `Slot::layout` -(`rs/moq-mux/src/container/ts/schedule.rs`) keys each packet by its own PID's -count and only then spreads the nulls uniformly across the media. PIDs with -equal counts therefore land at the same positions, so video, at 86 % of a -slot, runs 12-13 packets back to back and overruns TB on a 25 Mb/s CBR feed -whose video HRD is 18 Mb/s. - -- Lay the slot out by smooth weighted round-robin over every position, with - each PID and the nulls weighted by their count, and each PID keeping its - order. It replaces both the key sort and the null spread. Tables keep their - rule of going just ahead of the packet after them. The reporter measured a - 459 B peak on the failing slot, down from 1,067 B. SWRR was chosen over - staggering the existing keys (more special cases) and over placing packets - against a simulated TB (it ties the layout to the buffer model). -- When the PCR PID is the video PID, the clock packet counts against that - PID's per-slot budget (`Buffer::per_slot`). Revisit whether the "less one" - slack is still needed once the layout is even. -- Tests: the issue's full-slot TB unit test is the regression test. The CI'd - `just test ts` harness also gains an arm where a CBR multiplex runs above the - video's HRD rate, which no clip covers today, graded by the strict T-STD - check. - -## Closes - -- [#5142](https://github.com/moq-dev/moq/issues/5142) - the slot layout overflows video's transport buffer when the multiplex runs faster than its Rx diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index d2e55e0501..c6cf1f3d48 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -1650,6 +1650,7 @@ impl Export { self.schedule.set_buffer(0, Buffer::SYSTEM); self.schedule.set_buffer(pmt_pid, Buffer::SYSTEM); + self.schedule.set_clock(pcr_pid); for track in self.tracks.values() { self.liveness.register(track.pid); } @@ -1918,12 +1919,12 @@ impl Export { }; let (pcr_pid, pmt_pid) = (psi.pcr_pid, psi.pmt_pid); while let Some(slot) = self.schedule.next(known)? { - let mut payload = pcr_packet(pcr_pid, slot.pcr)?; + let mut clock = pcr_packet(pcr_pid, slot.pcr)?; let discontinuity = std::mem::take(&mut self.pcr_discontinuity); if discontinuity { - payload[5] |= 0x80; + clock[5] |= 0x80; } - payload.extend_from_slice(&slot.layout(pmt_pid, &NULL_PACKET)); + let mut payload = slot.layout(&clock, pmt_pid, &NULL_PACKET); self.number(&mut payload); let frame = Frame { timestamp: slot_stamp(slot.index)?, diff --git a/rs/moq-mux/src/container/ts/schedule.rs b/rs/moq-mux/src/container/ts/schedule.rs index b583bc77b1..17db9403f8 100644 --- a/rs/moq-mux/src/container/ts/schedule.rs +++ b/rs/moq-mux/src/container/ts/schedule.rs @@ -78,8 +78,9 @@ impl Buffer { size: None, }; - /// The packets one slot may carry on the PID: what its transport buffer passes on within - /// the slot, less one, since the layout spreads them across it. + /// The packets one slot may carry on the PID, a clock packet on it included: what its + /// transport buffer passes on within the slot, less one. Even laid out evenly, the packets + /// bunch a little where one slot meets the next, and the slack drains that off. fn per_slot(&self) -> usize { let bits = u128::from(self.rate) * PCR_INTERVAL.as_nanos() / 1_000_000_000; (bits / (TsPacket::SIZE as u128 * 8)).saturating_sub(1).max(1) as usize @@ -144,66 +145,73 @@ pub(super) struct Slot { } impl Slot { - /// The slot's packets after its clock packet: the media spread evenly among the null - /// packets, and each PID's packets spread evenly among the others, every PID keeping its + /// The whole slot, opening with its `clock` packet, interleaved by smooth weighted + /// round-robin: each PID (the clock packet counted on its own) and the null packets take + /// positions in proportion to their count, as evenly as they can, every PID keeping its /// own order. /// - /// A low-rate stream muxed whole, like an audio frame's few packets or two frames back to - /// back, would otherwise arrive at the multiplex's full rate and overflow its 512-byte - /// transport buffer, which drains at 2 Mb/s (ISO 13818-1 2.4.2.3). The program tables (PAT - /// and the PMT on `pmt_pid`) move ahead of every packet muxed after them, so they still - /// lead the keyframe they were written for and a reader knows each PID before its first - /// packet. - pub fn layout(&self, pmt_pid: u16, null: &[u8]) -> Vec { - let packets: Vec<&[u8]> = self - .packets - .as_chunks::<{ TsPacket::SIZE }>() - .0 - .iter() - .map(|p| p.as_slice()) - .collect(); - let mut counts: HashMap = HashMap::new(); - for packet in &packets { - *counts.entry(pid(packet)).or_default() += 1; + /// A PID's packets in a run would arrive at the multiplex's full rate and overflow its + /// 512-byte transport buffer, which drains only at its Rx (ISO 13818-1 2.4.2.3): an audio + /// frame's few packets muxed whole, or a video PID taking most of the slot. The program + /// tables (PAT and the PMT on `pmt_pid`) go just ahead of the first packet muxed after + /// them, so they still lead the keyframe they were written for and a reader knows each PID + /// before its first packet. + pub fn layout(&self, clock: &[u8], pmt_pid: u16, null: &[u8]) -> Vec { + let packets = self.packets.as_chunks::<{ TsPacket::SIZE }>().0; + let mut tables = Vec::new(); + // Each PID's packets in mux order: the clock's PID first, then the rest as they appear. + let mut lanes: Vec<(u16, VecDeque)> = vec![(pid(clock), VecDeque::new())]; + for (i, packet) in packets.iter().enumerate() { + let pid = pid(packet); + if pid == 0 || pid == pmt_pid { + tables.push(i); + } else if let Some((_, lane)) = lanes.iter_mut().find(|(p, _)| *p == pid) { + lane.push_back(i); + } else { + lanes.push((pid, VecDeque::from([i]))); + } } - // The k-th of a PID's n packets sits at (2k + 1) / 2n of the way through. - let mut seen: HashMap = HashMap::new(); - let mut keys: Vec<(u64, u64)> = packets + // Every position, each lane (the nulls last) gains its weight and the one furthest + // ahead takes it, paying back the total: over the slot each takes exactly its weight. + // The clock takes the first. + let mut weights: Vec = lanes .iter() - .map(|packet| { - let pid = pid(packet); - let k = seen.entry(pid).or_default(); - *k += 1; - (2 * *k - 1, 2 * counts[&pid]) - }) + .map(|(_, lane)| lane.len() as i64) + .chain([self.nulls as i64]) .collect(); - let cmp = |(an, ad): (u64, u64), (bn, bd): (u64, u64)| { - (u128::from(an) * u128::from(bd)).cmp(&(u128::from(bn) * u128::from(ad))) - }; - let mut after = (1, 1); - for (packet, key) in packets.iter().zip(keys.iter_mut()).rev() { - let pid = pid(packet); - if pid == 0 || pid == pmt_pid { - *key = after; - } else if cmp(*key, after).is_lt() { - after = *key; + weights[0] += 1; + let total: i64 = weights.iter().sum(); + let mut credit = weights.clone(); + credit[0] -= total; + let mut tables = tables.into_iter().peekable(); + let mut out = Vec::with_capacity((1 + packets.len() + self.nulls) * TsPacket::SIZE); + out.extend_from_slice(clock); + for _ in 1..total { + for (credit, weight) in credit.iter_mut().zip(&weights) { + *credit += weight; } - } - let mut order: Vec = (0..packets.len()).collect(); - order.sort_by(|&a, &b| cmp(keys[a], keys[b])); - - // Media packet i of k lands at (2i + 1) / 2k of the way through, nulls in between. - let (k, total) = (order.len(), order.len() + self.nulls); - let mut out = Vec::with_capacity(total * TsPacket::SIZE); - let mut next = 0; - for at in 0..total { - if next < k && at == (2 * next + 1) * total / (2 * k) { - out.extend_from_slice(packets[order[next]]); - next += 1; - } else { + let mut pick = 0; + for lane in 1..credit.len() { + if credit[lane] > credit[pick] { + pick = lane; + } + } + credit[pick] -= total; + let Some((_, lane)) = lanes.get_mut(pick) else { out.extend_from_slice(null); + continue; + }; + let i = lane + .pop_front() + .expect("a lane takes no more positions than its weight"); + while let Some(table) = tables.next_if(|&table| table < i) { + out.extend_from_slice(&packets[table]); } + out.extend_from_slice(&packets[i]); + } + for table in tables { + out.extend_from_slice(&packets[table]); } out } @@ -240,6 +248,8 @@ pub(super) struct Schedule { last: Option, /// Each PID's buffers, from [`Self::set_buffer`]. buffers: HashMap, + /// The PID every slot's clock packet rides on, from [`Self::set_clock`]. + clock: Option, /// Units sent into a decoder buffer: their PID, due slot and bytes held. decoding: VecDeque<(u16, u128, usize)>, /// Every source has ended, so a unit that misses its deadline is the tail going out late @@ -257,6 +267,7 @@ impl Schedule { horizon: None, last: None, buffers: HashMap::new(), + clock: None, decoding: VecDeque::new(), ended: false, } @@ -277,6 +288,11 @@ impl Schedule { self.buffers.insert(pid, buffer); } + /// Count every slot's clock packet, which rides on `pid`, against that PID's buffers. + pub fn set_clock(&mut self, pid: u16) { + self.clock = Some(pid); + } + /// Queue an access unit on `pid` (whole TS packets, the tables muxed ahead of it first) /// whose last byte has to arrive by `by` nanoseconds: its decode time, less what the /// receiver needs to pass the last packet on. @@ -451,7 +467,7 @@ impl Schedule { let mut order: Vec = (0..self.units.len()).collect(); order.sort_by_key(|&i| self.units[i].due); let mut take = vec![0; self.units.len()]; - let mut carried: HashMap = HashMap::new(); + let mut carried: HashMap = self.clock.map(|pid| (pid, 1)).into_iter().collect(); let mut stopped: Vec = Vec::new(); for i in order { let unit = &self.units[i]; @@ -674,6 +690,29 @@ mod tests { } } + /// The clock packet opening every slot rides its PID's transport buffer too, so that PID + /// takes one packet fewer of its own. + #[test] + fn the_clock_packet_counts_against_its_pid() { + let mut schedule = Schedule::new(Duration::from_millis(100)); + schedule.set_rate(Some(RATE)); + // 2 Mb/s: 33 packets a slot, less one, the clock packet among them. + let audio = Buffer { + rate: 2_000_000, + size: Some(100_000), + }; + schedule.set_buffer(1, audio); + schedule.set_clock(1); + for at in 0..20 { + schedule.push(1, ms(1_000 + at), unit(1, 4), false); + } + let most = drain(&mut schedule) + .into_iter() + .map(|(_, per_pid, _)| per_pid.get(&1).copied().unwrap_or(0)) + .max(); + assert_eq!(most, Some(31)); + } + /// A PID never holds more in its decoder buffer than it has: the next unit waits for the /// one before it to decode. #[test] @@ -784,4 +823,67 @@ mod tests { .collect(); assert_eq!(sent, [(40, 50, 0), (41, 4, 0), (42, 0, 0), (43, 0, 0), (44, 0, 0)]); } + + /// A slot of a 25 Mb/s CBR feed whose video takes 86 % of it still keeps the video within + /// its 512-byte transport buffer, which drains at Rx (moq-dev/moq#5142). + #[test] + fn a_full_slot_keeps_video_within_its_transport_buffer() { + // 416 packets a slot at 25 Mb/s: the clock packet on the video PID, 358 video packets, 9 AC-3, + // three MPEG audio PIDs of 5, 1 teletext, and nulls. + let mut packets = unit(111, 358); + packets.extend(unit(122, 9)); + for pid in [121, 123, 124] { + packets.extend(unit(pid, 5)); + } + packets.extend(unit(131, 1)); + let media = packets.len() / TsPacket::SIZE; + let slot = Slot { + index: 0, + pcr: 0, + packets, + nulls: 416 - 1 - media, + keyframe: false, + units: vec![], + }; + let mut null = unit(0, 1); + (null[1], null[2]) = (0x1f, 0xff); + let laid = slot.layout(&unit(111, 1), 100, &null); + + // TB: 188 B in per packet on the PID, drained at Rx = 1.2 x 17,999,872 b/s. + let drain = 21_599_846.0 / 8.0 * (188.0 * 8.0 / 25_000_000.0); + let (mut tb, mut peak) = (0.0f64, 0.0f64); + for _ in 0..4 { + for packet in laid.chunks(188) { + tb = (tb - drain).max(0.0); + if pid(packet) == 111 { + tb += 188.0; + peak = peak.max(tb); + } + } + } + assert!(peak <= 512.0, "TB peak {peak:.0} B"); + } + + /// The program tables go just ahead of the first packet muxed after them, on whichever PID + /// the round-robin lays out first, so they still lead the keyframe they were written for. + #[test] + fn the_tables_lead_every_packet_muxed_after_them() { + // The PAT and PMT, a keyframe of one packet on PID 1, then four packets on PID 3. + let mut packets = unit(0, 1); + packets.extend(unit(100, 1)); + packets.extend(unit(1, 1)); + packets.extend(unit(3, 4)); + let slot = Slot { + index: 0, + pcr: 0, + packets, + nulls: 2, + keyframe: true, + units: vec![1, 3], + }; + let mut null = unit(0, 1); + (null[1], null[2]) = (0x1f, 0xff); + let laid: Vec = slot.layout(&unit(1, 1), 100, &null).chunks(188).map(pid).collect(); + assert_eq!(laid, [1, 0, 100, 3, 0x1fff, 3, 3, 1, 0x1fff, 3]); + } } diff --git a/test/ts/README.md b/test/ts/README.md index 9f39baa508..47e495ebe6 100644 --- a/test/ts/README.md +++ b/test/ts/README.md @@ -34,6 +34,7 @@ just test ts --live # grade PCR release timing off the live pipe just test ts --pair # two exporters of one broadcast, grade table anchoring just test ts --open-gop # open-GOP clip; its leading pictures must survive just test ts --hrd # 1080p video filling a broadcast-sized 9 Mbit CPB +just test ts --headroom # the same with four audio PIDs, muxed above the video's Rx just test ts --delay 1s # pass the exporter's --delay ``` @@ -367,7 +368,12 @@ DTS; CI runs that too (`just test ts --bitrate 2000000`). `--hrd` goes further: 1080p encode with a 9 Mbit NAL HRD kept near full by noise, the shape of a contribution encoder's output, which sends pictures most of a second ahead of their decode time and loads the decoder buffer past 60 % at the default delay (the recipe -moq-dev/moq#4645 graded with; CI runs it too). A real constant-rate +moq-dev/moq#4645 graded with; CI runs it too). Every other clip muxes at or below the +video's Rx, where its transport buffer cannot fill. `--headroom` puts that video +beside three MPEG-1 Layer II PIDs and an AC-3 one in a 12.5 Mb/s multiplex, above +the video's 10.8 Mb/s Rx, as a broadcast feed runs; each slot then has to +interleave the video with the audio and the nulls, or TB overflows +(moq-dev/moq#5142; CI runs it too). A real constant-rate capture discriminates further. A 60 s cut of a 9.95 Mb/s broadcast clip round-tripped through the harness before the export kept a schedule came back with a median of 1,316 B between PCRs against 31,081 B nominal and 3.1 % of intervals @@ -584,9 +590,9 @@ exporter re-emits SI on its own repetition cadence rather than the source's. ## CI `.github/workflows/interop.yml` runs `just test ts`, `just test ts --bitrate -2000000`, `just test ts --hrd`, `just test ts --open-gop`, `just test ts-eit`, and -`just test ts-tstd` after the interop matrix (nightly, on demand, and on PRs -touching `test/ts/`). +2000000`, `just test ts --hrd`, `just test ts --headroom`, +`just test ts --open-gop`, `just test ts-eit`, and `just test ts-tstd` after the +interop matrix (nightly, on demand, and on PRs touching `test/ts/`). `ts-eit` is `eit-roundtrip.sh`: it builds the sparse-schedule and pending-version fixtures from a generated clip, round-trips them through a relay, and censuses the capture, so a break in the generators or in the SI diff --git a/test/ts/run.sh b/test/ts/run.sh index 12cb5a79c7..3a2ef41ddd 100755 --- a/test/ts/run.sh +++ b/test/ts/run.sh @@ -20,6 +20,7 @@ # ./run.sh --pair # two exporters of one broadcast, grade table anchoring # ./run.sh --open-gop # open-GOP clip; its leading pictures must survive # ./run.sh --hrd # 1080p video filling a broadcast-sized 9 Mbit CPB +# ./run.sh --headroom # the same with four audio PIDs, muxed above the video's Rx # ./run.sh --delay 1s # pass the exporter's --delay # `--live` swaps the analyzer, not the rig. compliance.py grades a captured file @@ -65,6 +66,7 @@ LIVE="" # grade the exporter's stdout as it arrives, rather than a capture PAIR="" # subscribe twice and grade the two captures against each other OPEN_GOP="" # publish open GOP with leading pictures, and grade them through the round-trip HRD="" # publish video that fills a broadcast-sized CPB, as a contribution encoder does +HEADROOM="" # the same plus four audio PIDs, in a multiplex faster than the video's Rx EXPORT=() # extra `export ts` flags (--delay) # How far into the run the second subscriber joins. A late join is the point: two # exporters started together can share a cadence by starting together, which is @@ -74,6 +76,7 @@ PAIR_JOIN="${TSC_PAIR_JOIN:-5}" # the analyzer's emission floor and go report-only, which reads as a pass. PAIR_MIN_OVERLAP="${TSC_PAIR_MIN_OVERLAP:-25}" DURATION_SET="" # so pair mode can raise the default without overriding an explicit --duration +BITRATE_SET="" # so headroom mode can raise the default without overriding an explicit --bitrate PASSTHRU=() # forwarded to compliance.py (thresholds, --report-json, ...) while [[ $# -gt 0 ]]; do @@ -93,6 +96,7 @@ while [[ $# -gt 0 ]]; do ;; --bitrate) BITRATE="$2" + BITRATE_SET=1 shift 2 ;; --port) @@ -131,6 +135,10 @@ while [[ $# -gt 0 ]]; do HRD=1 shift ;; + --headroom) + HEADROOM=1 + shift + ;; --delay) EXPORT=(--delay "$2") shift 2 @@ -166,6 +174,12 @@ if [[ -n "$HRD" && -n "$OPEN_GOP$SOURCE$ANALYZE_ONLY" ]]; then echo "error: --hrd generates its own clip and cannot be combined with --open-gop, --source, or --analyze-only" >&2 exit 1 fi +if [[ -n "$HEADROOM" && -n "$HRD$OPEN_GOP$SOURCE$ANALYZE_ONLY" ]]; then + echo "error: --headroom generates its own clip and cannot be combined with --hrd, --open-gop, --source, or --analyze-only" >&2 + exit 1 +fi +# Room for the audio, with the video still taking up to 86 % of the multiplex. +[[ -n "$HEADROOM" && -z "$BITRATE_SET" ]] && BITRATE=12500000 if [[ -n "$OPEN_GOP" && -n "$ANALYZE_ONLY$LIVE$PAIR" ]]; then echo "error: --open-gop cannot be combined with --analyze-only, --live, or --pair" >&2 exit 1 @@ -254,14 +268,29 @@ if [[ -n "$SOURCE" ]]; then sed 's/^/ tsp: /' "$HARNESS_RUN/tsp-cut.log" >&2 || true exit 1 } -elif [[ -n "$HRD" ]]; then - echo "### generating ~${DURATION}s 1080p clip filling a 9 Mbit CPB with ffmpeg" +elif [[ -n "$HRD$HEADROOM" ]]; then + echo "### generating ~${DURATION}s 1080p clip filling a 9 Mbit CPB${HEADROOM:+, with four audio PIDs,} with ffmpeg" # A contribution encoder's shape: CBR video whose HRD declares the whole buffer, kept # near full by noise, so the source sends pictures most of a second ahead of their # decode time and the export has to as well (moq-dev/moq#4645). + INPUTS=(-f lavfi -i "testsrc2=size=1920x1080:rate=25,noise=alls=12:allf=t") + AUDIO=(-an) + if [[ -n "$HEADROOM" ]]; then + # A broadcast feed's audio beside it: three MPEG-1 Layer II PIDs and an AC-3 one, in a + # multiplex faster than the video's Rx (10.8 Mb/s). Only then can video packets arrive + # faster than its transport buffer drains, so each slot has to interleave them with + # the audio and the nulls (moq-dev/moq#5142). At 192 kb/s an AC-3 frame stays within + # a third of the 2,592-byte buffer ffmpeg's ATSC carriage gives it. + AUDIO=(-map 0:v) + for k in 1 2 3 4; do + INPUTS+=(-f lavfi -i "sine=frequency=$((330 * k)):sample_rate=48000") + AUDIO+=(-map "$k:a") + done + AUDIO+=(-ac 2 -c:a mp2 -b:a 192k -c:a:3 ac3) + fi ffmpeg -y -hide_banner -loglevel error \ - -f lavfi -i "testsrc2=size=1920x1080:rate=25,noise=alls=12:allf=t" \ - -t "$DURATION" -an \ + "${INPUTS[@]}" \ + -t "$DURATION" "${AUDIO[@]}" \ -c:v libx264 -profile:v high -level 4.0 -preset veryfast -pix_fmt yuv420p \ -x264-params "keyint=25:min-keyint=25:scenecut=0:nal-hrd=cbr" -b:v 9M -maxrate 9M -bufsize 9M \ -f mpegts -muxrate "$BITRATE" -pcr_period 20 -pes_payload_size 0 "$SRC_TS" From ec1d3763f71906685cd9b27db39b3b80c098f90f Mon Sep 17 00:00:00 2001 From: Thomas Drapier Date: Sat, 10 Oct 2026 19:19:48 +0100 Subject: [PATCH 3/7] fix(moq-mux): keep a one-packet PCR PID's media moving beside the clock The clock packet counted against its PID's per-slot budget left nothing for media on a PID below about 180 kb/s, so its units never finished. The clock now counts only when the budget has a packet to spare. Also honor TSC_BITRATE under --headroom. Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor --- rs/moq-mux/src/container/ts/schedule.rs | 45 ++++++++++++++++++++++--- test/ts/run.sh | 2 +- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/rs/moq-mux/src/container/ts/schedule.rs b/rs/moq-mux/src/container/ts/schedule.rs index 17db9403f8..c0fa042322 100644 --- a/rs/moq-mux/src/container/ts/schedule.rs +++ b/rs/moq-mux/src/container/ts/schedule.rs @@ -78,9 +78,9 @@ impl Buffer { size: None, }; - /// The packets one slot may carry on the PID, a clock packet on it included: what its - /// transport buffer passes on within the slot, less one. Even laid out evenly, the packets - /// bunch a little where one slot meets the next, and the slack drains that off. + /// The packets one slot may carry on the PID: what its transport buffer passes on within + /// the slot, less one, but at least one. Even laid out evenly, the packets bunch a little + /// where one slot meets the next, and the slack drains that off. fn per_slot(&self) -> usize { let bits = u128::from(self.rate) * PCR_INTERVAL.as_nanos() / 1_000_000_000; (bits / (TsPacket::SIZE as u128 * 8)).saturating_sub(1).max(1) as usize @@ -288,7 +288,8 @@ impl Schedule { self.buffers.insert(pid, buffer); } - /// Count every slot's clock packet, which rides on `pid`, against that PID's buffers. + /// Count every slot's clock packet, which rides on `pid`, against that PID's transport + /// buffer, as long as the PID keeps a packet a slot for its media. pub fn set_clock(&mut self, pid: u16) { self.clock = Some(pid); } @@ -467,7 +468,14 @@ impl Schedule { let mut order: Vec = (0..self.units.len()).collect(); order.sort_by_key(|&i| self.units[i].due); let mut take = vec![0; self.units.len()]; - let mut carried: HashMap = self.clock.map(|pid| (pid, 1)).into_iter().collect(); + // The clock packet counts against its PID unless that would leave the PID's media no + // packet at all, which would never finish a unit however sparse its frames. + let mut carried: HashMap = self + .clock + .filter(|pid| self.buffers.get(pid).is_some_and(|buffer| buffer.per_slot() > 1)) + .map(|pid| (pid, 1)) + .into_iter() + .collect(); let mut stopped: Vec = Vec::new(); for i in order { let unit = &self.units[i]; @@ -713,6 +721,33 @@ mod tests { assert_eq!(most, Some(31)); } + /// A PID whose transport buffer passes on a single packet a slot still carries one of its + /// own beside the clock packet, so its units finish on time and the stream ends. + #[test] + fn a_one_packet_pid_still_progresses_beside_the_clock() { + let mut schedule = Schedule::new(Duration::from_millis(100)); + schedule.set_rate(Some(RATE)); + // 76.8 kb/s: one packet a slot. + let slow = Buffer { + rate: 76_800, + size: Some(100_000), + }; + schedule.set_buffer(1, slow); + schedule.set_clock(1); + schedule.push(1, ms(1_000), unit(1, 4), true); + schedule.push(1, ms(1_200), unit(1, 4), false); + let mut sent = Vec::new(); + for _ in 0..100 { + let Some(slot) = schedule.next(None).unwrap() else { + break; + }; + sent.push(slot.packets.len() / TsPacket::SIZE); + } + assert!(sent.iter().all(|&n| n <= 1), "one packet a slot: {sent:?}"); + assert_eq!(sent.iter().sum::(), 8, "both units go out"); + assert!(schedule.is_empty(), "and the stream ends"); + } + /// A PID never holds more in its decoder buffer than it has: the next unit waits for the /// one before it to decode. #[test] diff --git a/test/ts/run.sh b/test/ts/run.sh index 3a2ef41ddd..5deecc3963 100755 --- a/test/ts/run.sh +++ b/test/ts/run.sh @@ -179,7 +179,7 @@ if [[ -n "$HEADROOM" && -n "$HRD$OPEN_GOP$SOURCE$ANALYZE_ONLY" ]]; then exit 1 fi # Room for the audio, with the video still taking up to 86 % of the multiplex. -[[ -n "$HEADROOM" && -z "$BITRATE_SET" ]] && BITRATE=12500000 +[[ -n "$HEADROOM" && -z "$BITRATE_SET" && -z "${TSC_BITRATE:-}" ]] && BITRATE=12500000 if [[ -n "$OPEN_GOP" && -n "$ANALYZE_ONLY$LIVE$PAIR" ]]; then echo "error: --open-gop cannot be combined with --analyze-only, --live, or --pair" >&2 exit 1 From 88c98f804e79beb2fcd012ad6c29bcce56fcd250 Mon Sep 17 00:00:00 2001 From: Thomas Drapier Date: Sat, 10 Oct 2026 19:19:48 +0100 Subject: [PATCH 4/7] quest: plan ATSC AC-3 at every A/52 rate in TS export Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor --- quest/m1/README.md | 1 + quest/m1/ts-atsc-ac3.md | 42 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 quest/m1/ts-atsc-ac3.md diff --git a/quest/m1/README.md b/quest/m1/README.md index e06cb20693..f89bd7a330 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -168,6 +168,7 @@ ladders. - [Encoder colour](/quest/m1/color-model.md) - every moq-video encode path signals the colour its output actually has, or refuses instead of mislabelling - [T-STD TS export](/quest/m1/tstd/README.md) - `moq export ts` is a proper remux that passes the T-STD buffer model, starting with a fixed `--delay` - [Multi-packet PMT](/quest/m1/ts-pmt-split.md) - `export ts` splits a PMT longer than one packet instead of exiting before its first packet +- [ATSC AC-3](/quest/m1/ts-atsc-ac3.md) - `export ts` carries ATSC AC-3 at every A/52 rate up to 640 kb/s instead of aborting from 384 kb/s up - [Rust non-continuous signal](/quest/m1/rust-continuous.md) - the Rust container consumer reports a frame after a subscribe or discontinuity as non-continuous, like JS, for the tune-in and warmup trims - [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - a viewer joining at a recovery point drops the leading pictures it cannot decode; continuous viewers keep them - [Watch decode errors](/quest/m1/watch-decode-error.md) - a WebCodecs error ends the subscription and the element reports it diff --git a/quest/m1/ts-atsc-ac3.md b/quest/m1/ts-atsc-ac3.md new file mode 100644 index 0000000000..54e4f9de2d --- /dev/null +++ b/quest/m1/ts-atsc-ac3.md @@ -0,0 +1,42 @@ +# [M] TS export carries ATSC AC-3 at every A/52 rate + +## Goal + +`moq export ts` carries AC-3 as ATSC (`stream_type` 0x81, with the 2,592-byte +main buffer of ATSC A/53 Part 5) at every A/52 rate up to 640 kb/s without +missing a decode deadline, and the output passes `compliance.py`'s strict +`tstd` check. Today every rate from 384 kb/s up aborts the export, and 384 and +448 kb/s 5.1 is ATSC's standard main audio. The carriage stays ATSC: DVB AC-3 +(5,696 B) already fits every rate, and E-AC-3 has +[its own quest](/quest/m2/ts-eac3.md). + +## Plan + +Decided (2026-10-10), planned from #5207, whose multi-PID `--headroom` arm had +to drop its AC-3 to 192 kb/s. `Schedule::admit` +(`rs/moq-mux/src/container/ts/schedule.rs`) keeps a unit's bytes in its +decoder buffer until the 25 ms slot after its due slot is past, not until it +decodes. AC-3 frames are 32 ms apart, so two frames' due slots can be one +apart, and the second must then fit beside the whole first: at 384 kb/s the +two hold 3,312 B against 2,592. The T-STD (ISO 13818-1 2.4.2.3) frees B at the +decode instant, and a 448 kb/s frame takes about 7 ms at Rx = 2 Mb/s, leaving +some 25 ms of its 32 ms spacing. The B overflow (122 %) graded on that run's +partial output is the same coarse timing, not a separate bug. + +- Free each unit's bytes at its decode instant, for every PID with a decoder + buffer (AC-3, AAC, MP2, E-AC-3, and video's EB), so the schedule follows the + standard everywhere rather than special-casing AC-3. In the slot where a + buffer frees up, the packets admitted against the freed room go after that + instant: `Slot::layout` (from #5207) gains an earliest position per PID + beside its round-robin. Chosen over admitting by sub-slots (the same idea, + coarser) and over shrinking the 25 ms PCR grid, which would cost each PID + about a tenth of its Rx to the per-slot slack, 2.5 times the clock packets, + and every PCR-timing expectation. +- [Send-ahead](/quest/m1/tstd/send-ahead.md) bounds a unit's reach by what its + decoder buffer holds and changes the same code; whichever lands second + rebases onto the other. +- Test: schedule unit tests at 384, 448, 512, and 640 kb/s frame sizes against + the 2,592 B buffer, each frame finishing by its deadline, with the existing + decoder-buffer tests moved to the exact decode instant. Raise the + `--headroom` arm's AC-3 (`test/ts/run.sh`) from 192 to 640 kb/s, the worst + case, so CI grades it under strict `tstd` on every run. From 4bb297259bc89e09fbc4a1f4adf5238ca37f567b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 12:31:32 -0700 Subject: [PATCH 5/7] fix(moq-mux): carry each program table once per TS slot Tables repeated ahead of several PIDs' keyframes in one slot all went ahead of the first packet muxed after them, in a run that overflows the system transport buffer. A repeat tells a reader nothing new, so a null takes its place. Also scope the ATSC AC-3 quest to 48 kHz. Co-Authored-By: Claude Opus 5.5 --- quest/m1/README.md | 2 +- quest/m1/ts-atsc-ac3.md | 12 ++++--- rs/moq-mux/src/container/ts/schedule.rs | 45 ++++++++++++++++++++++--- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/quest/m1/README.md b/quest/m1/README.md index c8afc10778..9894b9cfd9 100644 --- a/quest/m1/README.md +++ b/quest/m1/README.md @@ -172,7 +172,7 @@ bench all require its crate. - [Encoder colour](/quest/m1/color-model.md) - every moq-video encode path signals the colour its output actually has, or refuses instead of mislabelling - [T-STD TS export](/quest/m1/tstd/README.md) - `moq export ts` is a proper remux that passes the T-STD buffer model, starting with a fixed `--delay` - [Multi-packet PMT](/quest/m1/ts-pmt-split.md) - `export ts` splits a PMT longer than one packet instead of exiting before its first packet -- [ATSC AC-3](/quest/m1/ts-atsc-ac3.md) - `export ts` carries ATSC AC-3 at every A/52 rate up to 640 kb/s instead of aborting from 384 kb/s up +- [ATSC AC-3](/quest/m1/ts-atsc-ac3.md) - `export ts` carries 48 kHz ATSC AC-3 at every A/52 rate up to 640 kb/s instead of aborting from 384 kb/s up - [Rust non-continuous signal](/quest/m1/rust-continuous.md) - the Rust container consumer reports a frame after a subscribe or discontinuity as non-continuous, like JS, for the tune-in and warmup trims - [Open-GOP leading pictures](/quest/m1/open-gop-leading-pictures.md) - a viewer joining at a recovery point drops the leading pictures it cannot decode; continuous viewers keep them - [Watch decode errors](/quest/m1/watch-decode-error.md) - a WebCodecs error ends the subscription and the element reports it diff --git a/quest/m1/ts-atsc-ac3.md b/quest/m1/ts-atsc-ac3.md index 54e4f9de2d..589e3dfde0 100644 --- a/quest/m1/ts-atsc-ac3.md +++ b/quest/m1/ts-atsc-ac3.md @@ -1,14 +1,16 @@ -# [M] TS export carries ATSC AC-3 at every A/52 rate +# [M] TS export carries 48 kHz ATSC AC-3 at every A/52 rate ## Goal -`moq export ts` carries AC-3 as ATSC (`stream_type` 0x81, with the 2,592-byte -main buffer of ATSC A/53 Part 5) at every A/52 rate up to 640 kb/s without -missing a decode deadline, and the output passes `compliance.py`'s strict +`moq export ts` carries 48 kHz AC-3 as ATSC (`stream_type` 0x81, with the +2,592-byte main buffer of ATSC A/53 Part 5) at every A/52 rate up to 640 kb/s +without missing a decode deadline, and the output passes `compliance.py`'s strict `tstd` check. Today every rate from 384 kb/s up aborts the export, and 384 and 448 kb/s 5.1 is ATSC's standard main audio. The carriage stays ATSC: DVB AC-3 (5,696 B) already fits every rate, and E-AC-3 has -[its own quest](/quest/m2/ts-eac3.md). +[its own quest](/quest/m2/ts-eac3.md). 48 kHz is the only rate ATSC carries; +a 44.1 or 32 kHz frame can outgrow the buffer on its own (2,786 and 3,840 B at +640 kb/s), so whether those error or go out as DVB is left open. ## Plan diff --git a/rs/moq-mux/src/container/ts/schedule.rs b/rs/moq-mux/src/container/ts/schedule.rs index c0fa042322..077be09927 100644 --- a/rs/moq-mux/src/container/ts/schedule.rs +++ b/rs/moq-mux/src/container/ts/schedule.rs @@ -155,16 +155,22 @@ impl Slot { /// frame's few packets muxed whole, or a video PID taking most of the slot. The program /// tables (PAT and the PMT on `pmt_pid`) go just ahead of the first packet muxed after /// them, so they still lead the keyframe they were written for and a reader knows each PID - /// before its first packet. + /// before its first packet. A table repeated within the slot, as when several PIDs' + /// keyframes each carry the tables, tells a reader nothing new and would only add to that + /// run, so a null takes its place. pub fn layout(&self, clock: &[u8], pmt_pid: u16, null: &[u8]) -> Vec { let packets = self.packets.as_chunks::<{ TsPacket::SIZE }>().0; - let mut tables = Vec::new(); + let mut tables: Vec = Vec::new(); + let mut nulls = self.nulls; // Each PID's packets in mux order: the clock's PID first, then the rest as they appear. let mut lanes: Vec<(u16, VecDeque)> = vec![(pid(clock), VecDeque::new())]; for (i, packet) in packets.iter().enumerate() { let pid = pid(packet); if pid == 0 || pid == pmt_pid { - tables.push(i); + match tables.iter().any(|&table| packets[table] == *packet) { + true => nulls += 1, + false => tables.push(i), + } } else if let Some((_, lane)) = lanes.iter_mut().find(|(p, _)| *p == pid) { lane.push_back(i); } else { @@ -178,7 +184,7 @@ impl Slot { let mut weights: Vec = lanes .iter() .map(|(_, lane)| lane.len() as i64) - .chain([self.nulls as i64]) + .chain([nulls as i64]) .collect(); weights[0] += 1; let total: i64 = weights.iter().sum(); @@ -921,4 +927,35 @@ mod tests { let laid: Vec = slot.layout(&unit(1, 1), 100, &null).chunks(188).map(pid).collect(); assert_eq!(laid, [1, 0, 100, 3, 0x1fff, 3, 3, 1, 0x1fff, 3]); } + + /// Tables repeated ahead of several PIDs' keyframes in one slot would all go ahead of the + /// first of them in a run, overflowing the system transport buffer, so the slot carries each + /// once and nulls take the repeats' places. + #[test] + fn a_slot_carries_each_table_once() { + // The PAT and PMT ahead of a keyframe on each of PIDs 1, 2 and 3, the last the heaviest. + let mut packets = Vec::new(); + for (pid, count) in [(1, 2), (2, 2), (3, 10)] { + packets.extend(unit(0, 1)); + packets.extend(unit(100, 1)); + packets.extend(unit(pid, count)); + } + let slot = Slot { + index: 0, + pcr: 0, + packets, + nulls: 4, + keyframe: true, + units: vec![1, 2, 3], + }; + let mut null = unit(0, 1); + (null[1], null[2]) = (0x1f, 0xff); + let laid: Vec = slot.layout(&unit(1, 1), 100, &null).chunks(188).map(pid).collect(); + assert_eq!(laid.len(), 1 + 20 + 4, "the slot keeps its length"); + assert_eq!(laid.iter().filter(|&&pid| pid == 0).count(), 1, "one PAT: {laid:?}"); + assert_eq!(laid.iter().filter(|&&pid| pid == 100).count(), 1, "one PMT: {laid:?}"); + // After the clock packet, no media goes ahead of the tables. + let pmt = laid.iter().position(|&pid| pid == 100).unwrap(); + assert!(laid[1..pmt].iter().all(|&pid| pid == 0 || pid == 0x1fff), "the tables lead: {laid:?}"); + } } From 9cd2a76985627e9e15edfc785792b8b62568e6dd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Sat, 10 Oct 2026 12:34:37 -0700 Subject: [PATCH 6/7] revert(moq-mux): keep every repeated table in a TS slot Dropping a repeated PAT/PMT breaks the AAC program config's tune-in points: with send-ahead a slot carries several units, each led by its own tables. The burst of repeated tables is left for a follow-up. Co-Authored-By: Claude Opus 5.5 --- rs/moq-mux/src/container/ts/schedule.rs | 45 +++---------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/rs/moq-mux/src/container/ts/schedule.rs b/rs/moq-mux/src/container/ts/schedule.rs index 2e2c8c1964..81096c74ea 100644 --- a/rs/moq-mux/src/container/ts/schedule.rs +++ b/rs/moq-mux/src/container/ts/schedule.rs @@ -155,22 +155,16 @@ impl Slot { /// frame's few packets muxed whole, or a video PID taking most of the slot. The program /// tables (PAT and the PMT on `pmt_pid`) go just ahead of the first packet muxed after /// them, so they still lead the keyframe they were written for and a reader knows each PID - /// before its first packet. A table repeated within the slot, as when several PIDs' - /// keyframes each carry the tables, tells a reader nothing new and would only add to that - /// run, so a null takes its place. + /// before its first packet. pub fn layout(&self, clock: &[u8], pmt_pid: u16, null: &[u8]) -> Vec { let packets = self.packets.as_chunks::<{ TsPacket::SIZE }>().0; - let mut tables: Vec = Vec::new(); - let mut nulls = self.nulls; + let mut tables = Vec::new(); // Each PID's packets in mux order: the clock's PID first, then the rest as they appear. let mut lanes: Vec<(u16, VecDeque)> = vec![(pid(clock), VecDeque::new())]; for (i, packet) in packets.iter().enumerate() { let pid = pid(packet); if pid == 0 || pid == pmt_pid { - match tables.iter().any(|&table| packets[table] == *packet) { - true => nulls += 1, - false => tables.push(i), - } + tables.push(i); } else if let Some((_, lane)) = lanes.iter_mut().find(|(p, _)| *p == pid) { lane.push_back(i); } else { @@ -184,7 +178,7 @@ impl Slot { let mut weights: Vec = lanes .iter() .map(|(_, lane)| lane.len() as i64) - .chain([nulls as i64]) + .chain([self.nulls as i64]) .collect(); weights[0] += 1; let total: i64 = weights.iter().sum(); @@ -928,35 +922,4 @@ mod tests { let laid: Vec = slot.layout(&unit(1, 1), 100, &null).chunks(188).map(pid).collect(); assert_eq!(laid, [1, 0, 100, 3, 0x1fff, 3, 3, 1, 0x1fff, 3]); } - - /// Tables repeated ahead of several PIDs' keyframes in one slot would all go ahead of the - /// first of them in a run, overflowing the system transport buffer, so the slot carries each - /// once and nulls take the repeats' places. - #[test] - fn a_slot_carries_each_table_once() { - // The PAT and PMT ahead of a keyframe on each of PIDs 1, 2 and 3, the last the heaviest. - let mut packets = Vec::new(); - for (pid, count) in [(1, 2), (2, 2), (3, 10)] { - packets.extend(unit(0, 1)); - packets.extend(unit(100, 1)); - packets.extend(unit(pid, count)); - } - let slot = Slot { - index: 0, - pcr: 0, - packets, - nulls: 4, - keyframe: true, - units: vec![1, 2, 3], - }; - let mut null = unit(0, 1); - (null[1], null[2]) = (0x1f, 0xff); - let laid: Vec = slot.layout(&unit(1, 1), 100, &null).chunks(188).map(pid).collect(); - assert_eq!(laid.len(), 1 + 20 + 4, "the slot keeps its length"); - assert_eq!(laid.iter().filter(|&&pid| pid == 0).count(), 1, "one PAT: {laid:?}"); - assert_eq!(laid.iter().filter(|&&pid| pid == 100).count(), 1, "one PMT: {laid:?}"); - // After the clock packet, no media goes ahead of the tables. - let pmt = laid.iter().position(|&pid| pid == 100).unwrap(); - assert!(laid[1..pmt].iter().all(|&pid| pid == 0 || pid == 0x1fff), "the tables lead: {laid:?}"); - } } From fc5f407b717b8796b5223f049ca61bbe8a554f6f Mon Sep 17 00:00:00 2001 From: Thomas Drapier Date: Sat, 10 Oct 2026 21:08:50 +0100 Subject: [PATCH 7/7] quest: settle ATSC AC-3 at 44.1 and 32 kHz A frame that fits the 2,592-byte buffer goes out like any other; one that outgrows it fails the export naming both sizes, rather than the generic missed deadline or a switch to DVB carriage. Co-authored-by: Claude Opus 5.5 Co-authored-by: Cursor --- quest/m1/ts-atsc-ac3.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/quest/m1/ts-atsc-ac3.md b/quest/m1/ts-atsc-ac3.md index 589e3dfde0..111cf4f495 100644 --- a/quest/m1/ts-atsc-ac3.md +++ b/quest/m1/ts-atsc-ac3.md @@ -8,9 +8,11 @@ without missing a decode deadline, and the output passes `compliance.py`'s stric `tstd` check. Today every rate from 384 kb/s up aborts the export, and 384 and 448 kb/s 5.1 is ATSC's standard main audio. The carriage stays ATSC: DVB AC-3 (5,696 B) already fits every rate, and E-AC-3 has -[its own quest](/quest/m2/ts-eac3.md). 48 kHz is the only rate ATSC carries; -a 44.1 or 32 kHz frame can outgrow the buffer on its own (2,786 and 3,840 B at -640 kb/s), so whether those error or go out as DVB is left open. +[its own quest](/quest/m2/ts-eac3.md). 48 kHz is the only rate ATSC carries. +A 44.1 or 32 kHz frame that fits the buffer goes out like any other, and one +that outgrows it on its own (2,786 and 3,840 B at 640 kb/s) fails the export +with an error naming both sizes, rather than the misleading "raise the delay or +the multiplex rate" or a switch to DVB carriage. ## Plan @@ -34,11 +36,17 @@ partial output is the same coarse timing, not a separate bug. coarser) and over shrinking the 25 ms PCR grid, which would cost each PID about a tenth of its Rx to the per-slot slack, 2.5 times the clock packets, and every PCR-timing expectation. +- A unit bigger than its PID's decoder buffer can never be admitted, so the + export fails it when it is pushed, naming the unit's size and the buffer's, + rather than waiting for its deadline to pass. Once every source has ended, + the tail still goes out late, as today. Chosen over switching such a track + to DVB carriage, which would silently change what an ATSC receiver is handed. - [Send-ahead](/quest/m1/tstd/send-ahead.md) bounds a unit's reach by what its decoder buffer holds and changes the same code; whichever lands second rebases onto the other. - Test: schedule unit tests at 384, 448, 512, and 640 kb/s frame sizes against the 2,592 B buffer, each frame finishing by its deadline, with the existing - decoder-buffer tests moved to the exact decode instant. Raise the + decoder-buffer tests moved to the exact decode instant, and a 32 kHz + 448 kb/s frame (2,688 B) failing with both sizes named. Raise the `--headroom` arm's AC-3 (`test/ts/run.sh`) from 192 to 640 kb/s, the worst case, so CI grades it under strict `tstd` on every run.