Skip to main content

ferraus_analysis/
tempo.rs

1//! Tempo and beat tracking.
2//!
3//! 1. Onset strength: spectral flux on a log-mel spectrogram (10 ms hop), local mean
4//!    removed, normalised.
5//! 2. Tempo: autocorrelation of the onset envelope scored on a comb of metrical levels
6//!    (τ, 2τ, 3τ) and weighted by a log-normal perceptual prior around 120 BPM.
7//! 3. Beats: dynamic-programming beat tracking (Ellis 2007), then the BPM is refined by a
8//!    least-squares fit through the beat times.
9//! 4. Meter and downbeats: per-beat accents (low-frequency onset energy and overall onset
10//!    strength) are folded at 3 and 4 beats per bar; subdivision energy at thirds versus
11//!    halves of the beat separates 6/8 from 4/4.
12
13use crate::util::{self, mel_filterbank, next_pow2};
14use ferraus_core::AudioBuffer;
15use serde::Serialize;
16
17/// Options for [`detect_tempo`].
18#[derive(Clone, Debug)]
19pub struct TempoOptions {
20    /// Lowest tempo considered (BPM).
21    pub min_bpm: f64,
22    /// Highest tempo considered (BPM).
23    pub max_bpm: f64,
24    /// Centre of the perceptual tempo prior (BPM).
25    pub prior_bpm: f64,
26    /// Width of the prior in octaves.
27    pub prior_octaves: f64,
28    /// How strongly beats keep to the tempo (Ellis' α).
29    pub tightness: f64,
30}
31
32impl Default for TempoOptions {
33    fn default() -> Self {
34        TempoOptions { min_bpm: 60.0, max_bpm: 200.0, prior_bpm: 120.0, prior_octaves: 1.0, tightness: 100.0 }
35    }
36}
37
38/// A tempo candidate and its normalised score.
39#[derive(Clone, Debug, Serialize)]
40pub struct TempoCandidate {
41    pub bpm: f64,
42    pub score: f64,
43}
44
45/// Result of [`detect_tempo`].
46#[derive(Clone, Debug, Serialize)]
47pub struct TempoResult {
48    /// Tempo in beats per minute.
49    pub bpm: f64,
50    /// 0..1: how clearly beats line up with onsets and how regular they are.
51    pub confidence: f64,
52    /// Beat times in seconds.
53    pub beats: Vec<f64>,
54    /// Downbeat (bar start) times in seconds; empty when the bar phase is not confident.
55    pub downbeats: Vec<f64>,
56    /// 0..1 confidence of the downbeat phase.
57    pub downbeat_confidence: f64,
58    /// `4/4`, `3/4`, or `6/8` (best guess).
59    pub time_signature: String,
60    /// 0..1 confidence of the time signature guess.
61    pub time_signature_confidence: f64,
62    /// Other plausible tempi (metrical levels), best first.
63    pub alternatives: Vec<TempoCandidate>,
64}
65
66impl TempoResult {
67    fn empty() -> TempoResult {
68        TempoResult {
69            bpm: 0.0,
70            confidence: 0.0,
71            beats: Vec::new(),
72            downbeats: Vec::new(),
73            downbeat_confidence: 0.0,
74            time_signature: "4/4".into(),
75            time_signature_confidence: 0.0,
76            alternatives: Vec::new(),
77        }
78    }
79}
80
81/// Onset strength envelope and a low-frequency onset band, both at `frame_rate` Hz.
82/// Frame `i` is centred at time `i / frame_rate` seconds.
83#[derive(Clone, Debug)]
84pub struct OnsetEnvelope {
85    pub frame_rate: f64,
86    /// Normalised onset strength (unit standard deviation, local mean removed, ≥ 0).
87    pub values: Vec<f32>,
88    /// Onset strength of the bands below ~200 Hz (kick-drum accents), same normalisation.
89    pub low: Vec<f32>,
90    /// Level of the bands below ~200 Hz per frame in dB (for accent and downbeat cues).
91    pub low_level_db: Vec<f32>,
92}
93
94/// Compute the onset strength envelope of a buffer (mono mix).
95pub fn onset_envelope(buf: &AudioBuffer) -> OnsetEnvelope {
96    let sr = buf.sample_rate as f64;
97    let x = buf.mono_mix();
98    let hop = ((sr / 100.0).round() as usize).max(1);
99    let frame_rate = sr / hop as f64;
100    if x.len() < hop * 4 {
101        return OnsetEnvelope {
102            frame_rate,
103            values: vec![0.0; x.len() / hop + 1],
104            low: vec![0.0; x.len() / hop + 1],
105            low_level_db: vec![-80.0; x.len() / hop + 1],
106        };
107    }
108    let n_fft = next_pow2((sr * 0.023) as usize).max(256);
109    let n_mels = 40;
110    let bank = mel_filterbank(sr, n_fft, n_mels, 30.0, 8000.0_f64.min(sr / 2.0));
111    let low_bands = (0..n_mels)
112        .filter(|m| {
113            let c = bank[*m].iter().map(|(k, _)| *k).max().unwrap_or(0) as f64 * sr / n_fft as f64;
114            c < 200.0
115        })
116        .count()
117        .max(1);
118    let frames = util::stft_mag(&x, n_fft, hop);
119    // log-mel in dB, floored 80 dB below the loudest value
120    let mut mel: Vec<Vec<f32>> = frames
121        .iter()
122        .map(|f| {
123            let p: Vec<f32> = f.iter().map(|m| m * m).collect();
124            util::apply_bank(&bank, &p).into_iter().map(|e| 10.0 * (e.max(1e-12)).log10()).collect()
125        })
126        .collect();
127    let top = mel.iter().flat_map(|f| f.iter()).fold(f32::NEG_INFINITY, |m, v| m.max(*v));
128    for f in mel.iter_mut() {
129        for v in f.iter_mut() {
130            *v = v.max(top - 80.0);
131        }
132    }
133    let n = mel.len();
134    let low_level_db: Vec<f32> = mel
135        .iter()
136        .map(|f| {
137            let e: f32 = f[..low_bands].iter().map(|db| 10f32.powf(db / 10.0)).sum();
138            10.0 * e.max(1e-12).log10() - top
139        })
140        .collect();
141    let mut flux = vec![0.0f32; n];
142    let mut low = vec![0.0f32; n];
143    for t in 1..n {
144        let mut s = 0.0;
145        let mut sl = 0.0;
146        for (m, (cur, prev)) in mel[t].iter().zip(&mel[t - 1]).enumerate() {
147            let d = (cur - prev).max(0.0);
148            s += d;
149            if m < low_bands {
150                sl += d;
151            }
152        }
153        flux[t] = s / n_mels as f32;
154        low[t] = sl / low_bands as f32;
155    }
156    let values = normalise_onset(&flux, frame_rate);
157    let low = normalise_onset(&low, frame_rate);
158    OnsetEnvelope { frame_rate, values, low, low_level_db }
159}
160
161/// Subtract a 1 s moving average, half-wave rectify, and scale to unit std.
162fn normalise_onset(x: &[f32], frame_rate: f64) -> Vec<f32> {
163    let n = x.len();
164    let w = (frame_rate * 0.5).round() as usize;
165    let mut cs = vec![0.0f64; n + 1];
166    for i in 0..n {
167        cs[i + 1] = cs[i] + x[i] as f64;
168    }
169    let mut out: Vec<f32> = (0..n)
170        .map(|i| {
171            let a = i.saturating_sub(w);
172            let b = (i + w + 1).min(n);
173            let m = (cs[b] - cs[a]) / (b - a) as f64;
174            (x[i] as f64 - m).max(0.0) as f32
175        })
176        .collect();
177    let v: Vec<f64> = out.iter().map(|v| *v as f64).collect();
178    let sd = util::std_dev(&v);
179    if sd > 1e-9 {
180        for o in out.iter_mut() {
181            *o = (*o as f64 / sd) as f32;
182        }
183    }
184    out
185}
186
187/// Normalised autocorrelation of the onset envelope for lags `0..=max_lag`.
188fn autocorr(x: &[f32], max_lag: usize) -> Vec<f64> {
189    let n = x.len();
190    let m = x.iter().map(|v| *v as f64).sum::<f64>() / n.max(1) as f64;
191    let y: Vec<f64> = x.iter().map(|v| *v as f64 - m).collect();
192    let mut ac = vec![0.0f64; max_lag + 1];
193    for (lag, a) in ac.iter_mut().enumerate() {
194        if lag >= n {
195            break;
196        }
197        let s: f64 = y[..n - lag].iter().zip(&y[lag..]).map(|(a, b)| a * b).sum();
198        *a = s / (n - lag) as f64;
199    }
200    let a0 = ac[0];
201    if a0 > 0.0 {
202        for a in ac.iter_mut() {
203            *a /= a0;
204        }
205    }
206    ac
207}
208
209fn interp(v: &[f64], pos: f64) -> f64 {
210    if pos < 0.0 {
211        return 0.0;
212    }
213    let i = pos.floor() as usize;
214    if i + 1 >= v.len() {
215        return 0.0;
216    }
217    let f = pos - i as f64;
218    v[i] * (1.0 - f) + v[i + 1] * f
219}
220
221/// Detect tempo, beats, meter, and downbeats.
222pub fn detect_tempo(buf: &AudioBuffer, opts: &TempoOptions) -> TempoResult {
223    let env = onset_envelope(buf);
224    detect_tempo_from_onsets(&env, opts)
225}
226
227/// Tempo analysis from a precomputed onset envelope.
228pub fn detect_tempo_from_onsets(env: &OnsetEnvelope, opts: &TempoOptions) -> TempoResult {
229    let fr = env.frame_rate;
230    let o = &env.values;
231    let n = o.len();
232    let (min_bpm, max_bpm) = (opts.min_bpm.max(20.0).min(opts.max_bpm), opts.max_bpm.max(opts.min_bpm));
233    if n < (fr * 1.5) as usize || o.iter().all(|v| *v <= 0.0) {
234        return TempoResult::empty();
235    }
236    // lags up to three bar-level periods of the slowest tempo considered by the comb
237    let max_lag = ((60.0 * fr / (min_bpm / 3.0)).ceil() as usize).min(n - 1);
238    let ac = autocorr(o, max_lag);
239    let prior = |bpm: f64| -> f64 {
240        let z = (bpm / opts.prior_bpm).log2() / opts.prior_octaves;
241        (-0.5 * z * z).exp()
242    };
243    let tps = |bpm: f64| -> f64 {
244        let lag = 60.0 * fr / bpm;
245        // take the best of the interpolated value and its neighbours (period jitter)
246        let v = interp(&ac, lag).max(interp(&ac, lag - 0.5)).max(interp(&ac, lag + 0.5));
247        prior(bpm) * v.max(0.0)
248    };
249    let score = |bpm: f64| -> f64 { tps(bpm) + 0.5 * tps(bpm / 2.0) + 0.25 * tps(bpm / 3.0) };
250    let step = 0.25;
251    let mut grid = Vec::new();
252    let mut b = min_bpm;
253    while b <= max_bpm + 1e-9 {
254        grid.push((b, score(b)));
255        b += step;
256    }
257    let best_score = grid.iter().map(|g| g.1).fold(0.0, f64::max);
258    if best_score <= 0.0 {
259        return TempoResult::empty();
260    }
261    // local maxima as candidates
262    let mut peaks: Vec<(f64, f64)> = (0..grid.len())
263        .filter(|&i| {
264            let l = if i > 0 { grid[i - 1].1 } else { f64::NEG_INFINITY };
265            let r = if i + 1 < grid.len() { grid[i + 1].1 } else { f64::NEG_INFINITY };
266            grid[i].1 >= l && grid[i].1 > r
267        })
268        .map(|i| grid[i])
269        .collect();
270    peaks.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
271    let coarse_bpm = peaks.first().map(|p| p.0).unwrap_or(120.0);
272
273    let period = 60.0 * fr / coarse_bpm;
274    let beats_f = track_beats(o, period, opts.tightness);
275    if beats_f.len() < 2 {
276        return TempoResult::empty();
277    }
278    // timing offset: the flux peak sits slightly before the perceptual onset
279    let bias = 0.0;
280    let beats: Vec<f64> = beats_f.iter().map(|b| *b as f64 / fr + bias).collect();
281
282    // refine tempo by least squares through beat times
283    let bpm = refine_bpm(&beats).unwrap_or(coarse_bpm);
284
285    // confidence: onset salience at beats, periodicity, and interval regularity
286    let lm: Vec<f64> = (0..n).map(|i| local_max(o, i, 1) as f64).collect();
287    let mean_all = util::mean(&lm);
288    let at_beats = beats_f.iter().map(|&b| lm[b]).sum::<f64>() / beats_f.len() as f64;
289    let salience = if mean_all > 0.0 { ((at_beats / mean_all - 1.0) / 2.0).clamp(0.0, 1.0) } else { 0.0 };
290    let lag = 60.0 * fr / bpm;
291    let periodicity = (interp(&ac, lag).max(interp(&ac, lag - 0.5)).max(interp(&ac, lag + 0.5)) / 0.5).clamp(0.0, 1.0);
292    let ibis: Vec<f64> = beats.windows(2).map(|w| w[1] - w[0]).collect();
293    let mean_ibi = util::mean(&ibis);
294    let regularity = if mean_ibi > 0.0 { (-(util::std_dev(&ibis) / mean_ibi) / 0.1).exp() } else { 0.0 };
295    let confidence = ((salience * periodicity).sqrt() * regularity).clamp(0.0, 1.0);
296
297    let alternatives: Vec<TempoCandidate> = peaks
298        .iter()
299        .filter(|p| (p.0 - coarse_bpm).abs() > 3.0)
300        .take(3)
301        .map(|p| TempoCandidate { bpm: p.0, score: p.1 / best_score })
302        .collect();
303
304    let meter = analyse_meter(env, &beats_f, 60.0 * fr / bpm);
305    let downbeats = if meter.downbeat_confidence >= 0.35 && confidence > 0.3 {
306        beats.iter().enumerate().filter(|(i, _)| i % meter.beats_per_bar == meter.phase).map(|(_, t)| *t).collect()
307    } else {
308        Vec::new()
309    };
310    TempoResult {
311        bpm,
312        confidence,
313        beats,
314        downbeats,
315        downbeat_confidence: meter.downbeat_confidence,
316        time_signature: meter.signature,
317        time_signature_confidence: meter.confidence,
318        alternatives,
319    }
320}
321
322fn local_max(x: &[f32], i: usize, r: usize) -> f32 {
323    let a = i.saturating_sub(r);
324    let b = (i + r + 1).min(x.len());
325    x[a..b].iter().fold(0.0f32, |m, v| m.max(*v))
326}
327
328/// Least-squares tempo through beat times (robust to a few missing beats by using the
329/// median interval to index beats).
330fn refine_bpm(beats: &[f64]) -> Option<f64> {
331    if beats.len() < 4 {
332        return None;
333    }
334    let ibis: Vec<f64> = beats.windows(2).map(|w| w[1] - w[0]).collect();
335    let med = util::median(&ibis);
336    if med <= 0.0 {
337        return None;
338    }
339    // beat indices accounting for gaps
340    let mut idx = vec![0.0f64];
341    for w in ibis.iter() {
342        let k = (w / med).round().max(1.0);
343        idx.push(idx.last().unwrap() + k);
344    }
345    let n = beats.len() as f64;
346    let mx = idx.iter().sum::<f64>() / n;
347    let my = beats.iter().sum::<f64>() / n;
348    let (mut sxy, mut sxx) = (0.0, 0.0);
349    for (x, y) in idx.iter().zip(beats) {
350        sxy += (x - mx) * (y - my);
351        sxx += (x - mx) * (x - mx);
352    }
353    if sxx <= 0.0 {
354        return None;
355    }
356    let slope = sxy / sxx;
357    if slope <= 0.0 {
358        None
359    } else {
360        Some(60.0 / slope)
361    }
362}
363
364/// Ellis (2007) dynamic-programming beat tracker. Returns beat frame indices.
365fn track_beats(onset: &[f32], period: f64, tightness: f64) -> Vec<usize> {
366    let n = onset.len();
367    if period < 1.0 || n < 2 {
368        return Vec::new();
369    }
370    // local score: onset smoothed by a Gaussian of std period/32
371    let sigma = (period / 32.0).max(0.5);
372    let half = (sigma * 4.0).ceil() as isize;
373    let kernel: Vec<f64> = (-half..=half).map(|k| (-0.5 * (k as f64 / sigma).powi(2)).exp()).collect();
374    let local: Vec<f64> = (0..n as isize)
375        .map(|i| {
376            let mut s = 0.0;
377            for (j, w) in kernel.iter().enumerate() {
378                let idx = i + j as isize - half;
379                if idx >= 0 && (idx as usize) < n {
380                    s += onset[idx as usize] as f64 * w;
381                }
382            }
383            s
384        })
385        .collect();
386    let lo = (period / 2.0).round() as usize;
387    let hi = (2.0 * period).round() as usize;
388    let mut cum = vec![0.0f64; n];
389    let mut back = vec![usize::MAX; n];
390    // precompute transition costs for each distance
391    let cost: Vec<f64> = (0..=hi)
392        .map(|d| if d == 0 { f64::NEG_INFINITY } else { -tightness * ((d as f64 / period).ln()).powi(2) })
393        .collect();
394    for t in 0..n {
395        let mut best = f64::NEG_INFINITY;
396        let mut arg = usize::MAX;
397        if t >= lo {
398            let start = t.saturating_sub(hi);
399            for tau in start..=t - lo.max(1) {
400                let v = cum[tau] + cost[t - tau];
401                if v > best {
402                    best = v;
403                    arg = tau;
404                }
405            }
406        }
407        // chain onto the best predecessor when that beats starting fresh
408        if arg != usize::MAX && best > 0.0 {
409            cum[t] = local[t] + best;
410            back[t] = arg;
411        } else {
412            cum[t] = local[t];
413        }
414    }
415    // last beat: the latest local maximum of the cumulative score above half the median
416    let maxima: Vec<usize> =
417        (1..n.saturating_sub(1)).filter(|&i| cum[i] > cum[i - 1] && cum[i] >= cum[i + 1]).collect();
418    if maxima.is_empty() {
419        return Vec::new();
420    }
421    let vals: Vec<f64> = maxima.iter().map(|&i| cum[i]).collect();
422    let med = util::median(&vals);
423    // search only the final two periods for the end point
424    let tail_start = n.saturating_sub((2.0 * period) as usize + 1);
425    let mut last = *maxima.iter().rev().find(|&&i| cum[i] >= 0.5 * med).unwrap_or(maxima.last().unwrap());
426    let mut best_tail = f64::NEG_INFINITY;
427    for &i in maxima.iter().filter(|&&i| i >= tail_start) {
428        if cum[i] > best_tail {
429            best_tail = cum[i];
430            last = i;
431        }
432    }
433    let mut beats = vec![last];
434    let mut cur = last;
435    while back[cur] != usize::MAX {
436        cur = back[cur];
437        beats.push(cur);
438    }
439    beats.reverse();
440    trim_weak_beats(&local, beats)
441}
442
443/// Drop leading and trailing beats whose local onset score is far below typical.
444fn trim_weak_beats(local: &[f64], mut beats: Vec<usize>) -> Vec<usize> {
445    if beats.len() < 3 {
446        return beats;
447    }
448    let vals: Vec<f64> = beats.iter().map(|&b| local[b]).collect();
449    let rms = (vals.iter().map(|v| v * v).sum::<f64>() / vals.len() as f64).sqrt();
450    let thr = 0.5 * rms;
451    while beats.len() > 2 && local[beats[0]] < thr {
452        beats.remove(0);
453    }
454    while beats.len() > 2 && local[*beats.last().unwrap()] < thr {
455        beats.pop();
456    }
457    beats
458}
459
460struct Meter {
461    signature: String,
462    beats_per_bar: usize,
463    phase: usize,
464    confidence: f64,
465    downbeat_confidence: f64,
466}
467
468fn analyse_meter(env: &OnsetEnvelope, beats: &[usize], period: f64) -> Meter {
469    let default =
470        Meter { signature: "4/4".into(), beats_per_bar: 4, phase: 0, confidence: 0.0, downbeat_confidence: 0.0 };
471    if beats.len() < 8 {
472        return default;
473    }
474    let r = ((period / 8.0).round() as usize).max(1);
475    // accents: low-band onset dominates (kick drums), plus overall onset
476    let zscore = |v: Vec<f64>| -> Vec<f64> {
477        let m = util::mean(&v);
478        let sd = util::std_dev(&v).max(1e-9);
479        v.into_iter().map(|x| (x - m) / sd).collect()
480    };
481    let level = zscore(
482        beats
483            .iter()
484            .map(|&b| {
485                let a = b.saturating_sub(1);
486                let e = (b + r + 1).min(env.low_level_db.len());
487                env.low_level_db[a..e].iter().fold(-200.0f32, |m, v| m.max(*v)) as f64
488            })
489            .collect(),
490    );
491    let strength = zscore(beats.iter().map(|&b| local_max(&env.values, b, r) as f64).collect());
492    let acc: Vec<f64> = level.iter().zip(&strength).map(|(l, s)| 0.7 * l + 0.3 * s).collect();
493    let sd = util::std_dev(&acc).max(1e-9);
494    let fold = |m: usize| -> Vec<f64> {
495        (0..m)
496            .map(|phi| {
497                let (mut on, mut non, mut off, mut noff) = (0.0, 0usize, 0.0, 0usize);
498                for (i, a) in acc.iter().enumerate() {
499                    if i % m == phi {
500                        on += a;
501                        non += 1;
502                    } else {
503                        off += a;
504                        noff += 1;
505                    }
506                }
507                if non == 0 || noff == 0 {
508                    0.0
509                } else {
510                    (on / non as f64 - off / noff as f64) / sd
511                }
512            })
513            .collect()
514    };
515    let c3 = fold(3);
516    let c4 = fold(4);
517    let best = |c: &[f64]| -> (usize, f64, f64) {
518        let mut idx: Vec<usize> = (0..c.len()).collect();
519        idx.sort_by(|a, b| c[*b].partial_cmp(&c[*a]).unwrap_or(std::cmp::Ordering::Equal));
520        (idx[0], c[idx[0]], c[idx[1]])
521    };
522    let (p3, s3, s3b) = best(&c3);
523    let (p4, s4, s4b) = best(&c4);
524    // subdivision: thirds versus halves of the beat
525    let o = &env.values;
526    let (mut tern, mut bin, mut cnt) = (0.0f64, 0.0f64, 0usize);
527    for w in beats.windows(2) {
528        let (a, b) = (w[0] as f64, w[1] as f64);
529        let d = b - a;
530        if d < 3.0 {
531            continue;
532        }
533        let at = |pos: f64| local_max(o, pos.round() as usize, 1) as f64;
534        tern += 0.5 * (at(a + d / 3.0) + at(a + 2.0 * d / 3.0));
535        bin += at(a + d / 2.0);
536        cnt += 1;
537    }
538    let ternary = cnt > 0 && tern > 1.5 * bin && tern / cnt as f64 > 0.3;
539    let (m, phase, s, sb, other) = if s3 > s4 * 1.15 { (3usize, p3, s3, s3b, s4) } else { (4usize, p4, s4, s4b, s3) };
540    let confidence = if s > 0.0 { ((s - other.max(0.0)) / s).clamp(0.0, 1.0) } else { 0.0 };
541    let downbeat_confidence = if s > 0.0 { ((s - sb.max(0.0)) / s).clamp(0.0, 1.0) * (s / 1.5).min(1.0) } else { 0.0 };
542    let signature = if ternary && m == 4 {
543        "6/8".to_string()
544    } else if m == 3 {
545        "3/4".to_string()
546    } else {
547        "4/4".to_string()
548    };
549    Meter { signature, beats_per_bar: m, phase, confidence, downbeat_confidence }
550}
551
552/// Onsets per second: peaks of the onset envelope above an adaptive threshold.
553pub fn transient_density(buf: &AudioBuffer) -> f64 {
554    let env = onset_envelope(buf);
555    let o = &env.values;
556    if o.len() < 3 || buf.duration_s() <= 0.0 {
557        return 0.0;
558    }
559    let mut count = 0usize;
560    let min_gap = (env.frame_rate * 0.05) as usize;
561    let mut last: Option<usize> = None;
562    for i in 1..o.len() - 1 {
563        if o[i] > 1.5 && o[i] >= o[i - 1] && o[i] > o[i + 1] {
564            if let Some(l) = last {
565                if i - l < min_gap {
566                    continue;
567                }
568            }
569            count += 1;
570            last = Some(i);
571        }
572    }
573    count as f64 / buf.duration_s()
574}
575
576#[cfg(test)]
577pub(crate) mod tests {
578    use super::*;
579    use ferraus_core::testing;
580    use std::f64::consts::PI;
581
582    /// Simple deterministic RNG for fixtures.
583    pub(crate) struct Rng(u64);
584    impl Rng {
585        pub(crate) fn new(seed: u64) -> Rng {
586            Rng(seed.wrapping_mul(0x9E3779B97F4A7C15) | 1)
587        }
588        pub(crate) fn next(&mut self) -> f64 {
589            self.0 ^= self.0 << 13;
590            self.0 ^= self.0 >> 7;
591            self.0 ^= self.0 << 17;
592            (self.0 >> 11) as f64 / (1u64 << 53) as f64
593        }
594        pub(crate) fn sym(&mut self) -> f64 {
595            self.next() * 2.0 - 1.0
596        }
597    }
598
599    fn add_kick(x: &mut [f32], sr: f64, t: f64, amp: f64) {
600        let s = (t * sr) as usize;
601        let len = (0.25 * sr) as usize;
602        let mut ph = 0.0;
603        for i in 0..len {
604            if s + i >= x.len() {
605                break;
606            }
607            let tt = i as f64 / sr;
608            let f = 50.0 + 100.0 * (-tt / 0.03).exp();
609            ph += 2.0 * PI * f / sr;
610            x[s + i] += (amp * (-tt / 0.12).exp() * ph.sin()) as f32;
611        }
612    }
613
614    fn add_noise_hit(x: &mut [f32], sr: f64, t: f64, amp: f64, decay: f64, hp: bool, rng: &mut Rng) {
615        let s = (t * sr) as usize;
616        let len = (decay * 6.0 * sr) as usize;
617        let mut prev = 0.0;
618        for i in 0..len {
619            if s + i >= x.len() {
620                break;
621            }
622            let tt = i as f64 / sr;
623            let n = rng.sym();
624            let v = if hp { n - prev } else { n };
625            prev = n;
626            x[s + i] += (amp * (-tt / decay).exp() * v) as f32;
627        }
628    }
629
630    /// A drum pattern: kick on 1 and 3 (accented on 1), snare on 2 and 4, swung hi-hat
631    /// eighths, timing jitter, and background noise. Returns the buffer and true beats.
632    pub(crate) fn drum_track(bpm: f64, secs: f64, swing: f64, jitter_s: f64, seed: u64) -> (AudioBuffer, Vec<f64>) {
633        let sr = 22050.0;
634        let n = (secs * sr) as usize;
635        let mut x = vec![0.0f32; n];
636        let mut rng = Rng::new(seed);
637        let beat = 60.0 / bpm;
638        let start = 0.25;
639        let mut truth = Vec::new();
640        let mut i = 0usize;
641        loop {
642            let t0 = start + i as f64 * beat;
643            if t0 > secs - 0.3 {
644                break;
645            }
646            truth.push(t0);
647            let j = |r: &mut Rng| r.sym() * jitter_s;
648            match i % 4 {
649                0 => add_kick(&mut x, sr, t0 + j(&mut rng), 0.9),
650                2 => add_kick(&mut x, sr, t0 + j(&mut rng), 0.6),
651                _ => add_noise_hit(&mut x, sr, t0 + j(&mut rng), 0.5, 0.05, false, &mut rng),
652            }
653            add_noise_hit(&mut x, sr, t0 + j(&mut rng), 0.15, 0.015, true, &mut rng);
654            let off = t0 + beat * (0.5 + swing) + j(&mut rng);
655            add_noise_hit(&mut x, sr, off, 0.1, 0.015, true, &mut rng);
656            i += 1;
657        }
658        for v in x.iter_mut() {
659            *v += (rng.sym() * 0.02) as f32;
660        }
661        (AudioBuffer::from_mono(sr as u32, x), truth)
662    }
663
664    /// Fraction of reference beats with a detected beat within `tol` seconds, allowing
665    /// the detection to be at half or double the reference metrical level.
666    pub(crate) fn beat_hits(truth: &[f64], det: &[f64], tol: f64) -> f64 {
667        let score = |r: &[f64], d: &[f64]| -> f64 {
668            let hits = r.iter().filter(|t| d.iter().any(|b| (*b - **t).abs() <= tol)).count();
669            hits as f64 / r.len().max(1) as f64
670        };
671        let straight = score(truth, det);
672        let half_a: Vec<f64> = truth.iter().step_by(2).copied().collect();
673        let half_b: Vec<f64> = truth.iter().skip(1).step_by(2).copied().collect();
674        let dbl_a: Vec<f64> = det.iter().step_by(2).copied().collect();
675        let dbl_b: Vec<f64> = det.iter().skip(1).step_by(2).copied().collect();
676        let rd = det.len() as f64 / truth.len().max(1) as f64;
677        if rd < 0.7 {
678            score(&half_a, det).max(score(&half_b, det))
679        } else if rd > 1.4 {
680            straight.max(score(truth, &dbl_a)).max(score(truth, &dbl_b))
681        } else {
682            straight
683        }
684    }
685
686    fn tempo_ok(want: f64, got: f64) -> bool {
687        [1.0, 2.0, 0.5, 1.5, 2.0 / 3.0, 3.0, 1.0 / 3.0].iter().any(|m| (got - want * m).abs() <= 2.0)
688    }
689
690    #[test]
691    fn tempo_on_synthetic_drum_tracks() {
692        let tempi = [85.0, 100.0, 120.0, 128.0, 140.0, 174.0, 92.0, 110.0, 160.0, 70.0];
693        let mut ok = 0;
694        let mut exact = 0;
695        let mut total_hits = 0.0;
696        let mut total_beats = 0.0;
697        for (k, bpm) in tempi.iter().enumerate() {
698            let swing = if k % 3 == 1 { 0.12 } else { 0.0 };
699            let (buf, truth) = drum_track(*bpm, 10.0, swing, 0.008, k as u64 + 1);
700            let r = detect_tempo(&buf, &TempoOptions::default());
701            let good = tempo_ok(*bpm, r.bpm);
702            if good {
703                ok += 1;
704            }
705            if (r.bpm - bpm).abs() <= 2.0 {
706                exact += 1;
707            }
708            let h = beat_hits(&truth, &r.beats, 0.07);
709            eprintln!(
710                "bpm {bpm}: got {:.2} conf {:.2} beats {} hits {:.2} sig {} db {:?}",
711                r.bpm,
712                r.confidence,
713                r.beats.len(),
714                h,
715                r.time_signature,
716                r.downbeats.first()
717            );
718            total_hits += h * truth.len() as f64;
719            total_beats += truth.len() as f64;
720        }
721        assert!(ok as f64 >= 0.9 * tempi.len() as f64, "tempo ok {ok}/{}", tempi.len());
722        assert!(exact >= 7, "exact tempo {exact}/{}", tempi.len());
723        assert!(total_hits / total_beats >= 0.85, "beat accuracy {:.3}", total_hits / total_beats);
724    }
725
726    #[test]
727    fn click_track_and_range() {
728        let b = testing::clicks(22050, 0.5, 8.0, 0.9);
729        let r = detect_tempo(&b, &TempoOptions::default());
730        assert!((r.bpm - 120.0).abs() < 1.0, "{}", r.bpm);
731        assert!(r.confidence > 0.5, "{}", r.confidence);
732        // restricting the range forces the double-time level
733        let r = detect_tempo(&b, &TempoOptions { min_bpm: 180.0, max_bpm: 260.0, ..Default::default() });
734        assert!((r.bpm - 240.0).abs() < 2.0 || (r.bpm - 120.0).abs() < 1.0, "{}", r.bpm);
735        // other sample rates
736        let b = testing::add(&testing::clicks(48000, 0.6, 9.0, 0.8), &testing::noise(48000, 9.0, 0.01, 5));
737        let r = detect_tempo(&b, &TempoOptions::default());
738        assert!((r.bpm - 100.0).abs() < 1.0, "{}", r.bpm);
739        assert!(r.beats.iter().any(|b| (b - 1.2).abs() < 0.03), "{:?}", &r.beats[..3]);
740    }
741
742    #[test]
743    fn silence_and_noise() {
744        let s = AudioBuffer::new(22050, 1, 22050 * 3);
745        let r = detect_tempo(&s, &TempoOptions::default());
746        assert_eq!(r.bpm, 0.0);
747        assert!(r.beats.is_empty());
748        let short = testing::sine(22050, 440.0, 0.2, 0.5);
749        let r = detect_tempo(&short, &TempoOptions::default());
750        assert!(r.beats.is_empty());
751        let nz = testing::noise(22050, 5.0, 0.3, 3);
752        let r = detect_tempo(&nz, &TempoOptions::default());
753        assert!(r.confidence < 0.5, "noise conf {}", r.confidence);
754    }
755
756    #[test]
757    fn three_four_accent() {
758        // accented first beat of three
759        let sr = 22050.0;
760        let bpm = 120.0;
761        let secs = 12.0;
762        let mut x = vec![0.0f32; (secs * sr) as usize];
763        let mut rng = Rng::new(9);
764        let mut truth_db = Vec::new();
765        for i in 0..23 {
766            let t = 0.3 + i as f64 * 60.0 / bpm;
767            if i % 3 == 0 {
768                add_kick(&mut x, sr, t, 0.9);
769                truth_db.push(t);
770            }
771            add_noise_hit(&mut x, sr, t, 0.3, 0.03, true, &mut rng);
772        }
773        let r = detect_tempo(&AudioBuffer::from_mono(sr as u32, x), &TempoOptions::default());
774        assert_eq!(r.time_signature, "3/4", "{r:?}");
775        assert!(!r.downbeats.is_empty());
776        assert!(beat_hits(&truth_db, &r.downbeats, 0.07) > 0.8);
777    }
778
779    #[test]
780    fn density_counts_onsets() {
781        let b = testing::clicks(22050, 0.25, 6.0, 0.9);
782        let d = transient_density(&b);
783        assert!((d - 4.0).abs() < 0.6, "{d}");
784    }
785}