diff --git a/quest/m0/README.md b/quest/m0/README.md index 7136580..3d34c96 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -100,7 +100,6 @@ repository. - [Web studio](/quest/m0/web.md) - the full studio in wasm on WebGPU and WebCodecs, served from this repository - [Virtual camera](/quest/m0/vcam/README.md) - the program or a published scene exposed as a system camera on Linux, Windows, and macOS - [Browser sources on the GPU under Mesa](/quest/m0/cef-headless.md) - shared-texture pages paint on Intel and AMD under Wayland, and a page that cannot paint fails loudly -- [A replay's sound plays with its pictures](/quest/m0/replay-av-sync.md) - replay sources write a bus latency ahead, as media and broadcast sources do - [The Apple signing identity is available](/quest/m0/signing.md) - the Apple credentials packaging and the macOS camera need; Windows ships unsigned - [Packaging](/quest/m0/packaging.md) - a signed macOS app, a Windows installer, and a Flatpak for Linux - [The persistable portal restore token is pinned](/quest/m0/pin-portal-token.md) - moq-video's restore token, for Wayland sources diff --git a/quest/m0/replay-av-sync.md b/quest/m0/replay-av-sync.md deleted file mode 100644 index b3d5a39..0000000 --- a/quest/m0/replay-av-sync.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] A replay's sound plays with its pictures - -## Goal - -A replay source's sound plays in step with its own pictures in every output -(program, published scenes, recordings, and Listen), instead of a bus -latency (50 ms) behind them. This is about replays at normal speed; a slowed -replay stays silent, as it is today. - -## Plan - -From [moq-dev/studio#93](https://github.com/moq-dev/studio/pull/93), picked -2026-11-07 (maintainer away under /afk, recommended option), m0 by the rule -that fixes join m0. - -#93 moved media file sources to write their sound a bus latency ahead -through `playout::Writer`, with their pictures handed over at the same lead -and shown when due. Replay sources still write their sound when it is due, -so the bus mixes it 50 ms late. Apply the same fix (a replay has no loop to -handle) and test it the way #93's `a_media_files_sound_plays_with_its_pictures` -does. diff --git a/rs/moq-scene/src/playout.rs b/rs/moq-scene/src/playout.rs index 12afc98..d129867 100644 --- a/rs/moq-scene/src/playout.rs +++ b/rs/moq-scene/src/playout.rs @@ -9,8 +9,8 @@ //! `moq play` holds its jitter buffer in the speaker. The bus holds only //! [`BUS_LATENCY`] of a source, so a [`Speaker`] holds the rest: it places each //! frame the playout target moq-audio's decoder estimates after it arrives, -//! and a [`Writer`] writes it the bus's latency ahead of then. A media -//! source's sound goes through a [`Writer`] too, so it sounds with the +//! and a [`Writer`] writes it the bus's latency ahead of then. A media or +//! replay source's sound goes through a [`Writer`] too, so it sounds with the //! pictures it shows. use std::time::Duration; diff --git a/rs/moq-scene/src/publish.rs b/rs/moq-scene/src/publish.rs index dc86570..1ca698a 100644 --- a/rs/moq-scene/src/publish.rs +++ b/rs/moq-scene/src/publish.rs @@ -1528,10 +1528,9 @@ mod tests { }; let take = crate::replay::Take::new(&target, std::time::Duration::from_millis(2_500)).unwrap(); let feed = sources.get("replay").unwrap(); - let begin = kio::clock::now(); feed.replay(take, 1.0).unwrap(); - // The first picture is due at once, so the clock stays put while - // the encoder opens. + // The track opens before the replay begins, so the clock stays put + // while the encoder opens. let listed = until(|| { let listed = catalog.snapshot().ext.studio.sources.remove("replay"); listed.filter(|listed| !listed.video.renditions.is_empty()) @@ -1551,6 +1550,7 @@ mod tests { .unwrap(); let container = moq_mux::catalog::hang::Container::try_from(config).unwrap(); let mut track = moq_mux::container::Consumer::new(track, container); + let begin = until(|| feed.run().begun).await; until_time(begin + std::time::Duration::from_millis(1_500)).await; let now = kio::Waiter::noop(); until(|| match track.poll_read(&now) { diff --git a/rs/moq-scene/src/source.rs b/rs/moq-scene/src/source.rs index cb1dcf6..a670137 100644 --- a/rs/moq-scene/src/source.rs +++ b/rs/moq-scene/src/source.rs @@ -114,30 +114,36 @@ struct Played { type Playback = Arc>; -/// A take for a replay source to play, and how fast. +/// A take for a replay source to play, how fast, and which play it is, as +/// [`Run::play`] counts them. struct Play { take: Take, speed: f32, + play: u64, } /// A replay source's next replay, until its driver takes it, so a replay that /// ended holds its take no longer. type Next = Arc>>; -/// A media source's latest play: which it is, and when it began and ended on -/// the program clock. +/// A media or replay source's latest play: which it is, and when it began and +/// ended on the program clock. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Run { - /// Which play it is: 0 for the one the source opens with, and one more - /// for each [`Feed::play`] since. + /// Which play it is: 0 for the one the source opens with, in which a + /// replay source plays nothing, and one more for each [`Feed::play`] or + /// [`Feed::replay`] since. pub play: u64, - /// When its first picture shows and sound sounds, or `None` until it is - /// decoded. Set a little ahead: the bus sounds a write 50 ms later, so a - /// play begins that long after it is decoded. + /// When it begins, or `None` until its first picture and sound are + /// decoded: a media file's earlier of the two shows or sounds then, and a + /// replay's span starts then, even where it starts in a gap. Set a little + /// ahead: the bus sounds a write 50 ms later, so a play begins that long + /// after it is decoded. pub begun: Option, /// When its last picture or sound finishes, a looping one's first pass - /// does, or [`Feed::stop`] stopped it, or `None` until known. A looping - /// one's is known 50 ms ahead. A play cut off by the next never ends. + /// does, a replay's span does, or [`Feed::stop`] stopped it, or `None` + /// until known. A looping one's is known 50 ms ahead. A play cut off by + /// the next never ends. pub ended: Option, } @@ -478,8 +484,27 @@ impl Feed { "a replay of sound alone plays only in real time".into(), )); } - let play = Play { take, speed }; - // Fails once the driver is gone, which leaves the reason in the status. + if plays.is_closed() { + return Err(self.stopped()); + } + // Counted before the driver hears of it, so whatever the replay before + // still reports or shows is not taken for this one's. + let mut latest = self.runs.lock().unwrap(); + *latest = Latest { + run: Run { + play: latest.run.play + 1, + ..Run::default() + }, + stopped: false, + }; + // Cleared before the driver hears of it, so the replay cut off leaves + // nothing up until the new one's first picture. + self.picture.lock().unwrap().clear(); + let play = Play { + take, + speed, + play: latest.run.play, + }; plays.send(Arc::new(Mutex::new(Some(play)))).map_err(|_| self.stopped()) } @@ -539,7 +564,8 @@ impl Feed { } /// A media source's latest play, from its start or the latest - /// [`play`](Self::play). Never begins for any other source. + /// [`play`](Self::play), or a replay source's latest + /// [`replay`](Self::replay). Never begins for any other source. pub fn run(&self) -> Run { self.runs.lock().unwrap().run } @@ -1265,14 +1291,7 @@ impl Driver { return std::future::pending().await; } let play = plays.borrow_and_update().lock().unwrap().take(); - // A replay cut off must not leave its last picture up until - // the new one's first. Between replays the picture is clear - // already, and clearing it again would show before the new - // one's first picture even when that is due at once. - if playing.is_some() { - self.picture.lock().unwrap().clear(); - } - playing = play.map(|play| Box::pin(self.replay(play, listing)) as Playing<'_>); + playing =play.map(|play| Box::pin(self.replay(play, listing)) as Playing<'_>); } result = async { playing.as_mut().expect("guarded").await }, if playing.is_some() => { playing = None; @@ -1286,39 +1305,100 @@ impl Driver { } } - /// Play one take at its speed, each picture and sound when its moment - /// comes: one `t` into the span plays `t / speed` after the replay starts. - /// Ends once the whole span has played. + /// Play one take at its speed, each picture and sound at its moment: one + /// `t` into the span plays `t / speed` after the replay begins, once the + /// first of each is decoded and the bus can sound it. Ends once the whole + /// span has played. + /// + /// The bus sounds a write [`BUS_LATENCY`] later, so each sound is written + /// that far ahead of its moment. /// /// With `listing`, the pictures are also encoded onto the source's own /// track as they show, while it is used. async fn replay(&self, play: Play, listing: Option<&Listing<'_>>) -> Result<(), Error> { set(&self.status, Status::Live); let take = &play.take; - let begin = kio::clock::now(); + let broadcast = take.consume(); + let mut video = match &take.video { + Some((name, config)) => { + let consumer = moq_video::decode::Consumer::new(&broadcast, config, name, self.pictures()).await?; + Some((consumer, config)) + } + None => None, + }; + // Slowed down, sound would need stretching, so it plays only in real time. + let mut audio = match (&take.audio, &self.strip) { + (Some((name, config)), Some(strip)) if play.speed == 1.0 => { + let consumer = + moq_audio::decode::Consumer::new(&broadcast, config, name.clone(), self.sounds()).await?; + let bus = self.context.bus.config(); + let writer = Writer::new(self.channel(strip.clone())?, bus.sample_rate, bus.layout.channels()); + Some((consumer, writer)) + } + _ => None, + }; + + // The first of each from the start on is decoded before the clock + // starts, so neither is late. The group in progress at the start + // decodes from its keyframe, but plays only from the start on. A gap + // or end before the first picture leaves nothing to clear or mark. + let mut picture = None; + if let Some((consumer, _)) = &mut video { + while let Some(read) = consumer.read().await? { + if let moq_video::decode::Read::Frame(frame) = &read + && frame.timestamp >= take.start + { + picture = Some(read); + break; + } + } + } + // A sound across the start is written whole: the writer drops what + // is due before the replay begins. + let mut samples = None; + if let Some((consumer, _)) = &mut audio { + let bus = self.context.bus.config(); + let stride = bus.layout.channels() as usize * size_of::(); + while let Some(frame) = next_samples(consumer).await? { + let length = (frame.data.len() / stride) as f64 / f64::from(bus.sample_rate); + if Duration::from(frame.timestamp) + Duration::from_secs_f64(length) > Duration::from(take.start) { + samples = Some(frame); + break; + } + } + } + // The pictures' track opens before the clock starts too, so the + // encoder's probe holds back neither the first picture nor the sound + // beside it. + let mut own = Own::new(listing, kio::clock::now()); + if let (Some(moq_video::decode::Read::Frame(frame)), Some((_, config))) = (&picture, &video) { + self.open(&mut own, frame, config).await; + } + let begin = kio::clock::now() + BUS_LATENCY; + self.ran(play.play, |run| { + run.begun.get_or_insert(begin); + }); let at = |timestamp: moq_net::Timestamp| { - let into = Duration::from(timestamp).saturating_sub(Duration::from(take.start)); - begin + into.div_f32(play.speed) + let (timestamp, start) = (Duration::from(timestamp), Duration::from(take.start)); + match timestamp.checked_sub(start) { + Some(into) => begin + into.div_f32(play.speed), + None => begin - (start - timestamp).div_f32(play.speed), + } }; - let broadcast = take.consume(); - let video = async { - let Some((name, config)) = &take.video else { + let pictures = async { + let Some((consumer, config)) = &mut video else { return Ok(()); }; - let mut consumer = moq_video::decode::Consumer::new(&broadcast, config, name, self.pictures()).await?; - let mut own = Own::new(listing, begin); - while let Some(read) = consumer.read().await? { + while let Some(read) = picture.take() { match read { - // The group in progress at the start decodes from its - // keyframe, but shows only from the start on. moq_video::decode::Read::Frame(frame) if frame.timestamp < take.start => {} // One at the end would show for no time at all. moq_video::decode::Read::Frame(frame) if frame.timestamp >= take.end => break, moq_video::decode::Read::Frame(mut frame) => { kio::clock::sleep_until(at(frame.timestamp)).await; self.encode(&mut own, &mut frame, config, kio::clock::now()).await?; - self.show(Picture::Frame(frame)); + self.show_play(play.play, Picture::Frame(frame)); } // A stretch the broadcast was offline, paused, or lost // replays as the gap it was, on its own track too. Past @@ -1334,46 +1414,43 @@ impl Driver { } } } + picture = consumer.read().await?; } Ok::<_, Error>(()) }; - - // Slowed down, sound would need stretching, so it plays only in real time. - let audio = async { - let (Some((name, config)), Some(strip), true) = (&take.audio, &self.strip, play.speed == 1.0) else { + let sounds = async { + let Some((consumer, writer)) = &mut audio else { return Ok(()); }; - let mut consumer = - moq_audio::decode::Consumer::new(&broadcast, config, name.clone(), self.sounds()).await?; - let mut channel = self.channel(strip.clone())?; - while let Some(read) = consumer.read().await? { - match read { - moq_audio::decode::Read::Frame(frame) if frame.timestamp < take.start => {} - moq_audio::decode::Read::Frame(frame) if frame.timestamp >= take.end => break, - moq_audio::decode::Read::Frame(frame) => { - kio::clock::sleep_until(at(frame.timestamp)).await; - channel.write(&frame.data)?; - } - _ => {} - } + while let Some(frame) = samples.take() + && frame.timestamp < take.end + { + let due = at(frame.timestamp); + ahead(due).await; + writer.write(due, &frame.data, kio::clock::now())?; + samples = next_samples(consumer).await?; } - // The bus plays what is written after a buffer's delay, and drops - // what a closed channel has not played, so it stays open until the - // replay ends. + // The bus drops what a closed channel has not played, so it stays + // open until the replay ends, by when the last sound has played. std::future::pending().await }; // The span ends where it does even when its tracks have not ended yet. let played = async { - tokio::try_join!(video, audio)?; + tokio::try_join!(pictures, sounds)?; std::future::pending().await }; + let end = at(take.end); // Played first, so a picture due at the very end shows before it does. tokio::select! { biased; - result = played => result, - () = kio::clock::sleep_until(at(take.end)) => Ok(()), + result = played => return result, + () = kio::clock::sleep_until(end) => {} } + self.ran(play.play, |run| { + run.ended.get_or_insert(end); + }); + Ok(()) } /// Play the media file at `path` as `cues` asks: from its start for each @@ -1514,8 +1591,8 @@ impl Driver { } } - /// Note how a media source's `play` runs, unless the feed has asked for - /// another since or stopped it. + /// Note how a media or replay source's `play` runs, unless the feed has + /// asked for another since or stopped it. fn ran(&self, play: u64, note: impl FnOnce(&mut Run)) { let mut latest = self.runs.lock().unwrap(); if latest.run.play == play && latest.run.ended.is_none() { @@ -1523,8 +1600,8 @@ impl Driver { } } - /// Show a picture of a media source's `play`, unless the feed has asked - /// for another or stopped it since. + /// Show a picture of a media or replay source's `play`, unless the feed + /// has asked for another or stopped it since. fn show_play(&self, play: u64, picture: Picture) { let latest = self.runs.lock().unwrap(); if latest.run.play == play && !latest.stopped { @@ -1631,15 +1708,6 @@ impl Driver { let at = |timestamp: moq_net::Timestamp| begin + Duration::from(timestamp).saturating_sub(start); let end = std::cell::Cell::new(begin); let last = |until: Instant| end.set(end.get().max(until)); - // What is due at `due` is handed over a bus latency ahead. One due at - // once is handed over without a timer, which the timer wheel would - // round up to its next millisecond, a moment too late. - let ahead = async |due: Instant| { - let at = due - BUS_LATENCY; - if at > kio::clock::now() { - kio::clock::sleep_until(at).await; - } - }; let pictures = async { let Some((consumer, config)) = &mut video else { @@ -1808,6 +1876,17 @@ impl<'a, 'b> Own<'a, 'b> { } } +/// Wait to hand over what is due at `due`: a bus latency ahead, so a sound +/// written then is heard when due. One due at once is handed over without a +/// timer, which the timer wheel would round up to its next millisecond, a +/// moment too late. +async fn ahead(due: Instant) { + let at = due - BUS_LATENCY; + if at > kio::clock::now() { + kio::clock::sleep_until(at).await; + } +} + /// The next picture `consumer` decodes, passing over gaps, or `None` at the /// end of its track. async fn next_picture(consumer: &mut moq_video::decode::Consumer) -> Result, Error> { @@ -2891,13 +2970,18 @@ mod tests { }; let take = Take::new(&target, Duration::from_secs(1)).unwrap(); let speed = 0.5; - let begin = kio::clock::now(); feed.replay(take, speed).unwrap(); + assert!( + matches!(feed.next_picture(), Some(Picture::Rgba { .. })), + "cleared as it is asked" + ); + let begin = until(|| feed.run().begun).await; // From the start, not from the keyframe of its group at 1 s, each // picture `t / speed` into the replay, and none sooner. The clock - // moves only to each picture's moment, which the timer wheel rounds - // up to its millisecond. + // is held until the replay begins, then moves only to each + // picture's moment, which the timer wheel rounds up to its + // millisecond. let start = moq_net::Timestamp::from_micros(1_500_000).unwrap(); for index in 45..75u64 { let timestamp = moq_net::Timestamp::from_micros(index * 1_000_000 / 30).unwrap(); @@ -2924,6 +3008,7 @@ mod tests { assert!(feed.next_picture().is_none(), "cleared before the end"); until_time(end).await; until(|| matches!(feed.next_picture(), Some(Picture::Rgba { .. })).then_some(())).await; + assert_eq!(feed.run().ended, Some(end)); assert_eq!(feed.status(), Status::Live, "ready for the next replay"); }) .await; @@ -2953,16 +3038,56 @@ mod tests { }; Take::new(&target, Duration::from_secs(1)).unwrap() }; - // Its first picture, at 1.5 s, is due at once. + // Its first picture, at 1.5 s, is due as it begins. feed.replay(take(2_500_000), 1.0).unwrap(); + until_time(until(|| feed.run().begun).await).await; until(|| matches!(feed.next_picture(), Some(Picture::Frame(_))).then_some(())).await; - // This one's first picture, at the same 1.5 s, is due 20 ms in. + // This one's first picture, at the same 1.5 s, is due 20 ms in. The + // one playing is cleared as it is asked, before the driver hears + // of it, so nothing it shows meanwhile is drawn. feed.replay(take(2_480_000), 1.0).unwrap(); - let shown = until(|| feed.next_picture()).await; + let shown = feed.next_picture().expect("cleared at once"); assert!(matches!(shown, Picture::Rgba { pixels, .. } if pixels == [0; 4])); }) .await; } + + /// A replay that starts in a gap, just before the broadcast resumed, + /// decodes the first picture after it before it begins, so that picture + /// shows on time. + #[tokio::test(start_paused = true)] + async fn a_replay_from_a_gap_shows_its_first_picture_on_time() { + local(async { + let dir = tempfile::tempdir().unwrap(); + let (context, _origin) = context(dir.path()); + let mut sources = Sources::new(context); + let mut collection = Collection::new(); + collection.sources.insert("replay".into(), Source::Replay); + let drivers = sources.sync(&collection); + start(&sources, drivers).await; + let feed = sources.get("replay").unwrap(); + + // Paused through its second second, resuming with a picture at 2 s, + // a millisecond after the span starts. + let (broadcast, video, _live) = video(Some(1)).await; + let target = Target { + broadcast: broadcast.consume(), + video: Some(video), + audio: None, + end: moq_net::Timestamp::from_micros(2_500_000).unwrap(), + }; + let take = Take::new(&target, Duration::from_micros(501_000)).unwrap(); + feed.replay(take, 1.0).unwrap(); + let begun = until(|| feed.run().begun).await; + // Past its moment, without holding the clock for a decode. + until_time(begun + Duration::from_millis(2)).await; + let Some(Picture::Frame(frame)) = feed.next_picture() else { + panic!("the first picture is not up on time"); + }; + assert_eq!(frame.timestamp, moq_net::Timestamp::from_micros(2_000_000).unwrap()); + }) + .await; + } } #[cfg(test)] @@ -3349,35 +3474,121 @@ mod media_tests { let program = sources.mix(crate::AudioScene::DEFAULT).unwrap().clone(); let mut ear = Ear::new(mixer, &program); start(&sources, drivers).await; - let feed = sources.get("clip.mkv").unwrap(); + in_step(&mut ear, sources.get("clip.mkv").unwrap()).await; + }) + .await; + } - while !matches!(feed.next_picture(), Some(Picture::Frame(_))) { - ear.step().await; - } - // Seen at the step after it showed, which may be the block after, - // when the step woke before the picture's timer at the same moment. - let shown = ear.at; - let begun = feed.run().begun.unwrap(); - assert!( - begun <= shown && shown - begun <= BLOCK, - "the first picture shows when it begins: {:?} after", - shown - begun - ); - while ear.at < begun + Duration::from_millis(100) { + /// A replay's first picture shows as its first sound is heard, within a + /// bus block, rather than a bus latency ahead of it. + #[tokio::test(start_paused = true)] + async fn a_replays_sound_plays_with_its_pictures() { + local(async { + let dir = tempfile::tempdir().unwrap(); + let (mut context, _origin) = context(dir.path()); + let (bus, mixer) = Bus::new(Default::default()).unwrap(); + context.bus = bus; + let mut sources = Sources::new(context); + let mut collection = Collection::new(); + collection.sources.insert("replay".into(), Source::Replay); + collection.audio_scenes[0].sounds.insert("replay".into()); + let drivers = sources.sync(&collection); + let program = sources.mix(crate::AudioScene::DEFAULT).unwrap().clone(); + let mut ear = Ear::new(mixer, &program); + start(&sources, drivers).await; + + // The clip's whole second, pictures and sound from its start. + std::fs::write(dir.path().join("clip.mkv"), CLIP).unwrap(); + let clip = Clip::open(&dir.path().join("clip.mkv")).unwrap(); + let target = Target { + broadcast: clip.consume(), + video: clip.video.clone(), + audio: clip.audio.clone(), + end: moq_net::Timestamp::from_secs(1).unwrap(), + }; + let take = Take::new(&target, Duration::from_secs(1)).unwrap(); + let feed = sources.get("replay").unwrap(); + feed.replay(take, 1.0).unwrap(); + in_step(&mut ear, feed).await; + }) + .await; + } + + /// A replay that starts part way through a sound plays the rest of it as + /// it begins, rather than silence until the next. + #[tokio::test(start_paused = true)] + async fn a_replay_plays_the_sound_across_its_start() { + local(async { + let dir = tempfile::tempdir().unwrap(); + let (mut context, _origin) = context(dir.path()); + let (bus, mixer) = Bus::new(Default::default()).unwrap(); + context.bus = bus; + let mut sources = Sources::new(context); + let mut collection = Collection::new(); + collection.sources.insert("replay".into(), Source::Replay); + collection.audio_scenes[0].sounds.insert("replay".into()); + let drivers = sources.sync(&collection); + let program = sources.mix(crate::AudioScene::DEFAULT).unwrap().clone(); + let mut ear = Ear::new(mixer, &program); + start(&sources, drivers).await; + + // The clip's sound alone, from 5 ms in: inside its first 20 ms frame. + std::fs::write(dir.path().join("clip.mkv"), CLIP).unwrap(); + let clip = Clip::open(&dir.path().join("clip.mkv")).unwrap(); + let target = Target { + broadcast: clip.consume(), + video: None, + audio: clip.audio.clone(), + end: moq_net::Timestamp::from_millis(505).unwrap(), + }; + let take = Take::new(&target, Duration::from_millis(500)).unwrap(); + let feed = sources.get("replay").unwrap(); + feed.replay(take, 1.0).unwrap(); + // Held until it begins, so the first sound is written as the bus + // last mixed, and is heard just as the replay begins. + let begun = until(|| feed.run().begun).await; + while ear.at < begun + Duration::from_millis(50) { ear.step().await; } - let onset = ear.onset().expect("the clip sounds"); - let apart = onset - .saturating_duration_since(begun) - .max(begun.saturating_duration_since(onset)); + let onset = ear.onset().expect("it sounds"); assert!( - apart < BLOCK, - "its first sound is heard with its first picture: {onset:?} and {begun:?}" + onset < begun + Duration::from_millis(2), + "heard as it begins: {:?} after", + onset.saturating_duration_since(begun) ); }) .await; } + /// Mix along with `feed`'s latest play until its first picture shows and + /// its first sound is heard, and check both do as it begins: the picture + /// at once, and the sound within a bus block. + async fn in_step(ear: &mut Ear, feed: &Feed) { + while !matches!(feed.next_picture(), Some(Picture::Frame(_))) { + ear.step().await; + } + // Seen at the step after it showed, which may be the block after, + // when the step woke before the picture's timer at the same moment. + let shown = ear.at; + let begun = feed.run().begun.unwrap(); + assert!( + begun <= shown && shown - begun <= BLOCK, + "the first picture shows when it begins: {:?} after", + shown - begun + ); + while ear.at < begun + Duration::from_millis(100) { + ear.step().await; + } + let onset = ear.onset().expect("it sounds"); + let apart = onset + .saturating_duration_since(begun) + .max(begun.saturating_duration_since(onset)); + assert!( + apart < BLOCK, + "its first sound is heard with its first picture: {onset:?} and {begun:?}" + ); + } + /// A looping file's sound runs on from its end into its start again, as /// loud as the first time and as its pass begins, each pass heard from /// when it begins.