From 5f9ffe74f1b121b3bec72338fb60840590dc4a81 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 21 Sep 2026 04:21:01 -0700 Subject: [PATCH 1/3] quest(m2): claim ts-mux-rate Co-Authored-By: Claude Opus 5 From 1a629fac799ec0436ad1fe5ba78d047569416a4b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 21 Sep 2026 05:00:37 -0700 Subject: [PATCH 2/3] feat(moq-mux): record the TS mux rate and pad export to it `moq import ts` measures the multiplex rate off the PCR PID (every packet, stuffing included, over the ticks between clocks) and records it as `mpegts.muxRate` once a two-second window of half-second samples agrees on it, clearing the field when the source stops holding the rate or declares a time-base break. `moq export ts` pads its output with null packets to that rate, settling a fixed-point packet balance before every clock packet so the remainder carries across slots; `Export::with_mux_rate` and `moq export ts --mux-rate` override the catalog. Completes quest/m2/ts-mux-rate.md. Co-Authored-By: Claude Opus 5 --- doc/bin/cli.md | 8 + drafts/draft-lcurley-moq-mpegts.md | 10 + quest/next/README.md | 1 - quest/next/ts-mux-rate.md | 78 ----- rs/moq-cli/src/args.rs | 76 +++-- rs/moq-cli/src/main.rs | 11 +- rs/moq-cli/src/subscribe.rs | 7 + rs/moq-mux/src/container/ts/catalog.rs | 38 ++- rs/moq-mux/src/container/ts/export.rs | 119 +++++++- rs/moq-mux/src/container/ts/export_test.rs | 169 +++++++++++ rs/moq-mux/src/container/ts/import.rs | 127 +++++++- rs/moq-mux/src/container/ts/import_test.rs | 62 ++++ rs/moq-mux/src/container/ts/mod.rs | 1 + rs/moq-mux/src/container/ts/mux_rate.rs | 277 ++++++++++++++++++ .../src/container/ts/test_data/bbb_cbr.ts | Bin 0 -> 144948 bytes 15 files changed, 869 insertions(+), 115 deletions(-) delete mode 100644 quest/next/ts-mux-rate.md create mode 100644 rs/moq-mux/src/container/ts/mux_rate.rs create mode 100644 rs/moq-mux/src/container/ts/test_data/bbb_cbr.ts diff --git a/doc/bin/cli.md b/doc/bin/cli.md index bdb400332d..1efcaeea54 100644 --- a/doc/bin/cli.md +++ b/doc/bin/cli.md @@ -64,6 +64,14 @@ discarding the old mux buffer. The first new clock packet signals the break and stdout pacing re-anchors. Every rendition joins the new program generation; no track is fenced across the marker. +A constant-rate MPEG-TS source records its multiplex rate in the catalog +(`mpegts.muxRate`, measured off the PCR clock, null stuffing included), and +`export ts` pads its output with null packets back to that rate so an IRD or +groomer receives a constant-rate stream. `--mux-rate 5000000` pads to an explicit +rate instead, including for a broadcast that recorded none. Media is never delayed +or dropped to fit: a source that sustains more than the rate overruns it, and a +VBR source records nothing, so export without either stays unpadded. + fMP4 export writes one fragment per publisher group on each track. Audio follows the publisher's cuts; video normally follows GOPs. Closing a group flushes it even when the live publisher pauses. `--fragment-duration 2s` caps diff --git a/drafts/draft-lcurley-moq-mpegts.md b/drafts/draft-lcurley-moq-mpegts.md index 1d72b9106f..2a11af3a0e 100644 --- a/drafts/draft-lcurley-moq-mpegts.md +++ b/drafts/draft-lcurley-moq-mpegts.md @@ -110,6 +110,7 @@ type Mpegts = { "programDescriptors": Descriptor[] | undefined, "program": Program | undefined, "si": Map | undefined, + "muxRate": number | undefined, } ~~~ @@ -181,6 +182,13 @@ The key is `table_id` rather than PID because that is the granularity the requir PAT and PMT are never carried here: they are rebuilt from `program`, `programDescriptors`, and the per-track entries. +## muxRate {#field-mux-rate} +The rate the source's PCR clock paced the whole multiplex at, in bits per second: every PID, the PSI, and the null packets, measured as the packets between two PCRs over the time they span. +It is not a sum of the elementary streams, and a track's own bitrate keeps its codec meaning. + +A publisher MUST include `muxRate` only while the source holds a constant rate, and MUST omit it for a variable-rate or unpaced source; a value that has become invalid is removed rather than left stale. +A consumer rebuilding a transport stream SHOULD pad its output with null packets to `muxRate` ({{rebuild}}). + ### SI Track {#si-track} Each group is a complete picture of the entry's current sections: one frame per sub-table, each frame that sub-table's sections concatenated verbatim in `section_number` order. Sections are self-delimiting through `section_length`, so a frame needs no further framing. @@ -260,6 +268,7 @@ A consumer rebuilding a transport stream: - MUST re-emit each track's `descriptors` as its ES-level descriptors, and `programDescriptors` as the PMT's `program_info`. - MUST re-emit each `si` entry's sections byte-for-byte on that entry's PID, reading them from its track ({{si-track}}), at least as often as its `interval` when declared. - MUST repacketize each verbatim track per its `framing` and `streamType`, using `streamId` when recorded. +- SHOULD pad the output with null packets to `muxRate` when present, so the rebuilt stream is constant-rate again; a source that exceeds the rate is passed through rather than delayed or dropped. With no `program` the consumer synthesizes an identity, and SHOULD then omit any carried `si`, which describes a program that no longer exists. @@ -334,6 +343,7 @@ A broadcast demultiplexed from a DVB transport stream: video and audio described - Initial version. - The `Si` type is keyed by `table_id` only; the PID lives on the enclosing `si` map. - A consumer refuses a catalog with an unrecognized `framing` or an invalid `si` map key. +- Added `muxRate`, the source's constant multiplex rate. # Acknowledgments diff --git a/quest/next/README.md b/quest/next/README.md index cc34be245c..ccffb47203 100644 --- a/quest/next/README.md +++ b/quest/next/README.md @@ -143,7 +143,6 @@ Implementation that depends on a dev contract requires - [Intra-refresh GOPs](/quest/next/intra-refresh/README.md) - video with periodic intra refresh publishes, imports, and tunes in cleanly with one group per sweep and a catalog `warmup` - [Audio warmup](/quest/next/audio-warmup.md) - a viewer joining an Opus rendition mid-stream never hears the unconverged first 80 ms - [#3021](/quest/next/3021-moq-gst-anchor-generated-media-timelines-to-wall-clock.md) - GStreamer maps every pad onto one continuous broadcast clock across source restarts -- [Mux rate](/quest/next/ts-mux-rate.md) - moq ts: the source multiplex rate is recorded at import and export pads to it, so a CBR stream leaves as one - [#2779](/quest/next/2779-moq-export-ts-continuity-counters-are-numbered-from.md) - moq export ts: continuity counters are numbered from process state, so two exporters of the same broadcast emit streams that can never be compared - [#2829](/quest/next/2829-moq-export-ts-the-audio-video-interleave-is-decided-by.md) - moq export ts: the audio/video interleave is decided by arrival timing, so two exporters of one broadcast render the same media in different orders - [#3489](/quest/next/3489-ts-import-stream-liveness.md) - moq import ts: every elementary stream reports its access units and how long it has been quiet diff --git a/quest/next/ts-mux-rate.md b/quest/next/ts-mux-rate.md deleted file mode 100644 index f63dafe26c..0000000000 --- a/quest/next/ts-mux-rate.md +++ /dev/null @@ -1,78 +0,0 @@ -# [S] moq ts: record the source mux rate and pad export to it - -## Goal - -`moq import ts` records the transport stream's constant rate in the catalog, -and `moq export ts` pads its output with null packets to that rate, so a -broadcast that came in as a CBR multiplex leaves as one an IRD or groomer can -accept without measuring anything. The rate is the whole multiplex, every PID -plus PSI plus stuffing, which is what PCR clocks; per-rendition `bitrate` -keeps its codec meaning and is not touched. Import of a VBR or unpaced source -records nothing, and export of a catalog without the field is byte-identical to -today. - -Non-goals: MSFTS catalog convergence, carrying source PCR, SI coverage, and -group alignment stay in [#3731](https://github.com/moq-dev/moq/issues/3731) -until the next MSFTS revision is published; this quest settles only decision 6 -there. - -## Plan - -- `rs/moq-mux/src/container/ts/catalog.rs`: add `mux_rate: Option` to - `Mpegts` (serialized `muxRate`, bits per second), mark it with - `#[serde(default, skip_serializing_if = "Option::is_none")]`, and include it - in `is_empty`. Document it as the rate the PCR clock paces the multiplex at, - not a sum of elementary streams. An absent rate stays omitted from the - serialized catalog rather than becoming `null`. -- Import (`rs/moq-mux/src/container/ts/import.rs`): the PCR PID already parses - its adaptation field. Between two PCRs on that PID, the rate is - `packets * 188 * 8 / (delta PCR / 27 MHz)`; count every packet of the stream - including null PID `0x1fff`, which the routing gate currently drops before - counting. Publish the rate once it is stable across a window (a few seconds - of PCR intervals within a small tolerance) and republish the catalog only - when the stable value moves more than 1 % from the published one, so - measurement noise never churns the catalog. Model measurement as collecting - or published: a full window of disagreeing intervals transitions published - back to collecting, republishes the catalog once with `muxRate` omitted, and - discards the window; a PCR discontinuity does the same immediately. Publish - again only after a fresh stable window. The 1 % threshold applies only to - stable-to-stable updates, not clearing an invalid rate. A VBR or file-paced - source therefore leaves the field absent. -- Export (`rs/moq-mux/src/container/ts/export.rs`): the exporter already emits - one `Frame` per 25 ms PCR grid slot. When `mux_rate` is present, maintain a - signed fixed-point packet balance: add the exact fractional allowance - `mux_rate * 25 ms / (188 * 8)` each slot, subtract every emitted media, PSI, - and SI packet, then emit and subtract `floor(max(balance, 0))` null packets - (PID `0x1fff`, no adaptation, payload of `0xff`). Retain the fractional - remainder across slots instead of rounding each slot independently. A slot - that already exceeds its allowance emits no nulls and carries the negative - balance forward so the long-run rate holds; media is never delayed or dropped - to fit, so a source that sustains more than the recorded rate simply overruns - it, and the exporter logs once per overrun run rather than growing the debt - without bound (cap it at one second of packets). Pad only when the field is - present. Add `Export::with_mux_rate(u64) -> Self` as an explicit override of - the catalog value; `moq export ts --mux-rate ` calls it when provided, - including for a catalog without the field. -- Docs: `doc/bin/cli.md` gains the flag and a sentence on padding. Document the - field in the `rs/moq-mux/src/container/ts/catalog.rs` module docs; `mpegts` is - a `moq-mux` application extension and is not part of the Hang draft. -- Tests: an import fixture with a known CBR rate and stuffing (the existing - `test_data` sources, or a synthesized one) yields the expected `muxRate` - within tolerance; a VBR fixture yields none; a transition test covers - published rate to instability or discontinuity, omitted field, and a newly - stable rate; export with the field emits a stream whose measured rate matches - and whose PCR intervals stay under 40 ms; a non-integral packet rate such as - 1,000,000 bps verifies the cumulative packet count and retained fractional - remainder over many slots; the builder and CLI override beat a catalog value - and supply an absent one; export without a field or override is unchanged. - -Public API: one additive field on the `Mpegts` catalog section, one -`Export::with_mux_rate` builder, and one CLI flag. Wire: an additive catalog -field; no draft change, the `mpegts` section is an application extension. - -## Related - -- [#3731](https://github.com/moq-dev/moq/issues/3731) - decision 6 of six; the issue stays open for the MSFTS convergence questions - -- [#2779](/quest/next/2779-moq-export-ts-continuity-counters-are-numbered-from.md) - the other determinism gap in the same exporter -- [TR 101 290](/quest/future/1838-tr-101-290-monitoring-requirements-broadcast-contribution.md) - the monitoring that would grade the padded output diff --git a/rs/moq-cli/src/args.rs b/rs/moq-cli/src/args.rs index 0e590ef66d..3338de3dc5 100644 --- a/rs/moq-cli/src/args.rs +++ b/rs/moq-cli/src/args.rs @@ -792,9 +792,8 @@ impl Export { } match &self.sink { ExportSink::Fmp4(args) | ExportSink::Mkv(args) => found.extend(args.container.deprecated()), - ExportSink::Ts(args) | ExportSink::Flv(args) | ExportSink::H264(args) | ExportSink::H265(args) => { - found.extend(args.deprecated()) - } + ExportSink::Ts(args) => found.extend(args.container.deprecated()), + ExportSink::Flv(args) | ExportSink::H264(args) | ExportSink::H265(args) => found.extend(args.deprecated()), ExportSink::Hls(hls) => found.extend(hls.tls.deprecated()), ExportSink::Rtmp(rtmp) if rtmp.latency_max.is_some() => { found.flag("--latency-max", None, "--max-age"); @@ -814,7 +813,7 @@ pub enum ExportSink { /// Matroska / WebM to stdout. Mkv(Fragmented), /// MPEG-TS to stdout. - Ts(Container), + Ts(Transport), /// FLV / RTMP container to stdout. Flv(Container), /// H.264 Annex-B elementary stream to stdout. @@ -832,30 +831,49 @@ pub enum ExportSink { } impl ExportSink { - /// The stdout container format plus its latency and fragment cap, when this - /// sink writes to stdout (the container formats). The fragment cap is - /// fmp4/mkv-only. - pub fn stdout(&self) -> Option<(SubscribeFormat, std::time::Duration, Option)> { + /// Whether this sink writes to stdout (the container formats). + pub fn is_stdout(&self) -> bool { + self.stdout().is_some() + } + + /// The stdout container format and its options, when this sink writes to + /// stdout. The fragment cap is fmp4/mkv-only and the mux rate is TS-only. + pub fn stdout(&self) -> Option { + let container = |format, container: &Container| Stdout { + format, + max_age: container.max_age.into_std(), + fragment_duration: None, + mux_rate: None, + }; Some(match self { - Self::Fmp4(args) => ( - SubscribeFormat::Fmp4, - args.container.max_age.into_std(), - args.fragment_duration.map(crate::duration::Duration::into_std), - ), - Self::Mkv(args) => ( - SubscribeFormat::Mkv, - args.container.max_age.into_std(), - args.fragment_duration.map(crate::duration::Duration::into_std), - ), - Self::Ts(args) => (SubscribeFormat::Ts, args.max_age.into_std(), None), - Self::Flv(args) => (SubscribeFormat::Flv, args.max_age.into_std(), None), - Self::H264(args) => (SubscribeFormat::H264, args.max_age.into_std(), None), - Self::H265(args) => (SubscribeFormat::H265, args.max_age.into_std(), None), + Self::Fmp4(args) => Stdout { + fragment_duration: args.fragment_duration.map(crate::duration::Duration::into_std), + ..container(SubscribeFormat::Fmp4, &args.container) + }, + Self::Mkv(args) => Stdout { + fragment_duration: args.fragment_duration.map(crate::duration::Duration::into_std), + ..container(SubscribeFormat::Mkv, &args.container) + }, + Self::Ts(args) => Stdout { + mux_rate: args.mux_rate, + ..container(SubscribeFormat::Ts, &args.container) + }, + Self::Flv(args) => container(SubscribeFormat::Flv, args), + Self::H264(args) => container(SubscribeFormat::H264, args), + Self::H265(args) => container(SubscribeFormat::H265, args), _ => return None, }) } } +/// A stdout sink's format and the options that apply to it. +pub struct Stdout { + pub format: SubscribeFormat, + pub max_age: Duration, + pub fragment_duration: Option, + pub mux_rate: Option, +} + /// Options shared by every stdout container sink. #[derive(usage::Args, Clone)] #[usage(unknown_flags = "error", args_override_self = false)] @@ -879,6 +897,20 @@ impl Container { } } +/// The MPEG-TS stdout container: [`Container`] plus null padding. +#[derive(usage::Args, Clone)] +#[usage(unknown_flags = "error", args_override_self = false)] +pub struct Transport { + #[usage(flatten)] + pub container: Container, + + /// Pad the output with null packets to this constant rate, in bits per second. + /// Defaults to the multiplex rate the catalog recorded from a constant-rate + /// source (`mpegts.muxRate`); without either the output is unpadded. + #[usage(long)] + pub mux_rate: Option, +} + /// The fmp4 / mkv stdout containers: [`Container`] plus a fragment cap. #[derive(usage::Args, Clone)] #[usage(unknown_flags = "error", args_override_self = false)] diff --git a/rs/moq-cli/src/main.rs b/rs/moq-cli/src/main.rs index 18d38444d3..927745de40 100644 --- a/rs/moq-cli/src/main.rs +++ b/rs/moq-cli/src/main.rs @@ -499,7 +499,7 @@ async fn run_stages(moq: MoqSide, stages: Vec, net: Net) -> anyhow::Res } } Command::Export(export) => { - if export.sink.stdout().is_some() { + if export.sink.is_stdout() { claim("stdout", &mut stdout, &name)?; } spawn_export(&origin, export, name, &mut tasks)?; @@ -658,11 +658,12 @@ fn spawn_export( reject_listener_cors(&rtc.cors, "export rtc")?; } - if let Some((format, max_age, fragment_duration)) = export.sink.stdout() { + if let Some(stdout) = export.sink.stdout() { let args = SubscribeArgs { - format, - max_age, - fragment_duration, + format: stdout.format, + max_age: stdout.max_age, + fragment_duration: stdout.fragment_duration, + mux_rate: stdout.mux_rate, catalog: export.catalog_format, select: export.select, }; diff --git a/rs/moq-cli/src/subscribe.rs b/rs/moq-cli/src/subscribe.rs index d00b122ce3..0680d83f8b 100644 --- a/rs/moq-cli/src/subscribe.rs +++ b/rs/moq-cli/src/subscribe.rs @@ -142,6 +142,10 @@ pub struct SubscribeArgs { /// Cap the output duration: publisher groups by default for fMP4, video GOPs for MKV. pub fragment_duration: Option, + /// Pad MPEG-TS output with null packets to this rate, in bits per second, + /// overriding the catalog's recorded multiplex rate. + pub mux_rate: Option, + /// Catalog format for track discovery (default: detect from the broadcast suffix). pub catalog: Option, @@ -299,6 +303,9 @@ impl Subscribe { let mut ts = moq_mux::container::ts::Export::with_ts(self.source, self.catalog) .await? .with_max_age(self.args.max_age); + if let Some(mux_rate) = self.args.mux_rate { + ts = ts.with_mux_rate(mux_rate); + } // A TS byte stream carries no per-frame timing, so delivery time is the only // carrier of each frame's spacing: the exporter slices its output on the PCR diff --git a/rs/moq-mux/src/container/ts/catalog.rs b/rs/moq-mux/src/container/ts/catalog.rs index 2b368ca245..1446eb09c0 100644 --- a/rs/moq-mux/src/container/ts/catalog.rs +++ b/rs/moq-mux/src/container/ts/catalog.rs @@ -7,7 +7,9 @@ //! subtitles, private data, ...), the program-level PMT descriptors, the program //! identity ([`Program`]), and the standalone SI table map ([`SiEntry`]). //! Demuxed media tracks keep their codec config in the base `video`/`audio` -//! sections; only their MPEG-TS identity lands here. +//! sections; only their MPEG-TS identity lands here. A constant-rate source also +//! records its multiplex rate ([`Mpegts::mux_rate`]), so export can pad the rebuilt +//! stream back to it. //! //! The section is specified by `drafts/draft-lcurley-moq-mpegts.md` and rides the //! root of either catalog: the hang track alongside `video`/`audio`, and the MSF @@ -116,12 +118,24 @@ pub struct Mpegts { #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] #[serde_as(as = "BTreeMap>")] pub si: BTreeMap>, + + /// The rate the PCR clock paces the whole multiplex at, in bits per second: + /// every PID plus PSI plus null stuffing, measured between PCRs on import. Not a + /// sum of elementary streams; each rendition's `bitrate` keeps its codec meaning. + /// Present only while the source is constant-rate, so a VBR or file-paced input + /// leaves it absent. Export pads its output with null packets to this rate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mux_rate: Option, } impl Mpegts { /// True when the section carries nothing, so it's omitted from the catalog. pub fn is_empty(&self) -> bool { - self.tracks.is_empty() && self.program_descriptors.is_empty() && self.program.is_none() && self.si.is_empty() + self.tracks.is_empty() + && self.program_descriptors.is_empty() + && self.program.is_none() + && self.si.is_empty() + && self.mux_rate.is_none() } } @@ -324,6 +338,21 @@ mod test { assert_eq!(serde_json::to_string(&ext).unwrap(), "{}"); } + #[test] + fn mux_rate_alone_is_not_empty() { + // The rate is the only field of a media-only CBR source, so it must keep the + // section alive rather than be dropped with it. + let mpegts = Mpegts { + mux_rate: Some(2_500_000), + ..Default::default() + }; + assert!(!mpegts.is_empty()); + let json = serde_json::to_string(&Ext { mpegts }).unwrap(); + assert_eq!(json, r#"{"mpegts":{"muxRate":2500000}}"#); + // An absent rate is omitted, never `null`. + assert_eq!(serde_json::to_string(&Ext::default()).unwrap(), "{}"); + } + #[test] fn section_roundtrip() { let mut mpegts = Mpegts::default(); @@ -352,10 +381,15 @@ mod test { tag: 0x05, data: Bytes::from_static(b"CUEI"), }); + mpegts.mux_rate = Some(2_500_000); let json = serde_json::to_string(&Ext { mpegts: mpegts.clone() }).unwrap(); // Descriptor bytes are base64 ("CUEI" -> "Q1VFSQ=="). assert!(json.contains("\"Q1VFSQ==\""), "descriptor data is base64: {json}"); + assert!( + json.contains("\"muxRate\":2500000"), + "mux rate is a bare integer: {json}" + ); let parsed: Ext = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.mpegts, mpegts, "mpegts section round-trips"); diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index b0a763f8c8..7713e93189 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -65,6 +65,28 @@ pub(super) const PCR_INTERVAL: Duration = Duration::from_millis(25); /// clock history for a span that carried no bytes only stalls anything pacing on /// the asserted values. const PCR_BACKFILL: u128 = 40; +/// A null packet: PID 0x1FFF, payload only, all stuffing. Its continuity counter +/// is don't-care (ISO 13818-1), so one template serves every one. +pub(super) const NULL_PACKET: [u8; TsPacket::SIZE] = { + let mut packet = [0xff; TsPacket::SIZE]; + packet[0] = 0x47; + packet[1] = 0x1f; + packet[2] = 0xff; + packet[3] = 0x10; + packet +}; +/// Grid slots per second, so the multiplex rate in bits per second is also the +/// per-slot allowance in [`STUFFING_UNIT`]s. +const SLOTS_PER_SECOND: i64 = (Duration::from_secs(1).as_nanos() / PCR_INTERVAL.as_nanos()) as i64; +const _: () = assert!( + Duration::from_secs(1) + .as_nanos() + .is_multiple_of(PCR_INTERVAL.as_nanos()) +); +/// Fixed-point unit of the stuffing balance: one packet is this many units, so that +/// one slot at `mux_rate` bits per second is exactly `mux_rate` units and no slot +/// rounds on its own. The remainder carries across slots instead. +const STUFFING_UNIT: i64 = TsPacket::SIZE as i64 * 8 * SLOTS_PER_SECOND; /// Subscribe to a broadcast and produce an MPEG-TS byte stream. /// @@ -129,6 +151,12 @@ pub struct Export { /// step backwards all the time, so a span closes on a timestamp passing this /// high-water mark rather than on every frame. watermark: Option, + /// The rate to pad the output to with null packets, in bits per second: the + /// builder override when set, else the catalog's recorded multiplex rate, else + /// none and the output is unpadded ([`Self::stuff`]). + mux_rate: Option, + mux_rate_override: Option, + stuffing: Stuffing, /// Tune-in point: the first video keyframe's timestamp, captured when the program /// tables are built. Non-video frames before it are dropped so the keyframe leads /// the stream. @@ -210,6 +238,18 @@ enum Kind { }, } +/// The null stuffing owed to the multiplex rate ([`Export::stuff`]). +#[derive(Default)] +struct Stuffing { + /// Packets the rate has allowed minus packets sent, in [`STUFFING_UNIT`]s. + /// Negative while the media alone exceeds the rate. + balance: i64, + /// Packets sent since the last clock packet, that packet included. + since_pcr: u64, + /// Whether the debt cap has been hit and reported in the current overrun. + overrun: bool, +} + /// The program tables plus the resolved PID layout. struct Psi { pat: Pat, @@ -458,9 +498,21 @@ impl Export { low: None, watermark: None, video_start: None, + mux_rate: None, + mux_rate_override: None, + stuffing: Stuffing::default(), }) } + /// Pad the output with null packets to `mux_rate` bits per second, whatever the + /// catalog records. Without this the catalog's `mpegts.muxRate` decides, and a + /// catalog without one leaves the output unpadded. + pub fn with_mux_rate(mut self, mux_rate: u64) -> Self { + self.mux_rate_override = Some(mux_rate); + self.mux_rate = Some(mux_rate); + self + } + /// Set the max age for each per-track source. /// /// See [`Consumer`](crate::container::Consumer) for the per-track skip behavior. @@ -711,6 +763,11 @@ impl Export { let mpegts = catalog.ext.mpegts_mut().cloned().unwrap_or_default(); self.program_descriptors = mpegts.program_descriptors.clone(); self.program = mpegts.program.clone(); + let mux_rate = self.mux_rate_override.or(mpegts.mux_rate); + if self.mux_rate != mux_rate { + self.mux_rate = mux_rate; + self.stuffing = Stuffing::default(); + } // Reconcile the SI subscriptions with the catalog's map. Entries may appear // after the PAT/PMT is built (a table acquired late): they ride standalone @@ -932,6 +989,7 @@ impl Export { } self.video_start = None; self.pcr_discontinuity = true; + self.stuffing = Stuffing::default(); for track in self.tracks.values_mut() { track.last_dts = None; track.epoch = self.epoch; @@ -1379,8 +1437,10 @@ impl Export { let pcr_pid = self.psi.as_ref().context("PSI not built")?.pcr_pid; let mut payload = Vec::new(); for index in first..=open { + self.stuff(index, &mut payload); let before = counter_before(&bytes, 0, pcr_pid, self.pcr_cc); - payload.extend_from_slice(&self.pcr_at(index, before)?); + let clock = self.pcr_at(index, before)?; + self.send(&mut payload, &clock); } let mut cut = 0; let mut at = from; @@ -1395,15 +1455,18 @@ impl Export { } else { packets }; - payload.extend_from_slice(&bytes[cut * TsPacket::SIZE..next * TsPacket::SIZE]); + self.send(&mut payload, &bytes[cut * TsPacket::SIZE..next * TsPacket::SIZE]); + self.stuff(index, &mut payload); self.push(at, payload, &keyframes, cut, next); cut = next; at = boundary; let before = counter_before(&bytes, cut * TsPacket::SIZE, pcr_pid, self.pcr_cc); - payload = self.pcr_at(index, before)?; + let clock = self.pcr_at(index, before)?; + payload = Vec::new(); + self.send(&mut payload, &clock); } - payload.extend_from_slice(&bytes[cut * TsPacket::SIZE..]); + self.send(&mut payload, &bytes[cut * TsPacket::SIZE..]); self.push(at, payload, &keyframes, cut, packets); if let Some(cc) = counter_before(&bytes, bytes.len(), pcr_pid, None) { self.pcr_cc = Some(cc); @@ -1411,6 +1474,54 @@ impl Export { Ok(()) } + /// Append packets to the open output frame, counting them against the rate. + fn send(&mut self, payload: &mut Vec, packets: &[u8]) { + payload.extend_from_slice(packets); + self.stuffing.since_pcr += (packets.len() / TsPacket::SIZE) as u64; + } + + /// Settle the interval that closes at grid slot `index`, the one the clock packet + /// about to be written for it ends, appending the null packets that bring it up + /// to the multiplex rate. Nothing without a rate. + /// + /// The rate credits each slot its exact fractional allowance and every packet + /// sent debits one, so the remainder carries across slots and the long-run count + /// is exact. Media is never delayed or dropped to fit: a slot that already + /// exceeds its allowance gets no nulls and carries the debt forward, and a source + /// that sustains more than the rate simply overruns it. The debt is capped at one + /// second of packets and reported once per overrun, or the rate would never + /// recover after a long burst. Credit is capped the same way, matching the clock + /// backfill: past it the media gapped, and a second of stuffing marks that without + /// filling the whole gap. + fn stuff(&mut self, index: u128, payload: &mut Vec) { + let since = std::mem::take(&mut self.stuffing.since_pcr); + let (Some(rate), Some(last)) = (self.mux_rate, self.last_pcr) else { + return; + }; + let slots = index.saturating_sub(last).min(PCR_BACKFILL) as i64; + let rate = rate as i64; + let stuffing = &mut self.stuffing; + stuffing.balance = stuffing + .balance + .saturating_add(slots.saturating_mul(rate)) + .saturating_sub((since as i64).saturating_mul(STUFFING_UNIT)); + let floor = rate.saturating_mul(-SLOTS_PER_SECOND); + if stuffing.balance < floor { + if !std::mem::replace(&mut stuffing.overrun, true) { + tracing::warn!(mux_rate = rate, "MPEG-TS output exceeds the multiplex rate"); + } + stuffing.balance = floor; + } else if stuffing.balance >= 0 { + stuffing.overrun = false; + } + let nulls = (stuffing.balance / STUFFING_UNIT).max(0); + stuffing.balance -= nulls * STUFFING_UNIT; + payload.reserve(nulls as usize * TsPacket::SIZE); + for _ in 0..nulls { + payload.extend_from_slice(&NULL_PACKET); + } + } + /// Queue one output frame, unless it would be empty. `from`..`to` are the packet /// indices it carries, which decide whether a keyframe begins in it. fn push(&mut self, timestamp: Timestamp, payload: Vec, keyframes: &[usize], from: usize, to: usize) { diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 96b8afe8f0..07c3609d7e 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -4237,3 +4237,172 @@ async fn payload_less_clock_packets_repeat_the_counter() { assert_eq!(advanced, 0, "payload-less packets must repeat the counter"); assert_eq!(discontinuities, 0, "the counter must be continuous on every PID"); } + +/// What a constant-rate receiver would measure of `ts`: the packets between its +/// first and last clock packet over the PCR ticks they span, how much of that was +/// null stuffing, and the widest PCR gap. +struct Clocked { + packets: usize, + ticks: u64, + nulls: usize, + max_gap: u64, +} + +impl Clocked { + fn of(ts: &[u8]) -> Self { + const WRAP: u64 = (1 << 33) * 300; + let mut first = None; + let mut last = None; + let mut nulls = 0; + let mut max_gap = 0; + let mut span = 0; + for (index, packet) in ts.chunks(188).enumerate() { + let pid = (u16::from(packet[1] & 0x1f) << 8) | u16::from(packet[2]); + if pid == 0x1fff { + nulls += 1; + } + if packet[3] & 0x20 != 0 && packet[4] >= 7 && packet[5] & 0x10 != 0 { + let base = (u64::from(packet[6]) << 25) + | (u64::from(packet[7]) << 17) + | (u64::from(packet[8]) << 9) + | (u64::from(packet[9]) << 1) + | (u64::from(packet[10]) >> 7); + let ticks = base * 300 + ((u64::from(packet[10] & 1) << 8) | u64::from(packet[11])); + // The exporter backs the clock off through the 33-bit wrap at the start. + if let Some(prev) = last.replace((index, ticks)) { + let gap = (ticks + WRAP - prev.1) % WRAP; + max_gap = max_gap.max(gap); + span += gap; + } + first.get_or_insert(index); + } + } + let (first, last) = (first.expect("no PCR"), last.expect("no PCR")); + Self { + packets: last.0 - first, + ticks: span, + nulls, + max_gap, + } + } + + /// The multiplex rate in bits per second. + fn rate(&self) -> u64 { + (self.packets as u128 * 188 * 8 * 27_000_000 / u128::from(self.ticks)) as u64 + } +} + +/// Import a fixture into a broadcast whose catalog carries the `mpegts` section, +/// then export it. The producers live until the export drains. +async fn export_fixture(data: &[u8], mux_rate: Option) -> BytesMut { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let catalog = crate::catalog::Producer::new( + &mut broadcast, + crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::::default()), + ) + .unwrap(); + let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve()); + import.decode(data).unwrap(); + import.finish().unwrap(); + + let mut export = export_of(&consumer).await; + if let Some(mux_rate) = mux_rate { + export = export.with_mux_rate(mux_rate); + } + let ts = drain_with(export).await; + assert_packet_aligned(&ts); + ts +} + +/// `bbb_cbr.ts` is a 400 kb/s multiplex carrying 167 kb/s of media, which import +/// records as such, so export pads the media back to the recorded rate. +#[tokio::test(start_paused = true)] +async fn export_pads_to_the_recorded_mux_rate() { + let ts = export_fixture(include_bytes!("test_data/bbb_cbr.ts"), None).await; + let clocked = Clocked::of(&ts); + assert!(clocked.nulls > 0, "no null stuffing was emitted"); + let rate = clocked.rate(); + assert!( + rate.abs_diff(400_000) * 100 <= 400_000, + "output runs at {rate} b/s, not the recorded 400 kb/s" + ); + assert!( + clocked.max_gap <= 40 * 27_000, + "PCR gap of {} ticks exceeds 40 ms", + clocked.max_gap + ); +} + +/// The builder override wins over the catalog's recorded rate. +#[tokio::test(start_paused = true)] +async fn export_mux_rate_override_beats_the_catalog() { + let ts = export_fixture(include_bytes!("test_data/bbb_cbr.ts"), Some(1_000_000)).await; + let rate = Clocked::of(&ts).rate(); + assert!( + rate.abs_diff(1_000_000) * 100 <= 1_000_000, + "output runs at {rate} b/s, not the 1 Mb/s override" + ); +} + +/// A VBR source records no rate, and without one nothing is padded. +#[tokio::test(start_paused = true)] +async fn export_without_a_mux_rate_is_unpadded() { + let ts = export_fixture(include_bytes!("test_data/scte35/bbb5s.ts"), None).await; + assert_eq!(Clocked::of(&ts).nulls, 0, "null packets in an unpadded export"); +} + +/// 1 Mb/s is 16.62 packets per 25 ms slot. The count between the first and last +/// clock packet must be the floor of the exact allowance, which only holds when +/// the fractional remainder carries across slots instead of rounding each one. +/// Also the override supplying a rate to a catalog that has none. +#[tokio::test(start_paused = true)] +async fn export_stuffing_keeps_the_fractional_remainder() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let mut catalog = crate::catalog::Producer::new(&mut broadcast, crate::catalog::Config::default()).unwrap(); + + let track = broadcast + .create_track( + broadcast.unique_name(".aac"), + hang::container::track_info(hang::catalog::PRIORITY.audio), + ) + .unwrap(); + let name = track.name().to_string(); + { + let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2); + cfg.container = Container::Legacy; + catalog.modify().unwrap().audio.renditions.insert(name.clone(), cfg); + } + let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data)); + // Ten seconds of small frames: a few kb/s of media under a 1 Mb/s rate. + for i in 0..500u64 { + producer + .write(Frame { + timestamp: Timestamp::from_micros(i * 20_000).unwrap(), + duration: None, + payload: Bytes::from(vec![i as u8; 16]), + keyframe: i % 50 == 0, + }) + .unwrap(); + } + producer.finish().unwrap(); + + let export = Export::new(crate::source::announced(&consumer)) + .await + .unwrap() + .with_mux_rate(1_000_000); + let ts = drain_with(export).await; + assert_packet_aligned(&ts); + + let clocked = Clocked::of(&ts); + let slots = clocked.ticks / (PCR_INTERVAL.as_nanos() as u64 * 27 / 1000); + assert!(slots > 300, "expected a long run of slots, got {slots}"); + // Bits allowed over the run, in whole packets. + let expected = (slots as u128 * 1_000_000 * PCR_INTERVAL.as_nanos() / (1_000_000_000 * 188 * 8)) as u64; + assert_eq!( + clocked.packets as u64, expected, + "{slots} slots at 1 Mb/s carry {expected} packets" + ); + assert!(clocked.nulls > 0, "no null stuffing was emitted"); +} diff --git a/rs/moq-mux/src/container/ts/import.rs b/rs/moq-mux/src/container/ts/import.rs index 2256f9ba8e..cc9ff71764 100644 --- a/rs/moq-mux/src/container/ts/import.rs +++ b/rs/moq-mux/src/container/ts/import.rs @@ -80,6 +80,9 @@ pub struct Import { /// discontinuity only here; on any other PID it says nothing but that the /// continuity counter jumped ([`Continuity`]). pcr_pid: Option, + /// The multiplex rate measured off the PCR PID, recorded in the `mpegts` section + /// while the source holds one. Only fed with `mpegts` catalog support. + mux_rate: super::mux_rate::Meter, /// Whether any media or section has been published since the last timebase break, so /// consecutive markers (a repeated flag, a retransmitted clock packet) declare one /// break rather than one each. @@ -157,6 +160,7 @@ impl Import { pending: HashMap::new(), continuity: HashMap::new(), pcr_pid: None, + mux_rate: Default::default(), published: false, initialized: false, scratch: Vec::new(), @@ -247,6 +251,10 @@ impl Import { let pkt: [u8; TsPacket::SIZE] = self.scratch[off..off + TsPacket::SIZE].try_into().unwrap(); off += TsPacket::SIZE; let pid = (((pkt[1] & 0x1f) as u16) << 8) | pkt[2] as u16; + // Every packet paces the multiplex, null stuffing and retransmissions included. + if self.supports_mpegts { + self.mux_rate.packet(); + } let continuation = Pid::new(pid) .ok() .filter(|pid| self.streams.contains_key(pid) || self.pcr_pid == Some(*pid)) @@ -261,8 +269,15 @@ impl Import { // A packet the demodulator flagged corrupt (`transport_error_indicator`) is not // read: its adaptation field is as untrustworthy as its payload, and taking a bit // out of it would break every track in the program on line noise. - if self.pcr_pid.is_some_and(|p| p.as_u16() == pid) && pkt[1] & 0x80 == 0 && discontinuity_indicator(&pkt) { - self.timebase_break()?; + if self.pcr_pid.is_some_and(|p| p.as_u16() == pid) && pkt[1] & 0x80 == 0 { + if discontinuity_indicator(&pkt) { + self.timebase_break()?; + } else if self.supports_mpegts + && let Some(pcr) = pcr(&pkt) + && self.mux_rate.pcr(pcr) + { + self.record_mux_rate()?; + } } let pts = self.last_pts.unwrap_or(Timestamp::ZERO); if let Some(section) = self.sections.get_mut(&pid) { @@ -351,7 +366,13 @@ impl Import { Some(TsPayload::Pmt(pmt)) => { // Which PID speaks for the program clock, so a `discontinuity_indicator` there // can be read as a timebase reset rather than a counter jump. - self.pcr_pid = pmt.pcr_pid; + if self.pcr_pid != pmt.pcr_pid { + self.pcr_pid = pmt.pcr_pid; + // A new clock: the intervals straddling the switch measure nothing. + if self.mux_rate.discontinuity() { + self.record_mux_rate()?; + } + } // SCTE-35 is announced by a program-level registration descriptor with // format_identifier 'CUEI' (ITU-T J.181). The stream itself uses @@ -721,10 +742,24 @@ impl Import { self.media_unwrap.discontinuity(); self.last_pts = None; self.published = false; + if self.mux_rate.discontinuity() { + self.record_mux_rate()?; + } tracing::debug!("MPEG-TS system time-base discontinuity"); Ok(()) } + /// Copy the measured multiplex rate into the `mpegts` section, publishing the + /// catalog. Called only when the measurement changed, so a steady source never + /// republishes. + fn record_mux_rate(&mut self) -> anyhow::Result<()> { + let rate = self.mux_rate.published(); + if let Some(mpegts) = self.catalog.modify()?.ext.mpegts_mut() { + mpegts.mux_rate = rate; + } + Ok(()) + } + /// Record a decoded media stream's PID and ES descriptors into `mpegts.tracks`, /// once per track. No-op without the `mpegts` section, before the track exists, /// or for verbatim streams (which self-register). @@ -1236,6 +1271,20 @@ fn discontinuity_indicator(pkt: &[u8; 188]) -> bool { pkt[3] & 0x20 != 0 && pkt[4] > 0 && pkt[5] & 0x80 != 0 } +/// The PCR a packet's adaptation field carries, in 27 MHz ticks. +fn pcr(pkt: &[u8; 188]) -> Option { + if pkt[3] & 0x20 == 0 || pkt[4] < 7 || pkt[5] & 0x10 == 0 { + return None; + } + let base = (u64::from(pkt[6]) << 25) + | (u64::from(pkt[7]) << 17) + | (u64::from(pkt[8]) << 9) + | (u64::from(pkt[9]) << 1) + | (u64::from(pkt[10]) >> 7); + let ext = (u64::from(pkt[10] & 0x01) << 8) | u64::from(pkt[11]); + Some(base * 300 + ext) +} + /// Whether two packets differ only in the clock fields a retransmission may refresh. fn is_duplicate(last: &[u8; 188], pkt: &[u8; 188]) -> bool { if last == pkt { @@ -5156,6 +5205,78 @@ mod test { } } + /// An adaptation-only clock packet on `pid` carrying `ticks` of the 27 MHz PCR. + fn pcr_packet(pid: u16, ticks: u64) -> Vec { + let (base, ext) = (ticks / 300, ticks % 300); + let mut p = vec![ + 0x47, + (pid >> 8) as u8 & 0x1f, + (pid & 0xff) as u8, + 0x20, + 183, + 0x10, + (base >> 25) as u8, + (base >> 17) as u8, + (base >> 9) as u8, + (base >> 1) as u8, + ((base as u8 & 1) << 7) | 0x7e | (ext >> 8) as u8, + ext as u8, + ]; + p.resize(188, 0xff); + p + } + + /// The catalog follows the clock: a stable multiplex rate is recorded, a source that + /// stops holding it clears the record, a fresh stable window records it again, and a + /// declared time-base break clears it at once. + #[test] + fn mux_rate_follows_the_clock() { + use crate::catalog::hang::Catalog; + use crate::container::ts::catalog::Ext; + + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new( + &mut broadcast, + crate::catalog::Config::default().with_catalog(Catalog::::default()), + ) + .unwrap(); + let mut import = super::Import::new(broadcast, catalog.reserve()); + import + .decode(&synth_pmt(&[(StreamType::Mpeg1Audio, PCR_PID)], false)) + .unwrap(); + + // 40 ms PCR intervals; `stuffing(i)` packets of null padding after the i-th clock. + const INTERVAL: u64 = 27_000_000 / 25; + let mut ticks = 0; + let mut feed = |import: &mut super::Import, seconds: u64, stuffing: &dyn Fn(u64) -> usize| { + for i in 0..seconds * 25 { + let mut bytes = pcr_packet(PCR_PID, ticks); + for _ in 0..stuffing(i) { + bytes.extend_from_slice(&super::super::export::NULL_PACKET); + } + import.decode(&bytes).unwrap(); + ticks += INTERVAL; + } + }; + let rate = || catalog.snapshot().ext.mpegts.mux_rate; + + // 100 packets per 40 ms: 3.76 Mb/s, byte-locked. + feed(&mut import, 3, &|_| 99); + assert_eq!(rate(), Some(3_760_000), "a stable window records the rate"); + + // The stuffing comes and goes: nothing agrees with the record any more. Windows + // are 2 s and the feeds are not aligned to them, so allow one straddling window + // (which still holds intervals at the old rate) before the next full one clears. + feed(&mut import, 4, &|i| if (i / 5) % 2 == 0 { 10 } else { 90 }); + assert_eq!(rate(), None, "an unstable window clears the record"); + + feed(&mut import, 4, &|_| 99); + assert_eq!(rate(), Some(3_760_000), "a fresh stable window records it again"); + + import.decode(&clock_break_packet(PCR_PID)).unwrap(); + assert_eq!(rate(), None, "a time-base break clears the record at once"); + } + /// The same flag on an elementary PID declares only that the continuity counter jumped. /// The partial it interrupts is dropped, as ever, but the program clock is untouched, so /// no track takes a break and the peer PID never notices. diff --git a/rs/moq-mux/src/container/ts/import_test.rs b/rs/moq-mux/src/container/ts/import_test.rs index 69a92a6f37..348f2cf926 100644 --- a/rs/moq-mux/src/container/ts/import_test.rs +++ b/rs/moq-mux/src/container/ts/import_test.rs @@ -26,6 +26,23 @@ fn import_ts(data: &[u8]) -> crate::catalog::hang::Catalog { catalog.snapshot() } +/// Like [`import_ts`], with a catalog that carries the `mpegts` section. +fn import_ts_ext(data: &[u8]) -> crate::catalog::hang::Catalog { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let catalog = crate::catalog::Producer::new( + &mut broadcast, + crate::catalog::Config::default() + .with_catalog(crate::catalog::hang::Catalog::::default()), + ) + .unwrap(); + + let mut import = crate::container::ts::Import::new(broadcast, catalog.reserve()); + import.decode(data).unwrap(); + import.finish().unwrap(); + + catalog.snapshot() +} + #[test] fn import_bbb_catalog() { let data = include_bytes!("test_data/bbb.ts"); @@ -233,6 +250,51 @@ fn import_eac3_catalog() { /// document the capture, the audio path is what's under test) and the PMT, /// which evidences that the Kyrion itself pairs stream_type 0x81 with the /// 'AC-3' registration descriptor, the same announcement our export writes. +/// `bbb_cbr.ts` is `scte35/bbb5s.ts` remuxed at a constant 400 kb/s (`ffmpeg -i +/// scte35/bbb5s.ts -t 2.8 -c copy -muxrate 400000 -f mpegts bbb_cbr.ts`): ffmpeg +/// byte-locks the PCR to the mux rate and fills the rest with null packets, so the +/// whole-multiplex rate lands in the catalog exactly. +#[test] +fn import_records_the_cbr_mux_rate() { + let data = include_bytes!("test_data/bbb_cbr.ts"); + let catalog = import_ts_ext(data); + + let rate = catalog.ext.mpegts.mux_rate.expect("a CBR source records its mux rate"); + assert!( + rate.abs_diff(400_000) * 1000 <= 400_000, + "mux rate {rate} is not within 0.1% of 400 kb/s" + ); + let json = serde_json::to_string(&catalog.ext).unwrap(); + assert!( + json.contains("\"muxRate\":"), + "the rate rides the mpegts section: {json}" + ); +} + +/// The Kyrion stamps its clock at half-millisecond granularity and its rate wanders +/// a percent between half-second samples, which is what a hardware multiplexer's +/// "constant" looks like; the window still settles on what `tsanalyze` measures. +#[test] +fn import_records_a_hardware_mux_rate() { + let data = include_bytes!("test_data/scte35/kyrion_dirtystart.ts"); + let catalog = import_ts_ext(data); + + let rate = catalog.ext.mpegts.mux_rate.expect("a CBR source records its mux rate"); + assert!( + rate.abs_diff(2_573_445) * 100 <= 2_573_445, + "mux rate {rate} is not within 1% of what tsanalyze measured" + ); +} + +/// ffmpeg without `-muxrate` writes no stuffing and its PCR intervals carry whatever +/// the frames weighed, so no rate is ever stable enough to record. +#[test] +fn import_leaves_a_vbr_mux_rate_absent() { + let data = include_bytes!("test_data/scte35/bbb5s.ts"); + let catalog = import_ts_ext(data); + assert_eq!(catalog.ext.mpegts.mux_rate, None, "a VBR source records no mux rate"); +} + #[test] fn import_kyrion_ac3_mp2_catalog() { let data = include_bytes!("test_data/kyrion_mpeg2av_ac3.ts"); diff --git a/rs/moq-mux/src/container/ts/mod.rs b/rs/moq-mux/src/container/ts/mod.rs index 206913986c..1a8b9e0a0e 100644 --- a/rs/moq-mux/src/container/ts/mod.rs +++ b/rs/moq-mux/src/container/ts/mod.rs @@ -21,6 +21,7 @@ mod adts; mod export; mod import; +mod mux_rate; mod si; // The `mpegts` catalog section (per-track PID + descriptors plus verbatim carriage diff --git a/rs/moq-mux/src/container/ts/mux_rate.rs b/rs/moq-mux/src/container/ts/mux_rate.rs new file mode 100644 index 0000000000..59071f0e34 --- /dev/null +++ b/rs/moq-mux/src/container/ts/mux_rate.rs @@ -0,0 +1,277 @@ +//! Measuring a transport stream's multiplex rate from its PCR clock. +//! +//! A constant-rate multiplexer paces its packets on the PCR clock, so the packets +//! between two PCRs over the ticks between them is the rate of the whole +//! multiplex, stuffing included. [`Meter`] takes every packet and every PCR on +//! the clock PID and decides when that rate is worth recording: a full window +//! whose samples agree on one value publishes it, and a window that no longer +//! agrees clears it, so a VBR source (whose samples never agree) records nothing +//! and a rate change lands once rather than as churn. + +/// The PCR clock, in Hz. +const PCR_HZ: u64 = 27_000_000; +/// Bits per transport packet. +const PACKET_BITS: u64 = 188 * 8; +/// The 33-bit base times 300 plus the 9-bit extension: the PCR wraps here. +const PCR_WRAP: u64 = (1 << 33) * 300; +/// How much PCR time one window spans before it is judged. +const WINDOW: u64 = 2 * PCR_HZ; +/// Intervals are pooled into samples this long before they are compared. One +/// packet of rounding, or a clock a hardware muxer stamps at half-millisecond +/// granularity, is a few percent of one 25 ms interval but well inside the +/// tolerance over half a second. +const SAMPLE: u64 = WINDOW / 4; +/// A PCR interval longer than this is a gap or a wrap gone wrong, not a sample: +/// TR 101 290 already flags anything over 100 ms. +const MAX_INTERVAL: u64 = PCR_HZ; +/// How far one sample may sit from a rate and still agree with it, in thousandths. +/// A hardware multiplexer's rate wanders more than a percent between half-second +/// samples while its whole-window average holds far tighter; a VBR source is off by +/// multiples. +const AGREEMENT: u64 = 20; +/// How far a stable window must move before the published rate follows it, in +/// thousandths, so measurement noise never touches the catalog. +const DRIFT: u64 = 10; + +/// Packets over the PCR ticks they were paced across. +#[derive(Clone, Copy, Default)] +struct Interval { + packets: u64, + ticks: u64, +} + +impl Interval { + fn add(&mut self, other: Interval) { + self.packets += other.packets; + self.ticks += other.ticks; + } + + fn rate(&self) -> u64 { + rate(self.packets, self.ticks) + } + + fn agrees(&self, rate: u64) -> bool { + self.rate().abs_diff(rate) * 1000 <= rate * AGREEMENT + } +} + +/// Bits per second for `packets` spread over `ticks` of the 27 MHz clock. +fn rate(packets: u64, ticks: u64) -> u64 { + (u128::from(packets) * u128::from(PACKET_BITS) * u128::from(PCR_HZ) / u128::from(ticks)) as u64 +} + +/// Tracks the multiplex rate and the value currently recorded for it. +#[derive(Default)] +pub(super) struct Meter { + /// Packets since the last PCR, every PID and null stuffing included. + packets: u64, + /// The last PCR seen, in 27 MHz ticks. + last: Option, + /// The sample being pooled from PCR intervals. + sample: Interval, + /// The completed samples of the window being collected. + window: Vec, + /// The rate recorded in the catalog, if any. + published: Option, +} + +impl Meter { + /// The rate recorded in the catalog. + pub fn published(&self) -> Option { + self.published + } + + /// One transport packet went by. + pub fn packet(&mut self) { + self.packets += 1; + } + + /// A PCR arrived on the clock PID, carried by the packet just counted. Returns + /// whether [`published`](Self::published) changed. + pub fn pcr(&mut self, pcr: u64) -> bool { + let packets = std::mem::take(&mut self.packets); + let Some(last) = self.last.replace(pcr) else { + return false; + }; + // The clock wraps at 2^33 * 300 ticks, so a small forward step past the wrap + // reads as a huge one here and is caught by the interval cap below. + let ticks = (pcr + PCR_WRAP - last) % PCR_WRAP; + if ticks == 0 || ticks > MAX_INTERVAL { + // A stalled or backwards clock with no discontinuity flag is a break all the same. + return self.discontinuity(); + } + self.sample.add(Interval { packets, ticks }); + if self.sample.ticks < SAMPLE { + return false; + } + self.window.push(std::mem::take(&mut self.sample)); + if self.window.iter().map(|s| s.ticks).sum::() < WINDOW { + return false; + } + self.judge() + } + + /// The clock PID declared a system time-base discontinuity, or the clock PID + /// itself changed. The window is worthless and so is a rate measured before it. + /// Returns whether [`published`](Self::published) changed. + pub fn discontinuity(&mut self) -> bool { + self.packets = 0; + self.last = None; + self.sample = Interval::default(); + self.window.clear(); + self.published.take().is_some() + } + + /// Judge the full window and discard it. + fn judge(&mut self) -> bool { + let window = std::mem::take(&mut self.window); + let mut whole = Interval::default(); + window.iter().for_each(|s| whole.add(*s)); + let rate = whole.rate(); + let stable = window.iter().all(|s| s.agrees(rate)); + match self.published { + None if stable => { + self.published = Some(rate); + true + } + None => false, + // A stable window at a new rate replaces the old one once it has drifted. + Some(published) if stable => { + let drifted = rate.abs_diff(published) * 1000 > published * DRIFT; + if drifted { + self.published = Some(rate); + } + drifted + } + // An unstable window still holding samples at the published rate is a + // glitch in a source that is otherwise the same; one holding none is a + // source that stopped being constant-rate. + Some(published) => { + let gone = !window.iter().any(|s| s.agrees(published)); + if gone { + self.published = None; + } + gone + } + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + /// Feed `intervals` of (packets, ticks), returning the published rate after each + /// call that changed it. + fn feed(meter: &mut Meter, intervals: impl IntoIterator) -> Vec> { + let mut changes = Vec::new(); + let mut pcr = meter.last.unwrap_or(0); + if meter.last.is_none() { + meter.packet(); + assert!(!meter.pcr(pcr)); + } + for (packets, ticks) in intervals { + for _ in 0..packets { + meter.packet(); + } + pcr = (pcr + ticks) % PCR_WRAP; + if meter.pcr(pcr) { + changes.push(meter.published()); + } + } + changes + } + + /// 2.5 Mb/s: 44 packets per 714_701 ticks, like the kyrion fixture. + const CBR: (u64, u64) = (44, 714_701); + /// Intervals of that length that fill one window exactly, so each feed below + /// starts and ends on a window boundary. + const PER_WINDOW: usize = 4 * SAMPLE.div_ceil(CBR.1) as usize; + + #[test] + fn cbr_publishes_once_per_window() { + let mut meter = Meter::default(); + let changes = feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW * 3)); + assert_eq!(changes, vec![Some(2_499_999)], "published once, then held"); + } + + #[test] + fn vbr_never_publishes() { + let mut meter = Meter::default(); + // A file-paced source: the clock is a grid but the packets come in bursts. + let intervals = (0..PER_WINDOW as u64 * 3).map(|i| (if (i / 7) % 2 == 0 { 10 } else { 60 }, 714_701)); + assert!(feed(&mut meter, intervals).is_empty()); + assert_eq!(meter.published(), None); + } + + #[test] + fn a_glitch_holds_the_rate_and_a_new_rate_replaces_it() { + let mut meter = Meter::default(); + assert_eq!( + feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW)), + vec![Some(2_499_999)] + ); + + // One outlier interval throws its sample; the other samples still agree. + let glitch = std::iter::once((200, 714_701)).chain(std::iter::repeat_n(CBR, PER_WINDOW - 1)); + assert!(feed(&mut meter, glitch).is_empty()); + assert_eq!(meter.published(), Some(2_499_999)); + + // Half a percent is noise; the catalog does not follow it. + let noise = std::iter::repeat_n((44, 711_000), PER_WINDOW); + assert!(feed(&mut meter, noise).is_empty()); + + // A stable window at a clearly different rate replaces the published one. + let faster = std::iter::repeat_n((88, 714_701), PER_WINDOW); + assert_eq!(feed(&mut meter, faster), vec![Some(4_999_998)]); + } + + #[test] + fn instability_clears_and_a_fresh_window_republishes() { + let mut meter = Meter::default(); + assert_eq!( + feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW)), + vec![Some(2_499_999)] + ); + + // A full window with nothing at the published rate clears it once. + let vbr = (0..PER_WINDOW as u64).map(|i| (if (i / 7) % 2 == 0 { 10 } else { 30 }, 714_701)); + assert_eq!(feed(&mut meter, vbr), vec![None]); + + // The stream settles again: published after one fresh stable window, not before. + let changes = feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW - 1)); + assert!(changes.is_empty(), "no publish before the window fills"); + assert_eq!(feed(&mut meter, [CBR]), vec![Some(2_499_999)]); + } + + #[test] + fn discontinuity_clears_immediately() { + let mut meter = Meter::default(); + assert_eq!( + feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW)), + vec![Some(2_499_999)] + ); + assert!(meter.discontinuity(), "a published rate is cleared"); + assert_eq!(meter.published(), None); + assert!(!meter.discontinuity(), "nothing left to clear"); + + // A backwards clock without the flag is a discontinuity too. + assert_eq!( + feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW)), + vec![Some(2_499_999)] + ); + meter.packet(); + assert!(meter.pcr(1), "the clock jumped backwards"); + assert_eq!(meter.published(), None); + } + + #[test] + fn survives_the_pcr_wrap() { + let mut meter = Meter { + last: Some(PCR_WRAP - CBR.1 * 3), + ..Default::default() + }; + let changes = feed(&mut meter, std::iter::repeat_n(CBR, PER_WINDOW)); + assert_eq!(changes, vec![Some(2_499_999)]); + } +} diff --git a/rs/moq-mux/src/container/ts/test_data/bbb_cbr.ts b/rs/moq-mux/src/container/ts/test_data/bbb_cbr.ts new file mode 100644 index 0000000000000000000000000000000000000000..6e73820b3099de6c4041ddc50e1ccb26abb9e7e4 GIT binary patch literal 144948 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 Date: Mon, 21 Sep 2026 10:30:16 -0700 Subject: [PATCH 3/3] fix(moq-mux): refuse zero and absurd TS multiplex rates The catalog muxRate is untrusted input that reached the stuffing allocator unbounded: any u64 padded to nulls = balance / UNIT with a reserve plus loop, and rate as i64 wrapped past i64::MAX. Sanitize at both entries (builder override and catalog snapshot) to 1..=1 Gb/s, warn and leave the output unpadded when refused, convert with try_from in stuff(), and cap nulls per call to what the credited slots allow. Regression tests abort (SIGABRT in the allocator) without the fix. --- rs/moq-mux/src/container/ts/export.rs | 46 +++++++++++++++- rs/moq-mux/src/container/ts/export_test.rs | 64 ++++++++++++++++++++++ 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/rs/moq-mux/src/container/ts/export.rs b/rs/moq-mux/src/container/ts/export.rs index 7713e93189..c162ca7ee9 100644 --- a/rs/moq-mux/src/container/ts/export.rs +++ b/rs/moq-mux/src/container/ts/export.rs @@ -87,6 +87,19 @@ const _: () = assert!( /// one slot at `mux_rate` bits per second is exactly `mux_rate` units and no slot /// rounds on its own. The remainder carries across slots instead. const STUFFING_UNIT: i64 = TsPacket::SIZE as i64 * 8 * SLOTS_PER_SECOND; +/// Upper bound on an accepted multiplex rate, in bits per second: far above any +/// broadcast contribution multiplex, while bounding one slot's null allocation +/// to a few megabytes. Zero and anything past it are refused where they enter, +/// leaving the output unpadded. +const MAX_MUX_RATE: u64 = 1_000_000_000; + +/// A multiplex rate from the builder override or the (untrusted) catalog is only +/// worth padding to when it is a real rate: zero pads nothing, and anything past +/// [`MAX_MUX_RATE`] would allocate unbounded nulls per slot. Invalid rates are +/// refused, leaving the output unpadded. +fn sanitize_mux_rate(rate: u64) -> Option { + (1..=MAX_MUX_RATE).contains(&rate).then_some(rate) +} /// Subscribe to a broadcast and produce an MPEG-TS byte stream. /// @@ -507,9 +520,15 @@ impl Export { /// Pad the output with null packets to `mux_rate` bits per second, whatever the /// catalog records. Without this the catalog's `mpegts.muxRate` decides, and a /// catalog without one leaves the output unpadded. + /// + /// Zero and absurd rates are refused with a warning, leaving the output + /// unpadded rather than allocating unbounded nulls. pub fn with_mux_rate(mut self, mux_rate: u64) -> Self { + if sanitize_mux_rate(mux_rate).is_none() { + tracing::warn!(mux_rate, "ignoring invalid MPEG-TS multiplex rate override"); + } self.mux_rate_override = Some(mux_rate); - self.mux_rate = Some(mux_rate); + self.mux_rate = sanitize_mux_rate(mux_rate); self } @@ -763,7 +782,17 @@ impl Export { let mpegts = catalog.ext.mpegts_mut().cloned().unwrap_or_default(); self.program_descriptors = mpegts.program_descriptors.clone(); self.program = mpegts.program.clone(); - let mux_rate = self.mux_rate_override.or(mpegts.mux_rate); + // An explicit override wins even when it is refused (leaving the output + // unpadded), so a bad flag cannot silently fall back to the catalog rate. + let mux_rate = match self.mux_rate_override { + Some(override_rate) => sanitize_mux_rate(override_rate), + None => mpegts.mux_rate.and_then(|rate| { + sanitize_mux_rate(rate).or_else(|| { + tracing::warn!(mux_rate = rate, "ignoring invalid MPEG-TS multiplex rate in catalog"); + None + }) + }), + }; if self.mux_rate != mux_rate { self.mux_rate = mux_rate; self.stuffing = Stuffing::default(); @@ -1498,8 +1527,12 @@ impl Export { let (Some(rate), Some(last)) = (self.mux_rate, self.last_pcr) else { return; }; + // The rate is sanitized where it enters (the builder and the catalog), so + // this holds; refuse to pad rather than wrap if it ever does not. + let Ok(rate) = i64::try_from(rate) else { + return; + }; let slots = index.saturating_sub(last).min(PCR_BACKFILL) as i64; - let rate = rate as i64; let stuffing = &mut self.stuffing; stuffing.balance = stuffing .balance @@ -1515,6 +1548,13 @@ impl Export { stuffing.overrun = false; } let nulls = (stuffing.balance / STUFFING_UNIT).max(0); + // Never emit more than the slots just credited could allow: a bound on one + // call's allocation whatever the balance holds. A no-op on a rate that came + // through [`sanitize_mux_rate`], whose balance carries less than one packet. + // (A manual `div_ceil`: all terms are non-negative, and the toolchain's + // signed `div_ceil` is still unstable.) + let ceiling = slots.saturating_mul(rate).saturating_add(STUFFING_UNIT - 1) / STUFFING_UNIT; + let nulls = nulls.min(ceiling); stuffing.balance -= nulls * STUFFING_UNIT; payload.reserve(nulls as usize * TsPacket::SIZE); for _ in 0..nulls { diff --git a/rs/moq-mux/src/container/ts/export_test.rs b/rs/moq-mux/src/container/ts/export_test.rs index 07c3609d7e..2ad0de6deb 100644 --- a/rs/moq-mux/src/container/ts/export_test.rs +++ b/rs/moq-mux/src/container/ts/export_test.rs @@ -4345,6 +4345,70 @@ async fn export_mux_rate_override_beats_the_catalog() { ); } +/// Zero and absurd override rates are refused, leaving the output unpadded: zero +/// pads nothing, and an unbounded rate would allocate unbounded nulls per slot. +/// An explicit override wins even when refused, so the catalog rate is not used. +#[tokio::test(start_paused = true)] +async fn export_mux_rate_override_is_bounded() { + for rate in [0, i64::MAX as u64] { + let ts = export_fixture(include_bytes!("test_data/bbb_cbr.ts"), Some(rate)).await; + assert_eq!( + Clocked::of(&ts).nulls, + 0, + "null packets in an export with a refused override rate {rate}" + ); + } +} + +/// An absurd catalog multiplex rate is refused, not padded to: the catalog is +/// untrusted input, and padding to it would allocate unbounded nulls per slot. +#[tokio::test(start_paused = true)] +async fn export_refuses_an_absurd_catalog_mux_rate() { + let mut broadcast = moq_net::broadcast::Info::new().produce(); + let consumer = broadcast.consume(); + let mut catalog = crate::catalog::Producer::new( + &mut broadcast, + crate::catalog::Config::default().with_catalog(crate::catalog::hang::Catalog::::default()), + ) + .unwrap(); + + let track = broadcast + .create_track( + broadcast.unique_name(".aac"), + hang::container::track_info(hang::catalog::PRIORITY.audio), + ) + .unwrap(); + let name = track.name().to_string(); + catalog.modify().unwrap().audio.renditions.insert(name.clone(), { + let mut cfg = AudioConfig::new(AAC { profile: 2 }, 48_000, 2); + cfg.container = Container::Legacy; + cfg + }); + catalog.modify().unwrap().ext.mpegts.mux_rate = Some(i64::MAX as u64); + + let mut producer = Producer::new(track, HangContainer::Legacy(crate::container::Kind::Data)); + // Ten seconds of small frames: the media alone never approaches the refused rate. + for i in 0..500u64 { + producer + .write(Frame { + timestamp: Timestamp::from_micros(i * 20_000).unwrap(), + duration: None, + payload: Bytes::from(vec![i as u8; 16]), + keyframe: i % 50 == 0, + }) + .unwrap(); + } + producer.finish().unwrap(); + + let ts = drain_with(export_of(&consumer).await).await; + assert_packet_aligned(&ts); + assert_eq!( + Clocked::of(&ts).nulls, + 0, + "null packets in an export with a refused catalog mux rate" + ); +} + /// A VBR source records no rate, and without one nothing is padded. #[tokio::test(start_paused = true)] async fn export_without_a_mux_rate_is_unpadded() {