diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c5bfc40..151aae21 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -139,7 +139,8 @@ pub struct Playback { pub audio_frame: Option, /// The preview's sound waits for frames: while the frames ahead aren't cached, every frame /// shows as it renders, silently; the sound starts once the rest of the preview (or what - /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103). + /// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or + /// sooner once the silent preview keeps real time with the frames ahead cached (#208). pub audio_held: bool, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, @@ -168,6 +169,15 @@ impl Playback { let step = self.plan.map_or(1, |p| p.step).max(1) as f64; self.achieved_fps().map(|f| f * step >= self.fps * 0.95) } + + /// The preview has kept real time for the last quarter second (at least two frames) up to + /// `now`: rendering keeps up, so sound can start without waiting for the whole preview. + pub fn keeps_up(&self, now: f64) -> bool { + let step = self.plan.map_or(1, |p| p.step).max(1) as f64; + let frame = step / self.fps.max(1.0); + let (Some(a), Some(b)) = (self.shown_at.front(), self.shown_at.back()) else { return false }; + self.shown_at.len() >= 3 && b - a >= (2.0 * frame).max(0.25) && now - b <= 2.0 * frame && self.real_time() == Some(true) + } } pub struct EffectcraftApp { @@ -1016,10 +1026,12 @@ impl EffectcraftApp { return; } let snap = |f: i64| wa + (f - wa).div_euclid(step) * step; - // Held sound starts once the frames ahead are cached (see `Playback::audio_held`). + // Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the + // prefetch window when the silent preview keeps real time, else the rest of the preview. + let fit = self.frames_that_fit(&c, scale); if self.playback.audio_held && let Some(p) = plan - && self.cached_ahead(&p, &series, cur, self.frames_that_fit(&c, scale)) + && ((self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit)) { self.playback.audio_held = false; self.playback.start_wall = now; @@ -1052,6 +1064,8 @@ impl EffectcraftApp { self.audio = None; self.playback.audio_held = true; self.playback.waiting = true; + // The rate measured before the stall no longer says it keeps up. + self.playback.shown_at.clear(); self.playback.start_wall = now; self.playback.start_frame = cur; } diff --git a/crates/ui-egui/tests/ui_audio_scrub.rs b/crates/ui-egui/tests/ui_audio_scrub.rs index cf17fb18..a2d86b79 100644 --- a/crates/ui-egui/tests/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/ui_audio_scrub.rs @@ -30,10 +30,15 @@ impl AudioDevice for Fake { } } -/// A 30 fps comp whose solid sounds a tone (Audio switch on). +/// A 2 s, 30 fps comp whose solid sounds a tone (Audio switch on). fn setup(audible: bool, fake: &Fake) -> (EffectcraftApp, ItemId) { + setup_long(audible, fake, 2.0) +} + +/// A `duration`-second, 30 fps comp whose solid sounds a tone (Audio switch on). +fn setup_long(audible: bool, fake: &Fake, duration: f64) -> (EffectcraftApp, ItemId) { let mut s = Session::default(); - s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": 2})).unwrap(); + s.execute("comp.new", json!({"name": "A", "width": 64, "height": 36, "frameRate": 30, "duration": duration})).unwrap(); let cid = s.active_comp_id().unwrap(); let l = s.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap(); if audible { @@ -140,8 +145,41 @@ fn preview_with_audio_shows_every_frame_and_sounds_once_cached() { } assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached"); assert!(shown > 0, "frames played silently first"); - // A frame that isn't cached stops the sound rather than being skipped. + // A frame that isn't cached stops the sound rather than being skipped. (Frames render fast + // enough here to be cached again before the check: keep them out of the cache.) + h.state().frames.set_budget(0); h.state().frames.clear(); h.step(); assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing); } + +/// A preview whose frames render in real time sounds without waiting for the whole work area to +/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead +/// cached, the sound starts, still without skipping a frame before it. +#[test] +fn preview_with_audio_sounds_once_rendering_keeps_up() { + let fake = Fake::default(); + let (app, cid) = setup_long(true, &fake, 30.0); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.run_steps(2); + let frame = |h: &Harness<'_, EffectcraftApp>| { + let app = h.state(); + app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time()) + }; + let now = h.ctx.input(|i| i.time); + h.state_mut().play(now); + assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet"); + let mut last = frame(&h); + // Two seconds of playback, each step giving the frame workers time to keep up. + for _ in 0..60 { + h.step(); + if h.state().audio.is_some() { + break; + } + let f = frame(&h); + assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped"); + last = f; + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900"); +}