pub mod autocorrelation;
pub mod candidate_filter;
pub mod comb_filter;
pub mod peak_picking;
pub mod novelty;
pub mod tempogram_autocorr;
pub mod tempogram_fft;
pub mod tempogram;
pub mod multi_resolution;
pub use autocorrelation::estimate_bpm_from_autocorrelation;
pub use comb_filter::{estimate_bpm_from_comb_filter, coarse_to_fine_search};
pub use candidate_filter::merge_bpm_candidates;
pub use peak_picking::find_peaks;
pub use tempogram::estimate_bpm_tempogram;
pub use multi_resolution::multi_resolution_analysis;
#[derive(Debug, Clone)]
pub struct BpmCandidate {
pub bpm: f32,
pub confidence: f32,
}
#[derive(Debug, Clone)]
pub struct BpmEstimate {
pub bpm: f32,
pub confidence: f32,
pub method_agreement: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct LegacyBpmGuardrails {
pub preferred_min: f32,
pub preferred_max: f32,
pub soft_min: f32,
pub soft_max: f32,
pub mul_preferred: f32,
pub mul_soft: f32,
pub mul_extreme: f32,
}
impl LegacyBpmGuardrails {
fn mul_for(&self, bpm: f32) -> f32 {
if !bpm.is_finite() {
return 0.0;
}
if bpm >= self.preferred_min && bpm <= self.preferred_max {
self.mul_preferred
} else if bpm >= self.soft_min && bpm <= self.soft_max {
self.mul_soft
} else {
self.mul_extreme
}
}
fn clamp_sane(&self) -> Self {
let preferred_min = self.preferred_min.min(self.preferred_max);
let preferred_max = self.preferred_min.max(self.preferred_max);
let soft_min = self.soft_min.min(self.soft_max).min(preferred_min);
let soft_max = self.soft_min.max(self.soft_max).max(preferred_max);
Self {
preferred_min,
preferred_max,
soft_min,
soft_max,
mul_preferred: if self.mul_preferred.is_finite() { self.mul_preferred.max(0.0) } else { 0.0 },
mul_soft: if self.mul_soft.is_finite() { self.mul_soft.max(0.0) } else { 0.0 },
mul_extreme: if self.mul_extreme.is_finite() { self.mul_extreme.max(0.0) } else { 0.0 },
}
}
}
pub fn estimate_bpm(
onsets: &[usize],
sample_rate: u32,
hop_size: usize,
min_bpm: f32,
max_bpm: f32,
bpm_resolution: f32,
) -> Result<Option<BpmEstimate>, crate::error::AnalysisError> {
estimate_bpm_internal(
onsets,
sample_rate,
hop_size,
min_bpm,
max_bpm,
bpm_resolution,
None,
)
}
pub fn estimate_bpm_with_guardrails(
onsets: &[usize],
sample_rate: u32,
hop_size: usize,
min_bpm: f32,
max_bpm: f32,
bpm_resolution: f32,
guardrails: LegacyBpmGuardrails,
) -> Result<Option<BpmEstimate>, crate::error::AnalysisError> {
estimate_bpm_internal(
onsets,
sample_rate,
hop_size,
min_bpm,
max_bpm,
bpm_resolution,
Some(guardrails),
)
}
fn estimate_bpm_internal(
onsets: &[usize],
sample_rate: u32,
hop_size: usize,
min_bpm: f32,
max_bpm: f32,
bpm_resolution: f32,
guardrails: Option<LegacyBpmGuardrails>,
) -> Result<Option<BpmEstimate>, crate::error::AnalysisError> {
use candidate_filter::merge_bpm_candidates;
let autocorr_candidates = autocorrelation::estimate_bpm_from_autocorrelation(
onsets,
sample_rate,
hop_size,
min_bpm,
max_bpm,
)?;
let comb_candidates = comb_filter::estimate_bpm_from_comb_filter(
onsets,
sample_rate,
hop_size,
min_bpm,
max_bpm,
bpm_resolution,
)?;
log::debug!("=== BPM CANDIDATES BEFORE MERGING ===");
log::debug!("Autocorrelation top 5:");
for (i, cand) in autocorr_candidates.iter().take(5).enumerate() {
log::debug!(" {}. {:.2} BPM (confidence: {:.3})", i + 1, cand.bpm, cand.confidence);
}
log::debug!("Comb filter top 5:");
for (i, cand) in comb_candidates.iter().take(5).enumerate() {
log::debug!(" {}. {:.2} BPM (confidence: {:.3})", i + 1, cand.bpm, cand.confidence);
}
let guardrails = guardrails.map(|g| g.clamp_sane());
let (preferred_min, preferred_max) = guardrails
.map(|g| (g.preferred_min, g.preferred_max))
.unwrap_or((60.0, 180.0));
let autocorr_top_preferred = autocorr_candidates
.iter()
.find(|c| c.bpm >= preferred_min && c.bpm <= preferred_max)
.map(|c| c.bpm);
log::debug!(
"Autocorr top preferred BPM before merge (range {:.1}-{:.1}): {:?}",
preferred_min,
preferred_max,
autocorr_top_preferred
);
let merged = merge_bpm_candidates(autocorr_candidates, comb_candidates, 50.0)?;
log::debug!("=== MERGED BPM ESTIMATES ===");
for (i, est) in merged.iter().take(5).enumerate() {
log::debug!(" {}. {:.2} BPM (confidence: {:.3}, agreement: {})",
i + 1, est.bpm, est.confidence, est.method_agreement);
}
let mut merged_vec: Vec<_> = merged.into_iter().collect();
if let Some(g) = guardrails {
for est in merged_vec.iter_mut() {
let mul = g.mul_for(est.bpm);
let before = est.confidence;
est.confidence *= mul;
log::debug!(
"Legacy guardrail multiplier applied: {:.2} BPM (confidence {:.3} * {:.3} -> {:.3})",
est.bpm,
before,
mul,
est.confidence
);
}
merged_vec.sort_by(|a, b| {
b.confidence
.partial_cmp(&a.confidence)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
if let Some(autocorr_top_preferred_bpm) = autocorr_top_preferred {
if let Some(matching_idx) = merged_vec
.iter()
.position(|e| (e.bpm - autocorr_top_preferred_bpm).abs() < 2.0)
{
let matching = merged_vec.remove(matching_idx);
merged_vec.insert(0, matching);
log::debug!(
"Preferring autocorr top preferred result: {:.2} BPM",
autocorr_top_preferred_bpm
);
}
}
let best = merged_vec.first().cloned();
if let Some(ref est) = best {
log::debug!("=== FINAL BPM SELECTION ===");
log::debug!("Winner: {:.2} BPM (confidence: {:.3}, agreement: {})",
est.bpm, est.confidence, est.method_agreement);
if merged_vec.len() > 1 {
log::debug!("Runners-up:");
for (i, cand) in merged_vec.iter().take(4).enumerate().skip(1) {
log::debug!(" {}. {:.2} BPM (confidence: {:.3}, agreement: {})",
i + 1, cand.bpm, cand.confidence, cand.method_agreement);
}
}
let mut reasons = Vec::new();
if let Some(g) = guardrails {
if est.bpm >= g.preferred_min && est.bpm <= g.preferred_max {
reasons.push(format!(
"in preferred range ({:.0}-{:.0} BPM)",
g.preferred_min, g.preferred_max
));
} else if est.bpm >= g.soft_min && est.bpm <= g.soft_max {
reasons.push(format!(
"in soft range ({:.0}-{:.0} BPM)",
g.soft_min, g.soft_max
));
} else {
reasons.push(format!(
"OUTSIDE soft range ({:.0}-{:.0} BPM)",
g.soft_min, g.soft_max
));
}
} else if est.bpm >= 60.0 && est.bpm <= 180.0 {
reasons.push("in reasonable range (60-180 BPM)".to_string());
}
if est.method_agreement >= 5 {
reasons.push(format!("high method agreement ({})", est.method_agreement));
}
if est.confidence > 1.0 {
reasons.push(format!("boosted confidence ({:.3})", est.confidence));
}
if guardrails.is_none() && (est.bpm < 60.0 || est.bpm > 180.0) {
reasons.push("OUT OF RANGE (penalized)".to_string());
}
if !reasons.is_empty() {
log::debug!(" → Won because: {}", reasons.join(", "));
} else {
log::debug!(" → Won: highest confidence");
}
}
Ok(best)
}