use timestretch::{detect_beat_grid, BeatGrid};
const SAMPLE_RATE: u32 = 44100;
const F_MEASURE_TOLERANCE_SECS: f64 = 0.07;
fn render_clicks(len: usize, positions: &[f64], amps: &[f32]) -> Vec<f32> {
let mut samples = vec![0.0f32; len];
for (&pos, &) in positions.iter().zip(amps.iter()) {
let base = pos.round() as usize;
for j in 0..64 {
let idx = base + j;
if idx >= len {
break;
}
let t = j as f32 / 64.0;
let decay = (1.0 - t).powi(2);
let click = if j % 2 == 0 { 1.0 } else { -0.8 };
let low = (2.0 * std::f32::consts::PI * 60.0 * j as f32 / SAMPLE_RATE as f32).sin();
samples[idx] += amp * decay * (0.6 * click + 0.4 * low);
}
}
samples
}
fn constant_beats(bpm: f64, seconds: f64, phase_samples: f64) -> Vec<f64> {
let interval = 60.0 * SAMPLE_RATE as f64 / bpm;
let len = SAMPLE_RATE as f64 * seconds;
let mut beats = Vec::new();
let mut pos = phase_samples;
while pos + 64.0 < len {
beats.push(pos);
pos += interval;
}
beats
}
fn f_measure(detected: &[f64], truth: &[f64]) -> f64 {
if detected.is_empty() || truth.is_empty() {
return 0.0;
}
let tolerance = F_MEASURE_TOLERANCE_SECS * SAMPLE_RATE as f64;
let mut matched = vec![false; truth.len()];
let mut hits = 0usize;
for &d in detected {
let idx = truth.partition_point(|&t| t < d);
let mut best: Option<usize> = None;
for cand in [idx.wrapping_sub(1), idx, idx + 1] {
if cand < truth.len() && !matched[cand] {
let dist = (truth[cand] - d).abs();
if dist <= tolerance && best.is_none_or(|b: usize| dist < (truth[b] - d).abs()) {
best = Some(cand);
}
}
}
if let Some(b) = best {
matched[b] = true;
hits += 1;
}
}
let precision = hits as f64 / detected.len() as f64;
let recall = hits as f64 / truth.len() as f64;
if precision + recall == 0.0 {
return 0.0;
}
2.0 * precision * recall / (precision + recall)
}
fn f_measure_octave_tolerant(detected: &[f64], truth: &[f64]) -> f64 {
let half_even: Vec<f64> = truth.iter().step_by(2).copied().collect();
let half_odd: Vec<f64> = truth.iter().skip(1).step_by(2).copied().collect();
let mut double = Vec::with_capacity(truth.len() * 2);
for w in truth.windows(2) {
double.push(w[0]);
double.push((w[0] + w[1]) * 0.5);
}
if let Some(&last) = truth.last() {
double.push(last);
}
f_measure(detected, truth)
.max(f_measure(detected, &half_even))
.max(f_measure(detected, &half_odd))
.max(f_measure(detected, &double))
}
fn assert_grid_f_measure(grid: &BeatGrid, truth: &[f64], floor: f64, label: &str) {
let f = f_measure(&grid.beats, truth);
assert!(
f >= floor,
"{label}: F-measure {f:.3} below floor {floor} ({} detected vs {} truth beats, bpm {:.2})",
grid.beats.len(),
truth.len(),
grid.bpm
);
}
#[test]
fn constant_tempo_sweep_no_octave_folding() {
for &bpm in &[65.0, 90.0, 120.0, 128.0, 150.0] {
let truth = constant_beats(bpm, 30.0, 3000.0);
let amps = vec![1.0f32; truth.len()];
let audio = render_clicks((SAMPLE_RATE as f64 * 30.0) as usize, &truth, &s);
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
assert!(
(grid.bpm - bpm).abs() < bpm * 0.02,
"expected ~{bpm} BPM, got {:.2}",
grid.bpm
);
assert_grid_f_measure(&grid, &truth, 0.85, &format!("{bpm} BPM constant"));
assert_eq!(
grid.segments.len(),
1,
"{bpm} BPM constant tempo must collapse to one segment, got {:?}",
grid.segments
);
}
}
#[test]
fn dnb_tempo_octave_family() {
let truth = constant_beats(174.0, 30.0, 1000.0);
let amps = vec![1.0f32; truth.len()];
let audio = render_clicks((SAMPLE_RATE as f64 * 30.0) as usize, &truth, &s);
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
let family = [174.0, 87.0];
assert!(
family.iter().any(|t| (grid.bpm - t).abs() < t * 0.02),
"expected 174 or 87 BPM, got {:.2}",
grid.bpm
);
let f = f_measure_octave_tolerant(&grid.beats, &truth);
assert!(f >= 0.85, "octave-tolerant F-measure {f:.3} below floor");
}
#[test]
fn tempo_ramp_is_tracked() {
let len_secs = 40.0;
let len = (SAMPLE_RATE as f64 * len_secs) as usize;
let mut truth = Vec::new();
let mut pos = 2000.0f64;
while pos + 64.0 < len as f64 {
truth.push(pos);
let frac = pos / len as f64;
let bpm = 120.0 + 12.0 * frac;
pos += 60.0 * SAMPLE_RATE as f64 / bpm;
}
let amps = vec![1.0f32; truth.len()];
let audio = render_clicks(len, &truth, &s);
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
assert_grid_f_measure(&grid, &truth, 0.85, "tempo ramp");
}
#[test]
fn tempo_step_splits_segments_and_tracks_both_sides() {
let first = constant_beats(122.0, 20.0, 1500.0);
let second: Vec<f64> = constant_beats(138.0, 20.0, 500.0)
.iter()
.map(|&b| b + SAMPLE_RATE as f64 * 20.0)
.collect();
let mut truth = first;
truth.extend_from_slice(&second);
let amps = vec![1.0f32; truth.len()];
let audio = render_clicks((SAMPLE_RATE as f64 * 40.0) as usize, &truth, &s);
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
assert_grid_f_measure(&grid, &truth, 0.80, "tempo step");
assert!(
grid.segments.len() >= 2,
"a hard tempo step must produce multiple segments, got {:?}",
grid.segments
);
let first_bpm = grid.segments.first().unwrap().bpm;
let last_bpm = grid.segments.last().unwrap().bpm;
assert!(
(first_bpm - 122.0).abs() < 4.0,
"first segment ~122, got {first_bpm:.2}"
);
assert!(
(last_bpm - 138.0).abs() < 4.0,
"last segment ~138, got {last_bpm:.2}"
);
}
#[test]
fn live_timing_jitter_is_tolerated() {
let interval = 60.0 * SAMPLE_RATE as f64 / 100.0;
let len = (SAMPLE_RATE as f64 * 30.0) as usize;
let mut seed = 0x12345678u32;
let mut rand = move || {
seed = seed.wrapping_mul(1664525).wrapping_add(1013904223);
(seed >> 8) as f64 / (1u32 << 24) as f64 - 0.5
};
let jitter_max = 0.012 * SAMPLE_RATE as f64;
let mut truth = Vec::new();
let mut pos = 4000.0f64;
while pos + 64.0 < len as f64 {
let jittered = pos + rand() * 2.0 * jitter_max;
truth.push(jittered);
pos += interval;
}
let amps = vec![1.0f32; truth.len()];
let audio = render_clicks(len, &truth, &s);
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
assert!(
(grid.bpm - 100.0).abs() < 3.0,
"expected ~100 BPM under jitter, got {:.2}",
grid.bpm
);
assert_grid_f_measure(&grid, &truth, 0.80, "live jitter");
}
#[test]
fn downbeat_phase_follows_accents() {
let truth = constant_beats(126.0, 30.0, 2500.0);
let amps: Vec<f32> = (0..truth.len())
.map(|k| if k % 4 == 0 { 1.0 } else { 0.5 })
.collect();
let audio = render_clicks((SAMPLE_RATE as f64 * 30.0) as usize, &truth, &s);
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
assert_grid_f_measure(&grid, &truth, 0.85, "accented 4/4");
assert!(
!grid.downbeats.is_empty(),
"accented pattern must yield downbeats"
);
assert!(
grid.downbeat_confidence > 0.05,
"downbeat confidence too low: {}",
grid.downbeat_confidence
);
let tolerance = F_MEASURE_TOLERANCE_SECS * SAMPLE_RATE as f64;
let accented: Vec<f64> = truth.iter().step_by(4).copied().collect();
for &db_idx in &grid.downbeats {
let pos = grid.beats[db_idx];
let near = accented.iter().any(|&a| (a - pos).abs() <= tolerance);
assert!(near, "downbeat at {pos:.0} is not on an accented beat");
}
}
#[test]
fn analysis_speed_is_offline_friendly() {
let truth = constant_beats(128.0, 60.0, 0.0);
let amps = vec![1.0f32; truth.len()];
let audio = render_clicks((SAMPLE_RATE as f64 * 60.0) as usize, &truth, &s);
let started = std::time::Instant::now();
let grid = detect_beat_grid(&audio, SAMPLE_RATE);
let elapsed = started.elapsed().as_secs_f64();
assert!(grid.bpm > 0.0);
let realtime_factor = 60.0 / elapsed;
let floor = if cfg!(debug_assertions) { 5.0 } else { 50.0 };
assert!(
realtime_factor >= floor,
"analysis speed {realtime_factor:.1}x realtime below {floor}x floor"
);
}