Skip to main content

sva_samples/measure/
modulation.rs

1// Concern: how each band group's envelopes and centroid move per sliding window: rate, depth | Non-concern: the band filters, any verdict | IO: (planes, rate, start, hop, window, bpm) -> Modulation
2
3use std::f64::consts::{PI, TAU};
4use std::ops::Range;
5
6use sva_formula::C64;
7use sva_formula::filter::Shape;
8
9use super::bands::{self, Bands, Smoothing};
10use super::periodicity::SPLIT_HZ;
11use super::sliding::{self, Span, Stats, Unread, stats};
12use crate::biquad::{Coeffs, design};
13use crate::fft::fft;
14use crate::stft::hann_periodic;
15
16/// `low` under the first, `mid` under the second, `high` from it.
17pub const SPLITS_HZ: [f64; 2] = [SPLIT_HZ, 2000.0];
18pub const MIN_HZ: f64 = 0.5;
19pub const MAX_HZ: f64 = 20.0;
20pub const PEAKS: usize = 3;
21/// Each band's energy through four one-poles at 200 Hz before `bands` decimates it to 2 kHz:
22/// a tone's ripple at twice its frequency, near a multiple of 2 kHz, would alias onto a slow
23/// modulation, and falls some 78 dB by 1.92 kHz, while a 20 Hz modulation keeps 98%.
24pub const SMOOTHING: Smoothing = Smoothing {
25    hz: Some(SMOOTHING_HZ),
26    poles: 4,
27};
28const SMOOTHING_HZ: f64 = 200.0;
29/// The rate the envelopes are read at once the lowpass below has cleared the band.
30const ENVELOPE_HZ: f64 = 100.0;
31const LOWPASS_HZ: f64 = 50.0;
32/// Half the lowpass's taps less one: 257 taps at 2 kHz.
33const HALF_TAPS: usize = 128;
34
35pub const MODEL: &str = "modulation spectrum: the spectrum of each band's amplitude envelope \
36                         over a sliding window, the representation of Atlas, L. and Shamma, S. \
37                         A. (2003), Joint acoustic and modulation frequency, EURASIP J. Appl. \
38                         Signal Process. 2003(7) 668-675, read over the 0.5-20 Hz modulation \
39                         range the envelope filterbank of Dau, T., Kollmeier, B. and \
40                         Kohlrausch, A. (1997), Modeling auditory processing of amplitude \
41                         modulation I, J. Acoust. Soc. Am. 102(5) 2892-2905, models, as much \
42                         of it as the window holds two periods of: `min_hz` is the higher of \
43                         0.5 Hz and 2 / `window_secs`, and a window under two periods of \
44                         `max_hz` reads nothing and refuses. Envelopes: \
45                         the RMS of each ERB band of `bands` (Glasberg and Moore's filter \
46                         widths), its energy smoothed by `smoothing.poles` one-pole lowpasses at \
47                         `smoothing.hz` in place of the one at the band's own width, decimated \
48                         to 2 kHz, then by a 257-tap Blackman-windowed sinc lowpass at 50 Hz to \
49                         `envelope_hz` (Harris, F. J. (1978), On the use of windows for \
50                         harmonic analysis with the discrete Fourier transform, Proc. IEEE \
51                         66(1) 51-83: stopband some 74 dB down, from about 71 Hz, so what \
52                         would fold into 0-20 Hz is that far down), the taps renormalized \
53                         where they reach past the target. G(f) = |H(f)| L(f), H the \
54                         smoothing's response on the energy and L the 50 Hz lowpass's on the \
55                         envelope, off their coefficients, is what a modulation at f keeps to \
56                         first order, and each envelope peak's depth is divided by G at its \
57                         rate, a centroid peak's by L; `passband_gain_at_max_hz` is G at \
58                         `max_hz`. Every \
59                         channel's band energy averaged. Groups: a band is `low` where its centre is under \
60                         `splits_hz[0]`, `mid` under `splits_hz[1]` and `high` from it; the \
61                         filters' skirts overlap, so a tone leaks attenuated into the \
62                         neighbouring group, and its modulation with it. Each window is \
63                         `window_secs` of envelope centred on t, t stepping by `hop_secs` \
64                         from the target's start as every framed reading steps, clipped to \
65                         the target at its ends, `from`/`to` its span; a periodic Hann window \
66                         w over it, each band's Hann-weighted mean c = sum w e / sum w \
67                         subtracted (so the mean leaves no lobe), zero-padded to `bin_step_hz` \
68                         and transformed by FFT. depth(f) = 2 sum over the group's bands of \
69                         |W(f)| over (sum w times sum of c): the modulation index m of an \
70                         envelope c (1 + m cos 2 pi f t), read as the c-weighted mean of the \
71                         bands' indices, magnitudes summed so bands moving out of phase, as \
72                         under a filter sweep, do not cancel; an envelope near silent \
73                         between swells reads past 1, up to 2 for a pulse train. `level` is \
74                         the sum of c, linear amplitude. peaks: the three local maxima of \
75                         depth from `min_hz` to `max_hz` whose bins read deepest, the deepest \
76                         after correction first, each placed by inverting the periodic Hann's \
77                         exact transform W through its bin and the deeper neighbour, as \
78                         Grandke, T. (1983), Interpolation algorithms for discrete Fourier \
79                         transforms of weighted signals, IEEE Trans. Instrum. Meas. 32(2) \
80                         350-355, does between unpadded bins: the offset u, within q of the \
81                         bin, where |W(q - u)| / |W(u)| is the two bins' ratio, q the padded \
82                         bin in window bins, that ratio rising with u; its depth the bin's \
83                         over |W(u)| / W(0), then over G. Bounds: under the model that in the \
84                         window each band's envelope ahead of the smoothing is a carrier's \
85                         amplitude c (1 + m cos(2 pi f t + phi)), one m <= 1 in every band, \
86                         the carrier's ripple wholly removed by the smoothing, and a centroid \
87                         is mean + depth cos(2 pi f t + phi), the two bins read the main lobe \
88                         moved by: the modulation's image at -f and the removed mean, at most \
89                         r(2 (x - q)) + 2 r(x - q)^2 of its amplitude, x the bin in window \
90                         bins, r(y) the transform's envelope over its sidelobes (|W| with \
91                         |sin pi y| taken as 1, falling for 1 < y < n/2), the mean also moving \
92                         the level by m r(x - q); and for an envelope the square law: the \
93                         smoothing h, positive of unit sum, acts on energy, so each envelope \
94                         is h * a plus d, 0 <= d = var_h(a) / (sqrt(h * a^2) + h * a) <= c \
95                         min(m^2 V / (2 (1 - m)), m sqrt V), V = (1 - |H(f)|^2 + |H(2f) - \
96                         H(f)^2|) / 2 at the bin's rate plus q, V rising with f, so the \
97                         lowpass (S the sum of its taps' magnitudes) moves a bin by at most \
98                         (1 + 3 r(x - q)) S c min(...) and the level by S c min(...). The \
99                         ratio widened by those, \
100                         inverted, gives `hz_bounds`; the bin over the kernel's most and \
101                         least across them and over G's (|H| falling with f, L moving no \
102                         faster than its taps' slope) gives `depth_bounds`; m's ceiling is \
103                         1, or lower where the square law's sqrt V branch bounds it, and each \
104                         round's depth ceiling bounds the next. `period_beats_bounds` follow \
105                         from `hz_bounds`. They cover the bins, the interpolation, the \
106                         correction and the square law alone, under that model: a tone's \
107                         sidebands f either side meet each band's skirt unevenly, so the \
108                         bands' envelopes carry a tone's index changed, by up to several \
109                         percent near `max_hz` with where the tone falls among the bands, \
110                         which is the bands' view and not an error the bounds cover; a second \
111                         modulation, partial or component near f is outside the model. They \
112                         are null where the window is clipped, where no round bounds the \
113                         peak (the perturbations swamping the main lobe), where the two \
114                         bins' ratio, widened by those, is one no main lobe reads at bins q \
115                         apart, as at a sidelobe, or where the depth's floor stands over its \
116                         ceiling, refuting the model. Deep, fast \
117                         modulation bounds wide: near `max_hz` the square law's bound reaches \
118                         a fifth of m. A strong peak's Hann sidelobes, some \
119                         31 dB under it and 1.5 / window beside it, read as peaks of their \
120                         own; a modulation that is no sinusoid reads at its rate and its \
121                         harmonics; two modulations closer than \
122                         `resolution_hz`, the Hann main lobe's half-width 2 / window, or one \
123                         slower than that, interfere and are not resolved, and a window \
124                         clipped at the target's ends reads from 2 / (to - from) Hz. \
125                         period_beats, where `bpm` is given: bpm / (60 hz), so 0.5 is an \
126                         eighth-note wobble. centroid: per instant the group's spectral \
127                         centroid, sum of centre e over sum of e across its bands at 2 kHz, \
128                         lowpassed and decimated as the envelopes are; `mean_hz` its \
129                         Hann-weighted mean, and its peaks read as the envelopes' are, their \
130                         `depth_hz` the amplitude in Hz of a centroid swinging as mean + depth \
131                         cos 2 pi f t, which a filter sweep moves and tremolo does not; null \
132                         where any instant in the window has no energy in the group. Beating \
133                         between partials inside one band reads as modulation at their \
134                         difference, as it is heard; a modulation under `min_hz` (a pad \
135                         evolving over seconds) reads in `level` and `centroid.mean_hz` from \
136                         window to window, not in the peaks. No threshold: a static sound \
137                         reads peaks of small depth, never none. summary: the leading peak's \
138                         hz and depth over the windows with a peak, the centroid's mean_hz \
139                         over the windows with one, the leading centroid peak's depth_hz over \
140                         the windows with one, each its mean, min and max with the t each \
141                         stands at. Exact path: none; a closed form is read \
142                         off its rendered samples, because an envelope's band-limited \
143                         spectrum over a window is stated by no closed form";
144
145#[derive(Clone, Copy, Debug, PartialEq, Eq)]
146pub enum Group {
147    Low,
148    Mid,
149    High,
150}
151
152impl Group {
153    pub const ALL: [Group; 3] = [Group::Low, Group::Mid, Group::High];
154
155    pub fn key(self) -> &'static str {
156        match self {
157            Group::Low => "low",
158            Group::Mid => "mid",
159            Group::High => "high",
160        }
161    }
162}
163
164/// `depth` is a modulation index for an envelope, Hz for a centroid. Each bound is MODEL's,
165/// `None` where the window leaves or refutes MODEL's lone modulation.
166#[derive(Clone, Copy, Debug, PartialEq)]
167pub struct Peak {
168    pub hz: f64,
169    pub hz_bounds: Option<(f64, f64)>,
170    pub period_beats: Option<f64>,
171    pub period_beats_bounds: Option<(f64, f64)>,
172    pub depth: f64,
173    pub depth_bounds: Option<(f64, f64)>,
174}
175
176#[derive(Clone, Debug, PartialEq)]
177pub struct Centroid {
178    pub mean_hz: f64,
179    /// Highest first.
180    pub peaks: Vec<Peak>,
181}
182
183#[derive(Clone, Debug, PartialEq)]
184pub struct Window {
185    pub t_secs: f64,
186    pub from_secs: f64,
187    pub to_secs: f64,
188    pub level: f64,
189    /// Highest first.
190    pub peaks: Vec<Peak>,
191    pub centroid: Option<Centroid>,
192}
193
194#[derive(Clone, Copy, Debug, PartialEq)]
195pub struct Summary {
196    pub windows_with_peak: usize,
197    pub hz: Option<Stats>,
198    pub depth: Option<Stats>,
199    pub centroid_hz: Option<Stats>,
200    pub centroid_depth_hz: Option<Stats>,
201}
202
203#[derive(Clone, Debug, PartialEq)]
204pub struct Track {
205    pub group: Group,
206    pub centres_hz: Vec<f64>,
207    pub windows: Vec<Window>,
208    pub summary: Summary,
209}
210
211#[derive(Clone, Debug, PartialEq)]
212pub struct Modulation {
213    pub hop_secs: f64,
214    pub window_secs: f64,
215    pub envelope_hz: f64,
216    pub bin_step_hz: f64,
217    pub resolution_hz: f64,
218    pub min_hz: f64,
219    pub max_hz: f64,
220    pub bpm: Option<f64>,
221    pub passband_gain_at_max_hz: f64,
222    pub tracks: Vec<Track>,
223}
224
225pub fn measure(
226    planes: &[&[f64]],
227    sample_rate: f64,
228    start_secs: f64,
229    hop_secs: f64,
230    window_secs: f64,
231    bpm: Option<f64>,
232) -> Result<Modulation, Unread> {
233    let held = crate::measure::held(planes);
234    sliding::fits(held, sample_rate, window_secs, 1.0 / MAX_HZ)?;
235    let split = bands::smoothed(planes, sample_rate, start_secs, SMOOTHING);
236    let lowpass = Lowpass::new(split.rate_hz);
237    let groups = grouped(&split, &lowpass);
238    let rate = split.rate_hz / lowpass.step as f64;
239    let len = groups.first().map_or(0, |g| g.centroid.len());
240    let grid = sliding::spans(
241        held,
242        sample_rate,
243        start_secs,
244        hop_secs,
245        rate,
246        window_secs,
247        len,
248    );
249    let size = (grid.window_secs * rate).round() as usize;
250    let n = (2 * size).next_power_of_two();
251    let step = rate / n as f64;
252    let read = Read {
253        step,
254        bins: 1..((MAX_HZ / step).ceil() as usize + 2).min(n / 2),
255        n,
256        bpm,
257        size,
258        rate,
259        sample_rate,
260        smoothing: design(Shape::OnePole, SMOOTHING_HZ, 0.0, 0.0, sample_rate),
261        lowpass: &lowpass,
262    };
263    let tracks = Group::ALL
264        .into_iter()
265        .zip(groups)
266        .map(|(group, held)| {
267            let windows: Vec<Window> = grid.spans.iter().map(|s| read.window(&held, s)).collect();
268            Track {
269                group,
270                centres_hz: held.centres_hz,
271                summary: summary(&windows),
272                windows,
273            }
274        })
275        .collect();
276    Ok(Modulation {
277        hop_secs: grid.hop_secs,
278        window_secs: grid.window_secs,
279        envelope_hz: rate,
280        bin_step_hz: step,
281        resolution_hz: 2.0 / grid.window_secs,
282        min_hz: MIN_HZ.max(2.0 / grid.window_secs),
283        max_hz: MAX_HZ,
284        bpm,
285        passband_gain_at_max_hz: read.gain(MAX_HZ, Kind::Envelope),
286        tracks,
287    })
288}
289
290/// A Blackman-windowed sinc, unit gain at 0 Hz, taken every `step` samples.
291struct Lowpass {
292    taps: Vec<f64>,
293    step: usize,
294    rate: f64,
295}
296
297impl Lowpass {
298    fn new(rate: f64) -> Lowpass {
299        let cut = LOWPASS_HZ / rate;
300        let half = HALF_TAPS as f64;
301        let raw: Vec<f64> = (0..=2 * HALF_TAPS)
302            .map(|i| {
303                let j = i as f64 - half;
304                let sinc = match j == 0.0 {
305                    true => 2.0 * cut,
306                    false => (TAU * cut * j).sin() / (PI * j),
307                };
308                let x = PI * j / half;
309                sinc * (0.42 + 0.5 * x.cos() + 0.08 * (2.0 * x).cos())
310            })
311            .collect();
312        let sum: f64 = raw.iter().sum();
313        Lowpass {
314            taps: raw.into_iter().map(|h| h / sum).collect(),
315            step: (rate / ENVELOPE_HZ).round().max(1.0) as usize,
316            rate,
317        }
318    }
319
320    /// Symmetric, so its response is real.
321    fn gain(&self, hz: f64) -> f64 {
322        self.taps
323            .iter()
324            .enumerate()
325            .map(|(i, h)| h * (TAU * hz * (i as f64 - HALF_TAPS as f64) / self.rate).cos())
326            .sum()
327    }
328
329    /// Over the gain's slope in hz: each tap's cosine turns no faster than its lag.
330    fn slope(&self) -> f64 {
331        self.taps
332            .iter()
333            .enumerate()
334            .map(|(i, h)| h.abs() * TAU * (i as f64 - HALF_TAPS as f64).abs() / self.rate)
335            .sum()
336    }
337
338    /// The most the output can swing for an input swinging over a unit range.
339    fn spread(&self) -> f64 {
340        self.taps.iter().map(|h| h.abs()).sum()
341    }
342
343    /// Centred on each kept sample, the taps past either end left out and the rest
344    /// renormalized, so a constant stays that constant to the last sample. NaN in reach
345    /// stays NaN.
346    fn apply(&self, x: &[f64]) -> Vec<f64> {
347        (0..x.len())
348            .step_by(self.step)
349            .map(|m| {
350                let lo = m.saturating_sub(HALF_TAPS);
351                let hi = (m + HALF_TAPS + 1).min(x.len());
352                let taps = &self.taps[lo + HALF_TAPS - m..hi + HALF_TAPS - m];
353                let sum: f64 = taps.iter().sum();
354                taps.iter().zip(&x[lo..hi]).map(|(h, v)| h * v).sum::<f64>() / sum
355            })
356            .collect()
357    }
358}
359
360/// One group's decimated band envelopes and centroid.
361struct Held {
362    centres_hz: Vec<f64>,
363    envelopes: Vec<Vec<f64>>,
364    /// NaN where the group holds no energy.
365    centroid: Vec<f64>,
366}
367
368fn grouped(split: &Bands, lowpass: &Lowpass) -> Vec<Held> {
369    let tracks = &split.bands;
370    let edge = |hz: f64| {
371        tracks
372            .iter()
373            .position(|b| b.centre_hz >= hz)
374            .unwrap_or(tracks.len())
375    };
376    let (low, mid) = (edge(SPLITS_HZ[0]), edge(SPLITS_HZ[1]));
377    let len = tracks.first().map_or(0, |b| b.rms.len());
378    [0..low, low..mid, mid..tracks.len()]
379        .into_iter()
380        .map(|range: Range<usize>| {
381            let held = &tracks[range];
382            let centroid: Vec<f64> = (0..len)
383                .map(|i| {
384                    let (weighted, sum) = held.iter().fold((0.0, 0.0), |(w, s), b| {
385                        (w + b.centre_hz * b.rms[i], s + b.rms[i])
386                    });
387                    match sum > 0.0 {
388                        true => weighted / sum,
389                        false => f64::NAN,
390                    }
391                })
392                .collect();
393            Held {
394                centres_hz: held.iter().map(|b| b.centre_hz).collect(),
395                envelopes: held.iter().map(|b| lowpass.apply(&b.rms)).collect(),
396                centroid: lowpass.apply(&centroid),
397            }
398        })
399        .collect()
400}
401
402struct Read<'a> {
403    step: f64,
404    /// From the first bin past 0 Hz to one past `MAX_HZ`.
405    bins: Range<usize>,
406    n: usize,
407    bpm: Option<f64>,
408    /// A whole window's envelope samples.
409    size: usize,
410    rate: f64,
411    sample_rate: f64,
412    smoothing: Coeffs,
413    lowpass: &'a Lowpass,
414}
415
416/// An envelope's depth is an index under the smoothing's square law; a centroid's is Hz.
417#[derive(Clone, Copy, Debug, PartialEq, Eq)]
418enum Kind {
419    Envelope,
420    Centroid,
421}
422
423/// Each round of `Read::bounded` tightens the index's ceiling the last one bounded.
424const ROUNDS: usize = 8;
425
426impl Read<'_> {
427    fn window(&self, held: &Held, s: &Span) -> Window {
428        let w = hann_periodic(s.to - s.from);
429        let total: f64 = w.iter().sum();
430        let lowest = MIN_HZ.max(2.0 / (s.to_secs - s.from_secs));
431        let segments: Vec<&[f64]> = held.envelopes.iter().map(|e| &e[s.from..s.to]).collect();
432        let means: Vec<f64> = segments.iter().map(|e| weighted_mean(&w, e)).collect();
433        let level: f64 = means.iter().sum();
434        let peaks = match level > 0.0 {
435            true => {
436                let mut depth = vec![0.0; self.bins.len()];
437                for spectrum in self.magnitudes(&w, &segments, &means) {
438                    for (d, m) in depth.iter_mut().zip(spectrum) {
439                        *d += 2.0 * m / (total * level);
440                    }
441                }
442                self.peaks(&depth, lowest, s, Kind::Envelope)
443            }
444            false => Vec::new(),
445        };
446        let track = &held.centroid[s.from..s.to];
447        let centroid = (!track.iter().any(|c| c.is_nan())).then(|| {
448            let mean_hz = weighted_mean(&w, track);
449            let spectrum = self.magnitudes(&w, &[track], &[mean_hz]).remove(0);
450            let depth: Vec<f64> = spectrum.into_iter().map(|m| 2.0 * m / total).collect();
451            Centroid {
452                mean_hz,
453                peaks: self.peaks(&depth, lowest, s, Kind::Centroid),
454            }
455        });
456        Window {
457            t_secs: s.t_secs,
458            from_secs: s.from_secs,
459            to_secs: s.to_secs,
460            level,
461            peaks,
462            centroid,
463        }
464    }
465
466    /// |DFT| of each windowed, mean-removed segment at `bins`; two segments share one complex
467    /// transform as its real and imaginary parts.
468    fn magnitudes(&self, w: &[f64], segments: &[&[f64]], means: &[f64]) -> Vec<Vec<f64>> {
469        let fill = |out: &mut [f64], at: usize| {
470            for ((o, x), h) in out.iter_mut().zip(segments[at]).zip(w) {
471                *o = h * (x - means[at]);
472            }
473        };
474        let mut out = Vec::with_capacity(segments.len());
475        for pair in (0..segments.len()).step_by(2) {
476            let (mut re, mut im) = (vec![0.0; self.n], vec![0.0; self.n]);
477            fill(&mut re, pair);
478            let both = pair + 1 < segments.len();
479            if both {
480                fill(&mut im, pair + 1);
481            }
482            fft(&mut re, &mut im);
483            let half = |sign: f64| -> Vec<f64> {
484                self.bins
485                    .clone()
486                    .map(|k| {
487                        let (r, i) = (re[self.n - k], -im[self.n - k]);
488                        (re[k] + sign * r).hypot(im[k] + sign * i) / 2.0
489                    })
490                    .collect()
491            };
492            out.push(half(1.0));
493            if both {
494                out.push(half(-1.0));
495            }
496        }
497        out
498    }
499
500    /// `depth` is indexed from `bins.start`: the three local maxima whose bins read deepest,
501    /// each located as MODEL states, the deepest first.
502    fn peaks(&self, depth: &[f64], lowest: f64, s: &Span, kind: Kind) -> Vec<Peak> {
503        let first = self.bins.start;
504        let mut found: Vec<usize> = (1..depth.len().saturating_sub(1))
505            .filter(|&i| {
506                let hz = (first + i) as f64 * self.step;
507                (lowest..=MAX_HZ).contains(&hz)
508                    && depth[i] > depth[i - 1]
509                    && depth[i] >= depth[i + 1]
510            })
511            .collect();
512        found.sort_by(|a, b| depth[*b].total_cmp(&depth[*a]).then(a.cmp(b)));
513        let mut peaks: Vec<Peak> = found
514            .into_iter()
515            .take(PEAKS)
516            .map(|i| self.located(depth, i, s.to - s.from, kind))
517            .collect();
518        peaks.sort_by(|a, b| b.depth.total_cmp(&a.depth).then(a.hz.total_cmp(&b.hz)));
519        peaks
520    }
521
522    /// The Hann kernel inverted through the deepest bin and its deeper neighbour, and MODEL's
523    /// bounds where the window holds its whole span.
524    fn located(&self, depth: &[f64], i: usize, size: usize, kind: Kind) -> Peak {
525        let q = size as f64 / self.n as f64;
526        let x = (self.bins.start + i) as f64 * q;
527        let side = match depth[i + 1] >= depth[i - 1] {
528            true => 1.0,
529            false => -1.0,
530        };
531        let d0 = depth[i];
532        let ratio = depth[(i as f64 + side) as usize] / d0;
533        let u = offset(ratio, q, size);
534        let hz = (x + side * u) * self.rate / size as f64;
535        let bounds = match size == self.size {
536            true => self.bounded(d0, ratio, x, side, size, kind),
537            false => None,
538        };
539        let beats = |hz: f64| self.bpm.map(|bpm| bpm / (60.0 * hz));
540        Peak {
541            hz,
542            hz_bounds: bounds.map(|b| b.0),
543            period_beats: beats(hz),
544            period_beats_bounds: bounds.and_then(|((lo, hi), _)| beats(hi).zip(beats(lo))),
545            depth: d0 / kernel(u, size) / self.gain(hz, kind),
546            depth_bounds: bounds.map(|b| b.1),
547        }
548    }
549
550    /// MODEL's (hz, depth) bounds for a peak at natural bin `x` reading `d0`, its neighbour on
551    /// `side` reading `ratio` of that; `None` where no round can bound it.
552    fn bounded(
553        &self,
554        d0: f64,
555        ratio: f64,
556        x: f64,
557        side: f64,
558        size: usize,
559        kind: Kind,
560    ) -> Option<((f64, f64), (f64, f64))> {
561        let q = size as f64 / self.n as f64;
562        let to_hz = |nu: f64| nu * self.rate / size as f64;
563        let kappa = reach(x - q, size);
564        let lambda = reach(2.0 * (x - q), size) + 2.0 * kappa * kappa;
565        let spread = self.lowpass.spread();
566        let v = match kind {
567            Kind::Envelope => self.square_law(to_hz(x + q)),
568            Kind::Centroid => 0.0,
569        };
570        let law = |m: f64| {
571            let tight = match m < 1.0 {
572                true => m * m * v / (2.0 * (1.0 - m)),
573                false => f64::INFINITY,
574            };
575            tight.min(m * v.sqrt())
576        };
577        let square = |m: f64| (1.0 + 3.0 * kappa) * spread * law(m);
578        let mut m = match kind {
579            Kind::Envelope => {
580                let (lo, _) = self.gains((to_hz(x - q), to_hz(x + q)), kind);
581                let room = lo * kernel(q, size)
582                    - d0 * (kappa + spread * v.sqrt())
583                    - spread * lambda
584                    - square(1.0);
585                match room > 0.0 {
586                    true => (d0 / room).min(1.0),
587                    false => 1.0,
588                }
589            }
590            Kind::Centroid => f64::INFINITY,
591        };
592        let mut out = None;
593        for _ in 0..ROUNDS {
594            let (level, amp) = match kind {
595                Kind::Envelope => (m * kappa + spread * law(m), m * spread),
596                Kind::Centroid => (0.0, d0 / (kernel(q, size) - lambda)),
597            };
598            let e = amp * lambda + square(m);
599            let floor = d0 * (1.0 - level) - e;
600            if !(floor > 0.0 && amp > 0.0 && amp.is_finite()) {
601                break;
602            }
603            let wide = (1.0 + ratio) * e / floor;
604            let (least, most) = ratios(q, size);
605            if ratio + wide < least || ratio - wide > most {
606                return None;
607            }
608            let (ua, ub) = (offset(ratio - wide, q, size), offset(ratio + wide, q, size));
609            let near = match ua <= 0.0 && ub >= 0.0 {
610                true => 0.0,
611                false => ua.abs().min(ub.abs()),
612            };
613            let far = ua.abs().max(ub.abs());
614            let (ha, hb) = (to_hz(x + side * ua), to_hz(x + side * ub));
615            let hz = (ha.min(hb), ha.max(hb));
616            let g = self.gains(hz, kind);
617            let amps = (
618                floor / kernel(near, size),
619                (d0 * (1.0 + level) + e) / kernel(far, size),
620            );
621            let depth = (amps.0 / g.1, amps.1 / g.0);
622            if depth.0 > m {
623                return None;
624            }
625            out = Some((hz, (depth.0, depth.1.min(m))));
626            if kind == Kind::Centroid || depth.1 >= m {
627                break;
628            }
629            m = depth.1;
630        }
631        out
632    }
633
634    /// The smoothing's response on energy at `hz`.
635    fn smoothed(&self, hz: f64) -> C64 {
636        let w = TAU * hz / self.sample_rate;
637        let back = C64::new(w.cos(), -w.sin()).scale(self.smoothing.a1);
638        (C64::real(self.smoothing.b0) / (C64::ONE + back)).powi(SMOOTHING.poles as u32)
639    }
640
641    /// What a lone modulation at `hz` keeps to first order: the smoothing's gain on energy
642    /// and the lowpass's for an envelope, the lowpass's alone for a centroid.
643    fn gain(&self, hz: f64, kind: Kind) -> f64 {
644        match kind {
645            Kind::Envelope => self.smoothed(hz).abs() * self.lowpass.gain(hz),
646            Kind::Centroid => self.lowpass.gain(hz),
647        }
648    }
649
650    /// `gain`'s least and most over `hz`: the smoothing's falls with hz, the lowpass's moves
651    /// no faster than its slope from the middle.
652    fn gains(&self, hz: (f64, f64), kind: Kind) -> (f64, f64) {
653        let (mid, half) = ((hz.0 + hz.1) / 2.0, (hz.1 - hz.0) / 2.0);
654        let pass = self.lowpass.gain(mid);
655        let moved = self.lowpass.slope() * half;
656        let (lo, hi) = (pass - moved, pass + moved);
657        match kind {
658            Kind::Envelope => (
659                self.smoothed(hz.1).abs() * lo,
660                self.smoothed(hz.0).abs() * hi,
661            ),
662            Kind::Centroid => (lo, hi),
663        }
664    }
665
666    /// V at `hz`: (1 - |H(f)|^2 + |H(2f) - H(f)^2|) / 2, H the smoothing on energy.
667    fn square_law(&self, hz: f64) -> f64 {
668        let (one, two) = (self.smoothed(hz), self.smoothed(2.0 * hz));
669        (1.0 - one.norm_sqr() + (two - one * one).abs()) / 2.0
670    }
671}
672
673/// The periodic Hann over `n` samples, its transform as three Dirichlet kernels: at `x` of
674/// its bins, sin(pi x) times this.
675fn dirichlet(x: f64, n: usize) -> C64 {
676    let n = n as f64;
677    let turn = C64::new((PI / n).cos(), (PI / n).sin());
678    let s = |y: f64| (PI * y / n).sin();
679    C64::real(0.5 / s(x)) - turn.conj().over(4.0 * s(x - 1.0)) - turn.over(4.0 * s(x + 1.0))
680}
681
682/// |W(x)| / W(0), W the periodic Hann's transform over `n` samples at `x` of its bins: exact.
683fn kernel(x: f64, n: usize) -> f64 {
684    match x.abs() {
685        0.0 => 1.0,
686        1.0 => 0.5,
687        x => 2.0 / n as f64 * (PI * x).sin().abs() * dirichlet(x, n).abs(),
688    }
689}
690
691/// `kernel` with |sin(pi x)| taken as 1: over it, and falling, for 1 < x < n / 2.
692fn reach(x: f64, n: usize) -> f64 {
693    2.0 / n as f64 * dirichlet(x, n).abs()
694}
695
696/// The least and most kernel(q - u) / kernel(u) takes for u in [-q, q]: two bins `q` apart
697/// on one main lobe read no other ratio.
698fn ratios(q: f64, n: usize) -> (f64, f64) {
699    (kernel(2.0 * q, n) / kernel(q, n), 1.0 / kernel(q, n))
700}
701
702/// The u in [-q, q] where kernel(q - u) / kernel(u) is `ratio`, by bisection; that ratio rises
703/// with u, so a ratio past either end gives that end.
704fn offset(ratio: f64, q: f64, n: usize) -> f64 {
705    let (mut lo, mut hi) = (-q, q);
706    for _ in 0..60 {
707        let mid = (lo + hi) / 2.0;
708        match kernel(q - mid, n) / kernel(mid, n) < ratio {
709            true => lo = mid,
710            false => hi = mid,
711        }
712    }
713    (lo + hi) / 2.0
714}
715
716fn weighted_mean(w: &[f64], x: &[f64]) -> f64 {
717    w.iter().zip(x).map(|(h, v)| h * v).sum::<f64>() / w.iter().sum::<f64>()
718}
719
720fn summary(windows: &[Window]) -> Summary {
721    let leads = || {
722        windows
723            .iter()
724            .filter_map(|w| w.peaks.first().map(|p| (w.t_secs, *p)))
725    };
726    let centroids = || {
727        windows
728            .iter()
729            .filter_map(|w| w.centroid.as_ref().map(|c| (w.t_secs, c)))
730    };
731    Summary {
732        windows_with_peak: leads().count(),
733        hz: stats(leads().map(|(t, p)| (t, p.hz))),
734        depth: stats(leads().map(|(t, p)| (t, p.depth))),
735        centroid_hz: stats(centroids().map(|(t, c)| (t, c.mean_hz))),
736        centroid_depth_hz: stats(
737            centroids().filter_map(|(t, c)| c.peaks.first().map(|p| (t, p.depth))),
738        ),
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    /// The closed form against the transform summed sample by sample; `reach` over it and
747    /// falling on (1, n/2); V rising with f to `MAX_HZ`: what MODEL's bounds lean on.
748    #[test]
749    fn the_kernel_its_reach_and_the_square_law_hold_as_model_states() {
750        for n in [250, 400, 401] {
751            let w = hann_periodic(n);
752            let total: f64 = w.iter().sum();
753            for x in [0.0, 0.01, 0.39, 0.5, 1.0, 1.7, 2.3, 7.5, 61.2] {
754                let summed = w.iter().enumerate().fold(C64::ZERO, |acc, (k, h)| {
755                    let turn = -TAU * x * k as f64 / n as f64;
756                    acc + C64::new(turn.cos(), turn.sin()).scale(*h)
757                });
758                assert!(
759                    (kernel(x, n) - summed.abs() / total).abs() < 1e-12,
760                    "{n} {x}"
761                );
762            }
763            let ys: Vec<f64> = (1..(n * 5)).map(|k| 1.0 + k as f64 / 10.0).collect();
764            for pair in ys.windows(2).filter(|p| p[1] < n as f64 / 2.0) {
765                assert!(reach(pair[1], n) < reach(pair[0], n), "{n} {pair:?}");
766                assert!(kernel(pair[1], n) <= reach(pair[1], n) * (1.0 + 1e-12));
767            }
768        }
769        let lowpass = Lowpass::new(2000.0);
770        for sample_rate in [8_192.0, 16_000.0, 44_100.0, 48_000.0, 96_000.0] {
771            let read = Read {
772                step: 1.0,
773                bins: 1..2,
774                n: 2,
775                bpm: None,
776                size: 1,
777                rate: 100.0,
778                sample_rate,
779                smoothing: design(Shape::OnePole, SMOOTHING_HZ, 0.0, 0.0, sample_rate),
780                lowpass: &lowpass,
781            };
782            let v: Vec<f64> = (1..=2000)
783                .map(|k| read.square_law(k as f64 * 0.01))
784                .collect();
785            assert!(v.windows(2).all(|p| p[1] > p[0]), "{sample_rate}");
786        }
787    }
788}