Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 16 additions & 10 deletions doc/bin/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,11 @@ Other video waits for its first keyframe.
MPEG-TS export pads to the source's constant mux rate when the catalog
recorded one, or to `--mux-rate`, on a constant-rate schedule an IRD or groomer
can lock to. Each frame goes out as early as the receiver's buffers admit, up
to `--delay` ahead of its decode time on the output's clock, so the output trails the source by twice
the delay; a frame that cannot arrive in time at the rate fails the export.
to `--delay` ahead of its decode time and no further than its decoder buffer
holds. That send-ahead comes out of the delay, so the output trails a TS source
by `--delay`. A frame that cannot arrive in time at the rate fails the export,
as does video whose HRD declares a longer buffer delay than `--delay`; the
error names the `--delay` it needs.

## Play

Expand Down Expand Up @@ -353,14 +356,17 @@ may fall behind the live edge before *this* consumer skips it. Raising
At stdin EOF, `import` keeps the finished broadcast up for its max-age, so a
subscriber still catching up can drain it; Ctrl-C ends that early.

`export ts`, `export flv`, and `export mkv` take `--delay` (default 500 ms)
instead, like an SRT receiver's
latency: each frame is muxed that long after its decode time, all tracks in
decode order, so two exporters of one broadcast emit the same order. A frame
arriving after its deadline is dropped, and video resumes at its next
keyframe. The delay also bounds how stale a group may get, so `--delay 0` writes each
frame as soon as it is read and drops none as late, but still skips a stalled
group once a newer one is ready.
`export ts`, `export flv`, and `export mkv` take `--delay` instead, like an SRT
receiver's latency, all tracks in decode order, so two exporters of one broadcast
emit the same order. FLV and MKV mux each frame that long after its decode time
(default 500 ms). TS output runs that far behind the source (default 1 s): the
send-ahead that fills a receiver's buffers comes out of the same budget, and 1 s
fits a broadcast-sized CPB. A source that sends no track ahead of its decode
time, as RTMP and browser publishers do, trails by the video's send-ahead on
top. A frame arriving after its deadline is dropped, and video resumes at its
next keyframe. The delay also bounds how stale a group may get, so `--delay 0`
writes each frame as soon as it is read and drops none as late, but still skips
a stalled group once a newer one is ready.
FLV and MKV reject their former `--max-delay` spelling with a migration
message. The output follows the source's clock within what ISO/IEC 13818-1
allows a TS PCR. A source further off fails the export, naming its rate, once
Expand Down
5 changes: 3 additions & 2 deletions doc/bin/srt.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ moq --connect https://relay.example.com/anon --broadcast event.hang import srt -
```

`--latency` (default 500 ms) sets the SRT receive buffer and doubles as the
export's jitter buffer, like `export ts --delay` in the [CLI](/bin/cli#export):
a frame arriving later than that is dropped. A `--connect` URL needs a
export's delay, like `export ts --delay` in the [CLI](/bin/cli#export), but no
less than that flag's 1 s default: the send-ahead that fills a receiver's
buffers comes out of the delay. A frame arriving later than that is dropped. A `--connect` URL needs a
`streamid` query or a path; a listener bridges one `--broadcast` and ignores
the stream id it is offered. The library is [`moq-srt`](https://docs.rs/moq-srt).
10 changes: 10 additions & 0 deletions doc/setup/upgrade.md
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,16 @@ These land with the next breaking release, not the 2026-09-23 train.
`next()` and `for await` over a snapshot consumer rather than relying on
type errors. On an untimed track nothing marks a group stale, so a `next()`
reader that falls behind replays the whole backlog where it used to skip it.
- **`moq export ts --delay` defaults to 1 s and includes the send-ahead.** Each
frame still goes out up to the delay ahead of its decode time, now no further
than its decoder buffer holds, and that comes out of the same budget: the
output trails a TS source by the delay instead of twice it. With a multiplex
rate, video whose HRD declares a longer buffer delay than `--delay` stops the
export with the delay it needs named, so a broadcast feed run at
`--delay 500ms` needs `--delay 1s`. A source that sends every track at its
decode time (RTMP, a browser) trails by the delay plus the video's
send-ahead: 2 s at the new default, where `--delay 500ms` keeps the 1 s it
had. moq-srt egress runs at its SRT latency or 1 s, whichever is longer.

## Wire

Expand Down
6 changes: 2 additions & 4 deletions quest/m1/tstd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,15 @@ first.
Measured (2026-10-01, #4645): with the fixed-delay jitter buffer, per-PID
admission against each PID's T-STD buffers, and PCRs at their byte position, a
clean-path round trip passes the strict T-STD check and TSDuck's ±500 ns
pcrverify at the default 500 ms delay, for the generated clip at 10 Mb/s and
pcrverify at the then-default 500 ms delay, for the generated clip at 10 Mb/s and
2 Mb/s and for a 1080p encode filling a 9 Mbit CPB (`just test ts --hrd`, after
t0ms's recipe). A unit that cannot arrive by its DTS fails the export rather
than arrive late. t0ms graded it on a CNN broadcast capture (PAFF H.264, MP2,
DVB AC-3, teletext, SCTE-35; 2026-10-02): at 1 s on loopback, across hosts and
under the #4613 rig at 0 % and 1 % loss, every track passed T-STD and
`compliance.py` with no late drops, once the clock anchored on the track sent
latest. At 500 ms a 540 s run still misses a video deadline about 157 s in,
which [send-ahead](/quest/m1/tstd/send-ahead.md)'s 1 s default addresses, and
10 % loss is still to pass.
which the 1 s default `--delay` addresses, and 10 % loss is still to pass.

This README owns the remaining end-to-end proof: make the #4613 netem rig
(10% loss, a redistributable or reproducible ~10 Mb/s broadcast-style TS)
Expand All @@ -49,7 +48,6 @@ MB/AAC controls. Do not relabel the current subset a full standards check.

## Required

- [Send-ahead within the delay](/quest/m1/tstd/send-ahead.md) - total lag is `--delay`, send-ahead included, with a 1 s default
- [PSI pacing](/quest/m1/tstd/psi-runs.md) - padded output paces the PAT and PMT so TBsys and Bsys never overflow

## Related
Expand Down
36 changes: 0 additions & 36 deletions quest/m1/tstd/send-ahead.md

This file was deleted.

20 changes: 11 additions & 9 deletions rs/moq-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,8 @@ impl ExportSink {
/// A stdout sink's format and the options that apply to it.
pub struct Stdout {
pub format: SubscribeFormat,
/// The staleness budget, also used as the TS/FLV/MKV release delay.
/// The staleness budget, also the FLV/MKV release delay and how far behind the source `ts`
/// runs (`--delay`).
pub max_delay: Duration,
pub linger: Duration,
/// Follow a replacement as a program switch (`ts` only).
Expand Down Expand Up @@ -1152,10 +1153,11 @@ pub struct BufferedFragmented {
#[derive(usage::Args, Clone)]
#[usage(unknown_flags = "error", args_override_self = false)]
pub struct Transport {
/// How long after its decode time each frame is written (e.g. `500ms`), like an SRT
/// receiver's latency. A frame that arrives later than this is dropped, and a stalled
/// group is skipped once it falls half this behind the newest content.
#[usage(long, default = "500ms")]
/// How far behind the source the output runs (e.g. `2s`), like an SRT receiver's latency,
/// including how far ahead of its decode time each frame is sent. A frame that arrives
/// later than this is dropped, and a stalled group is skipped once it falls half this
/// behind the newest content.
#[usage(long, default = "1s")]
pub delay: crate::duration::Duration,

/// How long to wait for the same publisher instance to come back once it ends (e.g. `10s`),
Expand Down Expand Up @@ -1193,13 +1195,13 @@ pub struct Transport {

impl Transport {
fn deprecated(&self) -> moq_tokio::cli::Deprecated {
const HOLDS: &str = "it also holds every frame that long after its decode time";
const BEHIND: &str = "it also sets how far behind the source the output runs";
let mut found = moq_tokio::cli::Deprecated::default();
if self.max_age.is_some() {
found.changed("--max-age", None, "--delay", HOLDS);
found.changed("--max-age", None, "--delay", BEHIND);
}
if self.latency_max.is_some() {
found.changed("--latency-max", None, "--delay", HOLDS);
found.changed("--latency-max", None, "--delay", BEHIND);
}
found
}
Expand Down Expand Up @@ -1280,7 +1282,7 @@ mod tests {
};
export.sink.stdout().unwrap().max_delay
};
assert_eq!(stdout(&[]), Duration::from_millis(500));
assert_eq!(stdout(&[]), Duration::from_secs(1));
assert_eq!(stdout(&["--delay", "2s"]), Duration::from_secs(2));
for old in ["--max-age", "--latency-max"] {
let Err(err) = parse(&[old, "1s"]) else {
Expand Down
4 changes: 2 additions & 2 deletions rs/moq-cli/src/subscribe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ pub struct SubscribeArgs {
/// The format to write to stdout.
pub format: SubscribeFormat,

/// How far playback may drift from the live edge before skipping groups. TS also
/// holds every frame this long after its decode time (`--delay`).
/// How far playback may drift from the live edge before skipping groups. TS also runs
/// this far behind the source (`--delay`).
pub max_delay: Duration,

/// How long to wait for the broadcast to come back after it ends (TS only).
Expand Down
4 changes: 4 additions & 0 deletions rs/moq-mux/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,7 @@ harness = false
[[bench]]
name = "export"
harness = false

[[bench]]
name = "jitter"
harness = false
61 changes: 61 additions & 0 deletions rs/moq-mux/benches/jitter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//! The jitter buffer's clock steering swept over track count and how many floors the rate
//! window holds.
use std::time::Duration;

use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main};
use moq_net::Timestamp;
use web_async::time::Instant;

#[path = "../src/jitter.rs"]
#[allow(dead_code)]
mod jitter;

/// Every track sends a frame a second as it decodes, for as many two-second steering steps as
/// fill the rate window with `floors`, each step measuring every track heard.
async fn steer(tracks: u16, floors: u64) -> u64 {
let start = Instant::now();
let mut buffer = jitter::Buffer::new(Duration::from_millis(500));
buffer.expect(1..=tracks);
let waiter = kio::Waiter::noop();
for second in 0..floors * 2 {
let at = start + Duration::from_secs(second);
tokio::time::advance(at.saturating_duration_since(Instant::now())).await;
for track in 1..=tracks {
let arrival = jitter::Arrival {
arrived: at,
read: at,
decode: Timestamp::from_millis(second * 1_000).unwrap(),
restart: 0,
skip: 0,
sync: true,
item: (),
};
buffer.push(track, arrival).unwrap();
}
while buffer.poll_next(&waiter).is_ready() {}
}
buffer.dropped()
}

fn steering(c: &mut Criterion) {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.start_paused(true)
.build()
.unwrap();
let mut group = c.benchmark_group("jitter_steering");
group.measurement_time(Duration::from_secs(1));
group.warm_up_time(Duration::from_millis(200));
group.sample_size(10);
for tracks in [1u16, 4, 16, 64] {
for floors in [10u64, 100, 300] {
group.bench_function(BenchmarkId::new(tracks.to_string(), floors), |b| {
b.iter(|| assert_eq!(runtime.block_on(steer(tracks, floors)), 0));
});
}
}
group.finish();
}

criterion_group!(benches, steering);
criterion_main!(benches);
1 change: 1 addition & 0 deletions rs/moq-mux/benches/ts_schedule.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ fn admission(c: &mut Criterion) {
leak: None,
size: Some(1_000_000),
transport: 512,
reach: Duration::from_secs(1),
},
);
}
Expand Down
28 changes: 28 additions & 0 deletions rs/moq-mux/src/codec/h264/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,12 @@ pub(crate) mod fixtures {
0x67, 0xf4, 0x00, 0x15, 0x91, 0x9d, 0x02, 0x82, 0x1f, 0x89, 0xc0, 0x44, 0x00, 0x00, 0x03, 0x00, 0x04, 0x00,
0x00, 0x03, 0x00, 0xca, 0x7c, 0x50, 0xaa, 0x80,
];
/// `bframes=1:force-cfr=1:nal-hrd=cbr:bitrate=9000:vbv-maxrate=9000:vbv-bufsize=9000`: a
/// contribution encoder's 9 Mbit CBR NAL HRD at 9 Mb/s, one second of buffer delay.
pub(crate) const SPS_HRD: &[u8] = &[
0x67, 0x64, 0x00, 0x1e, 0xac, 0xe4, 0x10, 0x9b, 0x01, 0x10, 0x00, 0x00, 0x03, 0x00, 0x10, 0x00, 0x00, 0x03,
0x03, 0x2e, 0x04, 0x00, 0x00, 0x89, 0x54, 0x40, 0x00, 0x11, 0x2a, 0x8e, 0x4c, 0x30, 0x07, 0x8b, 0x16, 0x89,
];
pub(crate) const PPS: &[u8] = &[0x68, 0xeb, 0xe3, 0xcb, 0x22, 0xc0];
}

Expand Down Expand Up @@ -804,6 +810,28 @@ mod tests {
assert_eq!(sps_framerate(&[0x67, 0x42, 0xc0, 0x1f, 0xde]), None);
}

#[test]
fn sps_hrd_reads_the_nal_hrd() {
use crate::codec::video::{Hrd, Reorder};

assert_eq!(
sps_hrd(fixtures::SPS_HRD),
Some(Hrd {
bit_rate: 9_000_000,
cpb_size: 9_000_000
})
);
// The bitstream restriction follows the HRD in the VUI, so it still reads past it.
assert_eq!(
sps_reorder(fixtures::SPS_HRD),
Some(Reorder {
depth: 1,
period: Some((2, 50))
})
);
assert_eq!(sps_hrd(fixtures::SPS_IPB), None);
}

fn annexb_frame(nals: &[&[u8]]) -> Bytes {
let mut buf = BytesMut::new();
for nal in nals {
Expand Down
Loading
Loading