1use std::path::Path;
11
12
13use crate::media;
14
15pub const MIN_BPM: f64 = 60.0;
17pub const MAX_BPM: f64 = 200.0;
18
19const SILENCE: f32 = 0.001;
21const FRAME_RATE: f64 = 100.0;
23const COMB: usize = 4;
25const STEP: f64 = 0.01;
27const MIN_RISE: f64 = 0.05;
29const MIN_PERIODICITY: f64 = 0.15;
31
32pub fn analysed_seconds(duration: f64) -> Option<f64> {
36 if duration > 3600.0 {
37 Some(60.0)
38 } else if duration > 600.0 {
39 Some(120.0)
40 } else if duration > 180.0 {
41 Some(180.0)
42 } else {
43 None
44 }
45}
46
47pub fn bpm(path: &Path, ext: &str) -> Option<f64> {
50 let limit = media::audio_duration(path, ext).and_then(analysed_seconds);
51 let (samples, rate) = mono(path, ext, limit)?;
52 estimate(&samples, rate)
53}
54
55fn mono(path: &Path, ext: &str, seconds: Option<f64>) -> Option<(Vec<f32>, u32)> {
58 let mut source = crate::audio::open(path, ext).ok()?;
59 let rate = source.sample_rate();
60 let max = seconds.map_or(usize::MAX, |s| (s * f64::from(rate)) as usize);
61 let mut out = Vec::new();
62 while out.len() < max {
63 let Some(block) = source.next_block() else { break };
64 out.extend(block.frames().map(|f| f.iter().sum::<f32>() / f.len() as f32));
65 }
66 out.truncate(max);
67 Some((out, rate))
68}
69
70pub fn estimate(samples: &[f32], rate: u32) -> Option<f64> {
73 if rate == 0 || !samples.iter().any(|s| s.abs() >= SILENCE) {
74 return None;
75 }
76 let hop = (f64::from(rate) / FRAME_RATE).round().max(1.0) as usize;
77 let fps = f64::from(rate) / hop as f64;
78 let onset = onset_envelope(samples, hop);
79 let max_lag = (60.0 * fps / MIN_BPM * COMB as f64).ceil() as usize + 2;
80 if onset.len() < 2 * (60.0 * fps / MIN_BPM) as usize + 2 {
82 return None;
83 }
84 let max_lag = max_lag.min(onset.len() - 1);
85 let ac: Vec<f64> = (0..=max_lag).map(|lag| onset.iter().zip(&onset[lag..]).map(|(a, b)| a * b).sum()).collect();
86 if ac[0] <= 0.0 {
87 return None;
88 }
89 let at = |lag: f64| -> Option<f64> {
91 let i = lag.floor() as usize;
92 let t = lag - lag.floor();
93 let (p0, p1, p2, p3) = (*ac.get(i.checked_sub(1)?)?, *ac.get(i)?, *ac.get(i + 1)?, *ac.get(i + 2)?);
94 Some(p1 + 0.5 * t * (p2 - p0 + t * (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3 + t * (3.0 * (p1 - p2) + p3 - p0))))
95 };
96 let mut best: Option<(f64, f64)> = None;
97 let steps = ((MAX_BPM - MIN_BPM) / STEP).round() as usize;
98 for s in 0..=steps {
99 let bpm = MIN_BPM + s as f64 * STEP;
100 let period = 60.0 * fps / bpm;
101 let comb: Vec<f64> = (1..=COMB).map_while(|k| at(period * k as f64)).collect();
102 if comb.is_empty() {
103 continue;
104 }
105 let score = comb.iter().sum::<f64>() / comb.len() as f64 * prior(bpm);
106 if best.is_none_or(|(_, b)| score > b) {
107 best = Some((bpm, score));
108 }
109 }
110 let (bpm, score) = best?;
111 let rises = onset.iter().copied().fold(f64::MIN, f64::max);
114 (rises >= MIN_RISE && score >= MIN_PERIODICITY * ac[0]).then(|| (bpm * 1000.0).round() / 1000.0)
115}
116
117fn onset_envelope(samples: &[f32], hop: usize) -> Vec<f64> {
120 let frames = samples.len() / hop;
121 let level: Vec<f64> = (0..frames)
122 .map(|i| {
123 let w = &samples[i * hop..((i + 2) * hop).min(samples.len())];
124 let rms = (w.iter().map(|s| f64::from(*s) * f64::from(*s)).sum::<f64>() / w.len() as f64).sqrt();
125 (1.0 + 1000.0 * rms).ln()
126 })
127 .collect();
128 let rise: Vec<f64> = level.windows(2).map(|w| (w[1] - w[0]).max(0.0)).collect();
129 let smooth: Vec<f64> = (0..rise.len())
130 .map(|i| {
131 let prev = if i > 0 { rise[i - 1] } else { rise[i] };
132 let next = rise.get(i + 1).copied().unwrap_or(rise[i]);
133 0.25 * prev + 0.5 * rise[i] + 0.25 * next
134 })
135 .collect();
136 let mean = smooth.iter().sum::<f64>() / smooth.len().max(1) as f64;
137 smooth.into_iter().map(|v| v - mean).collect()
138}
139
140fn prior(bpm: f64) -> f64 {
143 let octaves = (bpm / 120.0).log2();
144 (-0.5 * octaves * octaves).exp()
145}
146
147pub fn json_value(bpm: f64) -> serde_json::Value {
149 if bpm.fract() == 0.0 { serde_json::json!(bpm as i64) } else { serde_json::json!(bpm) }
150}
151
152#[cfg(test)]
153mod tests {
154 use super::*;
155
156 fn clicks(bpm: f64, secs: f64) -> impl Fn(f64) -> f64 {
158 let beat = 60.0 / bpm;
159 move |t: f64| {
160 if t > secs {
161 return 0.0;
162 }
163 let since = t % beat;
164 if since < 0.004 { 0.8 * (since * 1500.0 * std::f64::consts::TAU).sin() * (1.0 - since / 0.004) } else { 0.0 }
165 }
166 }
167
168 fn samples(rate: u32, secs: f64, f: impl Fn(f64) -> f64) -> Vec<f32> {
169 (0..(f64::from(rate) * secs) as usize).map(|i| f(i as f64 / f64::from(rate)) as f32).collect()
170 }
171
172 #[test]
173 fn finds_click_tempos() {
174 for want in [60.0, 75.0, 90.0, 120.0, 140.0, 174.0] {
175 let got = estimate(&samples(22050, 20.0, clicks(want, 20.0)), 22050).unwrap();
176 assert!((got - want).abs() <= 1.0, "{want} -> {got}");
177 }
178 }
179
180 #[test]
181 fn silence_and_short_clips_have_no_tempo() {
182 assert_eq!(estimate(&vec![0.0; 22050 * 10], 22050), None);
183 assert_eq!(estimate(&samples(22050, 1.0, clicks(120.0, 1.0)), 22050), None);
184 assert_eq!(estimate(&[], 44100), None);
185 assert_eq!(estimate(&[0.5; 100], 0), None);
186 let tone = samples(22050, 10.0, |t| 0.5 * (t * 440.0 * std::f64::consts::TAU).sin());
188 assert_eq!(estimate(&tone, 22050), None);
189 let mut rng = fastrand::Rng::with_seed(1);
190 let hiss: Vec<f32> = (0..22050 * 10).map(|_| rng.f32() - 0.5).collect();
191 assert_eq!(estimate(&hiss, 22050), None);
192 }
193
194 #[test]
195 fn a_beat_over_a_bed_of_sound() {
196 let mut rng = fastrand::Rng::with_seed(2);
198 let mix: Vec<f32> = (0..22050 * 20)
199 .map(|i| {
200 let t = i as f64 / 22050.0;
201 let (b, e) = (t % 0.5, t % 0.25);
202 let kick = if b < 0.08 { (b * 60.0 * std::f64::consts::TAU).sin() * (1.0 - b / 0.08) } else { 0.0 };
203 let hat = if e < 0.02 { (rng.f64() - 0.5) * 0.4 * (1.0 - e / 0.02) } else { 0.0 };
204 (0.8 * kick + hat + (rng.f64() - 0.5) * 0.1 + 0.2 * (t * 220.0 * std::f64::consts::TAU).sin()) as f32
205 })
206 .collect();
207 let got = estimate(&mix, 22050).unwrap();
208 assert!((got - 120.0).abs() <= 1.0, "{got}");
209 let fast = estimate(&samples(22050, 20.0, clicks(200.0, 20.0)), 22050).unwrap();
211 assert!((fast - 100.0).abs() <= 1.0, "{fast}");
212 }
213
214 #[test]
215 fn long_files_are_sampled_like_eagle() {
216 assert_eq!(analysed_seconds(100.0), None);
217 assert_eq!(analysed_seconds(200.0), Some(180.0));
218 assert_eq!(analysed_seconds(700.0), Some(120.0));
219 assert_eq!(analysed_seconds(4000.0), Some(60.0));
220 assert_eq!(json_value(120.0).to_string(), "120");
221 assert_eq!(json_value(119.985).to_string(), "119.985");
222 }
223
224 #[test]
225 fn reads_a_wav_file() {
226 let dir = tempfile::tempdir().unwrap();
227 let p = dir.path().join("beat.wav");
228 std::fs::write(&p, media::tests::wav(22050, 15.0, clicks(120.0, 15.0))).unwrap();
229 let got = bpm(&p, "wav").unwrap();
230 assert!((got - 120.0).abs() <= 2.0, "{got}");
231 let quiet = dir.path().join("quiet.wav");
232 std::fs::write(&quiet, media::tests::wav(22050, 5.0, |_| 0.0)).unwrap();
233 assert_eq!(bpm(&quiet, "wav"), None);
234 }
235}