//! BPM and musical key detection via stratum-dsp, with minimum-duration gating. use tracing::instrument; /// BPM and key detection results from stratum-dsp. pub struct BpmKeyResult { pub bpm: Option, pub bpm_confidence: Option, pub key: Option, pub key_confidence: Option, } /// Detect BPM and musical key using stratum-dsp's `analyze_audio`. /// /// Skips samples shorter than `min_duration` seconds (caller typically passes 2.0) /// since tempo detection needs enough rhythmic content to find a reliable beat grid. #[instrument(skip_all)] pub fn detect_bpm_key(samples: &[f32], sample_rate: u32, min_duration: f64) -> BpmKeyResult { if sample_rate == 0 { return BpmKeyResult { bpm: None, bpm_confidence: None, key: None, key_confidence: None, }; } let duration = samples.len() as f64 / sample_rate as f64; if duration < min_duration { return BpmKeyResult { bpm: None, bpm_confidence: None, key: None, key_confidence: None, }; } // stratum-dsp handles the heavy lifting (autocorrelation-based BPM, chroma-based key). // On error (corrupt audio, unsupported format), return None for both rather than // propagating — callers treat missing BPM/key as "not detected". let config = stratum_dsp::AnalysisConfig::default(); let result = match stratum_dsp::analyze_audio(samples, sample_rate, config) { Ok(r) => r, Err(_) => { return BpmKeyResult { bpm: None, bpm_confidence: None, key: None, key_confidence: None, } } }; // Filter out implausible BPM values. Musically meaningful tempos range from // ~30 BPM (very slow ambient) to ~300 BPM (extreme speedcore). Values outside // 0-300 are detection artifacts (silence, noise, etc.). let bpm = if result.bpm > 0.0 && result.bpm < 300.0 { Some(result.bpm as f64) } else { None }; let key = { let name = result.key.name(); if name.is_empty() || name == "Unknown" { None } else { Some(name) } }; BpmKeyResult { bpm, bpm_confidence: bpm.map(|_| result.bpm_confidence), key, key_confidence: Some(result.key_confidence), } } #[cfg(test)] mod tests { use super::*; #[test] fn short_sample_returns_none() { let short = vec![0.0f32; 44100]; // 1 second let result = detect_bpm_key(&short, 44100, 2.0); assert!(result.bpm.is_none()); assert!(result.key.is_none()); } #[test] fn silence_bpm() { let silence = vec![0.0f32; 44100 * 4]; let result = detect_bpm_key(&silence, 44100, 2.0); // Silence shouldn't have a meaningful BPM // (stratum may return 0 or garbage — we filter those) if let Some(bpm) = result.bpm { assert!(bpm > 0.0 && bpm < 300.0); } } }