From cf3e84e687a7e68640e8edac7339c356f7f26a48 Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:03:31 +0100 Subject: [PATCH] Preview: each stall doubles the frames the held sound waits for (#3260) A preview that renders just below real time stopped its sound at the first uncached frame, then started it again as soon as the silent preview kept up with `ahead` frames cached. Those ran out within a few frames, so the sound started and stopped over and over, which plays as distortion (#2473, #3260). Each stall that stops the sound now doubles the cached frames ahead it waits for before starting again, up to what fits in the RAM preview. A new preview starts counting from zero. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/app/playback.rs | 1 + crates/ui-egui/src/app/prefetch.rs | 33 ++++++++-- crates/ui-egui/src/lib.rs | 5 ++ crates/ui-egui/tests/it/ui_audio_scrub.rs | 78 +++++++++++++++++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index d40bc69ff..7cda624e9 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -44,6 +44,7 @@ impl EffectcraftApp { starting_since: now, first_cached: None, shown_at: Default::default(), + stalls: 0, }; if !self.playback.caching { if plan.video { diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 4af91782f..683b52303 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -27,6 +27,15 @@ fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> ahead.min(window).min(fit).max(1) } +/// How many cached frames ahead a held sound waits for before it starts again while the silent +/// preview keeps real time: the prefetch's `ahead`, doubled for each of the preview's `stalls` +/// that stopped the sound, never more than `fit` (what the RAM preview holds) nor less than one. +/// Without the doubling, a preview that renders just below real time started its sound as soon +/// as `ahead` frames were cached, ran out of them, stopped it again, and so on (#2473, #3260). +fn held_audio_frames(ahead: i64, fit: i64, stalls: u32) -> i64 { + ahead.saturating_mul(1i64 << stalls.min(32)).min(fit).max(1) +} + impl EffectcraftApp { /// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold /// and keep rendering ahead (the green cache bar fills, then playback runs in real time). @@ -248,15 +257,17 @@ impl EffectcraftApp { } /// Held sound starts once the frames ahead are cached again after a stall (see - /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else - /// the rest of the preview. + /// `Playback::audio_held`): the prefetch window when the silent preview keeps real time + /// (twice as many frames after each stall, see [`held_audio_frames`]), else the rest of the + /// preview. fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) { let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass; if !self.playback.audio_held { return; } let Some(p) = plan else { return }; - if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, ahead.min(fit))) || self.cached_ahead(&p, &series, cur, fit) { + let need = held_audio_frames(ahead, fit, self.playback.stalls); + if (self.playback.keeps_up(now) && self.cached_ahead(&p, &series, cur, need)) || self.cached_ahead(&p, &series, cur, fit) { self.playback.audio_held = false; self.playback.start_wall = now; self.playback.start_frame = cur; @@ -294,6 +305,7 @@ impl EffectcraftApp { // frame shows as it renders until the frames ahead are cached again. self.audio = None; self.playback.audio_held = true; + self.playback.stalls = self.playback.stalls.saturating_add(1); self.playback.waiting = true; // The rate measured before the stall no longer says it keeps up. self.playback.shown_at.clear(); @@ -464,7 +476,20 @@ impl EffectcraftApp { #[cfg(test)] mod startup_tests { - use super::startup_frames; + use super::{held_audio_frames, startup_frames}; + + /// After each stall the held sound waits for twice as many frames ahead, up to what fits in + /// the RAM preview (#2473, #3260). + #[test] + fn held_sound_waits_longer_after_each_stall() { + assert_eq!(held_audio_frames(8, 1000, 0), 8, "before any stall: the prefetch's frames ahead"); + assert_eq!(held_audio_frames(8, 1000, 1), 16); + assert_eq!(held_audio_frames(8, 1000, 3), 64); + assert_eq!(held_audio_frames(8, 100, 4), 100, "never more than the RAM preview holds"); + assert_eq!(held_audio_frames(8, 100, u32::MAX), 100, "no overflow however many stalls"); + assert_eq!(held_audio_frames(8, 5, 0), 5, "as before: no more than fits"); + assert_eq!(held_audio_frames(0, 0, 2), 1, "at least the current frame"); + } /// The frames a preview with sound waits for are always requested and fit together (#947). #[test] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 795a01e1e..e641da3d4 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -215,6 +215,11 @@ pub struct Playback { pub first_cached: Option, /// When the last frames were shown (seconds, the last second's worth): the achieved rate. pub shown_at: std::collections::VecDeque, + /// How many stalls have stopped this preview's sound. After each one the held sound waits + /// for twice as many cached frames ahead before it starts again (up to what fits in the RAM + /// preview), so a preview that renders just below real time settles instead of starting and + /// stopping its sound every few frames, which plays as distortion (#2473, #3260). + pub stalls: u32, } impl Playback { diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index 81e55817e..fcaaea4ac 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -269,6 +269,84 @@ fn preview_with_audio_sounds_without_caching_the_work_area() { assert!((1..900).contains(&cached), "it started with {cached} of the 900 frames cached, not the whole work area"); } +/// Pretend the silent preview showed its last ten frames in real time, up to now: the next step +/// then finds it keeping up (`Playback::keeps_up`), however long steps take on the test host. +fn kept_up(h: &mut Harness<'_, EffectcraftApp>) { + let now = h.state().now(); + h.state_mut().playback.shown_at = (0..10i32).map(|i| now - f64::from(9 - i) / 30.0).collect(); +} + +/// Frames `from..from + n`. +fn run(from: i64, n: i64) -> Vec { + (from..from + n).collect() +} + +/// A preview that renders just below real time (#2473, #3260): once a stall stopped the sound, +/// `ahead` cached frames were enough to start it again while the silent preview kept up, so it +/// ran out of them, stopped and started again every few frames, which played as distortion. +/// After each stall the sound waits for twice as many cached frames ahead. +#[test] +fn each_stall_doubles_the_frames_ahead_the_sound_waits_for() { + let fake = Fake::default(); + let (mut app, cid) = setup_long(true, &fake, 30.0); + let gate = GatedFrames::default(); + let renderer: Arc = Arc::new(gate.clone()); + app.frames.set_remote(Some(renderer)); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app); + h.state_mut().use_manual_clock(); + h.run_steps(2); + gate.discard(&mut h); + let ahead = (h.state().frames.parallelism() * 2).max(4) as i64; + let now = h.state().now(); + h.state_mut().play(now); + gate.render(&mut h, cid, &run(0, ahead)); + step(&mut h); + assert!(h.state().audio.is_some(), "the sound started"); + assert_eq!(h.state().playback.stalls, 0); + + // A stall stops the sound. + gate.discard(&mut h); + h.state().frames.clear(); + h.step(); + assert!(h.state().audio.is_none() && h.state().playback.audio_held, "a stall stops the sound"); + assert_eq!(h.state().playback.stalls, 1); + // One and a half times `ahead` cached while it keeps up: that used to start the sound again. + let at = frame(&h, cid); + gate.render(&mut h, cid, &run(at, ahead + ahead / 2)); + kept_up(&mut h); + step(&mut h); + assert!(h.state().audio.is_none(), "after one stall, {} frames ahead are not enough", ahead + ahead / 2); + // Twice `ahead` are. + let at = frame(&h, cid); + gate.render(&mut h, cid, &run(at, 3 * ahead)); + kept_up(&mut h); + step(&mut h); + assert!(h.state().audio.is_some(), "after one stall, {} frames ahead start the sound", 3 * ahead); + + // A second stall: now three times `ahead` are not enough, and five times are. + gate.discard(&mut h); + h.state().frames.clear(); + h.step(); + assert!(h.state().audio.is_none() && h.state().playback.audio_held); + assert_eq!(h.state().playback.stalls, 2); + let at = frame(&h, cid); + gate.render(&mut h, cid, &run(at, 3 * ahead)); + kept_up(&mut h); + step(&mut h); + assert!(h.state().audio.is_none(), "after two stalls, {} frames ahead are not enough", 3 * ahead); + let at = frame(&h, cid); + gate.render(&mut h, cid, &run(at, 5 * ahead)); + kept_up(&mut h); + step(&mut h); + assert!(h.state().audio.is_some(), "after two stalls, {} frames ahead start the sound", 5 * ahead); + + // A new preview starts counting again. + h.state_mut().stop(); + let now = h.state().now(); + h.state_mut().play(now); + assert_eq!(h.state().playback.stalls, 0); +} + /// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them. fn pull(feed: &AudioFeed, frames: usize) -> Vec { let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);