diff --git a/Cargo.toml b/Cargo.toml index f836a4e..9d991c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,9 @@ required-features = ["cli"] name = "generate_test_audio" path = "test_audio/generate.rs" +[[example]] +name = "stage16_audition" + [features] cli = [] qa-harnesses = [] diff --git a/ROADMAP.md b/ROADMAP.md index 9432a37..03df1ef 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -295,6 +295,21 @@ broken `LR8Crossover` kept only for baselines that no longer exist. Automation: manual +> **Status (2026-08-07): audition prep complete** (branch +> `feat/stage16-audition-prep`) — the listening session is ready to run. +> `examples/stage16_audition.rs` renders the full blind set: +> 3 corpus excerpts (hot_stuff strings, msbwy filtered strings, +> cold_heart vocal) × ±4/±8% × 4 arms (shipped SOLA path, PV-512 and +> PV-1024 prototypes behind the 120 Hz split for the corrected-range +> re-audition, Rubber Band reference), RMS-matched, in +> `target/stage16_audition/`. Characterization pinned in +> `tests/tonal_purity_characterization.rs` with a finding the sine probe +> missed: the granulation floor is strongly ASYMMETRIC — harmonic-15 +> purity 52 dB at +8% tempo but 22 dB at −8% (slowdowns re-cross +> material, so splices recur through sustained content), with two-tone +> beating well preserved both ways (47/42 dB). Focus the listening on +> SLOWDOWNS. Remaining: the blind listen and the verdict recorded here. + ### Why The review's headline measurement: a 3.3 kHz tone through the DJ keylock diff --git a/examples/stage16_audition.rs b/examples/stage16_audition.rs new file mode 100644 index 0000000..8ac5a96 --- /dev/null +++ b/examples/stage16_audition.rs @@ -0,0 +1,194 @@ +//! ROADMAP Stage 16 audition-set renderer: prepares the blind-listening +//! material for (a) the tonal-HF granulation verdict and (b) the +//! corrected-range re-audition of the Stage 7 "SOLA owns the corrected +//! range" decision on the post-Stage-13 (phase-fixed) vocoder. +//! +//! Per corpus excerpt and DJ rate, renders four arms: +//! - `ours_sola.wav` — the shipped keylock path (`stretch()`, which is +//! the live graph for DJ ratios by construction) +//! - `pv512.wav` / `pv1024.wav` — the re-audition prototype: the SAME +//! two-band topology as the keylock chain (LinkwitzRiley8 at 120 Hz, +//! low band pitch-follows via band-limited resample), but with the +//! high band corrected by a fixed small/medium-FFT identity-locked +//! phase vocoder instead of SOLA +//! - `rubberband.wav` — Rubber Band R3 (`rubberband-r3` or +//! `rubberband --fine`) reference, skipped with a warning if the CLI +//! is absent +//! +//! All arms are RMS-matched to the source excerpt. Output: +//! `target/stage16_audition///.wav` plus a +//! `summary.txt` of levels. Usage: +//! cargo run --release --example stage16_audition [corpus_dir] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use timestretch::core::crossover::LinkwitzRiley8; +use timestretch::core::resample::resample_sinc_default; +use timestretch::core::window::WindowType; +use timestretch::io::{read_wav_file, write_wav_file_16bit}; +use timestretch::stretch::{PhaseLockingMode, PhaseVocoder}; +use timestretch::{AudioBuffer, Channels, StretchParams}; + +const CROSSOVER_HZ: f64 = 120.0; +const EXCERPT_SECS: f64 = 20.0; +/// DJ rates for the audition (tempo rate; stretch ratio is 1/rate). +const RATES: [f64; 4] = [0.92, 0.96, 1.04, 1.08]; + +/// (tag, corpus file, excerpt start secs — chosen for sustained tonal +/// content over a beat: strings, vocals, pads). +const TRACKS: [(&str, &str, f64); 3] = [ + ("hot_stuff", "14220825_Hot Stuff_(Original Mix).wav", 60.0), + ( + "msbwy", + "12247392_Music Sounds Better With You_(Original Mix).wav", + 90.0, + ), + ( + "cold_heart", + "15836669_Cold Heart_(PNAU Extended Mix).wav", + 45.0, + ), +]; + +fn rms(x: &[f32]) -> f64 { + (x.iter().map(|&s| s as f64 * s as f64).sum::() / x.len().max(1) as f64).sqrt() +} + +fn match_rms(x: &mut [f32], target: f64) { + let current = rms(x); + if current > 1e-9 { + let g = (target / current) as f32; + for s in x.iter_mut() { + *s *= g; + } + } +} + +/// The re-audition prototype: keylock's two-band topology with a fixed +/// phase vocoder as the high-band corrector. Constant-rate offline +/// equivalence: the high band is PV-stretched at the ratio (pitch +/// preserved), the low band is resampled to length (pitch follows tempo +/// — the deck contract), and both sum at equal length. +fn render_pv_prototype(input: &[f32], sample_rate: u32, ratio: f64, fft: usize) -> Vec { + let mut split = LinkwitzRiley8::new(CROSSOVER_HZ, sample_rate); + let mut low = vec![0.0f32; input.len()]; + let mut high = vec![0.0f32; input.len()]; + split.process(input, &mut low, &mut high); + + let out_len = (input.len() as f64 * ratio).round() as usize; + let low_out = resample_sinc_default(&low, out_len); + + let mut pv = PhaseVocoder::with_options( + fft, + fft / 8, + ratio, + sample_rate, + 100.0, + WindowType::Hann, + PhaseLockingMode::Identity, + ); + let mut high_out = pv.process(&high).expect("pv render"); + high_out.resize(out_len, 0.0); + + low_out + .iter() + .zip(high_out.iter()) + .map(|(&l, &h)| l + h) + .collect() +} + +fn render_rubberband(src: &Path, out: &Path, ratio: f64) -> bool { + let cli = ["rubberband-r3", "rubberband"] + .iter() + .find(|c| Command::new(c).arg("--version").output().is_ok()); + let Some(cli) = cli else { + return false; + }; + let mut cmd = Command::new(cli); + if *cli == "rubberband" { + cmd.arg("--fine"); + } + cmd.arg("--time") + .arg(format!("{ratio}")) + .arg(src) + .arg(out) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn main() { + let corpus = std::env::args() + .nth(1) + .unwrap_or_else(|| "benchmarks/audio/bpm-corpus".to_string()); + let out_base = PathBuf::from("target/stage16_audition"); + let mut summary = String::new(); + + for (tag, file, start_secs) in TRACKS { + let path = Path::new(&corpus).join(file); + let Ok(buf) = read_wav_file(path.to_str().unwrap()) else { + eprintln!("skipping {tag}: cannot read {}", path.display()); + continue; + }; + let ch = buf.channels.count(); + let sr = buf.sample_rate; + let start = (start_secs * sr as f64) as usize * ch; + let len = (EXCERPT_SECS * sr as f64) as usize * ch; + if start + len > buf.data.len() { + eprintln!("skipping {tag}: excerpt out of range"); + continue; + } + let excerpt = &buf.data[start..start + len]; + let source_rms = rms(excerpt); + + for rate in RATES { + let ratio = 1.0 / rate; + let pct = (rate - 1.0) * 100.0; + let rate_tag = format!("{}{:.0}pct", if pct >= 0.0 { "+" } else { "-" }, pct.abs()); + let dir = out_base.join(tag).join(&rate_tag); + std::fs::create_dir_all(&dir).expect("mkdir"); + + // Source excerpt (for the rubberband CLI and as the anchor). + let src_wav = dir.join("source.wav"); + let src_buf = AudioBuffer::new(excerpt.to_vec(), sr, buf.channels); + write_wav_file_16bit(src_wav.to_str().unwrap(), &src_buf).expect("write"); + + // Arm 1: shipped keylock path. + let params = StretchParams::new(ratio) + .with_sample_rate(sr) + .with_channels(ch as u32); + let mut ours = timestretch::stretch(excerpt, ¶ms).expect("stretch"); + match_rms(&mut ours, source_rms); + let ours_buf = AudioBuffer::new(ours, sr, buf.channels); + write_wav_file_16bit(dir.join("ours_sola.wav").to_str().unwrap(), &ours_buf) + .expect("write"); + + // Arms 2+3: the PV-behind-the-split prototypes (mono mid for + // the corrected path comparison; stereo nuance is not what + // this audition decides). + let mid = timestretch::downmix_to_mid(excerpt, ch); + for fft in [512usize, 1024] { + let mut rendered = render_pv_prototype(&mid, sr, ratio, fft); + match_rms(&mut rendered, rms(&mid)); + let pv_buf = AudioBuffer::new(rendered, sr, Channels::Mono); + write_wav_file_16bit(dir.join(format!("pv{fft}.wav")).to_str().unwrap(), &pv_buf) + .expect("write"); + } + + // Arm 4: Rubber Band reference. + let rb_ok = render_rubberband(&src_wav, &dir.join("rubberband.wav"), ratio); + if !rb_ok { + eprintln!(" rubberband CLI unavailable/failed for {tag}/{rate_tag}"); + } + + summary.push_str(&format!( + "{tag}/{rate_tag}: source_rms {source_rms:.4} rb={}\n", + if rb_ok { "ok" } else { "MISSING" } + )); + println!("rendered {tag}/{rate_tag}"); + } + } + std::fs::write(out_base.join("summary.txt"), summary).expect("summary"); + println!("audition set in {}", out_base.display()); +} diff --git a/tests/tonal_purity_characterization.rs b/tests/tonal_purity_characterization.rs new file mode 100644 index 0000000..05a1f1f --- /dev/null +++ b/tests/tonal_purity_characterization.rs @@ -0,0 +1,96 @@ +//! Tonal-purity characterization at DJ ratios (ROADMAP Stage 16). +//! +//! These pin the MEASURED granulation floor of the shipped keylock path +//! on music-like tonal probes — characterization, not quality gates: the +//! floors sit just below current behavior so a regression is caught, but +//! the numbers themselves are the evidence base for the Stage 16 +//! listening verdict (a sine-purity figure overstates audibility on +//! dense mixes, which is exactly why the verdict is by ear). + +use timestretch::{StretchParams, stretch}; + +const SR: u32 = 44_100; + +fn goertzel(seg: &[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 seg { + let s0 = x as f64 + coeff * s1 - s2; + s2 = s1; + s1 = s0; + } + ((s1 * s1 + s2 * s2 - coeff * s1 * s2).max(0.0)).sqrt() / (seg.len() as f64 / 2.0) +} + +fn render(input: &[f32], ratio: f64) -> Vec { + let params = StretchParams::new(ratio) + .with_sample_rate(SR) + .with_channels(1); + stretch(input, ¶ms).expect("stretch") +} + +/// Harmonic stack (a sustained string/organ-like probe): fundamental at +/// 220 Hz with 1/k harmonics through 4.4 kHz. Measures how much energy a +/// high harmonic keeps versus the sidebands SOLA splicing spreads around +/// it. Measured 2026-08-07: harmonic-15 purity 52.0 dB at +8% tempo but +/// 22.1 dB at −8% — SLOWDOWN is the weak direction (the read cursor +/// re-crosses material, so splices recur through sustained content). +/// This asymmetry is the granulation floor the Stage 16 listening +/// decides on. +#[test] +fn harmonic_stack_purity_at_dj_ratios() { + let n = SR as usize * 6; + let input: Vec = (0..n) + .map(|i| { + let t = i as f64 / SR as f64; + (1..=20) + .map(|k| (2.0 * std::f64::consts::PI * 220.0 * k as f64 * t).sin() / k as f64) + .sum::() as f32 + * 0.2 + }) + .collect(); + for ratio in [1.0 / 1.08, 1.0 / 0.92] { + let out = render(&input, ratio); + let mid = &out[out.len() / 2 - (1 << 16)..out.len() / 2 + (1 << 16)]; + // Harmonic 15 (3300 Hz) vs the energy at half-harmonic offsets + // around it (splice sidebands land off the harmonic grid). + let h15 = goertzel(mid, 3_300.0); + let side = goertzel(mid, 3_190.0).max(goertzel(mid, 3_410.0)); + let purity_db = 20.0 * (h15 / side.max(1e-12)).log10(); + println!("harmonic stack ratio {ratio:.3}: h15 vs sidebands {purity_db:.1} dB"); + assert!( + purity_db > 12.0, + "harmonic-15 purity regressed: {purity_db:.1} dB (characterized 52/+8%, 22/-8%)" + ); + } +} + +/// Two nearby tones (2000 and 2030 Hz): the 30 Hz beat pattern must +/// survive time-stretching — splice-rate modulation that disrupts it +/// reads as roughness. Characterized 2026-08-07: carriers-vs-spur floor +/// 47.3 dB (+8%) / 41.7 dB (−8%) — beating structure survives well. +#[test] +fn nearby_tone_pair_keeps_beating_structure() { + let n = SR as usize * 6; + let input: Vec = (0..n) + .map(|i| { + let t = i as f64 / SR as f64; + (0.3 * (2.0 * std::f64::consts::PI * 2_000.0 * t).sin() + + 0.3 * (2.0 * std::f64::consts::PI * 2_030.0 * t).sin()) as f32 + }) + .collect(); + for ratio in [1.0 / 1.08, 1.0 / 0.92] { + let out = render(&input, ratio); + let mid = &out[out.len() / 2 - (1 << 16)..out.len() / 2 + (1 << 16)]; + let carriers = goertzel(mid, 2_000.0) + goertzel(mid, 2_030.0); + // Spur floor away from the pair and its beat neighborhood. + let spur = goertzel(mid, 1_850.0).max(goertzel(mid, 2_180.0)); + let clarity_db = 20.0 * (carriers / spur.max(1e-12)).log10(); + println!("tone pair ratio {ratio:.3}: carriers vs spurs {clarity_db:.1} dB"); + assert!( + clarity_db > 15.0, + "tone-pair clarity regressed: {clarity_db:.1} dB" + ); + } +}