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