use crate::core::resample::{bessel_i0, 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 {
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 len = ring.len();
let s = (start as usize) & (len - 1);
let n = weights.len();
if s + n <= len {
dot_f32_f64(&ring[s..s + n], weights)
} else {
let first = 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 {
let mask = ch.ring.len() - 1;
for (k, m) in out.iter_mut().enumerate() {
*m += ch.ring[(start as usize).wrapping_add(k) & mask] as f64;
}
}
}
const RING_LEN: usize = 4_096;
const DRIFT_TRIGGER: f64 = 192.0;
const MILD_DRIFT_TRIGGER: f64 = 96.0;
const MILD_DEV: f64 = 0.012;
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 Tuning {
ring_len: usize,
drift_trigger: f64,
mild_drift_trigger: f64,
hard_trigger: f64,
search_range: isize,
corr_window: usize,
xfade_frames: usize,
onset_protect_pre: f64,
onset_protect_post: f64,
opportunistic_drift: f64,
masked_window_start: f64,
masked_window_end: f64,
rest_dwell_blocks: u32,
rest_splice_drift: f64,
slowdown_force_cap: f64,
rest_trim_slew: f64,
energy_smooth: f64,
}
impl Tuning {
fn for_sample_rate(sample_rate: u32) -> Self {
let s = (f64::from(sample_rate) / 44_100.0).max(1.0);
let frames = |reference: usize| ((reference as f64 * s).round() as usize).max(1);
Self {
ring_len: ((RING_LEN as f64 * s).ceil() as usize).next_power_of_two(),
drift_trigger: DRIFT_TRIGGER * s,
mild_drift_trigger: MILD_DRIFT_TRIGGER * s,
hard_trigger: HARD_TRIGGER * s,
search_range: (SEARCH_RANGE as f64 * s).round() as isize,
corr_window: frames(CORR_WINDOW),
xfade_frames: frames(XFADE_FRAMES),
onset_protect_pre: ONSET_PROTECT_PRE * s,
onset_protect_post: ONSET_PROTECT_POST * s,
opportunistic_drift: OPPORTUNISTIC_DRIFT * s,
masked_window_start: MASKED_WINDOW_START * s,
masked_window_end: MASKED_WINDOW_END * s,
rest_dwell_blocks: (f64::from(REST_DWELL_BLOCKS) * s).round() as u32,
rest_splice_drift: REST_SPLICE_DRIFT * s,
slowdown_force_cap: SLOWDOWN_FORCE_CAP * s,
rest_trim_slew: 0.001 / s,
energy_smooth: 0.02 / s,
}
}
}
#[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,
failed_attempts: u32,
rest_blocks: u32,
corr_a: Vec<f64>,
corr_b: Vec<f64>,
frac_mix: Vec<f64>,
tune: Tuning,
}
const CADENCE_SCALE_MAX: f64 = 2.0;
const CADENCE_TAPER_START: f64 = 1.09;
const CADENCE_TAPER_END: f64 = 1.15;
const SLOWDOWN_FORCE_CAP: f64 = 448.0;
fn cadence_scale(transposition: f64) -> f64 {
if transposition <= CADENCE_TAPER_START {
return CADENCE_SCALE_MAX;
}
if transposition >= CADENCE_TAPER_END {
return 1.0;
}
let t = (transposition - CADENCE_TAPER_START) / (CADENCE_TAPER_END - CADENCE_TAPER_START);
CADENCE_SCALE_MAX + (1.0 - CADENCE_SCALE_MAX) * t
}
impl SolaCorrector {
pub(crate) fn new(num_channels: usize, nominal_lag_frames: usize, sample_rate: u32) -> Self {
let tune = Tuning::for_sample_rate(sample_rate);
assert!(
nominal_lag_frames as f64
+ tune.hard_trigger * CADENCE_SCALE_MAX
+ (tune.search_range as f64)
+ MIN_READ_MARGIN
< (tune.ring_len - tune.corr_window - BLOCK_FRAMES) as f64,
"SOLA ring too small for nominal lag {nominal_lag_frames}"
);
assert!(
tune.slowdown_force_cap + MIN_READ_MARGIN + 2.0 * BLOCK_FRAMES as f64
<= nominal_lag_frames as f64,
"SOLA nominal lag {nominal_lag_frames} cannot cover the slowdown force cap"
);
Self {
channels: (0..num_channels)
.map(|_| SolaChannel {
ring: vec![0.0; tune.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,
failed_attempts: 0,
rest_blocks: 0,
corr_a: vec![0.0; tune.corr_window],
corr_b: vec![0.0; 2 * tune.search_range as usize + tune.corr_window],
frac_mix: vec![0.0; tune.xfade_frames + 2 * READ_TAPS],
tune,
}
}
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 };
}
fn cadence(&self) -> f64 {
if self.rate_slope == 0.0 {
cadence_scale(self.transposition)
} else {
1.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.failed_attempts = 0;
self.rest_blocks = 0;
self.rate_slope = 0.0;
}
fn span_hits_onset(&self, onsets: &[OnsetEvent], start: f64) -> bool {
let span = self.tune.xfade_frames as f64 * self.transposition.max(1.0);
onsets.iter().any(|event| {
start - FINE_SEARCH_RADIUS < event.stage_frame + self.tune.onset_protect_post
&& start + span + FINE_SEARCH_RADIUS
> event.stage_frame - self.tune.onset_protect_pre
})
}
fn in_masked_window(&self, onsets: &[OnsetEvent]) -> bool {
onsets.iter().any(|event| {
let since = self.read_pos - event.stage_frame;
(self.tune.masked_window_start..self.tune.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 mask = self.tune.ring_len - 1;
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) & 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 =
(1.0 - self.tune.energy_smooth) * self.energy_avg + self.tune.energy_smooth * 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 >= self.tune.rest_dwell_blocks
} else {
self.rest_blocks = 0;
false
};
if self.xfade_remaining == 0 {
let cadence = self.cadence();
let drift = self.lag_error_frames();
if drift.abs() > self.tune.drift_trigger * cadence {
self.try_splice(drift, onsets);
} else if drift.abs() > self.tune.opportunistic_drift * cadence
&& self.in_masked_window(onsets)
{
self.try_splice(drift, onsets);
} else if drift.abs() > self.tune.opportunistic_drift * cadence
&& self.energy_avg > 1e-6
&& self.region_rms(self.read_pos, self.tune.corr_window)
< QUIET_SPLICE_RATIO * self.energy_avg
&& self.region_rms(self.read_pos + drift, self.tune.corr_window)
< QUIET_SPLICE_RATIO * self.energy_avg
{
self.try_splice(drift, onsets);
} else if deviation < MILD_DEV && settled_drift.abs() > self.tune.mild_drift_trigger {
self.try_rest_splice(drift, onsets);
} else if at_rest && settled_drift.abs() > self.tune.rest_splice_drift {
self.try_rest_splice(drift, onsets);
}
}
let trim = if at_rest {
(settled_drift * self.tune.rest_trim_slew).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) / (self.tune.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 stretched = self.tune.hard_trigger * self.cadence();
let threshold = if drift < 0.0 {
stretched.min(self.tune.slowdown_force_cap)
} else {
stretched
};
let force = drift.abs() >= threshold && self.failed_attempts > 0;
if self.plan_splice(drift, force, f64::INFINITY, onsets) {
self.failed_attempts = 0;
} else {
self.failed_attempts = self.failed_attempts.saturating_add(1);
}
}
fn try_rest_splice(&mut self, drift: f64, onsets: &[OnsetEvent]) {
if self.plan_splice(drift, false, self.tune.rest_splice_drift, onsets) {
self.failed_attempts = 0;
}
}
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 - self.tune.search_range;
let hi = nominal_jump as isize + self.tune.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, self.tune.corr_window + self.tune.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 + self.tune.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() / self.tune.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,
self.tune.corr_window + self.tune.xfade_frames,
) && self.readable_span(
base + best_jump + 1.0,
self.tune.corr_window + self.tune.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[..self.tune.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, self.tune.corr_window + self.tune.xfade_frames) {
return false; }
if !force
&& self.energy_avg > 1e-6
&& self.region_rms(target, self.tune.corr_window)
> TRANSIENT_POSTPONE_RATIO * self.energy_avg
{
return false;
}
self.xfade_from = self.read_pos;
self.read_pos = target;
self.xfade_remaining = self.tune.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 - self.tune.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 mask = self.tune.ring_len - 1;
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) & 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, 44_100);
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, 44_100);
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, 44_100);
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, 44_100);
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 * CADENCE_SCALE_MAX + BLOCK_FRAMES as f64,
"lag error {error:.0} frames escaped the elastic band"
);
}
#[test]
fn cadence_scale_tapers_with_transposition() {
assert_eq!(cadence_scale(0.9), CADENCE_SCALE_MAX);
assert_eq!(cadence_scale(1.0), CADENCE_SCALE_MAX);
assert_eq!(cadence_scale(CADENCE_TAPER_START), CADENCE_SCALE_MAX);
assert_eq!(cadence_scale(CADENCE_TAPER_END), 1.0);
assert_eq!(cadence_scale(1.25), 1.0);
let mid = cadence_scale((CADENCE_TAPER_START + CADENCE_TAPER_END) * 0.5);
assert!(mid > 1.0 && mid < CADENCE_SCALE_MAX);
}
#[test]
fn cadence_stretch_halves_splice_rate_in_window() {
let secs = 20;
let frames = 44_100 * secs;
let mut corrector = SolaCorrector::new(1, LAG, 44_100);
let input = sine(500.0, frames, 0.4);
let _ = run(&mut corrector, &input, 1.05);
let accrual = frames as f64 * 0.05;
let scale1_rate = accrual / DRIFT_TRIGGER;
let count = corrector.splice_count() as f64;
assert!(
count < 0.7 * scale1_rate,
"cadence stretch inactive: {count} splices vs ~{scale1_rate:.0} at shipped cadence"
);
assert!(
count > 0.3 * scale1_rate,
"implausibly few splices ({count}) — corrector may be stalling"
);
}
#[test]
fn ride_slope_restores_shipped_cadence() {
let secs = 20;
let frames = 44_100 * secs;
let mut corrector = SolaCorrector::new(1, LAG, 44_100);
corrector.set_rate_slope(1e-4);
let input = sine(500.0, frames, 0.4);
corrector.set_transposition(1.05);
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, &[]);
}
let scale1_rate = frames as f64 * 0.05 / DRIFT_TRIGGER;
let count = corrector.splice_count() as f64;
assert!(
count > 0.7 * scale1_rate,
"cadence stretch active during a ride: {count} splices vs \
~{scale1_rate:.0} expected at shipped cadence"
);
}
#[test]
fn splice_fades_avoid_artifact_onsets() {
let mut corrector = SolaCorrector::new(1, LAG, 44_100);
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, 44_100);
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]
);
}
}
}