Skip to main content

sound_to_patch/audio/
tempo.rs

1//! The tempo of a stem, refined near the given BPM.
2//!
3//! A tempo taken from a file or folder name is close to the true tempo but
4//! rarely exact, and an error of 1 % drifts the notes off the grid within
5//! a few bars. [`Tempo::refine`] searches the tempos that the given value
6//! lies within [`SEARCH_SHARE`] of, 2.91 % below to 3.09 % above it, in
7//! steps of [`BPM_STEP`]. Each candidate scores the autocorrelation of the
8//! onset strength summed at 1 to [`MAX_BEAT_LAGS`] beats. The beat offset
9//! stays 0: only the period is refined. A window this narrow never reaches
10//! half or double the tempo.
11
12use realfft::RealFftPlanner;
13use realfft::num_complex::Complex64;
14
15use super::SampleRate;
16use crate::cast;
17use crate::confidence::{ConfidenceError, Estimate, Level, NoSpread, Reason, ReasonCode, Signal};
18use crate::dsp::{Stft, StftParams, low_pass_zero_phase};
19use crate::units::{Bpm, Hz};
20
21/// How far the given tempo may lie from the true one: the search covers
22/// every tempo `t` with `|given / t - 1| <= SEARCH_SHARE`.
23pub const SEARCH_SHARE: f64 = 0.03;
24/// The step of the tempo search, in BPM.
25pub const BPM_STEP: f64 = 0.01;
26/// The most beat lags the score sums.
27pub const MAX_BEAT_LAGS: usize = 32;
28/// The fewest beat lags that refine a tempo; a shorter stem keeps the
29/// given tempo.
30pub const MIN_BEAT_LAGS: usize = 4;
31/// Beats of the stem that every lag keeps in its overlap, so the longest
32/// lag still averages several beats.
33pub const OVERLAP_BEATS: usize = 4;
34/// The lowest ratio of the best score to the median score over the search
35/// that the refined tempo needs before it replaces the given one.
36pub const PEAK_RATIO_MIN: f64 = 1.5;
37/// The lowest share of the onset strength that repeats at the refined
38/// beat.
39///
40/// The share is the best score over the lags times the zero-lag
41/// autocorrelation. Onsets on every beat read near 1, one onset and a
42/// steady tone near 0. Generated drum, bass and lead parts read 0.78 to
43/// 0.99 and pad chords that change once a bar 0.04 at most; the threshold
44/// sits between them.
45pub const PERIODICITY_MIN: f64 = 0.4;
46/// The lowest share of the onset strength that repeats every 2 or every 4
47/// refined beats, for a part whose beat periodicity is too low.
48///
49/// One onset a bar repeats on a quarter of the beat lags, so its beat
50/// periodicity reads near 0.25. Read on every second or fourth lag alone,
51/// it reads near 1. Those lags are fewer and line up by chance more
52/// often: a drum part whose `--bpm` is 3.5 % off reads 0.47 at a tempo
53/// 16/15 of the truth, where every eighth beat meets an eighth note. A
54/// tempo ramp of 1 % reads 0.62 at most, and generated parts with one kick
55/// a bar or every other beat read 0.875 or more; the threshold sits
56/// between them.
57pub const SPARSE_PERIODICITY_MIN: f64 = 0.75;
58/// How far either side of a peak of the onset strength the sharpness reads.
59const SHARP_HALF_S: f64 = 0.025;
60/// How far either side a peak of the onset strength is the largest value:
61/// under the 84 ms between sixteenth notes at 178 BPM.
62const PEAK_REACH_S: f64 = 0.03;
63/// The smallest median score, as a share of the best one, that the peak
64/// ratio divides by: the ratio reads at most 1e9.
65const PEAK_RATIO_FLOOR: f64 = 1e-9;
66/// The beat spacings of the sparse periodicity.
67const SPARSE_BEATS: [usize; 2] = [2, 4];
68/// The lowest onset sharpness that trusts a refined tempo.
69///
70/// A pad under a smooth tremolo repeats as well as a drum part does, so
71/// its beat or sparse periodicity reads near 1. Its onsets are swells, not
72/// hits: the rise of each band spreads over 180 ms or more. Generated
73/// tremolo pads whose shares pass read 0.80 at most, and chord pads 0.76;
74/// generated drum, hat, bass, lead and kick parts, swung or humanised,
75/// read 0.985 or more, and a bass with an 80 ms attack 0.92. The threshold
76/// sits between them.
77pub const SHARPNESS_MIN: f64 = 0.9;
78/// The most onsets per beat for which the sparse periodicity trusts a
79/// tempo.
80///
81/// A dense grid lines up with any spacing that is a whole number of its
82/// steps: sixteenth notes at 70.3 BPM read a sparse periodicity of 0.98 at
83/// 16/15 of the tempo, where 4 beats are 15 sixteenths. Generated parts
84/// with one kick a bar read 0.19 to 0.26 onsets per beat, kicks on every
85/// other beat 0.48 to 0.52, and leads 1.23 or more; the threshold sits
86/// between them.
87pub const SPARSE_ONSETS_MAX: f64 = 0.8;
88/// The convention of the refined tempo in `layers.json`.
89pub const TEMPO_CONVENTION: &str = "onset strength: the rise of each third-octave band power \
90from 30 Hz, compressed as ln(1 + P / knee) with the knee 60 dB below full scale, against the \
91frame 10 ms earlier, in Hann frames of the power of two of samples nearest 46.4 ms (2048 at 44.1 \
92and 48 kHz) on a 1 ms hop, low-passed at 20 Hz, less its \
93100 ms mean, below 10 % of its largest rise set to 0; each tempo t with |given / t - 1| at \
94most 3 % (2.91 % below to 3.09 % above the given one), in 0.01 BPM steps, scores the unbiased \
95autocorrelation summed at 1 to 32 beats; the refined tempo is used when its score is at least \
961.5 times the median score (tempo_peak) and either 0.4 of the lag count times the zero-lag \
97autocorrelation (beat_periodicity), or 0.75 of that on every second or fourth beat lag alone \
98(sparse_periodicity) with at most 0.8 peaks of the onset strength per beat at the slowest \
99tempo of the search (onsets_per_beat), \
100and at least 0.9 of the energy of the rise before its low-pass lies within 25 ms of its peaks, \
101each the largest within 30 ms (onset_sharpness); both shares stop at 1; cycles_in_span counts \
102the whole beats of the stem at the slowest tempo of the search, given / 1.03, so the longest lag \
103keeps 4 beats of overlap at every candidate; a stem of fewer than 8 keeps the given tempo";
104
105/// The hop of the onset strength.
106const ONSET_HOP_S: f64 = 0.001;
107/// The frame of the onset strength, rounded to the nearest power of two of
108/// samples: 2048 samples, 46.4 ms at 44.1 kHz and 42.7 ms at 48 kHz.
109const ONSET_FRAME_S: f64 = 0.0464;
110/// How far apart the two frames of the spectral flux lie.
111const FLUX_LAG_S: f64 = 0.01;
112/// The low-pass of the onset strength. It removes the ripple that a steady
113/// tone leaves in the flux at its period, which reads as a tempo when the
114/// period divides the beat (110 Hz is 55 periods per beat at 120 BPM).
115const ONSET_LOW_PASS_HZ: f64 = 20.0;
116/// The span of the local mean that the onset strength loses.
117const ONSET_MEAN_S: f64 = 0.1;
118/// The share of the largest rise that a rise of the onset strength needs
119/// to count. A noise-free steady tone stays exactly periodic through every
120/// earlier step, and its faint ripple repeats at any beat its period
121/// divides; the floor removes it.
122const ONSET_FLOOR_SHARE: f64 = 0.1;
123/// The knee of the log compression, below a full-scale bin. Bins far
124/// under it count linearly and barely at all, so the leakage between the
125/// partials of a steady tone, which swings with the phase of each frame,
126/// leaves no ripple that repeats with the tone's period.
127const FLUX_KNEE_DB: f64 = -60.0;
128
129/// The lowest band edge of the onset strength.
130const BAND_LOW_HZ: f64 = 30.0;
131/// The fewest bins in a band: wider than the main lobe of a Hann window,
132/// four bins.
133const BAND_MIN_BINS: usize = 6;
134
135/// The tempo of a stem: the given one, the refined one and the one used.
136#[derive(Clone, Debug, PartialEq)]
137pub struct Tempo {
138    given: Bpm,
139    refined: Estimate<Bpm, NoSpread>,
140    used: Bpm,
141}
142
143impl Tempo {
144    /// Uses `given` as it is, as `--bpm-exact` asks.
145    ///
146    /// # Errors
147    ///
148    /// Never in practice: the reason list is fixed and finite.
149    pub fn exact(given: Bpm) -> Result<Self, ConfidenceError> {
150        Ok(Self {
151            given,
152            refined: Estimate::none(vec![Reason::new(ReasonCode::BpmExact, Signal::Flag(true))?])?,
153            used: given,
154        })
155    }
156
157    /// Refines `given` from the onsets of `mono`.
158    ///
159    /// The refined tempo replaces the given one when its score is at least
160    /// [`PEAK_RATIO_MIN`] times the median of the search, and either at
161    /// least [`PERIODICITY_MIN`] of the score that onsets on every beat
162    /// give or at least [`SPARSE_PERIODICITY_MIN`] of it on every second
163    /// or fourth beat lag with at most [`SPARSE_ONSETS_MAX`] onsets per
164    /// beat. Either way the onset sharpness needs [`SHARPNESS_MIN`].
165    /// Otherwise it is reported at low confidence and
166    /// the given tempo stays. A stem shorter than [`MIN_BEAT_LAGS`] plus
167    /// [`OVERLAP_BEATS`] beats, or one without onsets, keeps the given
168    /// tempo and reports no refined one.
169    ///
170    /// # Errors
171    ///
172    /// Never in practice: every signal value is finite.
173    pub fn refine(mono: &[f64], rate: SampleRate, given: Bpm) -> Result<Self, ConfidenceError> {
174        let keep = |reason: Reason| -> Result<Self, ConfidenceError> {
175            Ok(Self {
176                given,
177                refined: Estimate::none(vec![reason])?,
178                used: given,
179            })
180        };
181        let hop = cast::round_to_usize(f64::from(rate.get()) * ONSET_HOP_S)
182            .unwrap_or(1)
183            .max(1);
184        let fps = f64::from(rate.get()) / cast::usize_to_f64(hop);
185        let candidates = candidates(given);
186        let (Some(&slowest), Some(_)) = (candidates.first(), candidates.last()) else {
187            return keep(Reason::new(ReasonCode::TempoPeak, Signal::Number(0.0))?);
188        };
189        let flux = onset_strength(mono, rate, hop);
190        let onsets = smooth(&flux, fps);
191        let beats = cast::usize_to_f64(onsets.len()) / fps * slowest / 60.0;
192        let whole_beats = cast::floor_to_usize(beats).unwrap_or(0);
193        let lags = whole_beats.saturating_sub(OVERLAP_BEATS).min(MAX_BEAT_LAGS);
194        if lags < MIN_BEAT_LAGS {
195            return keep(Reason::new(
196                ReasonCode::CyclesInSpan,
197                Signal::Count(u64::try_from(whole_beats).unwrap_or(u64::MAX)),
198            )?);
199        }
200        let longest = cast::usize_to_f64(lags) * 60.0 / slowest * fps;
201        let acf = autocorrelation(&onsets, cast::ceil_to_usize(longest).unwrap_or(0) + 2);
202        let scores: Vec<f64> = candidates
203            .iter()
204            .map(|&bpm| {
205                let period = 60.0 / bpm * fps;
206                (1..=lags)
207                    .map(|k| interpolate(&acf, cast::usize_to_f64(k) * period))
208                    .sum()
209            })
210            .collect();
211        let Some((best_at, &best)) = scores.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1))
212        else {
213            return keep(Reason::new(ReasonCode::TempoPeak, Signal::Number(0.0))?);
214        };
215        if best.is_nan() || best <= 0.0 {
216            return keep(Reason::new(ReasonCode::TempoPeak, Signal::Number(0.0))?);
217        }
218        // A sparse part scores exactly 0 at most candidates, so the median
219        // can be 0; the floor keeps the ratio finite.
220        let ratio = best / median(&scores).max(best * PEAK_RATIO_FLOOR);
221        // The unbiased lags can sum a little above the zero lag; a share
222        // stops at 1.
223        let periodicity = (best / (cast::usize_to_f64(lags) * acf[0])).min(1.0);
224        let period = 60.0 / candidates[best_at] * fps;
225        let sparse = SPARSE_BEATS
226            .iter()
227            .filter(|&&every| lags / every >= MIN_BEAT_LAGS)
228            .map(|&every| {
229                let count = lags / every;
230                let sum: f64 = (1..=count)
231                    .map(|j| interpolate(&acf, cast::usize_to_f64(j * every) * period))
232                    .sum();
233                sum / (cast::usize_to_f64(count) * acf[0])
234            })
235            .fold(0.0, f64::max)
236            .min(1.0);
237        let sharp = sharpness(&flux, fps);
238        let per_beat = cast::usize_to_f64(peaks(&onsets, fps).len()) / beats;
239        let refined_bpm = Bpm::clamped(candidates[best_at]);
240        let trusted = ratio >= PEAK_RATIO_MIN
241            && sharp >= SHARPNESS_MIN
242            && (periodicity >= PERIODICITY_MIN
243                || (sparse >= SPARSE_PERIODICITY_MIN && per_beat <= SPARSE_ONSETS_MAX));
244        let reasons = vec![
245            Reason::new(ReasonCode::TempoPeak, Signal::Number(ratio))?,
246            Reason::new(ReasonCode::BeatPeriodicity, Signal::Number(periodicity))?,
247            Reason::new(ReasonCode::SparsePeriodicity, Signal::Number(sparse))?,
248            Reason::new(ReasonCode::OnsetSharpness, Signal::Number(sharp))?,
249            Reason::new(ReasonCode::OnsetsPerBeat, Signal::Number(per_beat))?,
250            Reason::new(
251                ReasonCode::CyclesInSpan,
252                Signal::Count(u64::try_from(whole_beats).unwrap_or(u64::MAX)),
253            )?,
254        ];
255        Ok(Self {
256            given,
257            refined: Estimate::new(
258                refined_bpm,
259                if trusted { Level::High } else { Level::Low },
260                None,
261                reasons,
262            )?,
263            used: if trusted { refined_bpm } else { given },
264        })
265    }
266
267    /// Returns the tempo given with `--bpm`.
268    #[must_use]
269    pub fn given(&self) -> Bpm {
270        self.given
271    }
272
273    /// Returns the refined tempo with its confidence; no value under
274    /// `--bpm-exact`, for a short stem or for one without onsets.
275    #[must_use]
276    pub fn refined(&self) -> &Estimate<Bpm, NoSpread> {
277        &self.refined
278    }
279
280    /// Returns the tempo every later step uses.
281    #[must_use]
282    pub fn used(&self) -> Bpm {
283        self.used
284    }
285
286    /// Returns the refined tempo when it is trusted, so it replaced the
287    /// given one.
288    #[must_use]
289    pub fn trusted(&self) -> Option<Bpm> {
290        (self.refined.confidence().level() == Some(Level::High))
291            .then(|| self.refined.value().copied())
292            .flatten()
293    }
294
295    /// Returns one line that says which tempo is used and why.
296    #[must_use]
297    pub fn summary(&self) -> String {
298        let given = self.given.get();
299        let reasons = self.refined.confidence().reasons();
300        let signal = |code| {
301            reasons
302                .iter()
303                .find(|reason| reason.code() == code)
304                .map(|reason| reason.value())
305        };
306        match (self.refined.value(), signal(ReasonCode::TempoPeak)) {
307            (Some(refined), Some(Signal::Number(ratio))) if self.trusted().is_some() => format!(
308                "tempo: {} BPM, refined from --bpm {given} (peak ratio {ratio:.2})",
309                refined.get()
310            ),
311            (Some(refined), Some(Signal::Number(ratio))) => {
312                let share = |code| match signal(code) {
313                    Some(Signal::Number(share)) => share,
314                    _ => 0.0,
315                };
316                format!(
317                    "tempo: {given} BPM as given; the refined {} BPM is not trusted (peak ratio {ratio:.2}, needs {PEAK_RATIO_MIN}; onset sharpness {:.2}, needs {SHARPNESS_MIN}; beat periodicity {:.2}, needs {PERIODICITY_MIN}, or {:.2} at 2 or 4 beats with {:.2} onsets per beat, needs {SPARSE_PERIODICITY_MIN} with {SPARSE_ONSETS_MAX} at most)",
318                    refined.get(),
319                    share(ReasonCode::OnsetSharpness),
320                    share(ReasonCode::BeatPeriodicity),
321                    share(ReasonCode::SparsePeriodicity),
322                    share(ReasonCode::OnsetsPerBeat),
323                )
324            }
325            _ if signal(ReasonCode::BpmExact).is_some() => {
326                format!("tempo: {given} BPM as given (--bpm-exact)")
327            }
328            _ => match (
329                signal(ReasonCode::CyclesInSpan),
330                candidates(self.given).first(),
331            ) {
332                (Some(Signal::Count(0)), Some(slowest)) => format!(
333                    "tempo: {given} BPM as given; the stem is shorter than one beat at {slowest} BPM, the slowest tempo of the search"
334                ),
335                (Some(Signal::Count(beats)), Some(slowest)) => format!(
336                    "tempo: {given} BPM as given; the stem holds {beats} whole beat{} at {slowest} BPM, the slowest tempo of the search, and refining needs {}",
337                    if beats == 1 { "" } else { "s" },
338                    MIN_BEAT_LAGS + OVERLAP_BEATS,
339                ),
340                _ => format!("tempo: {given} BPM as given; the stem has no onsets to refine it"),
341            },
342        }
343    }
344}
345
346/// The candidate tempos in BPM, slowest first: every step `t` with
347/// `|given / t - 1| <= SEARCH_SHARE`, within the range of [`Bpm`].
348fn candidates(given: Bpm) -> Vec<f64> {
349    let low = (given.get() / (1.0 + SEARCH_SHARE)).max(4.0);
350    let high = (given.get() / (1.0 - SEARCH_SHARE)).min(1000.0);
351    // Dividing by the steps per BPM keeps 11962 steps at exactly 119.62.
352    let per_bpm = (1.0 / BPM_STEP).round();
353    let first = cast::ceil_to_usize(low * per_bpm - 1e-9).unwrap_or(0);
354    let last = cast::floor_to_usize(high * per_bpm + 1e-9).unwrap_or(0);
355    (first..=last)
356        .map(|step| cast::usize_to_f64(step) / per_bpm)
357        .collect()
358}
359
360/// The onset strength, one value per hop: the summed rise of every band
361/// against the frame [`FLUX_LAG_S`] earlier, on the band power compressed
362/// as `ln(1 + P / knee)` with the knee [`FLUX_KNEE_DB`] below a full-scale
363/// bin.
364fn onset_strength(mono: &[f64], rate: SampleRate, hop: usize) -> Vec<f64> {
365    let frame_len = onset_frame_len(rate);
366    let peak = mono.iter().fold(0.0_f64, |m, x| m.max(x.abs()));
367    let Ok(params) = StftParams::new(frame_len).and_then(|p| p.with_hop(hop)) else {
368        return Vec::new();
369    };
370    let frames = params.frame_count(mono.len());
371    if peak == 0.0 {
372        return vec![0.0; frames];
373    }
374    // A full-scale sine reads peak * N / 4 in its bin under a Hann window.
375    let full_scale = peak * cast::usize_to_f64(frame_len) / 4.0;
376    let knee = full_scale * full_scale * 10_f64.powf(FLUX_KNEE_DB / 10.0);
377    let bands = bands(params.bins(), params.bin_width(rate).get());
378    let lag = cast::round_to_usize(FLUX_LAG_S * f64::from(rate.get()) / cast::usize_to_f64(hop))
379        .unwrap_or(1)
380        .max(1);
381    let mut stft = Stft::new(params);
382    let mut history: Vec<Vec<f64>> = Vec::with_capacity(lag + 1);
383    let mut strength = Vec::with_capacity(frames);
384    for frame in 0..frames {
385        let spectrum = stft.frame(mono, frame);
386        let levels: Vec<f64> = bands
387            .iter()
388            .map(|&(lo, hi)| {
389                let power: f64 = spectrum[lo..hi].iter().map(Complex64::norm_sqr).sum();
390                (power / knee).ln_1p()
391            })
392            .collect();
393        let rise = history
394            .first()
395            .filter(|_| history.len() == lag)
396            .map_or(0.0, |earlier| {
397                levels
398                    .iter()
399                    .zip(earlier)
400                    .map(|(now, then)| (now - then).max(0.0))
401                    .sum()
402            });
403        strength.push(rise);
404        if history.len() == lag {
405            history.remove(0);
406        }
407        history.push(levels);
408    }
409    strength
410}
411
412/// The frame length of the onset strength: the power of two of samples
413/// nearest [`ONSET_FRAME_S`].
414fn onset_frame_len(rate: SampleRate) -> usize {
415    let wanted = f64::from(rate.get()) * ONSET_FRAME_S;
416    let up = cast::ceil_to_usize(wanted)
417        .unwrap_or(4)
418        .max(4)
419        .next_power_of_two();
420    if cast::usize_to_f64(up) - wanted > wanted - cast::usize_to_f64(up / 2) {
421        (up / 2).max(4)
422    } else {
423        up
424    }
425}
426
427/// The bands of the onset strength, as bin ranges: a third of an octave
428/// from [`BAND_LOW_HZ`] up, and at least [`BAND_MIN_BINS`] bins wide, so
429/// each band sums whole main lobes and the energy of a steady partial does
430/// not swing with the phase of the frame.
431fn bands(bins: usize, bin_hz: f64) -> Vec<(usize, usize)> {
432    let mut bands = Vec::new();
433    let mut lo = cast::ceil_to_usize(BAND_LOW_HZ / bin_hz)
434        .unwrap_or(1)
435        .max(1);
436    while lo < bins {
437        let third =
438            cast::ceil_to_usize(cast::usize_to_f64(lo) * 2_f64.powf(1.0 / 3.0)).unwrap_or(bins);
439        let hi = third.max(lo + BAND_MIN_BINS).min(bins);
440        bands.push((lo, hi));
441        lo = hi;
442    }
443    bands
444}
445
446/// The onset strength low-passed at [`ONSET_LOW_PASS_HZ`] with no lag,
447/// less its centred mean over [`ONSET_MEAN_S`], floored at 0. A rise
448/// under [`ONSET_FLOOR_SHARE`] of the largest one becomes 0, so only
449/// onsets remain.
450fn smooth(onsets: &[f64], fps: f64) -> Vec<f64> {
451    let rate = cast::round_to_usize(fps)
452        .and_then(|fps| u32::try_from(fps).ok())
453        .and_then(|fps| SampleRate::new(fps).ok());
454    let smoothed = rate
455        .and_then(|rate| low_pass_zero_phase(onsets, rate, Hz::from_const(ONSET_LOW_PASS_HZ)).ok());
456    let smoothed = smoothed.unwrap_or_else(|| onsets.to_vec());
457    let half = cast::round_to_usize(ONSET_MEAN_S * fps / 2.0).unwrap_or(0);
458    let mut prefix = Vec::with_capacity(smoothed.len() + 1);
459    prefix.push(0.0);
460    for value in &smoothed {
461        prefix.push(prefix[prefix.len() - 1] + value);
462    }
463    let rises: Vec<f64> = (0..smoothed.len())
464        .map(|i| {
465            let (lo, hi) = (i.saturating_sub(half), (i + half + 1).min(smoothed.len()));
466            let mean = (prefix[hi] - prefix[lo]) / cast::usize_to_f64(hi - lo);
467            (smoothed[i] - mean).max(0.0)
468        })
469        .collect();
470    let floor = ONSET_FLOOR_SHARE * rises.iter().fold(0.0_f64, |m, &v| m.max(v));
471    rises
472        .into_iter()
473        .map(|v| if v >= floor { v } else { 0.0 })
474        .collect()
475}
476
477/// The share of the energy of `onsets` within [`SHARP_HALF_S`] of its
478/// peaks: values that are the largest within [`PEAK_REACH_S`] either side.
479/// A hit or a plucked note leaves a narrow peak and reads near 1; a swell
480/// leaves a broad hump and reads low.
481fn sharpness(onsets: &[f64], fps: f64) -> f64 {
482    let total: f64 = onsets.iter().map(|v| v * v).sum();
483    if total <= 0.0 {
484        return 0.0;
485    }
486    let half = cast::round_to_usize(SHARP_HALF_S * fps).unwrap_or(0);
487    let mut near = vec![false; onsets.len()];
488    for i in peaks(onsets, fps) {
489        let end = (i + half + 1).min(onsets.len());
490        near[i.saturating_sub(half)..end].fill(true);
491    }
492    let peaked: f64 = onsets
493        .iter()
494        .zip(&near)
495        .filter(|&(_, &near)| near)
496        .map(|(v, _)| v * v)
497        .sum();
498    peaked / total
499}
500
501/// The positive values of `onsets` that are the largest within
502/// [`PEAK_REACH_S`] either side, by index; the first of equal values.
503fn peaks(onsets: &[f64], fps: f64) -> Vec<usize> {
504    let reach = cast::round_to_usize(PEAK_REACH_S * fps).unwrap_or(1).max(1);
505    (0..onsets.len())
506        .filter(|&i| {
507            let value = onsets[i];
508            let (lo, hi) = (i.saturating_sub(reach), (i + reach + 1).min(onsets.len()));
509            value > 0.0
510                && onsets[lo..i].iter().all(|&other| other < value)
511                && onsets[i + 1..hi].iter().all(|&other| other <= value)
512        })
513        .collect()
514}
515
516/// The unbiased autocorrelation of `x` at lags 0 to `lags - 1`: each sum
517/// is divided by the number of products in it.
518fn autocorrelation(x: &[f64], lags: usize) -> Vec<f64> {
519    let lags = lags.min(x.len());
520    if lags == 0 {
521        return Vec::new();
522    }
523    let len = (x.len() + lags).next_power_of_two();
524    let mut planner = RealFftPlanner::<f64>::new();
525    let forward = planner.plan_fft_forward(len);
526    let inverse = planner.plan_fft_inverse(len);
527    let mut input = forward.make_input_vec();
528    input[..x.len()].copy_from_slice(x);
529    let mut spectrum = forward.make_output_vec();
530    if forward.process(&mut input, &mut spectrum).is_err() {
531        return vec![0.0; lags];
532    }
533    for bin in &mut spectrum {
534        *bin = Complex64::new(bin.norm_sqr(), 0.0);
535    }
536    let mut out = inverse.make_output_vec();
537    if inverse.process(&mut spectrum, &mut out).is_err() {
538        return vec![0.0; lags];
539    }
540    let scale = cast::usize_to_f64(len);
541    out.iter()
542        .take(lags)
543        .enumerate()
544        .map(|(lag, sum)| sum / scale / cast::usize_to_f64(x.len() - lag))
545        .collect()
546}
547
548/// `values` at a fractional index, by linear interpolation; 0 past the end.
549fn interpolate(values: &[f64], at: f64) -> f64 {
550    let Some(index) = cast::floor_to_usize(at) else {
551        return 0.0;
552    };
553    let fraction = at - cast::usize_to_f64(index);
554    match (values.get(index), values.get(index + 1)) {
555        (Some(a), Some(b)) => a + (b - a) * fraction,
556        (Some(a), None) => *a,
557        _ => 0.0,
558    }
559}
560
561/// The median, the mean of the middle pair for an even count.
562fn median(values: &[f64]) -> f64 {
563    let mut sorted = values.to_vec();
564    sorted.sort_by(f64::total_cmp);
565    let n = sorted.len();
566    match n {
567        0 => 0.0,
568        _ if n % 2 == 1 => sorted[n / 2],
569        _ => f64::midpoint(sorted[n / 2 - 1], sorted[n / 2]),
570    }
571}
572
573#[cfg(test)]
574mod tests {
575    use super::*;
576
577    fn bpm(value: f64) -> Bpm {
578        Bpm::new(value).unwrap()
579    }
580
581    #[test]
582    fn the_search_holds_every_tempo_the_given_one_lies_within_three_percent_of() {
583        let steps = candidates(bpm(120.0));
584        assert!((steps[0] - 116.51).abs() < 1e-9, "{}", steps[0]);
585        assert!((steps[steps.len() - 1] - 123.71).abs() < 1e-9);
586        // 120 is 3 % above 116.505 and 3 % below 123.711.
587        assert!(120.0 / steps[0] - 1.0 <= SEARCH_SHARE);
588        assert!(1.0 - 120.0 / steps[steps.len() - 1] <= SEARCH_SHARE);
589    }
590
591    #[test]
592    fn the_search_never_reaches_half_or_double_the_tempo() {
593        for given in [4.0, 60.0, 120.0, 173.5, 1000.0] {
594            let steps = candidates(bpm(given));
595            assert!(steps.iter().all(|&t| t > given * 0.9 && t < given * 1.1));
596            assert!(steps.iter().all(|&t| (4.0..=1000.0).contains(&t)));
597        }
598    }
599
600    #[test]
601    fn the_autocorrelation_divides_each_lag_by_its_products() {
602        let acf = autocorrelation(&[1.0, 1.0, 1.0, 1.0], 4);
603        for value in acf {
604            assert!((value - 1.0).abs() < 1e-12, "{value}");
605        }
606    }
607
608    #[test]
609    fn interpolation_reads_between_samples() {
610        assert!((interpolate(&[0.0, 2.0], 0.25) - 0.5).abs() < 1e-12);
611        assert!(interpolate(&[0.0, 2.0], 5.0).abs() < 1e-12);
612    }
613
614    #[test]
615    fn exact_keeps_the_given_tempo() {
616        let tempo = Tempo::exact(bpm(120.0)).unwrap();
617        assert_eq!(tempo.used(), bpm(120.0));
618        assert!(tempo.refined().value().is_none());
619        assert_eq!(tempo.summary(), "tempo: 120 BPM as given (--bpm-exact)");
620    }
621
622    #[test]
623    fn silence_keeps_the_given_tempo() {
624        let rate = SampleRate::new(8000).unwrap();
625        let tempo = Tempo::refine(&vec![0.0; 8000 * 6], rate, bpm(120.0)).unwrap();
626        assert_eq!(tempo.used(), bpm(120.0));
627        assert!(tempo.refined().value().is_none());
628        assert_eq!(
629            tempo.summary(),
630            "tempo: 120 BPM as given; the stem has no onsets to refine it"
631        );
632    }
633
634    #[test]
635    fn a_short_stem_keeps_the_given_tempo() {
636        let rate = SampleRate::new(8000).unwrap();
637        // 3 s at 120 BPM is 6 beats: 2 lags after the overlap.
638        let clicks: Vec<f64> = (0..8000 * 3)
639            .map(|n| if n % 4000 == 0 { 1.0 } else { 0.0 })
640            .collect();
641        let tempo = Tempo::refine(&clicks, rate, bpm(120.0)).unwrap();
642        assert_eq!(tempo.used(), bpm(120.0));
643        // 3 s at 116.51 BPM is 5.8 beats.
644        assert_eq!(
645            tempo.summary(),
646            "tempo: 120 BPM as given; the stem holds 5 whole beats at 116.51 BPM, the slowest \
647tempo of the search, and refining needs 8"
648        );
649    }
650
651    #[test]
652    fn a_stem_of_8_beats_at_the_given_tempo_names_the_slowest_one() {
653        let rate = SampleRate::new(8000).unwrap();
654        // 4 s at 120 BPM is 8 beats, but 7.77 at 116.51 BPM, where the
655        // longest lag must still keep 4 beats of overlap.
656        let clicks: Vec<f64> = (0..8000 * 4)
657            .map(|n| if n % 4000 == 0 { 1.0 } else { 0.0 })
658            .collect();
659        let tempo = Tempo::refine(&clicks, rate, bpm(120.0)).unwrap();
660        assert_eq!(
661            tempo.summary(),
662            "tempo: 120 BPM as given; the stem holds 7 whole beats at 116.51 BPM, the slowest \
663tempo of the search, and refining needs 8"
664        );
665    }
666
667    #[test]
668    fn the_onset_frame_is_the_power_of_two_nearest_46_ms() {
669        let frame = |hz| onset_frame_len(SampleRate::new(hz).unwrap());
670        assert_eq!(frame(44_100), 2048);
671        assert_eq!(frame(48_000), 2048);
672        assert_eq!(frame(22_050), 1024);
673        assert_eq!(frame(96_000), 4096);
674    }
675
676    #[test]
677    fn a_stem_under_one_beat_says_so() {
678        let rate = SampleRate::new(8000).unwrap();
679        let tempo = Tempo::refine(&[0.5; 1600], rate, bpm(120.0)).unwrap();
680        assert_eq!(
681            tempo.summary(),
682            "tempo: 120 BPM as given; the stem is shorter than one beat at 116.51 BPM, the \
683slowest tempo of the search"
684        );
685    }
686
687    #[test]
688    fn an_untrusted_tempo_equal_to_the_given_one_is_not_called_refined() {
689        let reasons = vec![
690            Reason::new(ReasonCode::TempoPeak, Signal::Number(322.03)).unwrap(),
691            Reason::new(ReasonCode::BeatPeriodicity, Signal::Number(0.24)).unwrap(),
692            Reason::new(ReasonCode::SparsePeriodicity, Signal::Number(0.5)).unwrap(),
693            Reason::new(ReasonCode::OnsetSharpness, Signal::Number(1.0)).unwrap(),
694            Reason::new(ReasonCode::OnsetsPerBeat, Signal::Number(0.25)).unwrap(),
695        ];
696        let tempo = Tempo {
697            given: bpm(120.0),
698            refined: Estimate::new(bpm(120.0), Level::Low, None, reasons).unwrap(),
699            used: bpm(120.0),
700        };
701        assert_eq!(tempo.trusted(), None);
702        assert_eq!(
703            tempo.summary(),
704            "tempo: 120 BPM as given; the refined 120 BPM is not trusted (peak ratio 322.03, \
705needs 1.5; onset sharpness 1.00, needs 0.9; beat periodicity 0.24, needs 0.4, or 0.50 at 2 \
706or 4 beats with 0.25 onsets per beat, needs 0.75 with 0.8 at most)"
707        );
708    }
709}