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 <noreply@anthropic.com>
This commit is contained in:
FredApps
2026-11-08 02:29:22 +01:00
co-authored by Claude Opus 5.5
parent cf3e84e687
commit 2195a5ba30
2 changed files with 93 additions and 11 deletions
+34 -11
View File
@@ -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() {
+59
View File
@@ -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<dyn RemoteFrames> = 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<f32> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);