diff --git a/ROADMAP.md b/ROADMAP.md index 9432a37..1056f10 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -236,6 +236,22 @@ benefits regardless. Touches no DSP. Automation: auto +> **Status (2026-08-07): behavior half landed** (branch +> `feat/wide-path-consolidation`): offline wide ratios inside the engine +> range now render through the shipped `WideKeylockStage` (the batch PV +> survives only beyond 4× either way); streaming-vs-offline determinism +> extended to wide rates (sample-identical at 0.5×/1.5×); the two-tone +> sub-bass attenuation pinned at Stage 13 is GONE through the live stage +> (balance at the ideal at every wide ratio — the live PV runs at unity +> with the resampler transposing); the corrected stereo path runs in +> mid/side (identical channels stay bit-identical, center cannot leak +> into side by construction — honest measurement: the per-channel leak +> was modest, 64.6 → 70.9 dB rejection, so the audible width verdict +> belongs to the owner listen); peak-magnitude floor unified across the +> vocoder and locking passes. Remaining: the dead-code/doc sweep +> (separate PR) and the owner ±30/±50 listen (note: M/S touches exactly +> the "a bit crowded vs R3" quality heard at Stage 11). + ### Why Offline wide ratios run a *different algorithm configuration* than the diff --git a/src/engine/offline.rs b/src/engine/offline.rs index 52875f9..6483595 100644 --- a/src/engine/offline.rs +++ b/src/engine/offline.rs @@ -65,7 +65,28 @@ pub fn stretch_offline( debug_assert_eq!(input.len() % channels.max(1), 0); let rate = 1.0 / ratio; if (1.0 - rate).abs() <= OFFLINE_GRAPH_MAX_DEV { - stretch_via_graph(input, channels, sample_rate, ratio, pre_analysis) + stretch_via_graph( + input, + channels, + sample_rate, + ratio, + pre_analysis, + EngineProfile::Keylock, + ) + } else if (crate::engine::MIN_TEMPO_RATE..=crate::engine::MAX_TEMPO_RATE).contains(&rate) { + // Wide ratios inside the engine's rate range run the SHIPPED wide + // corrector (ROADMAP Stage 14): offline and live wide renders are + // the same algorithm, stereo image handling included. The direct + // batch PV below survives only for ratios beyond any deck use + // (> 4x either way). + stretch_via_graph( + input, + channels, + sample_rate, + ratio, + pre_analysis, + EngineProfile::WideKeylock, + ) } else { stretch_wide_pv(input, channels, sample_rate, ratio) } @@ -78,6 +99,7 @@ fn stretch_via_graph( sample_rate: u32, ratio: f64, pre_analysis: Option>, + profile: EngineProfile, ) -> Result, StretchError> { let rate = 1.0 / ratio; let frames = input.len() / channels; @@ -100,7 +122,7 @@ fn stretch_via_graph( let handles = Engine::build(EngineConfig { sample_rate, channels, - profile: EngineProfile::Keylock, + profile, initial_tempo_rate: rate, pre_analysis: Some(artifact), ..EngineConfig::default() diff --git a/src/engine/stages/wide_keylock.rs b/src/engine/stages/wide_keylock.rs index 7901635..6956c72 100644 --- a/src/engine/stages/wide_keylock.rs +++ b/src/engine/stages/wide_keylock.rs @@ -142,6 +142,13 @@ pub(crate) struct WideKeylockStage { raw: Vec<[f32; BLOCK_FRAMES]>, /// Per-channel corrected scratch popped from the FIFOs. corrected: Vec<[f32; BLOCK_FRAMES]>, + /// Mid/side encode scratch (stereo only). The corrected path + /// processes M/S instead of L/R (ROADMAP Stage 14): two independent + /// phase vocoders on L and R let their phase states diverge and the + /// center image wander; in M/S, centered content lives entirely in M + /// under ONE vocoder, and identical L/R yields S = 0 exactly — so + /// the corrected image cannot wander. The raw/toggle path stays L/R. + ms: Vec<[f32; BLOCK_FRAMES]>, /// Transposition currently applied to the PV/resampler pair. transposition: f64, sample_rate: u32, @@ -217,6 +224,7 @@ impl WideKeylockStage { raw_delay, raw: vec![[0.0; BLOCK_FRAMES]; num_channels], corrected: vec![[0.0; BLOCK_FRAMES]; num_channels], + ms: vec![[0.0; BLOCK_FRAMES]; num_channels], transposition: 1.0, sample_rate, ingested: 0.0, @@ -315,6 +323,21 @@ impl Stage for WideKeylockStage { self.begin_block(ctx); + // Stereo runs the corrected path in mid/side (see the `ms` field + // doc); other channel counts pass through per-channel. + let stereo = block.channels() == 2; + if stereo { + let (l, r) = (block.channel(0), block.channel(1)); + for i in 0..BLOCK_FRAMES { + self.ms[0][i] = 0.5 * (l[i] + r[i]); + self.ms[1][i] = 0.5 * (l[i] - r[i]); + } + } else { + for ch in 0..block.channels() { + self.ms[ch].copy_from_slice(block.channel(ch)); + } + } + // Channels in order 0..n (FixedDelay's shared-cursor contract). for ch in 0..block.channels() { self.raw[ch].copy_from_slice(block.channel(ch)); @@ -322,7 +345,7 @@ impl Stage for WideKeylockStage { let state = &mut self.channels[ch]; debug_assert!(state.window.len() + BLOCK_FRAMES <= WINDOW_CAPACITY); - state.window.extend_from_slice(block.channel(ch)); + state.window.extend_from_slice(&self.ms[ch]); // Render at most ONE hop per block: per-callback FFT work is // bounded by construction. Steady state hops once per 8 @@ -395,6 +418,16 @@ impl Stage for WideKeylockStage { } self.enable = enable; + // Decode the corrected M/S pair back to L/R before the toggle + // blend (the raw arm is L/R throughout). + if stereo { + for i in 0..BLOCK_FRAMES { + let (m, side) = (self.corrected[0][i], self.corrected[1][i]); + self.corrected[0][i] = m + side; + self.corrected[1][i] = m - side; + } + } + for ch in 0..block.channels() { let out = block.channel_mut(ch); for (i, sample) in out.iter_mut().enumerate() { diff --git a/src/stretch/phase_locking.rs b/src/stretch/phase_locking.rs index cd88ca3..bb46ff1 100644 --- a/src/stretch/phase_locking.rs +++ b/src/stretch/phase_locking.rs @@ -25,6 +25,11 @@ use std::f32::consts::PI; +/// Minimum magnitude for a bin to count as a spectral peak — shared by +/// the vocoder's IF-refinement pass and the locking region pass so both +/// operate on the same peak set. +pub(crate) const MIN_PEAK_MAGNITUDE: f32 = 1e-8; + /// Phase locking mode for the phase vocoder. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PhaseLockingMode { @@ -194,7 +199,14 @@ fn fill_spectral_peaks( } let search_start = start_bin.max(1); for k in search_start..num_bins - 1 { - if magnitudes[k] > magnitudes[k - 1] && magnitudes[k] > magnitudes[k + 1] { + // Same magnitude floor as the vocoder's own peak pass (ROADMAP + // Stage 14 unification): without it the two passes disagree in + // quiet passages, where noise-floor ripple here counted as peaks + // and anchored real bins to numerically random rotations. + if magnitudes[k] > MIN_PEAK_MAGNITUDE + && magnitudes[k] > magnitudes[k - 1] + && magnitudes[k] > magnitudes[k + 1] + { peaks_out.push(k); } } diff --git a/src/stretch/phase_vocoder.rs b/src/stretch/phase_vocoder.rs index b4914a5..2a530c2 100644 --- a/src/stretch/phase_vocoder.rs +++ b/src/stretch/phase_vocoder.rs @@ -17,7 +17,7 @@ const PEAKS_CAPACITY_DIVISOR: usize = 4; /// Blend factor for phase gradient integration (soft vertical coherence). const PHASE_GRADIENT_BLEND: f64 = 0.20; /// Minimum magnitude to consider a bin as a spectral peak (avoids noise peaks). -const MIN_PEAK_MAGNITUDE: f32 = 1e-8; +use crate::stretch::phase_locking::MIN_PEAK_MAGNITUDE; /// Treat values this close to integers as integral synthesis positions. const SYNTH_POS_EPSILON: f64 = 1e-9; /// Floor used in adaptive locking feature extraction. diff --git a/tests/streaming_offline_determinism.rs b/tests/streaming_offline_determinism.rs index d29efe8..668398d 100644 --- a/tests/streaming_offline_determinism.rs +++ b/tests/streaming_offline_determinism.rs @@ -37,11 +37,15 @@ fn fixture(len: usize) -> Vec { /// callback sizes) at a constant rate, with the same artifact offline /// analysis would compute. fn render_streaming(input: &[f32], rate: f64) -> Vec { + render_streaming_with(input, rate, EngineProfile::Keylock) +} + +fn render_streaming_with(input: &[f32], rate: f64, profile: EngineProfile) -> Vec { let artifact = Arc::new(timestretch::analyze_for_dj(input, SR)); let handles = Engine::build(EngineConfig { sample_rate: SR, channels: 1, - profile: EngineProfile::Keylock, + profile, initial_tempo_rate: rate, pre_analysis: Some(artifact), ..EngineConfig::default() @@ -110,6 +114,31 @@ fn streaming_and_offline_are_sample_identical() { } } +/// ROADMAP Stage 14: the consolidation routes offline wide ratios +/// through the shipped `WideKeylockStage`, so the determinism property +/// extends to wide rates — offline and a wide-profile pull render at the +/// same constant rate must be sample-identical. +#[test] +fn streaming_and_offline_are_sample_identical_at_wide_rates() { + let input = fixture(SR as usize * 4); + for rate in [0.5f64, 1.5] { + let ratio = 1.0 / rate; + let offline = stretch_offline(&input, 1, SR, ratio, None).unwrap(); + let streaming = render_streaming_with(&input, rate, EngineProfile::WideKeylock); + assert_eq!( + offline.len(), + streaming.len(), + "length differs at wide rate {rate}" + ); + for (i, (a, b)) in offline.iter().zip(streaming.iter()).enumerate() { + assert!( + a == b, + "sample {i} differs at wide rate {rate}: offline {a} vs streaming {b}" + ); + } + } +} + #[test] fn offline_render_is_deterministic_across_runs() { let input = fixture(SR as usize * 3); diff --git a/tests/stretch_quality_regressions.rs b/tests/stretch_quality_regressions.rs index e1f30a4..18506a0 100644 --- a/tests/stretch_quality_regressions.rs +++ b/tests/stretch_quality_regressions.rs @@ -180,30 +180,23 @@ fn test_ratio_sweep_sine_length_and_pitch() { // ~0.833–1.25) content below the 150 Hz crossover is deliberately NOT // pitch-corrected — its pitch follows tempo, exactly as on a deck — so // the 100 Hz partial lands at 100/ratio Hz on that path and at 100 Hz on -// the wide-ratio PV path. Balance bounds are engine-measured baselines -// (ideal amplitude ratio 0.35/0.65 ≈ 0.538). The ratio-0.5 band once sat -// at 1.5–4.0 — the wide-PV's low-band level loss at heavy compression; -// the Stage 13 phase-hygiene fixes plus hop = FFT/8 shrank that loss -// (measured balance 0.753, re-pinned with margin). +// the wide-ratio path. Balance bounds are engine-measured baselines +// (ideal amplitude ratio 0.35/0.65 ≈ 0.538). History: the batch wide-PV +// lost low-band level at heavy ratios (0.5-band once 1.5–4.0; 2.0-band +// pinned a rigid sub-bass attenuation at 1.3–3.4 after Stage 13). The +// Stage 14 consolidation routes wide ratios through the live +// WideKeylockStage — PV at unity, resampler transposing — and the +// balance measures at the ideal (~0.53–0.56) at every wide ratio. #[test] fn test_ratio_sweep_two_tone_peak_bins() { let n = 12_000usize; // (ratio, expected low-tone Hz, balance range for e1000/e_low) let cases = [ - (0.5, 100.0, 0.55..1.15), - (0.75, 100.0, 0.5..1.1), + (0.5, 100.0, 0.45..0.65), + (0.75, 100.0, 0.45..0.65), (1.0, 100.0, 0.49..0.59), (1.25, 80.0, 0.40..0.75), - // Ratio 2.0: hop = FFT/8 (the live wide stage's mandatory overlap, - // adopted offline at Stage 13) attenuates tones in the rigid - // sub-bass region (< ~107 Hz) at heavy slowdown — measured balance - // 2.14 vs ~0.5 at the old hop = FFT/4, while tones above the - // boundary stay at the ideal balance (probe 2026-08-05, LEARNINGS). - // The old hop kept sub-bass cleaner at ratio 2 but is the - // documented level/click blowup at ratio 4; offline follows the - // live configuration. Band pins the current loss so it cannot - // silently worsen; improving it is Stage 14/16 territory. - (2.0, 100.0, 1.3..3.4), + (2.0, 100.0, 0.45..0.65), ]; for (ratio, f_low, balance_range) in cases { let input = gen_two_tone(100.0, 0.65, 1000.0, 0.35, SR, n); diff --git a/tests/wide_stereo_coherence.rs b/tests/wide_stereo_coherence.rs new file mode 100644 index 0000000..88f3994 --- /dev/null +++ b/tests/wide_stereo_coherence.rs @@ -0,0 +1,141 @@ +//! Stereo image coherence through the wide keylock profile (ROADMAP +//! Stage 14). The corrected path runs in mid/side: centered content lives +//! entirely in M under one phase vocoder, so the image cannot wander +//! between independently-evolving per-channel phase states. Before the +//! change, identical L/R inputs measurably diverged through the two +//! independent vocoders. + +use timestretch::engine::{Engine, EngineConfig, EngineProfile}; + +const SR: u32 = 44_100; + +/// Renders `input` (interleaved stereo) through the wide profile at a +/// constant rate, returning interleaved output past latency + settle. +fn render_wide_stereo(input: &[f32], rate: f64) -> Vec { + let handles = Engine::build(EngineConfig { + sample_rate: SR, + channels: 2, + profile: EngineProfile::WideKeylock, + initial_tempo_rate: rate, + ..EngineConfig::default() + }) + .unwrap(); + let (_controller, mut processor, mut source) = + (handles.controller, handles.processor, handles.source); + let latency = processor.pipeline_latency_frames(); + let expected = (input.len() as f64 / 2.0 / rate) as usize; + let mut fed = 0usize; + let mut out = vec![0.0f32; 512 * 2]; + let mut collected = Vec::with_capacity(expected * 2); + while collected.len() < expected * 2 { + while fed < input.len() && source.free_frames() > 1024 { + fed += source.push(&input[fed..input.len().min(fed + 8_192)]) * 2; + } + if fed >= input.len() { + break; // enough source consumed for the assertion span + } + processor.process(&mut out); + collected.extend_from_slice(&out); + } + // Skip the latency prime and the stage's warm-up settle. + let skip = (latency + 4_096) * 2; + collected.split_off(skip.min(collected.len())) +} + +fn stereo_fixture(mono_gain_r: f32, frames: usize) -> Vec { + (0..frames) + .flat_map(|i| { + let t = i as f64 / SR as f64; + let s = (0.4 * (2.0 * std::f64::consts::PI * 330.0 * t).sin() + + 0.2 * (2.0 * std::f64::consts::PI * 2_700.0 * t).sin()) + as f32; + [s, s * mono_gain_r] + }) + .collect() +} + +/// Identical channels must stay bit-identical: S encodes to exactly zero, +/// zero stays zero through the vocoder and resampler, and decode adds +/// M ± 0. +#[test] +fn identical_channels_stay_identical_through_wide_keylock() { + for rate in [0.6f64, 1.5] { + let input = stereo_fixture(1.0, SR as usize * 6); + let out = render_wide_stereo(&input, rate); + assert!(out.len() > SR as usize, "not enough output at rate {rate}"); + for (i, fr) in out.chunks_exact(2).enumerate() { + assert!( + fr[0] == fr[1], + "center image diverged at frame {i} (rate {rate}): L={} R={}", + fr[0], + fr[1] + ); + } + } +} + +/// Goertzel amplitude of `signal` at `freq`. +fn goertzel(signal: &[f32], freq: f64) -> f64 { + let w = 2.0 * std::f64::consts::PI * freq / SR as f64; + let coeff = 2.0 * w.cos(); + let (mut s1, mut s2) = (0.0f64, 0.0f64); + for &x in signal { + let s0 = x as f64 + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + ((s1 * s1 + s2 * s2 - coeff * s1 * s2).max(0.0)).sqrt() / (signal.len() as f64 / 2.0) +} + +/// The discriminating gate for per-channel phase divergence: a CENTER +/// component under DIFFERENT side content per channel. With independent +/// L/R vocoders, L's and R's peak landscapes differ, the shared center's +/// phase evolves differently in each, and center energy leaks into the +/// side channel (image wander/width modulation). In M/S the center lives +/// in M under one vocoder and cannot leak by construction. Honest +/// measurement on this fixture: the per-channel leak was modest — 64.6 dB +/// rejection before, 70.9 dB after — so this gate is a regression +/// tripwire, and the audible verdict on width belongs to the owner +/// listen (Stage 11 heard ours "a bit crowded" vs R3). +#[test] +fn center_component_does_not_leak_into_side() { + let frames = SR as usize * 6; + // Independent noise beds per channel keep the peak landscape around + // the center bin different and MOVING in L vs R — steady side tones + // cannot discriminate (a strong center is always its own locked + // peak and never diverges). + let mut seed_l = 0x9e3779b97f4a7c15u64; + let mut seed_r = 0x2545f4914f6cdd1du64; + let noise = move |seed: &mut u64| -> f64 { + *seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); + ((*seed >> 33) as f64 / (1u64 << 31) as f64) - 1.0 + }; + let input: Vec = (0..frames) + .flat_map(|i| { + let t = i as f64 / SR as f64; + let center = 0.25 * (2.0 * std::f64::consts::PI * 500.0 * t).sin(); + let l = center + 0.2 * noise(&mut seed_l); + let r = center + 0.2 * noise(&mut seed_r); + [l as f32, r as f32] + }) + .collect(); + let out = render_wide_stereo(&input, 1.5); + assert!(out.len() > SR as usize); + let side: Vec = out + .chunks_exact(2) + .map(|fr| 0.5 * (fr[0] - fr[1])) + .collect(); + let mid: Vec = out + .chunks_exact(2) + .map(|fr| 0.5 * (fr[0] + fr[1])) + .collect(); + let leak = goertzel(&side, 500.0); + let center_level = goertzel(&mid, 500.0); + let rejection_db = 20.0 * (center_level / leak.max(1e-12)).log10(); + println!("center-to-side rejection: {rejection_db:.1} dB"); + assert!( + rejection_db > 60.0, + "center leaks into the side channel: {rejection_db:.1} dB rejection \ + (measured 70.9 dB with M/S, 64.6 dB per-channel)" + ); +}