From 7bf80663dc81b4d50cd90b584d3dd334578a68b2 Mon Sep 17 00:00:00 2001 From: Diego Lopes Date: Wed, 14 Oct 2026 13:45:13 -0400 Subject: [PATCH 1/2] Fix resampler edge read at device/session rate mismatch The output resampler interpolated toward a stale sample at the end of every mix block when the device rate differs from the session rate. It now holds the last valid sample. Co-Authored-By: Claude Sonnet 5.5 --- crates/playback/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index b945d4b6..fc3b6a4e 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -362,7 +362,9 @@ impl AudioState { let sample = |c: usize| { let ch = self.pending.get(c); let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0); - let b = ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a); + // Holds the last sample at the block edge (`pending` is BLOCK long, + // only `pending_len` is valid); carry the next block's first sample if audible. + let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a }; a + (b - a) * fr }; if nch > 2 && channels < nch { From dfde8a672474331051084510ea81b70a9d68592c Mon Sep 17 00:00:00 2001 From: Diego Lopes Date: Mon, 19 Oct 2026 23:36:00 -0400 Subject: [PATCH 2/2] Add regression test for the resampler block edge Render a long DC block, then short blocks of silence with the device rate below the session rate. The interpolation must not read the stale frame past pending_len. Also correct the comment on the edge hold. Co-Authored-By: Claude Opus 5.5 --- crates/playback/src/lib.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/playback/src/lib.rs b/crates/playback/src/lib.rs index f5023feb..92823286 100644 --- a/crates/playback/src/lib.rs +++ b/crates/playback/src/lib.rs @@ -362,8 +362,7 @@ impl AudioState { let sample = |c: usize| { let ch = self.pending.get(c); let a = ch.and_then(|v| v.get(i0)).copied().unwrap_or(0.0); - // Holds the last sample at the block edge (`pending` is BLOCK long, - // only `pending_len` is valid); carry the next block's first sample if audible. + // `pending` is BLOCK long; only `pending_len` frames are valid. Hold the last one at the edge. let b = if i0 + 1 < self.pending_len { ch.and_then(|v| v.get(i0 + 1)).copied().unwrap_or(a) } else { a }; a + (b - a) * fr }; @@ -1216,6 +1215,26 @@ mod tests { } } + #[test] + fn resampling_at_a_short_block_edge_does_not_mix_in_stale_frames() { + use soundcraft_model::ChannelFormat; + // DC for the first long block, then silence. The device runs slower than the session, so + // reads land between frames; a short block leaves the long block's DC past `pending_len`. + let mut st = audio_output_state(ChannelFormat::Stereo, vec![vec![0.8; BLOCK]; 2]); + st.device_rate = 44_100.0; + let mut long = vec![0.0; 2 * 470]; + st.fill(&mut long, 2); + assert_eq!(st.pending_len, BLOCK); + let mut short = [0.0; 2 * 8]; + for call in 0..20 { + st.fill(&mut short, 2); + assert!(st.pending_len < BLOCK); + // The first frame of the first short call still reads the DC block's last frame. + let from = if call == 0 { 2 } else { 0 }; + assert!(short[from..].iter().all(|x| x.abs() < 1e-6), "call {call}: {short:?}"); + } + } + #[test] fn mono_device_preserves_centered_mono_level_and_includes_surround_right_channels() { use soundcraft_model::ChannelFormat;