use crate::dsp::util::tiny;
pub struct BeatTrackParams {
pub sr: f64,
pub hop_length: usize,
pub start_bpm: f64,
pub tightness: f64,
pub trim: bool,
pub bpm: Option<f64>,
}
impl BeatTrackParams {
pub fn new(sr: f64) -> Self {
Self {
sr,
hop_length: 512,
start_bpm: 120.0,
tightness: 100.0,
trim: true,
bpm: None,
}
}
}
pub fn beat_track(onset_envelope: &[f64], params: &BeatTrackParams) -> (f64, Vec<usize>) {
let n = onset_envelope.len();
if n == 0 {
return (params.start_bpm, Vec::new());
}
let bpm = if let Some(bpm) = params.bpm {
bpm
} else {
let tempo_params = super::tempo::TempoParams {
sr: params.sr,
hop_length: params.hop_length,
start_bpm: params.start_bpm,
std_bpm: 1.0,
ac_size: 8.0,
max_tempo: Some(320.0),
};
super::tempo::tempo(onset_envelope, &tempo_params)
};
let frame_rate = params.sr / params.hop_length as f64;
let fpb = (frame_rate * 60.0 / bpm).round() as usize;
if fpb == 0 {
return (bpm, Vec::new());
}
let normalized = normalize_onsets(onset_envelope);
let localscore = beat_local_score(&normalized, fpb);
let (backlink, cumscore) = beat_track_dp(&localscore, fpb, params.tightness);
let mut beats = dp_backtrack(&backlink, &cumscore);
if params.trim {
beats = trim_beats(&localscore, &beats);
}
(bpm, beats)
}
fn normalize_onsets(onsets: &[f64]) -> Vec<f64> {
let n = onsets.len();
if n <= 1 {
return onsets.to_vec();
}
let mean = onsets.iter().sum::<f64>() / n as f64;
let var = onsets.iter().map(|&x| (x - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
let std = var.sqrt();
let denom = std + tiny();
onsets.iter().map(|&x| x / denom).collect()
}
fn beat_local_score(onset_envelope: &[f64], fpb: usize) -> Vec<f64> {
let n = onset_envelope.len();
if n == 0 {
return Vec::new();
}
let kernel_half = fpb;
let kernel_len = 2 * kernel_half + 1;
let mut kernel = Vec::with_capacity(kernel_len);
let scale = 32.0 / fpb as f64;
for i in 0..kernel_len {
let x = i as f64 - kernel_half as f64;
kernel.push((-0.5 * (x * scale).powi(2)).exp());
}
let ksum: f64 = kernel.iter().sum();
if ksum > 0.0 {
for k in kernel.iter_mut() {
*k /= ksum;
}
}
let mut result = vec![0.0; n];
for i in 0..n {
let mut sum = 0.0;
for (j, &k) in kernel.iter().enumerate() {
let idx = i as isize + j as isize - kernel_half as isize;
if idx >= 0 && (idx as usize) < n {
sum += onset_envelope[idx as usize] * k;
}
}
result[i] = sum;
}
result
}
fn beat_track_dp(localscore: &[f64], fpb: usize, tightness: f64) -> (Vec<i64>, Vec<f64>) {
let n = localscore.len();
let mut cumscore = vec![0.0; n];
let mut backlink = vec![-1i64; n];
if n == 0 {
return (backlink, cumscore);
}
let max_localscore = localscore.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let score_thresh = 0.01 * max_localscore;
let log_fpb = (fpb as f64).ln();
cumscore[0] = localscore[0];
let mut first_beat = true;
for i in 1..n {
let search_start = i.saturating_sub(2 * fpb);
let search_end = i.saturating_sub(fpb / 2);
let mut best_score = f64::NEG_INFINITY;
let mut best_loc: i64 = -1;
for loc in search_start..search_end {
let interval = (i - loc) as f64;
let penalty = tightness * (interval.ln() - log_fpb).powi(2);
let score = cumscore[loc] - penalty;
if score > best_score {
best_score = score;
best_loc = loc as i64;
}
}
if best_loc >= 0 {
cumscore[i] = best_score + localscore[i];
} else {
cumscore[i] = localscore[i];
}
if first_beat && localscore[i] < score_thresh {
backlink[i] = -1;
} else {
backlink[i] = best_loc;
if best_loc >= 0 {
first_beat = false;
}
}
}
(backlink, cumscore)
}
fn dp_backtrack(backlink: &[i64], cumscore: &[f64]) -> Vec<usize> {
let n = cumscore.len();
if n == 0 {
return Vec::new();
}
let maxima: Vec<usize> = (1..n)
.filter(|&i| {
let prev = cumscore[i] > cumscore[i - 1];
let next = if i + 1 < n {
cumscore[i] >= cumscore[i + 1]
} else {
true
};
prev && next
})
.collect();
if maxima.is_empty() {
return Vec::new();
}
let mut max_values: Vec<f64> = maxima.iter().map(|&i| cumscore[i]).collect();
max_values.sort_by(|a, b| a.total_cmp(b));
let median_val = if max_values.len().is_multiple_of(2) {
(max_values[max_values.len() / 2 - 1] + max_values[max_values.len() / 2]) / 2.0
} else {
max_values[max_values.len() / 2]
};
let threshold = 0.5 * median_val;
let tail = match maxima.iter().rev().find(|&&i| cumscore[i] >= threshold) {
Some(&i) => i,
None => return Vec::new(),
};
let mut beats = Vec::new();
let mut pos = tail as i64;
while pos >= 0 {
beats.push(pos as usize);
pos = backlink[pos as usize];
}
beats.reverse();
beats
}
fn trim_beats(localscore: &[f64], beats: &[usize]) -> Vec<usize> {
if beats.is_empty() || localscore.is_empty() {
return beats.to_vec();
}
let rms = (localscore.iter().map(|x| x * x).sum::<f64>() / localscore.len() as f64).sqrt();
let threshold = 0.5 * rms;
let start = beats
.iter()
.position(|&b| localscore[b] >= threshold)
.unwrap_or(beats.len());
let end = beats
.iter()
.rposition(|&b| localscore[b] >= threshold)
.map(|i| i + 1)
.unwrap_or(0);
if start < end {
beats[start..end].to_vec()
} else {
Vec::new()
}
}