use super::BpmCandidate;
use rustfft::FftPlanner;
use rustfft::num_complex::Complex;
const EPSILON: f32 = 1e-10;
pub fn estimate_bpm_from_autocorrelation(
onsets: &[usize],
sample_rate: u32,
hop_size: usize,
min_bpm: f32,
max_bpm: f32,
) -> Result<Vec<BpmCandidate>, crate::error::AnalysisError> {
log::debug!(
"Estimating BPM from autocorrelation: {} onsets, {} Hz, hop={}, range=[{:.1}, {:.1}] BPM",
onsets.len(),
sample_rate,
hop_size,
min_bpm,
max_bpm
);
if onsets.is_empty() {
return Err(crate::error::AnalysisError::InvalidInput(
"Empty onset list".to_string(),
));
}
if sample_rate == 0 {
return Err(crate::error::AnalysisError::InvalidInput(
"Invalid sample rate: 0".to_string(),
));
}
if hop_size == 0 {
return Err(crate::error::AnalysisError::InvalidInput(
"Invalid hop size: 0".to_string(),
));
}
if min_bpm <= 0.0 || max_bpm <= 0.0 || min_bpm >= max_bpm {
return Err(crate::error::AnalysisError::InvalidInput(format!(
"Invalid BPM range: [{:.1}, {:.1}]",
min_bpm, max_bpm
)));
}
if onsets.len() < 2 {
log::warn!("Too few onsets for autocorrelation: {}", onsets.len());
return Ok(vec![]);
}
let max_frame = onsets.iter().max().copied().unwrap_or(0) / hop_size;
let signal_length = max_frame + 1;
if signal_length < 2 {
return Err(crate::error::AnalysisError::ProcessingError(
"Signal too short for autocorrelation".to_string(),
));
}
let mut beat_signal = vec![0.0f32; signal_length];
for &onset_sample in onsets {
let frame_idx = onset_sample / hop_size;
if frame_idx < signal_length {
beat_signal[frame_idx] = 1.0;
}
}
let acf = compute_autocorrelation_fft(&beat_signal)?;
let lag_min = ((60.0 * sample_rate as f32) / (max_bpm * hop_size as f32)).ceil() as usize;
let lag_max = ((60.0 * sample_rate as f32) / (min_bpm * hop_size as f32)).floor() as usize;
if lag_min >= lag_max || lag_min >= acf.len() || lag_max >= acf.len() {
log::warn!(
"Invalid lag range: [{}, {}] for ACF length {}",
lag_min,
lag_max,
acf.len()
);
return Ok(vec![]);
}
let peaks = find_peaks_in_acf(&acf[lag_min..=lag_max], lag_min)?;
let mut candidates = Vec::new();
for (lag, value) in peaks {
let bpm = (60.0 * sample_rate as f32) / ((lag as f32) * (hop_size as f32));
if bpm >= min_bpm && bpm <= max_bpm {
let max_acf = acf.iter().copied().fold(0.0f32, f32::max);
let confidence = if max_acf > EPSILON {
(value / max_acf).min(1.0)
} else {
0.0
};
candidates.push(BpmCandidate { bpm, confidence });
}
}
candidates.sort_by(|a, b| b.confidence.partial_cmp(&a.confidence).unwrap_or(std::cmp::Ordering::Equal));
log::debug!(
"Autocorrelation found {} BPM candidates",
candidates.len()
);
Ok(candidates)
}
fn compute_autocorrelation_fft(signal: &[f32]) -> Result<Vec<f32>, crate::error::AnalysisError> {
let n = signal.len();
let fft_size = (2 * n).next_power_of_two();
let mut fft_input: Vec<Complex<f32>> = signal
.iter()
.map(|&x| Complex::new(x, 0.0))
.collect();
fft_input.resize(fft_size, Complex::new(0.0, 0.0));
let mut planner = FftPlanner::new();
let fft = planner.plan_fft_forward(fft_size);
fft.process(&mut fft_input);
for x in &mut fft_input {
*x = *x * x.conj();
}
let ifft = planner.plan_fft_inverse(fft_size);
ifft.process(&mut fft_input);
let scale = 1.0 / (fft_size as f32);
let acf: Vec<f32> = fft_input[..n]
.iter()
.map(|x| (x.re * scale).max(0.0)) .collect();
Ok(acf)
}
fn find_peaks_in_acf(
acf_slice: &[f32],
offset: usize,
) -> Result<Vec<(usize, f32)>, crate::error::AnalysisError> {
if acf_slice.is_empty() {
return Ok(vec![]);
}
let max_value = acf_slice.iter().copied().fold(0.0f32, f32::max);
if max_value < EPSILON {
return Ok(vec![]);
}
let min_prominence = max_value * 0.1;
let min_distance = 2;
let mut peaks: Vec<(usize, f32)> = Vec::new();
for i in 1..(acf_slice.len() - 1) {
let value = acf_slice[i];
if value > acf_slice[i - 1] && value > acf_slice[i + 1] {
let left_val = acf_slice[i - 1];
let right_val = acf_slice[i + 1];
let prominence = value - (left_val.max(right_val));
if prominence >= min_prominence {
let lag = i + offset;
if peaks.is_empty() || (lag as i32 - peaks.last().unwrap().0 as i32).abs() >= min_distance as i32 {
peaks.push((lag, value));
} else {
let last_idx = peaks.len() - 1;
if value > peaks[last_idx].1 {
peaks[last_idx] = (lag, value);
}
}
}
}
}
peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
Ok(peaks)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_autocorrelation_basic_120bpm() {
let sample_rate = 44100;
let hop_size = 512;
let bpm = 120.0;
let period_samples = (60.0 * sample_rate as f32) / bpm;
let period_frames = (period_samples / hop_size as f32).round() as usize;
let mut onsets = Vec::new();
for beat in 0..4 {
let frame = beat * period_frames;
let sample = frame * hop_size;
onsets.push(sample);
}
let candidates = estimate_bpm_from_autocorrelation(
&onsets,
sample_rate,
hop_size,
60.0,
180.0,
)
.unwrap();
assert!(!candidates.is_empty(), "Should find at least one candidate");
let best = &candidates[0];
assert!(
(best.bpm - 120.0).abs() < 5.0,
"Best BPM should be close to 120, got {:.2}",
best.bpm
);
assert!(best.confidence > 0.0, "Confidence should be positive");
}
#[test]
fn test_autocorrelation_empty_onsets() {
let result = estimate_bpm_from_autocorrelation(&[], 44100, 512, 60.0, 180.0);
assert!(result.is_err());
}
#[test]
fn test_autocorrelation_single_onset() {
let onsets = vec![1000];
let result = estimate_bpm_from_autocorrelation(&onsets, 44100, 512, 60.0, 180.0);
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[test]
fn test_autocorrelation_invalid_params() {
let onsets = vec![1000, 2000];
let result = estimate_bpm_from_autocorrelation(&onsets, 0, 512, 60.0, 180.0);
assert!(result.is_err());
let result = estimate_bpm_from_autocorrelation(&onsets, 44100, 0, 60.0, 180.0);
assert!(result.is_err());
let result = estimate_bpm_from_autocorrelation(&onsets, 44100, 512, 180.0, 60.0);
assert!(result.is_err());
}
#[test]
fn test_autocorrelation_128bpm() {
let sample_rate = 44100;
let hop_size = 512;
let bpm = 128.0;
let period_samples = (60.0 * sample_rate as f32) / bpm;
let period_frames = (period_samples / hop_size as f32).round() as usize;
let mut onsets = Vec::new();
for beat in 0..4 {
let frame = beat * period_frames;
let sample = frame * hop_size;
onsets.push(sample);
}
let candidates = estimate_bpm_from_autocorrelation(
&onsets,
sample_rate,
hop_size,
60.0,
180.0,
)
.unwrap();
assert!(!candidates.is_empty());
let best = &candidates[0];
assert!(
(best.bpm - 128.0).abs() < 5.0,
"Best BPM should be close to 128, got {:.2}",
best.bpm
);
}
#[test]
fn test_compute_autocorrelation_fft() {
let signal = vec![1.0, 0.0, 1.0, 0.0, 1.0, 0.0];
let acf = compute_autocorrelation_fft(&signal).unwrap();
assert_eq!(acf.len(), signal.len());
assert!(acf[0] > 0.0);
if acf.len() > 2 {
assert!(acf[2] > 0.0);
}
}
#[test]
fn test_find_peaks_in_acf() {
let acf = vec![0.1, 0.2, 0.5, 0.3, 0.4, 0.6, 0.2, 0.1];
let peaks = find_peaks_in_acf(&acf, 0).unwrap();
assert!(!peaks.is_empty());
assert!(peaks.iter().any(|(idx, _)| *idx == 2 || *idx == 5));
}
}