Skip to main content

sva_samples/measure/
periodicity.rs

1// Concern: how strongly onset strength repeats per sliding window: broadband, low, rest | Non-concern: the band filters (bands.rs), any verdict | IO: (planes, rate, start, hop, window) -> Periodicity
2
3use super::bands::{self, Bands, Smoothing};
4use super::sliding::{self, Stats, Unread, stats};
5use crate::fft::{fft, ifft};
6use crate::stft::hann_periodic;
7
8/// A band belongs to `low` where its centre is under this; kick and sub sit there.
9pub const SPLIT_HZ: f64 = 150.0;
10/// 600 bpm, a sixteenth at 150 bpm.
11pub const MIN_PERIOD_SECS: f64 = 0.1;
12/// 30 bpm, where the window holds two of it.
13pub const MAX_PERIOD_SECS: f64 = 2.0;
14pub const PEAKS: usize = 3;
15/// Each band's energy through two one-poles at 10 Hz before it is decimated: a sustained
16/// tone's ripple at twice its frequency, which would alias into the envelope, falls 12 dB an
17/// octave and no longer outweighs a slow swell's rise.
18pub const SMOOTHING: Smoothing = Smoothing {
19    hz: Some(10.0),
20    poles: 2,
21};
22
23pub const MODEL: &str = "onset strength: the spectral flux `onsets` takes, sum over a group's \
24                         bands of max(0, e[n] - e[n-1]), run over the ERB bands of `bands` \
25                         (Glasberg and Moore's filter widths), e each band's RMS envelope at \
26                         `envelope_hz`, its energy smoothed before decimation by \
27                         `smoothing.poles` one-pole lowpasses at `smoothing.hz` in place of the \
28                         one at the band's own width `bands` reads through, so an onset is \
29                         spread over some 30 ms; linear amplitude so a loud band outweighs a quiet one, \
30                         from the filters' silent start; every channel's band energy \
31                         averaged. A band is \
32                         `low` where its centre is under `split_hz` and `rest` otherwise; \
33                         `broadband` sums every band. The filters' skirts overlap, so a kick \
34                         leaks into `rest` and a hat's attack into `low`, attenuated. That \
35                         filterbank, smoothed envelope and rectified difference is Scheirer, E. D. \
36                         (1998), Tempo and beat analysis of acoustic musical signals, J. Acoust. \
37                         Soc. Am. 103(1) 588-601. Each window is `window_secs` of that envelope \
38                         centred on t, t stepping by `hop_secs` from the target's start as \
39                         every framed reading steps, clipped to the target at its ends, \
40                         `from`/`to` its span; its mean is subtracted, y. height(tau) = sum over \
41                         n < W - tau of y[n] y[n + tau] over sum of y[n]^2, the autocorrelation \
42                         of the onset envelope Ellis, D. P. W. (2007), Beat tracking by dynamic \
43                         programming, J. New Music Res. 36(1) 51-60, reads tempo from, \
44                         normalized at lag 0 and biased: |height| <= 1 by Cauchy-Schwarz, \
45                         taken by FFT and exact up to its rounding, and an envelope repeating \
46                         exactly every tau peaks near 1 - tau/W, so of two periods that repeat \
47                         equally the shorter reads higher, and heights at different periods or \
48                         spans are not on one scale. place(tau): the same y under a periodic \
49                         Hann taper w, sum of y[n] w[n] y[n + tau] w[n + tau] over sum of w[n] \
50                         w[n + tau], the taper's own autocorrelation divided out: the window's \
51                         ends no longer cut an onset abruptly, which in the plain sum skews a \
52                         peak by up to a quarter lag, and the bias toward short lags is gone \
53                         (Harris, F. J. (1978), On the use of windows for harmonic analysis \
54                         with the discrete Fourier transform, Proc. IEEE 66(1) 51-83). peaks: \
55                         lags L from `min_period_secs` to `max_period_secs`, the shorter of \
56                         2 s and half `window_secs` so the window holds two of every period \
57                         read (a window under two of `min_period_secs` refuses), whose height is \
58                         positive and whose place is over the lag before's and no lower than \
59                         the lag after's, the highest three by height, highest first, each \
60                         with the plain curve's height at L. period: L + d lags of \
61                         `lag_step_secs`, d the vertex of the parabola through place at L - 1, \
62                         L and L + 1 (Smith, J. O. and Serra, X. (1987), PARSHL, Proc. ICMC), \
63                         |d| <= 1/2; bpm = 60 / period. Bounds: under the model that place is \
64                         symmetric about the true period P and falls strictly away from it, L \
65                         reading at least its neighbours puts P within half a lag of L, and \
66                         the higher neighbour names the half: P lies in [L, L + 1/2] where \
67                         place at L + 1 is over place at L - 1, in [L - 1/2, L] otherwise; d \
68                         lies in that half too, so P is within r of the period, r the farther \
69                         end of that half from it, r <= 1/2 lag. `period_bounds_secs` is \
70                         period -+ r and `bpm_bounds` 60 over those. They cover the lag grid \
71                         and the interpolation alone, under that model, which no window can \
72                         confirm: an envelope not periodic across the window (a tempo moving, \
73                         onsets jittered) is outside it, and its place need not peak at any \
74                         one period. Both are null where the window refutes the model, place \
75                         not falling strictly from L - 1 to L - 2 and from L + 1 to L + 2, or \
76                         leaves the stated span, clipped at the target's ends. No tempo prior \
77                         (Ellis weights by a log-Gaussian) and no octave choice: a beat, its \
78                         half and its double may each lead, and each is reported; no inter-onset \
79                         clustering or beat tracking (Dixon, S. (2001), Automatic extraction \
80                         of tempo and beat from expressive performances, J. New Music Res. \
81                         30(1) 39-58) is done. drift_bpm: the leading peak's bpm less the \
82                         previous window's, null where either has none, so a lead jumping \
83                         between a beat and its double reads as a large drift. curve, where \
84                         asked: the height at every lag from `min_period_secs` by \
85                         `lag_step_secs` to the window's longest; heights and curves are null \
86                         where the window's envelope is constant. summary: over the windows \
87                         with a peak, the leading peak's bpm and height and the drift, their \
88                         mean, min and max with the t each stands at. Exact path: none; a \
89                         closed form, its onsets placed exactly or not, is read off its \
90                         rendered samples like any other, because the onset strength is the \
91                         band envelopes' rise, which no closed form states";
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub enum Group {
95    Broadband,
96    Low,
97    Rest,
98}
99
100impl Group {
101    pub const ALL: [Group; 3] = [Group::Broadband, Group::Low, Group::Rest];
102
103    pub fn key(self) -> &'static str {
104        match self {
105            Group::Broadband => "broadband",
106            Group::Low => "low",
107            Group::Rest => "rest",
108        }
109    }
110}
111
112#[derive(Clone, Copy, Debug, PartialEq)]
113pub struct Peak {
114    pub period_secs: f64,
115    /// Where MODEL's symmetric peak puts the true period; `None` where the window refutes or
116    /// leaves that model.
117    pub period_bounds_secs: Option<(f64, f64)>,
118    pub bpm: f64,
119    /// 60 over `period_bounds_secs`.
120    pub bpm_bounds: Option<(f64, f64)>,
121    /// The plain curve's height at the peak's lag.
122    pub height: f64,
123}
124
125#[derive(Clone, Debug, PartialEq)]
126pub struct Window {
127    pub t_secs: f64,
128    pub from_secs: f64,
129    pub to_secs: f64,
130    /// Highest first.
131    pub peaks: Vec<Peak>,
132    pub drift_bpm: Option<f64>,
133    /// `None` where not asked; NaN at each lag where the envelope is constant.
134    pub curve: Option<Vec<f64>>,
135}
136
137#[derive(Clone, Copy, Debug, PartialEq)]
138pub struct Summary {
139    pub windows_with_peak: usize,
140    pub bpm: Option<Stats>,
141    pub height: Option<Stats>,
142    pub drift_bpm: Option<Stats>,
143}
144
145#[derive(Clone, Debug, PartialEq)]
146pub struct Track {
147    pub group: Group,
148    pub centres_hz: Vec<f64>,
149    pub windows: Vec<Window>,
150    pub summary: Summary,
151}
152
153#[derive(Clone, Debug, PartialEq)]
154pub struct Periodicity {
155    pub hop_secs: f64,
156    pub window_secs: f64,
157    pub envelope_hz: f64,
158    pub lag_step_secs: f64,
159    pub min_period_secs: f64,
160    pub max_period_secs: f64,
161    pub tracks: Vec<Track>,
162}
163
164pub fn measure(
165    planes: &[&[f64]],
166    sample_rate: f64,
167    start_secs: f64,
168    hop_secs: f64,
169    window_secs: f64,
170    curve: bool,
171) -> Result<Periodicity, Unread> {
172    let held = crate::measure::held(planes);
173    sliding::fits(held, sample_rate, window_secs, MIN_PERIOD_SECS)?;
174    let split = bands::smoothed(planes, sample_rate, start_secs, SMOOTHING);
175    let rate = split.rate_hz;
176    let len = split.bands.first().map_or(0, |b| b.rms.len());
177    let grid = sliding::spans(
178        held,
179        sample_rate,
180        start_secs,
181        hop_secs,
182        rate,
183        window_secs,
184        len,
185    );
186    let size = (grid.window_secs * rate).round() as usize;
187    let lags = Lags {
188        min: (MIN_PERIOD_SECS * rate).ceil() as usize,
189        max: (MAX_PERIOD_SECS.min(grid.window_secs / 2.0) * rate).floor() as usize,
190        rate,
191    };
192    let tracks = Group::ALL
193        .into_iter()
194        .zip(envelopes(&split))
195        .map(|(group, (centres_hz, envelope))| {
196            let mut windows: Vec<Window> = grid
197                .spans
198                .iter()
199                .map(|s| {
200                    let (heights, place) = heights(&envelope[s.from..s.to], lags.max + 1);
201                    Window {
202                        t_secs: s.t_secs,
203                        from_secs: s.from_secs,
204                        to_secs: s.to_secs,
205                        peaks: lags.peaks(&heights, &place, s.to - s.from == size),
206                        drift_bpm: None,
207                        curve: curve.then(|| {
208                            heights[..heights.len().min(lags.max + 1)]
209                                .iter()
210                                .skip(lags.min)
211                                .copied()
212                                .collect()
213                        }),
214                    }
215                })
216                .collect();
217            for i in 1..windows.len() {
218                let lead = |w: &Window| w.peaks.first().map(|p| p.bpm);
219                windows[i].drift_bpm = lead(&windows[i])
220                    .zip(lead(&windows[i - 1]))
221                    .map(|(a, b)| a - b);
222            }
223            Track {
224                group,
225                centres_hz,
226                summary: summary(&windows),
227                windows,
228            }
229        })
230        .collect();
231    Ok(Periodicity {
232        hop_secs: grid.hop_secs,
233        window_secs: grid.window_secs,
234        envelope_hz: rate,
235        lag_step_secs: 1.0 / rate,
236        min_period_secs: lags.min as f64 / rate,
237        max_period_secs: lags.max as f64 / rate,
238        tracks,
239    })
240}
241
242/// Each group's band centres and onset-strength envelope, in `Group::ALL`'s order.
243fn envelopes(split: &Bands) -> Vec<(Vec<f64>, Vec<f64>)> {
244    let tracks = &split.bands;
245    let low = tracks
246        .iter()
247        .position(|b| b.centre_hz >= SPLIT_HZ)
248        .unwrap_or(tracks.len());
249    let ranges = [0..tracks.len(), 0..low, low..tracks.len()];
250    let len = tracks.first().map_or(0, |b| b.rms.len());
251    let mut flux: Vec<Vec<f64>> = ranges.iter().map(|_| vec![0.0; len]).collect();
252    let (mut prev, mut curr) = (vec![0.0; tracks.len()], vec![0.0; tracks.len()]);
253    for n in 0..len {
254        for (held, band) in curr.iter_mut().zip(tracks) {
255            *held = band.rms[n];
256        }
257        if n > 0 {
258            for (out, range) in flux.iter_mut().zip(&ranges) {
259                out[n] = spectral_flux(&prev[range.clone()], &curr[range.clone()]);
260            }
261        }
262        std::mem::swap(&mut prev, &mut curr);
263    }
264    ranges
265        .into_iter()
266        .zip(flux)
267        .map(|(range, envelope)| {
268            let centres = tracks[range].iter().map(|b| b.centre_hz).collect();
269            (centres, envelope)
270        })
271        .collect()
272}
273
274/// At lags `0..=top` the segment holds: the mean-removed segment's autocorrelation over its
275/// value at lag 0, and MODEL's place, the curve a peak is located on.
276fn heights(segment: &[f64], top: usize) -> (Vec<f64>, Vec<f64>) {
277    let top = top.min(segment.len().saturating_sub(1));
278    let mean = segment.iter().sum::<f64>() / segment.len() as f64;
279    let y: Vec<f64> = segment.iter().map(|x| x - mean).collect();
280    let taper = hann_periodic(segment.len());
281    let tapered: Vec<f64> = y.iter().zip(&taper).map(|(y, w)| y * w).collect();
282    let plain = correlated(&y, top);
283    let (curve, own) = (correlated(&tapered, top), correlated(&taper, top));
284    (
285        plain.iter().map(|r| r / plain[0]).collect(),
286        curve.iter().zip(&own).map(|(c, w)| c / w).collect(),
287    )
288}
289
290/// sum over n of x[n] x[n + tau] at lags `0..=top`, zero-padded past `len + top` so no lag wraps.
291fn correlated(x: &[f64], top: usize) -> Vec<f64> {
292    let n = (x.len() + top + 1).next_power_of_two();
293    let mut re = vec![0.0; n];
294    re[..x.len()].copy_from_slice(x);
295    let mut im = vec![0.0; n];
296    fft(&mut re, &mut im);
297    for (r, i) in re.iter_mut().zip(im.iter_mut()) {
298        *r = *r * *r + *i * *i;
299        *i = 0.0;
300    }
301    ifft(&mut re, &mut im);
302    re.truncate(top + 1);
303    re
304}
305
306struct Lags {
307    min: usize,
308    max: usize,
309    rate: f64,
310}
311
312impl Lags {
313    /// Local maxima of `place`, ranked by `heights`, each located and bounded as MODEL states;
314    /// `whole` where the window holds its full span.
315    fn peaks(&self, heights: &[f64], place: &[f64], whole: bool) -> Vec<Peak> {
316        let last = self.max.min(heights.len().saturating_sub(2));
317        let mut found: Vec<(usize, f64)> = (self.min.max(1)..=last)
318            .filter(|&l| heights[l] > 0.0 && place[l] > place[l - 1] && place[l] >= place[l + 1])
319            .map(|l| (l, heights[l]))
320            .collect();
321        found.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
322        found
323            .into_iter()
324            .take(PEAKS)
325            .map(|(lag, height)| {
326                let (before, at, after) = (place[lag - 1], place[lag], place[lag + 1]);
327                let shift = 0.5 * (before - after) / (before - 2.0 * at + after);
328                let half = match after > before {
329                    true => (0.0, 0.5),
330                    false => (-0.5, 0.0),
331                };
332                let reach = (shift - half.0).max(half.1 - shift);
333                let secs = |l: f64| (lag as f64 + l) / self.rate;
334                let period_secs = secs(shift);
335                let falls = place[lag - 2] < before
336                    && place.get(lag + 2).is_some_and(|&beyond| beyond < after);
337                let bounds = (whole && falls).then(|| (secs(shift - reach), secs(shift + reach)));
338                Peak {
339                    period_secs,
340                    period_bounds_secs: bounds,
341                    bpm: 60.0 / period_secs,
342                    bpm_bounds: bounds.map(|(lo, hi)| (60.0 / hi, 60.0 / lo)),
343                    height,
344                }
345            })
346            .collect()
347    }
348}
349
350fn summary(windows: &[Window]) -> Summary {
351    let leads = || {
352        windows
353            .iter()
354            .filter_map(|w| w.peaks.first().map(|p| (w.t_secs, *p)))
355    };
356    Summary {
357        windows_with_peak: leads().count(),
358        bpm: stats(leads().map(|(t, p)| (t, p.bpm))),
359        height: stats(leads().map(|(t, p)| (t, p.height))),
360        drift_bpm: stats(
361            windows
362                .iter()
363                .filter_map(|w| w.drift_bpm.map(|d| (w.t_secs, d))),
364        ),
365    }
366}
367
368fn spectral_flux(prev: &[f64], curr: &[f64]) -> f64 {
369    prev.iter().zip(curr).map(|(&p, &c)| (c - p).max(0.0)).sum()
370}