Skip to main content

sound_to_patch/dsp/
modulation.rs

1//! Modulation primitives: band envelopes at 200 Hz, the modulation
2//! spectrum, the cumulative-mean-normalised difference, cycle folding and
3//! the snap to a note division.
4//!
5//! The LFO and unison estimators call these. A plain FFT over gappy samples
6//! sees the note rhythm as modulation, so gated samples go to the
7//! Lomb-Scargle periodogram instead. Drum bleed adds lines at its pattern
8//! rates, so the caller computes envelopes on masked cells only.
9
10use realfft::RealFftPlanner;
11
12use crate::audio::{BeatGrid, Division, SampleRate};
13use crate::cast;
14use crate::confidence::{ConfidenceError, Estimate, Level, Reason, ReasonCode, Signal};
15use crate::units::{Bpm, Cents, Hz, Normalized, Ratio, Seconds};
16
17use super::Window;
18use super::analytic::{FilterError, band_magnitude};
19use super::lomb::lomb_scargle;
20
21/// The rate of envelope tracks: a 5 ms hop, Nyquist at 100 Hz.
22pub const TRACK_RATE_HZ: Hz = Hz::from_const(200.0);
23/// The top of the modulation scan. Above it a modulation leaves the
24/// envelope domain and shows as sidebands.
25pub const SCAN_TOP_HZ: Hz = Hz::from_const(20.0);
26/// The bottom of the modulation scan unless the 4-bar rate lies lower.
27pub const SCAN_BOTTOM_HZ: Hz = Hz::from_const(0.2);
28/// Zero padding of the modulation spectrum.
29pub const SPECTRUM_OVERSAMPLE: usize = 8;
30/// A rate needs this many full cycles in the span. A pure count, so a
31/// bare `f64`.
32pub const MIN_CYCLES: f64 = 2.0;
33/// How many times above the median amplitude of the scan a rate line must
34/// stand.
35///
36/// On 200 seeded noise tracks the largest line stood at most 4.4 times
37/// above the median; a 10 % LFO stands thousands of times higher.
38pub const LINE_PROMINENCE: Ratio = Ratio::from_const(LINE_PROMINENCE_VALUE);
39const LINE_PROMINENCE_VALUE: f64 = 8.0;
40/// A rate line needs at least this relative depth: the amplitude of its
41/// sine in the log track, about the share by which the level swings.
42pub const MIN_LINE_DEPTH: Normalized = Normalized::from_const(0.005);
43/// The bins of a folded cycle.
44pub const FOLD_BINS: usize = 64;
45/// The tempo scan of the snap, as a share of the BPM on either side.
46pub const BPM_SCAN: Normalized = Normalized::from_const(0.02);
47
48/// A time series at its own rate, such as a band envelope.
49#[derive(Clone, Debug, PartialEq)]
50pub struct Track {
51    values: Vec<f64>,
52    rate: Hz,
53}
54
55impl Track {
56    /// Pairs values with their rate.
57    #[must_use]
58    pub fn new(values: Vec<f64>, rate: Hz) -> Self {
59        Self { values, rate }
60    }
61
62    /// Returns the values.
63    #[must_use]
64    pub fn values(&self) -> &[f64] {
65        &self.values
66    }
67
68    /// Returns the rate.
69    #[must_use]
70    pub fn rate(&self) -> Hz {
71        self.rate
72    }
73
74    /// Returns the span, `len / rate`.
75    #[must_use]
76    pub fn span(&self) -> Seconds {
77        Seconds::clamped(cast::usize_to_f64(self.values.len()) / self.rate.get())
78    }
79}
80
81/// The scan range at a tempo: from two semitones below `min(0.2 Hz, the
82/// 4-bar rate)` to 20 Hz.
83///
84/// The 2-bar and 4-bar divisions then fall inside it with room for their
85/// spectral peak, which a picker needs to see rise and fall.
86#[must_use]
87pub fn scan_range(bpm: Bpm) -> (Hz, Hz) {
88    let four_bars = Division::ALL[0].rate(bpm).get();
89    let margin = 2.0_f64.powf(-2.0 / 12.0);
90    (
91        Hz::clamped(SCAN_BOTTOM_HZ.get().min(four_bars) * margin),
92        SCAN_TOP_HZ,
93    )
94}
95
96/// The envelope of one band at 200 Hz: [`band_magnitude`], then the mean
97/// of each 5 ms block.
98///
99/// # Errors
100///
101/// Returns a [`FilterError`] for an edge at 0 Hz or at or above the
102/// Nyquist frequency, or `hi` not above `lo`.
103pub fn band_envelope(
104    samples: &[f64],
105    rate: SampleRate,
106    lo: Hz,
107    hi: Hz,
108) -> Result<Track, FilterError> {
109    let envelope = band_magnitude(samples, rate, lo, hi)?;
110    Ok(Track::new(
111        block_means(&envelope, rate.hz().get() / TRACK_RATE_HZ.get()),
112        TRACK_RATE_HZ,
113    ))
114}
115
116/// Means of consecutive blocks of `ratio` samples; block `j` covers samples
117/// `floor(j ratio)` to `floor((j + 1) ratio) - 1`, so a fractional ratio
118/// keeps the track rate exact.
119fn block_means(values: &[f64], ratio: f64) -> Vec<f64> {
120    let blocks = cast::floor_to_usize(cast::usize_to_f64(values.len()) / ratio).unwrap_or(0);
121    (0..blocks)
122        .filter_map(|j| {
123            let start = cast::floor_to_usize(cast::usize_to_f64(j) * ratio)?;
124            let end = cast::floor_to_usize(cast::usize_to_f64(j + 1) * ratio)?.min(values.len());
125            let block = values.get(start..end).filter(|block| !block.is_empty())?;
126            Some(block.iter().sum::<f64>() / cast::usize_to_f64(block.len()))
127        })
128        .collect()
129}
130
131/// The relative modulation of a track: its natural log, floored 60 dB below
132/// the peak, minus its least-squares line. The log turns a decay or a fade
133/// in decibels times an LFO into a line plus the LFO. A higher-order
134/// polynomial would also take the line of a slow 4-bar LFO: on synthetic
135/// tracks a cubic lost a swell's 5 Hz LFO and a quintic misread a 2-cycle
136/// 4-bar LFO, where the line kept both.
137fn detrend(values: &[f64]) -> Vec<f64> {
138    let peak = values.iter().copied().fold(0.0_f64, f64::max);
139    if peak <= 0.0 || values.len() < 4 {
140        return vec![0.0; values.len()];
141    }
142    let floor = peak * 1e-3;
143    let logs: Vec<f64> = values.iter().map(|v| v.max(floor).ln()).collect();
144    remove_polynomial(&logs, 1)
145}
146
147/// Removes the least-squares line from a track, for the period search,
148/// which reads the waveform itself.
149fn detrend_line(values: &[f64]) -> Vec<f64> {
150    remove_polynomial(values, 1)
151}
152
153/// Subtracts the least-squares polynomial of `degree` in the sample index,
154/// fitted by Gram-Schmidt on the powers of `x` scaled to `[-1, 1]`.
155fn remove_polynomial(values: &[f64], degree: usize) -> Vec<f64> {
156    let n = values.len();
157    let half = cast::usize_to_f64(n.saturating_sub(1)).max(1.0) / 2.0;
158    let x: Vec<f64> = (0..n).map(|i| cast::usize_to_f64(i) / half - 1.0).collect();
159    let mut residual = values.to_vec();
160    let mut basis: Vec<Vec<f64>> = Vec::new();
161    for power in 0..=degree {
162        let mut vector: Vec<f64> = x
163            .iter()
164            .map(|&v| v.powi(i32::try_from(power).unwrap_or(0)))
165            .collect();
166        for done in &basis {
167            let dot: f64 = vector.iter().zip(done).map(|(a, b)| a * b).sum();
168            for (value, base) in vector.iter_mut().zip(done) {
169                *value -= dot * base;
170            }
171        }
172        let norm = vector.iter().map(|v| v * v).sum::<f64>().sqrt();
173        if norm <= f64::EPSILON {
174            continue;
175        }
176        for value in &mut vector {
177            *value /= norm;
178        }
179        let dot: f64 = residual.iter().zip(&vector).map(|(a, b)| a * b).sum();
180        for (value, base) in residual.iter_mut().zip(&vector) {
181            *value -= dot * base;
182        }
183        basis.push(vector);
184    }
185    residual
186}
187
188/// The magnitude spectrum of a detrended, Hann-windowed track, zero-padded
189/// 8x. Its resolution stays `1 / T`; the padding samples it finer.
190#[derive(Clone, Debug, PartialEq)]
191pub struct ModulationSpectrum {
192    magnitudes: Vec<f64>,
193    bin_hz: f64,
194    rate: f64,
195    span: Seconds,
196    detrended: Vec<f64>,
197}
198
199impl ModulationSpectrum {
200    /// Computes the spectrum of a track.
201    #[must_use]
202    pub fn of(track: &Track) -> Self {
203        let len = track.values().len();
204        let window = Window::Hann.samples(len);
205        let fft_len = (len * SPECTRUM_OVERSAMPLE).max(2);
206        let detrended = detrend(track.values());
207        let mut input: Vec<f64> = detrended.iter().zip(&window).map(|(v, w)| v * w).collect();
208        input.resize(fft_len, 0.0);
209        let fft = RealFftPlanner::<f64>::new().plan_fft_forward(fft_len);
210        let mut output = fft.make_output_vec();
211        let result = fft.process(&mut input, &mut output);
212        debug_assert!(result.is_ok(), "buffers come from the plan: {result:?}");
213        Self {
214            magnitudes: output.iter().map(|value| value.norm()).collect(),
215            bin_hz: track.rate().get() / cast::usize_to_f64(fft_len),
216            rate: track.rate().get(),
217            span: track.span(),
218            detrended,
219        }
220    }
221
222    /// Returns the magnitudes, one per bin from 0 Hz.
223    #[must_use]
224    pub fn magnitudes(&self) -> &[f64] {
225        &self.magnitudes
226    }
227
228    /// Returns the bin spacing.
229    #[must_use]
230    pub fn bin_width(&self) -> Hz {
231        Hz::clamped(self.bin_hz)
232    }
233
234    /// Returns the magnitude at a frequency, linearly interpolated between
235    /// bins; 0 beyond the last bin.
236    #[must_use]
237    pub fn magnitude_at(&self, frequency: f64) -> f64 {
238        let position = frequency / self.bin_hz;
239        let Some(lower) = cast::floor_to_usize(position) else {
240            return 0.0;
241        };
242        match (self.magnitudes.get(lower), self.magnitudes.get(lower + 1)) {
243            (Some(a), Some(b)) => a + (position - cast::usize_to_f64(lower)) * (b - a),
244            (Some(a), None) => *a,
245            _ => 0.0,
246        }
247    }
248
249    /// Returns the frequency of the largest bin between `lo` and `hi`, with
250    /// no interpolation.
251    #[must_use]
252    pub fn peak(&self, lo: Hz, hi: Hz) -> Option<Hz> {
253        self.bins_in(lo, hi)
254            .max_by(|&a, &b| self.magnitudes[a].total_cmp(&self.magnitudes[b]))
255            .map(|bin| Hz::clamped(cast::usize_to_f64(bin) * self.bin_hz))
256    }
257
258    /// Returns the modulation rate between `lo` and `hi`, or `None` when no
259    /// line stands out, as in [`Self::rate`].
260    #[must_use]
261    pub fn fundamental(&self, lo: Hz, hi: Hz) -> Option<Hz> {
262        match self.pick(lo, hi) {
263            Pick::Rate { rate, .. } => Some(Hz::clamped(rate)),
264            Pick::NoLine { .. } => None,
265        }
266    }
267
268    fn pick(&self, lo: Hz, hi: Hz) -> Pick {
269        // A sine of amplitude `A` peaks at `A N / 4` under a Hann window.
270        let scale = 4.0 / (self.span.get() * self.rate);
271        let bins = self.bins_in(lo, hi);
272        let guarded = bins.start().saturating_sub(1)
273            ..=(*bins.end() + 1).min(self.magnitudes.len().saturating_sub(1));
274        let points: Vec<(f64, f64)> = guarded
275            .map(|bin| {
276                (
277                    cast::usize_to_f64(bin) * self.bin_hz,
278                    self.magnitudes[bin] * scale,
279                )
280            })
281            .collect();
282        match pick_rate(&points) {
283            Pick::Rate { rate, prominence } => Pick::Rate {
284                rate: self.fit_rate(rate),
285                prominence,
286            },
287            none @ Pick::NoLine { .. } => none,
288        }
289    }
290
291    /// Refines a rate by the least-squares sine fit to the detrended track:
292    /// the floating-mean Lomb-Scargle power over one resolution `1 / T` on
293    /// either side, in 64 steps, with a parabola through the best three.
294    /// With two or three cycles in the span, the Hann window's peak sits
295    /// low by up to a tenth of a cycle; the fit has no window.
296    fn fit_rate(&self, rate: f64) -> f64 {
297        let resolution = 1.0 / self.span.get();
298        let times: Vec<Seconds> = (0..self.detrended.len())
299            .map(|i| Seconds::clamped(cast::usize_to_f64(i) / self.rate))
300            .collect();
301        let trials: Vec<Hz> = (0..=64)
302            .map(|k| rate - resolution + cast::usize_to_f64(k) * resolution / 32.0)
303            .filter(|f| *f > 0.0)
304            .map(Hz::clamped)
305            .collect();
306        let power = lomb_scargle(&times, &self.detrended, &trials);
307        let best =
308            (1..power.len().saturating_sub(1)).max_by(|&a, &b| power[a].total_cmp(&power[b]));
309        best.map_or(rate, |i| {
310            refine(&[
311                (trials[i - 1].get(), power[i - 1].sqrt()),
312                (trials[i].get(), power[i].sqrt()),
313                (trials[i + 1].get(), power[i + 1].sqrt()),
314            ])
315        })
316    }
317
318    fn bins_in(&self, lo: Hz, hi: Hz) -> std::ops::RangeInclusive<usize> {
319        let first = cast::ceil_to_usize(lo.get() / self.bin_hz)
320            .unwrap_or(0)
321            .max(1);
322        let last = cast::floor_to_usize(hi.get() / self.bin_hz)
323            .unwrap_or(0)
324            .min(self.magnitudes.len().saturating_sub(1));
325        first..=last
326    }
327
328    /// The modulation rate between `lo` and `hi`: the strongest strict
329    /// maximum inside the range, refined by a least-squares sine fit. It
330    /// must stand [`LINE_PROMINENCE`] times above the median amplitude and
331    /// reach [`MIN_LINE_DEPTH`]. No
332    /// estimate when no line stands out or the span holds fewer than two
333    /// cycles of the rate. The spread is the resolution, `1 / T`.
334    ///
335    /// # Errors
336    ///
337    /// Never in practice; the reasons are built from counts.
338    pub fn rate(&self, lo: Hz, hi: Hz) -> Result<Estimate<Hz, Hz>, ConfidenceError> {
339        rate_estimate(self.pick(lo, hi), self.span)
340    }
341}
342
343/// The outcome of a rate pick, with the prominence of the strongest line:
344/// its magnitude over the median magnitude of the scan.
345#[derive(Clone, Copy, Debug, PartialEq)]
346pub(crate) enum Pick {
347    /// A line that stands out, at `rate`.
348    Rate { rate: f64, prominence: f64 },
349    /// No line stands out.
350    NoLine { prominence: f64 },
351}
352
353/// Picks the modulation rate from `(frequency, amplitude)` points in a
354/// range, with one guard point just outside each end. An amplitude is
355/// the sine amplitude in the log track, the relative depth of the line.
356///
357/// 1. The candidates are the strict maxima inside the range, each higher
358///    than its left neighbour and at least its right one. The guards serve
359///    only as neighbours, so a line at a range end still counts.
360/// 2. The strongest candidate must stand [`LINE_PROMINENCE`] times above the
361///    median amplitude and reach [`MIN_LINE_DEPTH`], else no line stands
362///    out.
363/// 3. The strongest candidate is the rate. Sine, saw, square and triangle
364///    LFOs all put their largest line at the fundamental, and on gated
365///    notes a preference for subharmonics picks the alias lines of the
366///    note rhythm instead.
367pub(crate) fn pick_rate(points: &[(f64, f64)]) -> Pick {
368    let inside = points.get(1..points.len().saturating_sub(1)).unwrap_or(&[]);
369    let mut magnitudes: Vec<f64> = inside.iter().map(|p| p.1).collect();
370    magnitudes.sort_by(f64::total_cmp);
371    let median = magnitudes.get(magnitudes.len() / 2).copied().unwrap_or(0.0);
372    // Each candidate's frequency is refined by a parabola through the log
373    // amplitudes of its bin and both neighbours.
374    let candidates: Vec<(f64, f64)> = points
375        .windows(3)
376        .filter(|w| w[1].1 > w[0].1 && w[1].1 >= w[2].1)
377        .map(|w| (refine(w), w[1].1))
378        .collect();
379    let Some(&(strongest, magnitude)) = candidates.iter().max_by(|a, b| a.1.total_cmp(&b.1)) else {
380        return Pick::NoLine { prominence: 0.0 };
381    };
382    // A median of 0 leaves a lone line infinitely prominent; cap it so the
383    // reason stays finite.
384    let prominence = if median > 0.0 {
385        magnitude / median
386    } else {
387        f64::MAX.sqrt()
388    };
389    if prominence < LINE_PROMINENCE.get() || magnitude < MIN_LINE_DEPTH.get() {
390        return Pick::NoLine { prominence };
391    }
392    Pick::Rate {
393        rate: strongest,
394        prominence,
395    }
396}
397
398/// The vertex of the parabola through three equally spaced points of log
399/// amplitude; the centre point when one of them is not positive.
400fn refine(w: &[(f64, f64)]) -> f64 {
401    let [(f0, a), (f1, b), (f2, c)] = [w[0], w[1], w[2]];
402    if a <= 0.0 || b <= 0.0 || c <= 0.0 {
403        return f1;
404    }
405    let (a, b, c) = (a.ln(), b.ln(), c.ln());
406    let curvature = a - 2.0 * b + c;
407    if curvature >= 0.0 {
408        return f1;
409    }
410    let offset = (0.5 * (a - c) / curvature).clamp(-0.5, 0.5);
411    f1 + offset * (f2 - f0) / 2.0
412}
413
414/// An estimate of a picked rate in a span: none when no line stands out or
415/// the span holds fewer than two cycles.
416pub(crate) fn rate_estimate(
417    pick: Pick,
418    span: Seconds,
419) -> Result<Estimate<Hz, Hz>, ConfidenceError> {
420    let (rate, prominence) = match pick {
421        Pick::Rate { rate, prominence } => (rate, prominence),
422        Pick::NoLine { prominence } => {
423            return Estimate::none(vec![Reason::new(
424                ReasonCode::LineProminence,
425                Signal::Number(prominence),
426            )?]);
427        }
428    };
429    let cycles = rate * span.get();
430    let reasons = vec![
431        Reason::new(ReasonCode::CyclesInSpan, Signal::Number(cycles))?,
432        Reason::new(ReasonCode::LineProminence, Signal::Number(prominence))?,
433    ];
434    if cycles < MIN_CYCLES {
435        Estimate::none(reasons)
436    } else {
437        let resolution = Hz::clamped(1.0 / span.get());
438        Estimate::new(Hz::clamped(rate), Level::Medium, Some(resolution), reasons)
439    }
440}
441
442/// The period of a track between `1 / hi` and `1 / lo`.
443///
444/// The cumulative mean normalised difference picks the first lag whose
445/// value dips below 0.1, else the global minimum. Needs two to three
446/// cycles; `None` when the track is shorter than two periods of `lo`.
447#[must_use]
448pub fn cmndf_period(track: &Track, lo: Hz, hi: Hz) -> Option<Seconds> {
449    let values = detrend_line(track.values());
450    let rate = track.rate().get();
451    let min_lag = cast::ceil_to_usize(rate / hi.get()).unwrap_or(1).max(1);
452    let max_lag = cast::floor_to_usize(rate / lo.get())
453        .unwrap_or(0)
454        .min(values.len() / 2);
455    if max_lag <= min_lag {
456        return None;
457    }
458    let window = values.len() - max_lag;
459    let difference = |lag: usize| -> f64 {
460        values[..window]
461            .iter()
462            .zip(&values[lag..lag + window])
463            .map(|(a, b)| (a - b).powi(2))
464            .sum()
465    };
466    let mut running = 0.0;
467    let mut normalised = Vec::with_capacity(max_lag + 1);
468    for lag in 1..=max_lag {
469        let d = difference(lag);
470        running += d;
471        normalised.push(if running > 0.0 {
472            d * cast::usize_to_f64(lag) / running
473        } else {
474            1.0
475        });
476    }
477    let candidates = min_lag..=max_lag;
478    let dip = candidates.clone().find(|&lag| {
479        let value = normalised[lag - 1];
480        value < 0.1 && normalised.get(lag).is_none_or(|next| *next >= value)
481    });
482    let lag = dip
483        .or_else(|| candidates.min_by(|&a, &b| normalised[a - 1].total_cmp(&normalised[b - 1])))?;
484    // A parabola through the lag and its neighbours places the minimum
485    // between samples; at 200 Hz a whole lag is 0.7 % of a 7 Hz period.
486    let offset = match (normalised.get(lag.wrapping_sub(2)), normalised.get(lag)) {
487        (Some(&before), Some(&after)) if lag >= 2 => {
488            let at = normalised[lag - 1];
489            let curvature = before - 2.0 * at + after;
490            if curvature > 0.0 {
491                (0.5 * (before - after) / curvature).clamp(-0.5, 0.5)
492            } else {
493                0.0
494            }
495        }
496        _ => 0.0,
497    };
498    Some(Seconds::clamped((cast::usize_to_f64(lag) + offset) / rate))
499}
500
501/// Folds a track over a period into 64 bins: bin `b` is the mean of the
502/// samples whose phase falls in `[b / 64, (b + 1) / 64)`. The result is one
503/// cycle of the waveform.
504#[must_use]
505pub fn fold(track: &Track, period: Seconds) -> Vec<f64> {
506    let mut sums = vec![0.0; FOLD_BINS];
507    let mut counts = vec![0_usize; FOLD_BINS];
508    let samples_per_period = period.get() * track.rate().get();
509    if samples_per_period <= 0.0 {
510        return sums;
511    }
512    for (i, value) in track.values().iter().enumerate() {
513        let phase = (cast::usize_to_f64(i) / samples_per_period).fract();
514        let bin = cast::floor_to_usize(phase * cast::usize_to_f64(FOLD_BINS))
515            .unwrap_or(0)
516            .min(FOLD_BINS - 1);
517        sums[bin] += value;
518        counts[bin] += 1;
519    }
520    sums.iter()
521        .zip(&counts)
522        .map(|(sum, &count)| {
523            if count > 0 {
524                sum / cast::usize_to_f64(count)
525            } else {
526                0.0
527            }
528        })
529        .collect()
530}
531
532/// The coarse shape of a folded cycle. A sine and a triangle are never
533/// told apart.
534#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
535pub enum ShapeKind {
536    /// Rises and falls in about equal time, such as a sine or a triangle.
537    SmoothSymmetric,
538    /// Rises over most of the cycle and drops fast.
539    RampUp,
540    /// Rises fast and falls over most of the cycle.
541    RampDown,
542    /// Holds its top for a large share of the cycle.
543    SquareLike,
544}
545
546/// The shape of a folded cycle.
547#[derive(Clone, Copy, Debug, PartialEq)]
548pub struct Shape {
549    /// The share of the cycle from the minimum forward to the maximum.
550    pub rise: Normalized,
551    /// The share of bins at 90 % of the range or more.
552    pub plateau: Normalized,
553    /// The class from the two shares.
554    pub kind: ShapeKind,
555}
556
557/// Reads the shape of a folded cycle, or `None` for a flat one.
558///
559/// It is square-like when the plateau holds 35 % of the cycle, a ramp up
560/// when the rise takes 75 % or more, a ramp down when it takes 25 % or
561/// less, else smooth and symmetric. These thresholds are proposals.
562#[must_use]
563pub fn shape(cycle: &[f64]) -> Option<Shape> {
564    let (min_bin, min) = cycle
565        .iter()
566        .copied()
567        .enumerate()
568        .min_by(|a, b| a.1.total_cmp(&b.1))?;
569    let (max_bin, max) = cycle
570        .iter()
571        .copied()
572        .enumerate()
573        .max_by(|a, b| a.1.total_cmp(&b.1))?;
574    let range = max - min;
575    if range <= 0.0 {
576        return None;
577    }
578    let len = cycle.len();
579    let rise = cast::usize_to_f64((max_bin + len - min_bin) % len) / cast::usize_to_f64(len);
580    let plateau = cast::usize_to_f64(cycle.iter().filter(|&&v| (v - min) / range >= 0.9).count())
581        / cast::usize_to_f64(len);
582    let kind = if plateau >= 0.35 {
583        ShapeKind::SquareLike
584    } else if rise >= 0.75 {
585        ShapeKind::RampUp
586    } else if rise <= 0.25 {
587        ShapeKind::RampDown
588    } else {
589        ShapeKind::SmoothSymmetric
590    };
591    Some(Shape {
592        rise: Normalized::clamped(rise),
593        plateau: Normalized::clamped(plateau),
594        kind,
595    })
596}
597
598/// A rate snapped to the nearest note division.
599#[derive(Clone, Copy, Debug, PartialEq)]
600pub struct Snap {
601    /// The nearest division in log distance.
602    pub division: Division,
603    /// The tempo the division was read at.
604    pub bpm: Bpm,
605    /// The rate of the division at that tempo.
606    pub rate: Hz,
607    /// How far the measured rate lies from it.
608    pub residual: Cents,
609}
610
611/// Snaps a rate to the nearest division at a tempo, in log distance.
612#[must_use]
613pub fn snap(rate: Hz, bpm: Bpm) -> Snap {
614    let grid = BeatGrid::new(bpm, Seconds::clamped(0.0));
615    let residual = |division: Division| 1200.0 * (rate.get() / grid.rate(division).get()).log2();
616    let division = Division::ALL
617        .iter()
618        .copied()
619        .min_by(|&a, &b| residual(a).abs().total_cmp(&residual(b).abs()))
620        .unwrap_or(Division::ALL[0]);
621    Snap {
622        division,
623        bpm,
624        rate: grid.rate(division),
625        residual: Cents::clamped(residual(division)),
626    }
627}
628
629/// Snaps a rate at the BPM and at tempos within 2 % of it, in 0.1 % steps,
630/// and returns the snap with the smallest residual. A drifting tempo shows
631/// as a best BPM away from the nominal one.
632#[must_use]
633pub fn snap_scan(rate: Hz, bpm: Bpm) -> Snap {
634    let steps = cast::round_to_usize(BPM_SCAN.get() / 0.001).unwrap_or(0);
635    (0..=2 * steps)
636        .filter_map(|i| {
637            let share = (cast::usize_to_f64(i) - cast::usize_to_f64(steps)) * 0.001;
638            Bpm::new(bpm.get() * (1.0 + share)).ok()
639        })
640        .map(|tempo| snap(rate, tempo))
641        .min_by(|a, b| a.residual.get().abs().total_cmp(&b.residual.get().abs()))
642        .unwrap_or_else(|| snap(rate, bpm))
643}
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use proptest::prelude::*;
649
650    #[test]
651    fn the_constants_pass_the_unit_check() {
652        assert_eq!(TRACK_RATE_HZ, Hz::new(200.0).unwrap());
653        assert_eq!(SCAN_TOP_HZ, Hz::new(20.0).unwrap());
654        assert_eq!(SCAN_BOTTOM_HZ, Hz::new(0.2).unwrap());
655        assert_eq!(BPM_SCAN, Normalized::new(0.02).unwrap());
656        assert_eq!(LINE_PROMINENCE, Ratio::new(8.0).unwrap());
657        assert_eq!(MIN_LINE_DEPTH, Normalized::new(0.005).unwrap());
658    }
659
660    fn track(values: Vec<f64>) -> Track {
661        Track::new(values, TRACK_RATE_HZ)
662    }
663
664    fn periodic(period: usize, cycles: usize, f: impl Fn(f64) -> f64) -> Vec<f64> {
665        (0..period * cycles)
666            .map(|i| f(cast::usize_to_f64(i % period) / cast::usize_to_f64(period)))
667            .collect()
668    }
669
670    #[test]
671    fn the_scan_starts_two_semitones_below_the_lower_of_02_hz_and_the_4_bar_rate() {
672        let margin = 2f64.powf(-2.0 / 12.0);
673        let (lo, hi) = scan_range(Bpm::new(140.0).unwrap());
674        assert!((lo.get() - 140.0 / 60.0 / 16.0 * margin).abs() < 1e-12);
675        assert_eq!(hi.get(), 20.0);
676        assert!((scan_range(Bpm::new(400.0).unwrap()).0.get() - 0.2 * margin).abs() < 1e-12);
677    }
678
679    #[test]
680    fn block_means_keep_a_fractional_ratio() {
681        assert_eq!(block_means(&[1.0, 3.0, 5.0, 7.0, 9.0], 2.5), vec![2.0, 7.0]);
682    }
683
684    #[test]
685    fn shapes_read_their_class() {
686        let sine = periodic(64, 1, |p| (std::f64::consts::TAU * p).sin());
687        let up = periodic(64, 1, |p| p);
688        let down = periodic(64, 1, |p| 1.0 - p);
689        let square = periodic(64, 1, |p| if p < 0.5 { 1.0 } else { 0.0 });
690        assert_eq!(shape(&sine).unwrap().kind, ShapeKind::SmoothSymmetric);
691        assert_eq!(shape(&up).unwrap().kind, ShapeKind::RampUp);
692        assert_eq!(shape(&down).unwrap().kind, ShapeKind::RampDown);
693        assert_eq!(shape(&square).unwrap().kind, ShapeKind::SquareLike);
694        assert!(shape(&[1.0; 64]).is_none());
695    }
696
697    #[test]
698    fn cmndf_places_the_period_between_samples() {
699        // 7.3 Hz at 200 Hz is 27.4 samples a period; a whole lag reads 7.41.
700        let values: Vec<f64> = (0..1600)
701            .map(|i| {
702                1.0 + 0.3 * (std::f64::consts::TAU * 7.3 * cast::usize_to_f64(i) / 200.0).sin()
703            })
704            .collect();
705        let period = cmndf_period(
706            &track(values),
707            Hz::new(1.0).unwrap(),
708            Hz::new(20.0).unwrap(),
709        )
710        .unwrap();
711        let rate = 1.0 / period.get();
712        assert!((rate - 7.3).abs() < 0.01, "{rate}");
713    }
714
715    #[test]
716    fn cmndf_finds_the_period_of_a_ramp() {
717        let ramp = track(periodic(50, 6, |p| p));
718        let period = cmndf_period(&ramp, Hz::new(1.0).unwrap(), Hz::new(20.0).unwrap()).unwrap();
719        // The ramp's minimum is a cusp, so the parabola moves it by about a
720        // hundredth of a sample.
721        assert!((period.get() - 0.25).abs() < 1e-4, "{period:?}");
722    }
723
724    proptest! {
725        #[test]
726        fn folding_an_exactly_periodic_envelope_reproduces_it(cycles in 2usize..8, phase in 0.0..1.0f64) {
727            // 64 samples per period put one sample in each bin.
728            let wave = |p: f64| (std::f64::consts::TAU * (p + phase)).sin() + 0.3 * (2.0 * std::f64::consts::TAU * p).cos();
729            let values = periodic(64, cycles, wave);
730            let folded = fold(&track(values), Seconds::new(64.0 / TRACK_RATE_HZ.get()).unwrap());
731            for (b, value) in folded.iter().enumerate() {
732                prop_assert!((value - wave(cast::usize_to_f64(b) / 64.0)).abs() < 1e-12);
733            }
734        }
735
736        #[test]
737        fn a_division_rate_snaps_to_itself(index in 0usize..20, bpm in 40.0..300.0f64) {
738            let bpm = Bpm::new(bpm).unwrap();
739            let division = Division::ALL[index];
740            let snapped = snap(division.rate(bpm), bpm);
741            prop_assert_eq!(snapped.division, division);
742            prop_assert!(snapped.residual.get().abs() < 1e-9);
743        }
744
745        #[test]
746        fn doubling_the_bpm_doubles_the_snapped_rate(rate in 0.2..20.0f64, bpm in 40.0..300.0f64) {
747            let once = snap(Hz::new(rate).unwrap(), Bpm::new(bpm).unwrap());
748            let twice = snap(Hz::new(2.0 * rate).unwrap(), Bpm::new(2.0 * bpm).unwrap());
749            prop_assert_eq!(once.division, twice.division);
750            prop_assert!((twice.rate.get() / once.rate.get() - 2.0).abs() < 1e-12);
751        }
752
753        #[test]
754        fn rate_times_beats_per_cycle_is_the_beat_rate(index in 0usize..20, bpm in 4.0..1000.0f64) {
755            let division = Division::ALL[index];
756            let bpm = Bpm::new(bpm).unwrap();
757            let product = division.rate(bpm).get() * division.beats_per_cycle();
758            prop_assert!((product / (bpm.get() / 60.0) - 1.0).abs() < 1e-12);
759            let doubled = Bpm::new(bpm.get() * 2.0).unwrap_or(bpm);
760            if doubled != bpm {
761                prop_assert!((division.rate(doubled).get() / division.rate(bpm).get() - 2.0).abs() < 1e-12);
762            }
763        }
764    }
765}