Skip to main content

max / audiofiles

3.0 KB · 102 lines History Blame Raw
1 //! BPM and musical key detection via stratum-dsp, with minimum-duration gating.
2
3 use tracing::instrument;
4
5 /// BPM and key detection results from stratum-dsp.
6 pub struct BpmKeyResult {
7 pub bpm: Option<f64>,
8 pub bpm_confidence: Option<f32>,
9 pub key: Option<String>,
10 pub key_confidence: Option<f32>,
11 }
12
13 /// Detect BPM and musical key using stratum-dsp's `analyze_audio`.
14 ///
15 /// Skips samples shorter than `min_duration` seconds (caller typically passes 2.0)
16 /// since tempo detection needs enough rhythmic content to find a reliable beat grid.
17 #[instrument(skip_all)]
18 pub fn detect_bpm_key(samples: &[f32], sample_rate: u32, min_duration: f64) -> BpmKeyResult {
19 if sample_rate == 0 {
20 return BpmKeyResult {
21 bpm: None,
22 bpm_confidence: None,
23 key: None,
24 key_confidence: None,
25 };
26 }
27 let duration = samples.len() as f64 / sample_rate as f64;
28 if duration < min_duration {
29 return BpmKeyResult {
30 bpm: None,
31 bpm_confidence: None,
32 key: None,
33 key_confidence: None,
34 };
35 }
36
37 // stratum-dsp handles the heavy lifting (autocorrelation-based BPM, chroma-based key).
38 // On error (corrupt audio, unsupported format), return None for both rather than
39 // propagating — callers treat missing BPM/key as "not detected".
40 let config = stratum_dsp::AnalysisConfig::default();
41 let result = match stratum_dsp::analyze_audio(samples, sample_rate, config) {
42 Ok(r) => r,
43 Err(_) => {
44 return BpmKeyResult {
45 bpm: None,
46 bpm_confidence: None,
47 key: None,
48 key_confidence: None,
49 }
50 }
51 };
52
53 // Filter out implausible BPM values. Musically meaningful tempos range from
54 // ~30 BPM (very slow ambient) to ~300 BPM (extreme speedcore). Values outside
55 // 0-300 are detection artifacts (silence, noise, etc.).
56 let bpm = if result.bpm > 0.0 && result.bpm < 300.0 {
57 Some(result.bpm as f64)
58 } else {
59 None
60 };
61
62 let key = {
63 let name = result.key.name();
64 if name.is_empty() || name == "Unknown" {
65 None
66 } else {
67 Some(name)
68 }
69 };
70
71 BpmKeyResult {
72 bpm,
73 bpm_confidence: bpm.map(|_| result.bpm_confidence),
74 key,
75 key_confidence: Some(result.key_confidence),
76 }
77 }
78
79 #[cfg(test)]
80 mod tests {
81 use super::*;
82
83 #[test]
84 fn short_sample_returns_none() {
85 let short = vec![0.0f32; 44100]; // 1 second
86 let result = detect_bpm_key(&short, 44100, 2.0);
87 assert!(result.bpm.is_none());
88 assert!(result.key.is_none());
89 }
90
91 #[test]
92 fn silence_bpm() {
93 let silence = vec![0.0f32; 44100 * 4];
94 let result = detect_bpm_key(&silence, 44100, 2.0);
95 // Silence shouldn't have a meaningful BPM
96 // (stratum may return 0 or garbage — we filter those)
97 if let Some(bpm) = result.bpm {
98 assert!(bpm > 0.0 && bpm < 300.0);
99 }
100 }
101 }
102