Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/engine/offline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -78,6 +99,7 @@ fn stretch_via_graph(
sample_rate: u32,
ratio: f64,
pre_analysis: Option<Arc<PreAnalysisArtifact>>,
profile: EngineProfile,
) -> Result<Vec<f32>, StretchError> {
let rate = 1.0 / ratio;
let frames = input.len() / channels;
Expand All @@ -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()
Expand Down
35 changes: 34 additions & 1 deletion src/engine/stages/wide_keylock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -315,14 +323,29 @@ 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));
self.raw_delay.process_channel(ch, &mut self.raw[ch]);

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
Expand Down Expand Up @@ -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() {
Expand Down
14 changes: 13 additions & 1 deletion src/stretch/phase_locking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/stretch/phase_vocoder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 30 additions & 1 deletion tests/streaming_offline_determinism.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,15 @@ fn fixture(len: usize) -> Vec<f32> {
/// callback sizes) at a constant rate, with the same artifact offline
/// analysis would compute.
fn render_streaming(input: &[f32], rate: f64) -> Vec<f32> {
render_streaming_with(input, rate, EngineProfile::Keylock)
}

fn render_streaming_with(input: &[f32], rate: f64, profile: EngineProfile) -> Vec<f32> {
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()
Expand Down Expand Up @@ -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);
Expand Down
27 changes: 10 additions & 17 deletions tests/stretch_quality_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading