diff --git a/quest/m2/README.md b/quest/m2/README.md index 89a9b802fdb..ba7337a9446 100644 --- a/quest/m2/README.md +++ b/quest/m2/README.md @@ -106,7 +106,6 @@ lite-07; bitrate enforcement is not part of this release. - [iOS capture](/quest/m2/mobile-capture-ios.md) - Rust captures the camera and screen on iOS, setting the catalog rotation - [Android capture](/quest/m2/mobile-capture-android.md) - Rust captures through NDK/JNI on Android, reusing the existing codecs and setting the catalog rotation - [Time stretch](/quest/m2/watch-audio-time-stretch.md) - js/watch: the audio ring converges by time-stretching instead of skipping or going silent -- [Audio join budget](/quest/m2/audio-join-budget.md) - `moq-audio` joining at the latest group loses no group a publisher bursts before its budget widens - [Latest readers use Range::live()](/quest/m2/readers-range-live.md) - after #6278, readers start at the live edge without waiting on `largest()` - [Media QA audio cases](/quest/m2/media-qa-audio.md) - AAC, audio codec/rate/channel changes, rendition switches, output mute/volume, and a native publisher's A/V sync are graded like the video cases - [Audio outlives video](/quest/m2/audio-bufferbloat-av.md) - under bufferbloat audio stays within budget while video may skip, rendered frames stay in sync, and native audio rows are enforced diff --git a/quest/m2/audio-join-budget.md b/quest/m2/audio-join-budget.md deleted file mode 100644 index 138ea1f84f7..00000000000 --- a/quest/m2/audio-join-budget.md +++ /dev/null @@ -1,24 +0,0 @@ -# [S] Join moq-audio at the latest group without narrowing the wire budget - -## Goal - -`moq-audio`'s decode consumer joining with `Start::Latest` and a catalog warmup loses no -group a publisher bursts before its first frame is decoded. - -## Plan - -It subscribes with a budget of just the warmup and widens it to `max_delay + warmup` -after the first frame (`joining_latest` in `rs/moq-audio/src/decode/consumer.rs`). Until -the widening reaches the publisher, a group whose reach falls a warmup behind the newest -frame is skipped upstream, and no later update recovers it. The TS export had the same -shape and lost MPEG-1 audio frames right after its join; moq-mux's -`a_live_start_loses_no_group_the_publisher_bursts` shows the mechanism over an in-memory -session. - -Reproduce it the same way first: a publisher bursting one-frame groups over -`moq_net::transport::memory` before the update lands. If it holds, subscribe with the full -budget from the start and keep the warmup reach local, as the export now picks its live -edge in the consumer instead of on the wire. The export anchors that edge on the -publisher's largest (`track::Subscriber::poll_largest`), which a relay now forwards from -its source; `moq_mux::container::test_util::Link::held` delivers one group stream ahead of -the rest, the way the export's join test does. diff --git a/quest/m2/readers-range-live.md b/quest/m2/readers-range-live.md index a2ed3605058..ef239bac22d 100644 --- a/quest/m2/readers-range-live.md +++ b/quest/m2/readers-range-live.md @@ -25,7 +25,3 @@ Public API: none expected. Wire: none. ## Required - [JS ranges](/quest/m1/subscribe-ranges/js.md) - `@moq/room` chat needs relative ranges in `@moq/net` - -## Related - -- [Audio join budget](/quest/m2/audio-join-budget.md) - moq-audio's join at the latest group diff --git a/rs/moq-audio/src/decode/consumer.rs b/rs/moq-audio/src/decode/consumer.rs index 3e79ca5ee6e..fffd28b3aac 100644 --- a/rs/moq-audio/src/decode/consumer.rs +++ b/rs/moq-audio/src/decode/consumer.rs @@ -945,6 +945,74 @@ mod tests { assert_eq!(frame.timestamp.as_micros(), 80_000, "the backlog was replayed"); } + /// A latest join with a warmup loses no group the publisher bursts before its first + /// frame decodes. A join that put only the warmup on the wire, widening it once the + /// first packet was read, let the publisher skip every group of such a burst a warmup + /// behind its newest, a gap no later update recovers. + #[tokio::test(start_paused = true)] + async fn latest_loses_no_group_the_publisher_bursts_before_its_first_frame() { + let publisher = origin(1); + let broadcast = publisher.create_broadcast("bcast").unwrap(); + let track = broadcast + .create_track("audio", hang::container::track_info(hang::catalog::PRIORITY.audio)) + .unwrap(); + broadcast.announce(Default::default()).unwrap(); + let mut producer = moq_mux::container::Producer::new( + track, + moq_mux::catalog::hang::Container::Legacy(moq_mux::container::Kind::Audio), + ); + // One 20 ms packet per group. + let mut write = |packet: u64| { + producer + .write(moq_mux::container::Frame { + timestamp: Timestamp::from_micros(packet * 20_000).unwrap(), + duration: None, + payload: vec![0; 960 * size_of::()].into(), + keyframe: true, + }) + .unwrap(); + producer.cut(None).unwrap(); + }; + write(0); + + let subscriber = origin(2); + let _link = link(&publisher, &subscriber).await; + let consumer = subscriber.consume(); + let remote = consumer.follow("bcast", None).unwrap().available().await.unwrap(); + + let mut catalog = hang::catalog::AudioConfig::new(hang::catalog::AudioCodec::Pcm, 48_000, 1); + catalog.warmup = Some(Duration::from_millis(80)); + let mut consumer = Consumer::new( + &remote, + &catalog, + "audio", + Options { + start: Start::Latest, + max_delay: Duration::from_secs(1), + ..Options::new() + }, + ) + .await + .unwrap(); + + // Written in one pass, before the publisher's session runs again. + for packet in 1..=20 { + write(packet); + } + producer.finish().unwrap(); + + let mut stamps = Vec::new(); + while let Some(read) = consumer.read().await.unwrap() { + match read { + Read::Frame(frame) => stamps.push(frame.timestamp.as_micros()), + Read::Gap { timestamp, duration } => panic!("a gap of {duration:?} after {timestamp:?}"), + Read::End { .. } => {} + } + } + let expected: Vec = (4..=20).map(|packet| packet * 20_000).collect(); + assert_eq!(stamps, expected, "the burst reached the decoder whole"); + } + #[tokio::test(start_paused = true)] async fn warmup_trims_the_join_without_trimming_groups_or_declared_returns() { let (stamps, frames) = warmup_output(true).await;