use crate::core::resample::{dot_f32_f64, fill_row_lerp, polyphase_rows};
use crate::engine::stage::{BLOCK_FRAMES, OnsetEvent};
const READ_HALF_TAPS: usize = 32;
const READ_PHASES: usize = 512;
const READ_KAISER_BETA: f64 = 9.0;
#[derive(Debug)]
struct ReadInterpTable {
rows: Vec<f32>,
}
impl ReadInterpTable {
fn new() -> Self {
fn bessel_i0(x: f64) -> f64 {
let mut sum = 1.0f64;
let mut term = 1.0f64;
let half_x = x * 0.5;
for k in 1..=25 {
term *= (half_x / k as f64) * (half_x / k as f64);
sum += term;
if term < sum * 1e-16 {
break;
}
}
sum
}
let entries = READ_HALF_TAPS * READ_PHASES;
let mut taps = vec![0.0f32; entries + 2];
let bessel_beta = bessel_i0(READ_KAISER_BETA);
for (i, tap) in taps.iter_mut().enumerate().take(entries + 1) {
let u = i as f64 / READ_PHASES as f64;
let sinc_val = if u < 1e-12 {
1.0
} else {
let pi_u = std::f64::consts::PI * u;
pi_u.sin() / pi_u
};
let t = u / READ_HALF_TAPS as f64;
let window = if t <= 1.0 {
bessel_i0(READ_KAISER_BETA * (1.0 - t * t).max(0.0).sqrt()) / bessel_beta
} else {
0.0
};
*tap = (sinc_val * window) as f32;
}
Self {
rows: polyphase_rows(&taps, READ_HALF_TAPS, READ_PHASES),
}
}
#[inline]
fn fill_row(&self, frac: f64, row: &mut [f32; READ_TAPS]) -> f64 {
fill_row_lerp(&self.rows, READ_TAPS, READ_PHASES, frac, row)
}
}
const READ_TAPS: usize = 2 * READ_HALF_TAPS;
struct SincReadKernel {
row: [f32; READ_TAPS],
wsum: f64,
start: isize,
}
impl SincReadKernel {
#[inline]
fn at(table: &ReadInterpTable, pos: f64) -> Self {
let center = pos.floor();
let frac = pos - center;
let mut row = [0.0f32; READ_TAPS];
let wsum = table.fill_row(frac, &mut row);
Self {
row,
wsum,
start: center as isize + 1 - READ_HALF_TAPS as isize,
}
}
#[inline]
fn read(&self, ring: &[f32]) -> f32 {
if self.wsum.abs() <= 1e-12 {
return 0.0;
}
(ring_dot(ring, self.start, &self.row) / self.wsum) as f32
}
}
#[inline]
fn ring_dot(ring: &[f32], start: isize, weights: &[f32]) -> f64 {
let s = (start as usize) & RING_MASK;
let n = weights.len();
if s + n <= RING_LEN {
dot_f32_f64(&ring[s..s + n], weights)
} else {
let first = RING_LEN - s;
dot_f32_f64(&ring[s..], &weights[..first])
+ dot_f32_f64(&ring[..n - first], &weights[first..])
}
}
#[inline]
fn dot_f64(a: &[f64], b: &[f64]) -> f64 {
debug_assert_eq!(a.len(), b.len());
let mut acc = [0.0f64; 4];
let mut a_chunks = a.chunks_exact(4);
let mut b_chunks = b.chunks_exact(4);
for (x, y) in (&mut a_chunks).zip(&mut b_chunks) {
acc[0] += x[0] * y[0];
acc[1] += x[1] * y[1];
acc[2] += x[2] * y[2];
acc[3] += x[3] * y[3];
}
for (x, y) in a_chunks.remainder().iter().zip(b_chunks.remainder()) {
acc[0] += x * y;
}
(acc[0] + acc[1]) + (acc[2] + acc[3])
}
#[inline]
fn dot_f64_f32(samples: &[f64], weights: &[f32]) -> f64 {
debug_assert_eq!(samples.len(), weights.len());
let mut acc = [0.0f64; 4];
let mut s_chunks = samples.chunks_exact(4);
let mut w_chunks = weights.chunks_exact(4);
for (s, w) in (&mut s_chunks).zip(&mut w_chunks) {
acc[0] += s[0] * w[0] as f64;
acc[1] += s[1] * w[1] as f64;
acc[2] += s[2] * w[2] as f64;
acc[3] += s[3] * w[3] as f64;
}
for (s, w) in s_chunks.remainder().iter().zip(w_chunks.remainder()) {
acc[0] += s * *w as f64;
}
(acc[0] + acc[1]) + (acc[2] + acc[3])
}
fn mix_channels(channels: &[SolaChannel], start: isize, out: &mut [f64]) {
out.fill(0.0);
for ch in channels {
for (k, m) in out.iter_mut().enumerate() {
*m += ch.ring[(start as usize).wrapping_add(k) & RING_MASK] as f64;
}
}
}
const RING_LEN: usize = 4_096;
const RING_MASK: usize = RING_LEN - 1;
const DRIFT_TRIGGER: f64 = 192.0;
const HARD_TRIGGER: f64 = 320.0;
const SEARCH_RANGE: isize = 160;
const CORR_WINDOW: usize = 320;
const XFADE_FRAMES: usize = 96;
const MIN_READ_MARGIN: f64 = (READ_HALF_TAPS + 4) as f64;
const FINE_SEARCH_RADIUS: f64 = 0.5;
const FINE_SEARCH_STEPS: isize = 4;
const TRANSIENT_POSTPONE_RATIO: f64 = 3.0;
const ONSET_PROTECT_PRE: f64 = 96.0;
const ONSET_PROTECT_POST: f64 = 512.0;
const OPPORTUNISTIC_DRIFT: f64 = 96.0;
const QUIET_SPLICE_RATIO: f64 = 0.6;
const MASKED_WINDOW_START: f64 = 768.0;
const MASKED_WINDOW_END: f64 = 2_048.0;
const REST_DEV: f64 = 0.003;
const REST_DWELL_BLOCKS: u32 = 206;
const REST_SPLICE_DRIFT: f64 = 48.0;
const REST_TRIM_MAX: f64 = 0.0008;
#[derive(Debug)]
struct SolaChannel {
ring: Vec<f32>,
}
#[derive(Debug)]
pub(crate) struct SolaCorrector {
channels: Vec<SolaChannel>,
table: ReadInterpTable,
write_abs: u64,
read_pos: f64,
xfade_from: f64,
xfade_remaining: usize,
transposition: f64,
rate_slope: f64,
nominal_lag: f64,
energy_avg: f64,
splice_count: u64,
rest_blocks: u32,
corr_a: Vec<f64>,
corr_b: Vec<f64>,
frac_mix: Vec<f64>,
}
impl SolaCorrector {
pub(crate) fn new(num_channels: usize, nominal_lag_frames: usize) -> Self {
assert!(
nominal_lag_frames as f64 + HARD_TRIGGER + (SEARCH_RANGE as f64) + MIN_READ_MARGIN
< (RING_LEN - CORR_WINDOW - BLOCK_FRAMES) as f64,
"SOLA ring too small for nominal lag {nominal_lag_frames}"
);
Self {
channels: (0..num_channels)
.map(|_| SolaChannel {
ring: vec![0.0; RING_LEN],
})
.collect(),
table: ReadInterpTable::new(),
write_abs: 0,
read_pos: -(nominal_lag_frames as f64),
xfade_from: 0.0,
xfade_remaining: 0,
transposition: 1.0,
rate_slope: 0.0,
nominal_lag: nominal_lag_frames as f64,
energy_avg: 0.0,
splice_count: 0,
rest_blocks: 0,
corr_a: vec![0.0; CORR_WINDOW],
corr_b: vec![0.0; 2 * SEARCH_RANGE as usize + CORR_WINDOW],
frac_mix: vec![0.0; XFADE_FRAMES + 2 * READ_TAPS],
}
}
pub(crate) fn set_transposition(&mut self, transposition: f64) {
self.transposition = if transposition.is_finite() {
transposition.clamp(0.75, 1.35)
} else {
1.0
};
}
pub(crate) fn set_rate_slope(&mut self, slope: f64) {
self.rate_slope = if slope.is_finite() { slope } else { 0.0 };
}
pub(crate) fn latency_frames(&self) -> usize {
self.nominal_lag as usize
}
#[cfg(test)]
pub(crate) fn splice_count(&self) -> u64 {
self.splice_count
}
pub(crate) fn lag_error_frames(&self) -> f64 {
(self.write_abs as f64 - self.read_pos) - self.nominal_lag
}
pub(crate) fn reset(&mut self) {
for ch in &mut self.channels {
ch.ring.fill(0.0);
}
self.write_abs = 0;
self.read_pos = -self.nominal_lag;
self.xfade_from = 0.0;
self.xfade_remaining = 0;
self.energy_avg = 0.0;
self.splice_count = 0;
self.rest_blocks = 0;
self.rate_slope = 0.0;
}
fn span_hits_onset(&self, onsets: &[OnsetEvent], start: f64) -> bool {
let span = XFADE_FRAMES as f64 * self.transposition.max(1.0);
onsets.iter().any(|event| {
start - FINE_SEARCH_RADIUS < event.stage_frame + ONSET_PROTECT_POST
&& start + span + FINE_SEARCH_RADIUS > event.stage_frame - ONSET_PROTECT_PRE
})
}
fn in_masked_window(&self, onsets: &[OnsetEvent]) -> bool {
onsets.iter().any(|event| {
let since = self.read_pos - event.stage_frame;
(MASKED_WINDOW_START..MASKED_WINDOW_END).contains(&since)
})
}
pub(crate) fn process_block(&mut self, io: &mut [[f32; BLOCK_FRAMES]], onsets: &[OnsetEvent]) {
debug_assert_eq!(io.len(), self.channels.len());
let mut block_energy = 0.0f64;
for (ch, input) in self.channels.iter_mut().zip(io.iter()) {
for (i, &sample) in input.iter().enumerate() {
ch.ring[(self.write_abs as usize + i) & RING_MASK] = sample;
block_energy += (sample as f64) * (sample as f64);
}
}
self.write_abs += BLOCK_FRAMES as u64;
let block_rms = (block_energy / (BLOCK_FRAMES * io.len()) as f64).sqrt();
self.energy_avg = 0.98 * self.energy_avg + 0.02 * block_rms;
let deviation = (self.transposition - 1.0).abs();
let settled_drift = self.lag_error_frames() - BLOCK_FRAMES as f64;
let at_rest = if deviation < REST_DEV {
self.rest_blocks = self.rest_blocks.saturating_add(1);
self.rest_blocks >= REST_DWELL_BLOCKS
} else {
self.rest_blocks = 0;
false
};
if self.xfade_remaining == 0 {
let drift = self.lag_error_frames();
if drift.abs() > DRIFT_TRIGGER {
self.try_splice(drift, onsets);
} else if drift.abs() > OPPORTUNISTIC_DRIFT && self.in_masked_window(onsets) {
self.try_splice(drift, onsets);
} else if drift.abs() > OPPORTUNISTIC_DRIFT
&& self.energy_avg > 1e-6
&& self.region_rms(self.read_pos, CORR_WINDOW)
< QUIET_SPLICE_RATIO * self.energy_avg
&& self.region_rms(self.read_pos + drift, CORR_WINDOW)
< QUIET_SPLICE_RATIO * self.energy_avg
{
self.try_splice(drift, onsets);
} else if at_rest && settled_drift.abs() > REST_SPLICE_DRIFT {
self.try_rest_splice(drift, onsets);
}
}
let trim = if at_rest {
(settled_drift * 0.001).clamp(-REST_TRIM_MAX, REST_TRIM_MAX)
} else {
0.0
};
let t = if self.rate_slope != 0.0 {
let rate_at_cursor =
1.0 / self.transposition - (self.rate_slope * settled_drift).clamp(-0.02, 0.02);
if rate_at_cursor > 0.5 {
(1.0 / rate_at_cursor).clamp(0.75, 1.35) + trim
} else {
self.transposition + trim
}
} else {
self.transposition + trim
};
for i in 0..BLOCK_FRAMES {
if self.xfade_remaining > 0 {
let progress =
1.0 - (self.xfade_remaining as f64 - 1.0) / (XFADE_FRAMES as f64 - 1.0);
let g_in = (0.5 - 0.5 * (std::f64::consts::PI * progress).cos()) as f32;
let g_out = 1.0 - g_in;
let out_kernel = SincReadKernel::at(&self.table, self.xfade_from);
let in_kernel = SincReadKernel::at(&self.table, self.read_pos);
for (ch, out) in self.channels.iter().zip(io.iter_mut()) {
let a = out_kernel.read(&ch.ring);
let b = in_kernel.read(&ch.ring);
out[i] = g_out * a + g_in * b;
}
self.xfade_from += t;
self.read_pos += t;
self.xfade_remaining -= 1;
} else {
let kernel = SincReadKernel::at(&self.table, self.read_pos);
for (ch, out) in self.channels.iter().zip(io.iter_mut()) {
out[i] = kernel.read(&ch.ring);
}
self.read_pos += t;
}
}
let newest_read = if self.xfade_remaining > 0 {
self.read_pos.max(self.xfade_from)
} else {
self.read_pos
};
debug_assert!(
self.write_abs as f64 - newest_read >= MIN_READ_MARGIN - 1.0,
"SOLA read overtook the write head"
);
}
fn try_splice(&mut self, drift: f64, onsets: &[OnsetEvent]) {
let force = drift.abs() >= HARD_TRIGGER;
let _ = self.plan_splice(drift, force, f64::INFINITY, onsets);
}
fn try_rest_splice(&mut self, drift: f64, onsets: &[OnsetEvent]) {
let _ = self.plan_splice(drift, false, REST_SPLICE_DRIFT, onsets);
}
fn plan_splice(
&mut self,
drift: f64,
force: bool,
max_residual: f64,
onsets: &[OnsetEvent],
) -> bool {
if !force && self.span_hits_onset(onsets, self.read_pos) {
return false;
}
let nominal_jump = drift;
let (mut best_jump, mut best_score) = (nominal_jump, f64::MIN);
let base = self.read_pos;
let lo = nominal_jump as isize - SEARCH_RANGE;
let hi = nominal_jump as isize + SEARCH_RANGE;
let a0 = base.floor() as isize;
mix_channels(&self.channels, a0, &mut self.corr_a);
let a_sq = dot_f64(&self.corr_a, &self.corr_a);
mix_channels(&self.channels, a0 + lo, &mut self.corr_b);
for jump in lo..=hi {
if (drift - jump as f64).abs() > max_residual {
continue;
}
let candidate = base + jump as f64;
if !self.readable_span(candidate, CORR_WINDOW + XFADE_FRAMES) {
continue;
}
if !force && self.span_hits_onset(onsets, candidate) {
continue;
}
let off = (jump - lo) as usize;
let b_win = &self.corr_b[off..off + CORR_WINDOW];
let b_sq = dot_f64(b_win, b_win);
let norm = (a_sq * b_sq).sqrt();
let corr = if norm < 1e-12 {
0.0
} else {
dot_f64(&self.corr_a, b_win) / norm
};
let distance = (jump as f64 - nominal_jump).abs() / SEARCH_RANGE as f64;
let score = corr - 0.02 * distance;
if score > best_score {
best_score = score;
best_jump = jump as f64;
}
}
if best_score == f64::MIN {
return false; }
if self.readable_span(base + best_jump - 1.0, CORR_WINDOW + XFADE_FRAMES)
&& self.readable_span(base + best_jump + 1.0, CORR_WINDOW + XFADE_FRAMES)
{
const GRID: usize = 2 * FINE_SEARCH_STEPS as usize + 1;
let step = FINE_SEARCH_RADIUS / FINE_SEARCH_STEPS as f64;
let a_win = &self.corr_a[..XFADE_FRAMES];
let a_sq = dot_f64(a_win, a_win);
let span_lo = (base + best_jump - FINE_SEARCH_RADIUS).floor() as isize + 1
- READ_HALF_TAPS as isize;
mix_channels(&self.channels, span_lo, &mut self.frac_mix);
let mut row = [0.0f32; READ_TAPS];
let mut cs = [0.0f64; GRID];
let (mut best_k, mut best_c) = (0usize, f64::MIN);
for (k, c) in cs.iter_mut().enumerate() {
let frac_off = (k as f64 - FINE_SEARCH_STEPS as f64) * step;
let b_pos = base + best_jump + frac_off;
let b0 = b_pos.floor();
let wsum = self.table.fill_row(b_pos - b0, &mut row);
let base_off = (b0 as isize + 1 - READ_HALF_TAPS as isize - span_lo) as usize;
let (mut dot, mut b_sq) = (0.0f64, 0.0f64);
for (i, &a) in a_win.iter().enumerate() {
let seg = &self.frac_mix[base_off + i..base_off + i + READ_TAPS];
let b = if wsum.abs() <= 1e-12 {
0.0
} else {
dot_f64_f32(seg, &row) / wsum
};
dot += a * b;
b_sq += b * b;
}
let norm = (a_sq * b_sq).sqrt();
*c = if norm < 1e-12 { 0.0 } else { dot / norm };
if *c > best_c {
best_c = *c;
best_k = k;
}
}
let mut best_frac = (best_k as f64 - FINE_SEARCH_STEPS as f64) * step;
if best_k > 0 && best_k + 1 < GRID {
let (cm, c0, cp) = (cs[best_k - 1], cs[best_k], cs[best_k + 1]);
let denom = cm - 2.0 * c0 + cp;
if denom < -1e-12 {
best_frac += step * (0.5 * (cm - cp) / denom).clamp(-0.5, 0.5);
}
}
best_jump += best_frac;
}
let target = base + best_jump;
if !self.readable_span(target, CORR_WINDOW + XFADE_FRAMES) {
return false; }
if !force
&& self.energy_avg > 1e-6
&& self.region_rms(target, CORR_WINDOW) > TRANSIENT_POSTPONE_RATIO * self.energy_avg
{
return false;
}
self.xfade_from = self.read_pos;
self.read_pos = target;
self.xfade_remaining = XFADE_FRAMES;
self.splice_count += 1;
true
}
fn readable_span(&self, pos: f64, span: usize) -> bool {
let end = pos + span as f64 * self.transposition.max(1.0);
let newest_ok = end <= self.write_abs as f64 - MIN_READ_MARGIN;
let oldest_ok = pos
>= (self.write_abs as f64 - RING_LEN as f64) + MIN_READ_MARGIN + BLOCK_FRAMES as f64;
let started = pos >= MIN_READ_MARGIN;
newest_ok && oldest_ok && started
}
fn region_rms(&self, pos: f64, len: usize) -> f64 {
let p0 = pos.floor() as usize;
let mut acc = 0.0f64;
for i in 0..len {
let mut mix = 0.0f64;
for ch in &self.channels {
mix += ch.ring[(p0 + i) & RING_MASK] as f64;
}
let mix = mix / self.channels.len() as f64;
acc += mix * mix;
}
(acc / len as f64).sqrt()
}
}
#[cfg(test)]
mod tests {
use super::*;
const SR: f64 = 44_100.0;
const LAG: usize = 560;
fn sine(freq: f64, len: usize, amp: f32) -> Vec<f32> {
(0..len)
.map(|i| amp * (2.0 * std::f64::consts::PI * freq * i as f64 / SR).sin() as f32)
.collect()
}
fn run(corrector: &mut SolaCorrector, input: &[f32], t: f64) -> Vec<f32> {
corrector.set_transposition(t);
let mut out = Vec::with_capacity(input.len());
let mut block = [[0.0f32; BLOCK_FRAMES]; 1];
for chunk in input.chunks_exact(BLOCK_FRAMES) {
block[0].copy_from_slice(chunk);
corrector.process_block(&mut block, &[]);
out.extend_from_slice(&block[0]);
}
out
}
fn measure_freq(window: &[f32]) -> f64 {
let (mut first, mut last, mut count) = (None, None, 0usize);
for i in 1..window.len() {
let (a, b) = (window[i - 1] as f64, window[i] as f64);
if a <= 0.0 && b > 0.0 {
let t = (i - 1) as f64 + a / (a - b);
if first.is_none() {
first = Some(t);
}
last = Some(t);
count += 1;
}
}
match (first, last) {
(Some(f), Some(l)) if count >= 2 => (count - 1) as f64 * SR / (l - f),
_ => 0.0,
}
}
#[test]
fn unity_is_pure_delay_with_no_splices() {
let mut corrector = SolaCorrector::new(1, LAG);
let input = sine(700.0, 44_100, 0.5);
let out = run(&mut corrector, &input, 1.0);
assert_eq!(corrector.splice_count(), 0, "unity must never splice");
for i in 8_192..40_000 {
assert!(
(out[i] - input[i - LAG]).abs() < 1e-4,
"unity SOLA deviates at {i}"
);
}
}
#[test]
fn shifts_pitch_by_transposition() {
let mut corrector = SolaCorrector::new(1, LAG);
let input = sine(600.0, 44_100 * 4, 0.5);
let out = run(&mut corrector, &input, 1.04);
let f = measure_freq(&out[44_100..88_200]);
assert!(
(f - 624.0).abs() < 2.0,
"expected ~624 Hz at T=1.04, measured {f:.1}"
);
assert!(corrector.splice_count() > 0, "non-unity must splice");
}
#[test]
fn splices_are_click_free_on_tone() {
let freq = 330.0;
let amp = 0.5;
let mut corrector = SolaCorrector::new(1, LAG);
let input = sine(freq, 44_100 * 6, amp);
let out = run(&mut corrector, &input, 1.05);
let bound = amp * 2.0 * std::f32::consts::PI * (freq * 1.05) as f32 / SR as f32 * 1.35;
let mut worst = (0usize, 0.0f32);
for (i, w) in out[4_096..].windows(2).enumerate() {
let d = (w[1] - w[0]).abs();
if d > worst.1 {
worst = (i + 4_096, d);
}
}
println!(
"sola tone splice: {} splices, max diff {:.5} (bound {bound:.5})",
corrector.splice_count(),
worst.1
);
assert!(
worst.1 <= bound,
"splice click at {}: {:.5} > {bound:.5}",
worst.0,
worst.1
);
}
#[test]
fn lag_stays_bounded_over_long_runs() {
let mut corrector = SolaCorrector::new(1, LAG);
let input = sine(500.0, 44_100 * 20, 0.4);
let _ = run(&mut corrector, &input, 1.05);
let error = corrector.lag_error_frames().abs();
assert!(
error <= HARD_TRIGGER + BLOCK_FRAMES as f64,
"lag error {error:.0} frames escaped the elastic band"
);
}
#[test]
fn splice_fades_avoid_artifact_onsets() {
let mut corrector = SolaCorrector::new(1, LAG);
corrector.set_transposition(1.05);
let input = sine(500.0, 44_100 * 8, 0.4);
let mut block = [[0.0f32; BLOCK_FRAMES]; 1];
let mut fade_spans: Vec<(f64, f64)> = Vec::new();
let mut events: Vec<OnsetEvent> = Vec::new();
let mut was_fading = false;
for (bi, chunk) in input.chunks_exact(BLOCK_FRAMES).enumerate() {
let stage_now = (bi * BLOCK_FRAMES) as f64;
events.clear();
let mut onset = ((stage_now - 2_048.0).max(0.0) / 4_410.0).floor() * 4_410.0;
while onset <= stage_now + 2_048.0 {
if onset > 0.0 {
events.push(OnsetEvent {
stage_frame: onset,
strength: 0.9,
beat: false,
band_flux: [1.0; 4],
});
}
onset += 4_410.0;
}
block[0].copy_from_slice(chunk);
corrector.process_block(&mut block, &events);
let fading = corrector.xfade_remaining > 0;
if fading && !was_fading {
let elapsed = (XFADE_FRAMES - corrector.xfade_remaining) as f64 * 1.05;
let span = XFADE_FRAMES as f64 * 1.05;
let out_start = corrector.xfade_from - elapsed;
let in_start = corrector.read_pos - elapsed;
fade_spans.push((out_start, out_start + span));
fade_spans.push((in_start, in_start + span));
}
was_fading = fading;
}
assert!(
corrector.splice_count() > 10,
"fixture must splice ({} splices)",
corrector.splice_count()
);
for &(lo, hi) in &fade_spans {
let mut onset = 4_410.0;
while onset < 44_100.0 * 8.0 {
assert!(
hi <= onset - ONSET_PROTECT_PRE || lo >= onset + ONSET_PROTECT_POST,
"fade span [{lo:.0}, {hi:.0}] overlaps onset at {onset:.0}"
);
onset += 4_410.0;
}
}
}
#[test]
fn stereo_channels_splice_in_lockstep() {
let mut corrector = SolaCorrector::new(2, LAG);
corrector.set_transposition(1.05);
let mono = sine(440.0, 44_100 * 4, 0.5);
let mut left = Vec::new();
let mut right = Vec::new();
let mut block = [[0.0f32; BLOCK_FRAMES]; 2];
for chunk in mono.chunks_exact(BLOCK_FRAMES) {
for (i, &s) in chunk.iter().enumerate() {
block[0][i] = s;
block[1][i] = -0.8 * s;
}
corrector.process_block(&mut block, &[]);
left.extend_from_slice(&block[0]);
right.extend_from_slice(&block[1]);
}
for i in 4_096..left.len() {
assert!(
(right[i] + 0.8 * left[i]).abs() < 1e-4,
"stereo lockstep broken at {i}: L={} R={}",
left[i],
right[i]
);
}
}
}