Skip to content
Open
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
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ required-features = ["cli"]
name = "generate_test_audio"
path = "test_audio/generate.rs"

[[example]]
name = "stage16_audition"

[features]
cli = []
qa-harnesses = []
Expand Down
15 changes: 15 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
194 changes: 194 additions & 0 deletions examples/stage16_audition.rs
Original file line number Diff line number Diff line change
@@ -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/<track>/<rate_tag>/<arm>.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::<f64>() / 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<f32> {
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, &params).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());
}
96 changes: 96 additions & 0 deletions tests/tonal_purity_characterization.rs
Original file line number Diff line number Diff line change
@@ -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<f32> {
let params = StretchParams::new(ratio)
.with_sample_rate(SR)
.with_channels(1);
stretch(input, &params).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<f32> = (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::<f64>() 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<f32> = (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"
);
}
}
Loading