From 2195a5ba305e953a289e862edb57beeab8fe11eb Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Sun, 8 Nov 2026 02:29:22 +0100 Subject: [PATCH] Preview: cap the held sound's wait at frames the prefetch can keep cached Once the doubling reached the RAM preview's `fit`, the held sound waited for offsets 0..fit, but the prefetch only requests offsets below fit - 1, and while the silent preview moves on its last requests are still rendering. The sound then stayed silent almost to the end of the preview even after rendering recovered. The backoff now stops at half of the frames the prefetch requests, and never below the wait before any stall. A new test caches only the frames playback itself requests, with a RAM preview of `ahead + 2` frames. Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/app/prefetch.rs | 45 ++++++++++++----- crates/ui-egui/tests/it/ui_audio_scrub.rs | 59 +++++++++++++++++++++++ 2 files changed, 93 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index 683b52303..c8d5cd938 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -29,11 +29,22 @@ fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> /// 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) +/// that stopped the sound, never more than `cap` ([`held_audio_cap`]) 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, cap: i64, stalls: u32) -> i64 { + ahead.saturating_mul(1i64 << stalls.min(32)).min(cap).max(1) +} + +/// The most cached frames ahead a held sound waits for, with a RAM preview of `fit` frames. +/// `prefetch_playback` requests offsets below `fit - 1` (what `advance_playback` gives it), and +/// while the silent preview moves on, the frames it requested last are still rendering, so a +/// run of `fit` (or even `fit - 1`) frames ahead is never all cached until the end of the +/// preview cuts it short. Half of what is requested can be, and never fewer than the prefetch's +/// `ahead` (the wait before any stall) when that many are requested. +fn held_audio_cap(ahead: i64, fit: i64) -> i64 { + let reach = (fit - 1).max(1); + ahead.max(reach / 2).min(reach) } impl EffectcraftApp { @@ -266,7 +277,7 @@ impl EffectcraftApp { return; } let Some(p) = plan else { return }; - let need = held_audio_frames(ahead, fit, self.playback.stalls); + let need = held_audio_frames(ahead, held_audio_cap(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; @@ -476,21 +487,33 @@ impl EffectcraftApp { #[cfg(test)] mod startup_tests { - use super::{held_audio_frames, startup_frames}; + use super::{held_audio_cap, 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). + /// After each stall the held sound waits for twice as many frames ahead, up to its cap + /// (#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, 4), 100, "never more than the cap"); 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(8, 5, 0), 5, "no more than the cap"); assert_eq!(held_audio_frames(0, 0, 2), 1, "at least the current frame"); } + /// The cap is what the prefetch can keep cached ahead of a moving preview: never `fit`, which + /// includes a frame it never requests, nor `fit - 1`, whose last frames are still rendering. + #[test] + fn a_held_sound_waits_only_for_frames_that_can_be_cached() { + assert_eq!(held_audio_cap(8, 1000), 499, "half of the 999 frames requested ahead"); + assert_eq!(held_audio_cap(8, 10), 8, "never below the wait before any stall"); + assert_eq!(held_audio_cap(8, 6), 5, "nor above the frames requested"); + assert_eq!(held_audio_cap(8, 1), 1, "a one-frame RAM preview: the current frame"); + assert_eq!(held_audio_cap(8, 0), 1); + assert_eq!(held_audio_frames(8, held_audio_cap(8, 10), 5), 8, "the backoff stops short of fit"); + } + /// The frames a preview with sound waits for are always requested and fit together (#947). #[test] fn startup_waits_only_for_frames_it_requests_and_can_keep() { diff --git a/crates/ui-egui/tests/it/ui_audio_scrub.rs b/crates/ui-egui/tests/it/ui_audio_scrub.rs index fcaaea4ac..6ea74ef35 100644 --- a/crates/ui-egui/tests/it/ui_audio_scrub.rs +++ b/crates/ui-egui/tests/it/ui_audio_scrub.rs @@ -74,6 +74,16 @@ impl GatedFrames { } } + /// Cache every frame the app has asked for since the last call, and nothing else: a renderer + /// that keeps up with the real prefetch requests. + fn release_requested(&self, h: &mut Harness<'_, EffectcraftApp>) { + while h.state().frames.dispatch_remote() != 0 {} + for (_, width, height, done) in std::mem::take(&mut *self.pending.lock().unwrap()) { + let rgba = vec![0; (width as usize).saturating_mul(height as usize).saturating_mul(4)]; + done(Ok(RemoteFrame { width, height, rgba, ms: 1.0 })); + } + } + /// Drop everything queued without caching it (the warm-up passes request viewer frames). fn discard(&self, h: &mut Harness<'_, EffectcraftApp>) { while h.state().frames.dispatch_remote() != 0 {} @@ -347,6 +357,55 @@ fn each_stall_doubles_the_frames_ahead_the_sound_waits_for() { assert_eq!(h.state().playback.stalls, 0); } +/// Once the backoff reaches its cap, the held sound waits only for frames the prefetch really +/// requests and can keep cached while the silent preview moves on. It used to wait for `fit` +/// frames, one of which was never asked for, so a stall kept the preview silent almost to its +/// end even after rendering recovered. +#[test] +fn a_held_sound_at_the_ram_limit_starts_on_the_frames_prefetch_requests() { + 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; + // A RAM preview of `ahead + 2` frames (a 64x36 frame is 9216 bytes; 90 % of the budget counts). + let fit = ahead + 2; + h.state().frames.set_budget(((fit as f64 + 0.5) * 9216.0 / 0.9) as usize); + h.state().frames.clear(); + let now = h.state().now(); + h.state_mut().play(now); + for _ in 0..90 { + gate.release_requested(&mut h); + step(&mut h); + if h.state().audio.is_some() { + break; + } + } + assert!(h.state().audio.is_some(), "the sound started"); + + // A stall, and enough stalls before it that the backoff is past the RAM preview's limit. + 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"); + let stalled = frame(&h, cid); + h.state_mut().playback.stalls = 8; + // Rendering keeps up again, caching only what playback itself requests. + let started = (0..4 * fit).position(|_| { + gate.release_requested(&mut h); + kept_up(&mut h); + step(&mut h); + h.state().audio.is_some() + }); + assert!(started.is_some(), "the sound starts again on the frames the prefetch requests (RAM preview of {fit})"); + assert!(frame(&h, cid) < stalled + 4 * fit, "near where it stalled ({stalled}), not at the end of the preview"); +} + /// 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);