Skip to main content

falcon_library/
tempo.rs

1//! Tempo detection for audio items, stored at import as `bpm`.
2//!
3//! Eagle runs a beat tracker over the decoded audio (the first minutes of
4//! long files) and stores the tempo it settles on, between 60 and 200 BPM,
5//! with up to three decimals. Falcon estimates the same quantity its own
6//! way: a loudness-rise ("onset") envelope at about 100 frames a second,
7//! whose autocorrelation is scored by a comb of beat periods over 60–200
8//! BPM, with a mild preference for tempos near 120 to settle octave ties.
9
10use std::path::Path;
11
12
13use crate::media;
14
15/// The tempo range searched, in beats per minute.
16pub const MIN_BPM: f64 = 60.0;
17pub const MAX_BPM: f64 = 200.0;
18
19/// Peaks below this (about -60 dBFS) count as silence.
20const SILENCE: f32 = 0.001;
21/// Envelope frames per second (before rounding the hop to whole samples).
22const FRAME_RATE: f64 = 100.0;
23/// Beat-period multiples the comb adds up.
24const COMB: usize = 4;
25/// Search step, in BPM.
26const STEP: f64 = 0.01;
27/// The largest loudness rise (log RMS per frame) must reach this.
28const MIN_RISE: f64 = 0.05;
29/// The comb score must reach this share of the envelope's energy.
30const MIN_PERIODICITY: f64 = 0.15;
31
32/// How much of a file Eagle analyses, by its length in seconds: the first
33/// minute of files over an hour, two minutes past ten minutes, three past
34/// three, otherwise all of it.
35pub 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
47/// The tempo of an audio file in BPM, rounded to three decimals, or `None`
48/// if it can't be decoded or holds no beat (silence, too short).
49pub 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
55/// Decodes the mono mix of an audio file, stopping after `seconds` when
56/// given.
57fn 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
70/// Estimates the tempo of mono `samples` at `rate` Hz, in BPM rounded to
71/// three decimals.
72pub 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    // The longest period has to fit at least twice.
81    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    // Catmull-Rom between lags, so a peak can sit between two of them.
90    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    // No clear rises (a steady tone, a hiss), or no beat standing out
112    // against the envelope's own energy: no tempo.
113    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
117/// Rises in loudness, frame by frame: the log RMS over two hops, its
118/// positive differences, lightly smoothed and with the mean taken out.
119fn 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
140/// A gentle preference for tempos near 120 BPM (a log-normal weight one
141/// octave wide), so that a beat heard at both 60 and 120 reads as 120.
142fn prior(bpm: f64) -> f64 {
143    let octaves = (bpm / 120.0).log2();
144    (-0.5 * octaves * octaves).exp()
145}
146
147/// `bpm` as Eagle's JSON holds it: a whole tempo without a fraction.
148pub 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    /// Clicks (a 4 ms 1.5 kHz burst) at `bpm` over `secs` seconds.
157    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        // A steady tone and a hiss have no beat either.
187        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        // Kicks on the beat, hats on the off-beats, a drone and some hiss.
197        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        // At the top of the range the beat reads an octave down, at 100.
210        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}