Skip to main content

sound_to_patch/estimate/envelope/
pump.rs

1//! The sidechain pump: a level dip that repeats on every beat, read on the
2//! beats that a sustained note covers.
3
4use crate::audio::BeatGrid;
5use crate::cast;
6use crate::dsp::{ModulationSpectrum, SCAN_TOP_HZ, TRACK_RATE_HZ, Track, quantile, snap};
7use crate::notes::NoteList;
8use crate::units::{Bpm, Cents, Decibels, Hz, Seconds};
9
10use super::median;
11use super::track::{HOP, LevelTrack};
12
13/// The phase bins of one beat.
14pub const PUMP_BINS: usize = 64;
15/// Each beat's curve is taken relative to this quantile of its bins, the
16/// level between dips.
17pub const PUMP_REFERENCE: f64 = 0.9;
18/// The recovery ends where the curve comes back within this much of the
19/// reference.
20pub const PUMP_BACK: Decibels = Decibels::from_const(1.5);
21/// A dip at least this deep counts as a pump, and the layer strips it from
22/// the envelope tracks.
23///
24/// The beating of a constant three-note saw chord reads 1.64 to 1.94 dB,
25/// and a 6 dB duck reads 3.2 to 5.8 dB.
26pub const PUMP_MIN: Decibels = Decibels::from_const(3.0);
27/// The pump needs this many beats that a sustained note covers.
28pub const PUMP_BEATS_MIN: usize = 4;
29/// The layer strips a beat-locked pump only over this many counted beats.
30///
31/// With fewer, half the beats is two or three, and other sounds on the
32/// beat pass the lock: a sine sub under a kick on every beat, whose tail
33/// beats against the sub, read 3.4 to 4.6 dB locked to the beat over 4 or
34/// 5 beats.
35pub const PUMP_STRIP_BEATS: usize = 8;
36/// A beat dips with the curve when its level falls within this many bins
37/// of the curve's dip.
38pub const PHASE_SLACK: usize = 3;
39/// A beat dips with the curve when its level falls by this share of the
40/// curve's depth: from its highest bin over the [`FALL_MAX`] bins before
41/// the dip, to its lowest within [`PHASE_SLACK`] bins of it.
42///
43/// The bins before the dip run on into the beat before, as the level does,
44/// and do not wrap round the beat. A level that rises over each bar or
45/// each two beats and drops back makes every beat's curve start low: on
46/// the wrap of the beat, that reads as a fall at bin 0 on every beat.
47///
48/// A beat's own lowest bin is no test either: the slow beating of a chord
49/// moves it. At 174 BPM a 6 dB duck on the saw chord read 5.4 dB, and only
50/// 5 of 15 beats had their lowest bin within 3 bins of the curve's.
51pub const PHASE_DEPTH: f64 = 0.5;
52/// The pump locks to the beat when at least this share of the beats dips
53/// with the curve.
54pub const PHASE_SHARE: f64 = 0.5;
55/// The first stage of the period search reads this far either side of the
56/// grid's period, as a share of it; the second reads one step past it.
57///
58/// A step moves the last beat of the longest run of counted beats by 1 bin,
59/// so the reach follows that run: up to 2.73 % at 4 beats, 2.81 % at 5, 2.60 %
60/// at 6, 2.68 % at 7, 2.66 % at 10, 2.58 % at 20 and 2.51 % at 64.
61///
62/// A sidechain follows the song's own tempo, and a stem's tempo may be off
63/// by 1 or 2 %. Over 32 beats a 1 % error moves the duck by a third of a
64/// beat, so its beats no longer fold onto one curve.
65pub const PERIOD_RANGE: f64 = 0.025;
66/// The period search's first stage reads at most this many beats of the
67/// longest run; the second refines over all counted beats.
68///
69/// Its cost grows with the square of the beats it reads: a 10-minute held
70/// chord took 277 s over its whole run, against 6.5 s without the layer.
71pub const PERIOD_RUN_BEATS: usize = 64;
72/// A tremolo runs free when its rate lies this far from every note
73/// division of the tempo.
74pub const FREE_RESIDUAL: Cents = Cents::from_const(50.0);
75/// A free line lies within the period search's reach when it lies this far
76/// or nearer from the nearest division.
77///
78/// The search reaches 43 to 49 cents, by its longest run, and the line of a
79/// duck 2.5 % off reads up to 52 cents over a short span. 60 cents also holds
80/// a duck 3 % off.
81pub const FREE_REACH: Cents = Cents::from_const(60.0);
82/// A duck takes at least this many bins, and [`DUCK_TAIL_WINDOWS`] more,
83/// from half its depth back to a quarter of it. A read over a grid read of
84/// none asks this many bins alone.
85///
86/// A gate comes back at once: at 128 BPM the median is 1 bin for a gate and
87/// 7 to 13 for a duck's release of 80 ms or more.
88pub const DUCK_TAIL_BINS: f64 = 3.0;
89/// The shares of the level window that [`DUCK_TAIL_BINS`] grows by, for a read
90/// that replaces a free read. A read over a grid read of none drops this share,
91/// which costs real ducks under a low note.
92///
93/// The window smears a gate's edge over its own length, and a bin's time
94/// follows the tempo. A flat 5 bins let a gate through at 55 Hz and 174 BPM,
95/// and cost ducks at 60 and 80 BPM.
96pub const DUCK_TAIL_WINDOWS: f64 = 0.5;
97/// The period search's read replaces a read that locks on the grid when its
98/// fold error is under this share of the grid read's.
99///
100/// A smeared duck still locks on the grid: 12 dB, 2.5 % off over 16 beats,
101/// read 4.4 dB against 9.8 dB at its own period, and stripping it leaves its
102/// ripple in the notes. Depth is no test: the search deepens a duck on the
103/// tempo by fitting a chord's beating, up to 2.3 dB, and on a flat level a gain
104/// of 1.5 dB moved 15 such ducks against 3 with this share and
105/// [`FOLD_ERROR_GAIN`].
106pub const FOLD_ERROR_SHARE: f64 = 0.9;
107/// A fold error must also fall by at least this much, in dB, for the search's
108/// read to replace a read that locks on the grid.
109///
110/// A sub's fold error is small: at 174 BPM a read 0.1 % off, one beat
111/// shorter, passes the share by under this gain, and moved the sustain by up
112/// to 2.6 dB.
113pub const FOLD_ERROR_GAIN: Decibels = Decibels::from_const(0.15);
114/// A tremolo counts when its line swings the level by at least this much
115/// either way. The slow beating of a chord's near-coinciding harmonics
116/// stays below it.
117pub const FREE_SWING: Decibels = Decibels::from_const(1.0);
118/// A beat counts when one note covers it and started at least this long
119/// before it. A pluck struck on the beat then never counts, even when the
120/// transcriber places its onset a few milliseconds early.
121pub const SUSTAIN_LEAD: Seconds = Seconds::from_const(0.05);
122/// A pump falls into its dip within this many bins.
123///
124/// The fall counts from the last bin before the dip that lies within
125/// [`PUMP_BACK`] of the reference, on the median of the two-beat curves,
126/// so a dip early in the beat falls from the end of the beat before. A
127/// sidechain ducks within milliseconds;
128/// a note decaying across the beat falls over most of it and comes back
129/// at the next beat at once.
130pub const FALL_MAX: usize = 8;
131/// The lowest tremolo rate searched.
132pub const FREE_RATE_MIN: Hz = Hz::from_const(0.5);
133
134/// What a level dip follows.
135#[derive(Clone, Copy, Debug, PartialEq, Eq)]
136pub enum PumpLock {
137    /// A dip at one phase of every beat: a sidechain pump.
138    Beat,
139    /// Dips only on beats where a note starts: the notes' own envelopes.
140    Note,
141    /// A level modulation at a rate tied to no note division: a tremolo.
142    Free,
143    /// No dip and no tremolo.
144    None,
145}
146
147/// The pump read of a stem.
148#[derive(Clone, Debug, PartialEq)]
149pub struct Pump {
150    /// How far the median beat curve dips below its reference.
151    pub depth: Decibels,
152    /// The time from the dip until the curve comes back within
153    /// [`PUMP_BACK`]; `None` when it does not within the beat.
154    pub back: Option<Seconds>,
155    /// `back / ln(depth / 1.5)`, the time constant of an exponential
156    /// recovery; `None` without `back` and for any lock but the beat: a dip
157    /// that follows no beat has no recovery to model.
158    pub tau: Option<Seconds>,
159    /// What the dip follows.
160    pub lock: PumpLock,
161    /// The beats a sustained note covers.
162    pub beats: usize,
163    /// The median curve over those beats, in dB from the reference, one
164    /// value per phase bin.
165    pub curve: Vec<f64>,
166    grid: BeatGrid,
167    /// For a beat lock, each beat's fall into the dip, by beat of the grid;
168    /// `None` where a beat holds no frames.
169    falls: Vec<Option<f64>>,
170    /// The standard deviation of the level minus the curve over the counted
171    /// beats, each beat about its own mean, in dB: how well the curve explains
172    /// them.
173    fold_error: f64,
174    /// The cents from the nearest division to the level's strongest line
175    /// over the longest run of counted beats; `None` under [`FREE_SWING`].
176    free_line: Option<f64>,
177}
178
179impl Pump {
180    /// Reads the pump of a stem from its full-band level track.
181    ///
182    /// Only beats that the held part of one note covers from start to end
183    /// count, so a pluck that decays inside its beat reads no pump and a
184    /// release at the end of a beat reads no dip. `held` holds the start
185    /// and end of each held part, in seconds; `notes` gives the onsets for
186    /// the note lock. Each counted beat is binned into [`PUMP_BINS`] phase
187    /// bins and taken relative to its [`PUMP_REFERENCE`] quantile; the
188    /// median over the beats is the curve. `None` with fewer than
189    /// [`PUMP_BEATS_MIN`] counted beats.
190    ///
191    /// The beats are read on the grid and again at the period, within
192    /// about [`PERIOD_RANGE`] of it, whose beats fold onto the deepest mean
193    /// curve. A second read that locks to the beat replaces the first when
194    /// the first:
195    ///
196    /// - locks to the beat, and, when both strip or neither does, the second's
197    ///   fold error is under [`FOLD_ERROR_SHARE`] of the first's and
198    ///   [`FOLD_ERROR_GAIN`] lower. When only one of them strips, as the
199    ///   periods count different beats, the second must strip and have the
200    ///   lower fold error;
201    /// - runs free on a line within [`FREE_REACH`], and the second has a
202    ///   duck's shape: over a short span, a duck near the search's end reads
203    ///   free. A gate near a division folds onto one curve as well, but
204    ///   comes back at once;
205    /// - reads nothing, and the second has a duck's shape without the level
206    ///   window's share of the tail: a gate off the tempo often reads no lock
207    ///   on the grid;
208    /// - follows the notes.
209    #[must_use]
210    pub fn find(
211        track: &LevelTrack,
212        grid: BeatGrid,
213        notes: &NoteList,
214        held: &[(f64, f64)],
215    ) -> Option<Self> {
216        let read = Self::read(track, grid, notes, held)?;
217        let Some(fitted) = fit(track, grid, held)
218            .and_then(|g| Self::read(track, g, notes, held))
219            .filter(|p| p.lock == PumpLock::Beat)
220        else {
221            return Some(read);
222        };
223        let replaces = match read.lock {
224            PumpLock::Beat => fitted.replaces_a_beat_lock(&read),
225            PumpLock::Free => {
226                read.free_line
227                    .is_some_and(|cents| cents <= FREE_REACH.get())
228                    && fitted.has_a_ducks_shape(track.window().get())
229            }
230            // Over no lock, the window's share costs real ducks under a low
231            // note, and of 1733 gates here only 59 strip without it.
232            PumpLock::None => fitted.has_a_ducks_shape(0.0),
233            PumpLock::Note => true,
234        };
235        Some(if replaces { fitted } else { read })
236    }
237
238    /// Whether this read, from the period search, replaces `read`, a read on
239    /// the grid that locks to the beat.
240    fn replaces_a_beat_lock(&self, read: &Self) -> bool {
241        if self.strips() == read.strips() {
242            folds_better(self.fold_error, read.fold_error)
243        } else {
244            self.strips() && self.fold_error < read.fold_error
245        }
246    }
247
248    /// Reads the pump on the beats of one grid.
249    fn read(
250        track: &LevelTrack,
251        grid: BeatGrid,
252        notes: &NoteList,
253        held: &[(f64, f64)],
254    ) -> Option<Self> {
255        let period = grid.beat_period().get();
256        let mut beats = Vec::new();
257        let mut onset_beats = Vec::new();
258        let mut odd = Vec::new();
259        let mut longest = (0.0, 0.0);
260        let mut run: Option<(usize, f64)> = None;
261        let mut starts = Vec::new();
262        for b in covered(track, grid, held) {
263            let start = grid.beat_time(b).get();
264            let end = start + period;
265            let first = match run {
266                Some((last, first)) if b == last + 1 => first,
267                _ => start,
268            };
269            run = Some((b, first));
270            let Some(curve) = beat_curve(track, start, period, 1) else {
271                continue;
272            };
273            starts.push(start);
274            if end - first > longest.1 - longest.0 {
275                longest = (first, end);
276            }
277            onset_beats.push(starts_in(notes, start, end));
278            odd.push(b % 2 == 1);
279            beats.push(curve);
280        }
281        if beats.len() < PUMP_BEATS_MIN {
282            return None;
283        }
284        let wide = median_curve(&beats);
285        let curve = wide[PUMP_BINS..].to_vec();
286        let depth = dip(&curve);
287        let low = PUMP_BINS + dip_bin(&curve);
288        let falls: Vec<f64> = beats.iter().map(|b| fall_into(b, low)).collect();
289        let free_line = free_line(track, longest, grid);
290        let free = free_line.is_some_and(|cents| cents >= FREE_RESIDUAL.get());
291        let lock = lock(&beats, &onset_beats, &odd, &falls, &wide, depth, free);
292        let falls = if lock == PumpLock::Beat {
293            let end = track.time(track.db().len()) - grid.offset().get();
294            (0..cast::floor_to_usize(end / period).unwrap_or(0))
295                .map(|b| {
296                    beat_curve(track, grid.beat_time(b).get(), period, 1)
297                        .map(|c| fall_into(&c, low))
298                })
299                .collect()
300        } else {
301            Vec::new()
302        };
303        let fold_error = fold_error(track, &starts, period, &curve);
304        let back = recovery(&curve).map(|bins| bins * period / cast::usize_to_f64(PUMP_BINS));
305        let tau = back
306            .filter(|_| lock == PumpLock::Beat)
307            .map(|back| back / (depth / PUMP_BACK.get()).ln());
308        Some(Self {
309            depth: Decibels::clamped(depth),
310            back: back.map(Seconds::clamped),
311            tau: tau.map(Seconds::clamped),
312            lock,
313            beats: beats.len(),
314            curve,
315            grid,
316            falls,
317            fold_error,
318            free_line,
319        })
320    }
321
322    /// Returns whether the beats over `from` to `to` carry the duck: their
323    /// median fall into the dip is at least [`PHASE_DEPTH`] of its depth.
324    /// A stem can duck in one section only, and stripping a note outside it
325    /// would write the curve's ripple into its envelope. True when no beat
326    /// over the span has frames.
327    #[must_use]
328    pub fn carries(&self, from: f64, to: f64) -> bool {
329        let (period, offset) = (self.grid.beat_period().get(), self.grid.offset().get());
330        let first = cast::floor_to_usize((from - offset) / period).unwrap_or(0);
331        let last = cast::ceil_to_usize((to - offset) / period)
332            .unwrap_or(0)
333            .min(self.falls.len());
334        let falls: Vec<f64> = self.falls[first.min(last)..last]
335            .iter()
336            .flatten()
337            .copied()
338            .collect();
339        median(&falls).is_none_or(|fall| fall >= PHASE_DEPTH * self.depth.get())
340    }
341
342    /// Returns whether the layer strips the pump: it locks to the beat,
343    /// and so dips at least [`PUMP_MIN`], over [`PUMP_STRIP_BEATS`] or more
344    /// counted beats.
345    #[must_use]
346    pub fn strips(&self) -> bool {
347        self.lock == PumpLock::Beat && self.beats >= PUMP_STRIP_BEATS
348    }
349
350    /// Returns the tempo the pump was read at: the given tempo, or the
351    /// fitted one when that read replaced the read on the grid.
352    #[must_use]
353    pub fn bpm(&self) -> Bpm {
354        self.grid.bpm()
355    }
356
357    /// Whether the curve falls faster than it recovers, and takes long
358    /// enough from half its depth back to a quarter: [`DUCK_TAIL_BINS`] and
359    /// [`DUCK_TAIL_WINDOWS`] of a level window of `window` seconds.
360    fn has_a_ducks_shape(&self, window: f64) -> bool {
361        let low = dip_bin(&self.curve);
362        let depth = self.depth.get();
363        let first = |at: &dyn Fn(usize) -> usize, level: f64| {
364            (1..PUMP_BINS)
365                .find(|&k| self.curve[at(k) % PUMP_BINS] >= -level * depth)
366                .unwrap_or(PUMP_BINS)
367        };
368        let (after, before) = (|k| low + k, |k| low + PUMP_BINS - k);
369        let rise = first(&after, 0.25);
370        let bins = cast::usize_to_f64(PUMP_BINS) * window / self.grid.beat_period().get();
371        let tail = cast::usize_to_f64(rise.saturating_sub(first(&after, 0.5)));
372        tail >= DUCK_TAIL_BINS + DUCK_TAIL_WINDOWS * bins && rise > first(&before, 0.25)
373    }
374
375    /// Returns the curve at time `t`, linear between bin centres and
376    /// wrapping over the beat.
377    #[must_use]
378    pub fn at(&self, t: f64) -> f64 {
379        let period = self.grid.beat_period().get();
380        let phase = ((t - self.grid.offset().get()) / period).rem_euclid(1.0);
381        let bins = cast::usize_to_f64(PUMP_BINS);
382        let x = (phase * bins - 0.5).rem_euclid(bins);
383        let lo = cast::floor_to_usize(x).unwrap_or(0).min(PUMP_BINS - 1);
384        let hi = (lo + 1) % PUMP_BINS;
385        let w = x - cast::usize_to_f64(lo);
386        self.curve[lo] * (1.0 - w) + self.curve[hi] * w
387    }
388
389    /// Adds the dip back to a level track, so the track shows the note's
390    /// own envelope.
391    pub fn strip(&self, track: &mut LevelTrack) {
392        let start = track.time(0);
393        for (i, db) in track.db_mut().iter_mut().enumerate() {
394            *db -= self.at(start + cast::usize_to_f64(i) * HOP.get());
395        }
396    }
397}
398
399/// The beats of `grid` that the held part of one note covers from
400/// [`SUSTAIN_LEAD`] before the beat to its end, inside the track, in order.
401fn covered(track: &LevelTrack, grid: BeatGrid, held: &[(f64, f64)]) -> Vec<usize> {
402    let (period, offset) = (grid.beat_period().get(), grid.offset().get());
403    let end = track.time(track.db().len());
404    let mut beats: Vec<usize> = held
405        .iter()
406        .flat_map(|&(on, off)| {
407            let first = cast::ceil_to_usize((on + SUSTAIN_LEAD.get() - offset) / period - 1e-9);
408            let last = cast::floor_to_usize((off.min(end) - offset) / period + 1e-9);
409            first.unwrap_or(0)..last.unwrap_or(0)
410        })
411        .collect();
412    beats.sort_unstable();
413    beats.dedup();
414    beats
415}
416
417/// Whether a note starts in the beat from `start` to `end`: an onset up to
418/// [`SUSTAIN_LEAD`] before the beat belongs to it.
419fn starts_in(notes: &NoteList, start: f64, end: f64) -> bool {
420    let lead = SUSTAIN_LEAD.get();
421    notes
422        .notes()
423        .iter()
424        .any(|n| (start - lead..end - lead).contains(&n.onset().get()))
425}
426
427/// The grid at the beat period, within about [`PERIOD_RANGE`] of `grid`'s, whose
428/// counted beats fold onto the deepest mean curve; `None` when that is the
429/// grid's own period, or with fewer than [`PUMP_BEATS_MIN`] beats in a row.
430///
431/// The search runs over up to [`PERIOD_RUN_BEATS`] beats of the longest run
432/// of counted beats first, then over all of them around the best period
433/// of the run. Each step moves the last
434/// beat of the span searched by 1 bin.
435fn fit(track: &LevelTrack, grid: BeatGrid, held: &[(f64, f64)]) -> Option<BeatGrid> {
436    let beats = covered(track, grid, held);
437    let (mut longest, mut run) = ((0, 0), (0, 0));
438    for (i, &b) in beats.iter().enumerate() {
439        run = if i > 0 && b == beats[i - 1] + 1 {
440            (run.0, run.1 + 1)
441        } else {
442            (b, 1)
443        };
444        if run.1 > longest.1 {
445            longest = run;
446        }
447    }
448    if longest.1 < PUMP_BEATS_MIN {
449        return None;
450    }
451    let time = |b: usize| grid.beat_time(b).get();
452    let at = |share: f64| {
453        BeatGrid::new(
454            Bpm::clamped(grid.bpm().get() * (1.0 + share)),
455            grid.offset(),
456        )
457    };
458    let step = |(from, to): (f64, f64)| {
459        grid.beat_period().get() / (cast::usize_to_f64(PUMP_BINS) * (to - from))
460    };
461    let search = |centre: f64, half: f64, span: (f64, f64)| -> Option<f64> {
462        let step = step(span);
463        let n = cast::floor_to_usize(half / step).unwrap_or(0);
464        (0..=2 * n)
465            .map(|k| centre + (cast::usize_to_f64(k) - cast::usize_to_f64(n)) * step)
466            .map(|share| (share, folded_depth(track, at(share), held, span)))
467            .max_by(|a, b| a.1.total_cmp(&b.1))
468            .map(|(share, _)| share)
469    };
470    let run = (
471        time(longest.0),
472        time(longest.0 + longest.1.min(PERIOD_RUN_BEATS)),
473    );
474    let all = (time(beats[0]), time(beats[beats.len() - 1] + 1));
475    let coarse = search(0.0, PERIOD_RANGE, run)?;
476    let share = search(coarse, step(run), all)?;
477    (share != 0.0).then(|| at(share))
478}
479
480/// The depth of the mean curve over the counted beats of `grid` inside
481/// `(from, to)`.
482fn folded_depth(
483    track: &LevelTrack,
484    grid: BeatGrid,
485    held: &[(f64, f64)],
486    (from, to): (f64, f64),
487) -> f64 {
488    let period = grid.beat_period().get();
489    let mut sum = [0.0; PUMP_BINS];
490    let mut count = 0.0;
491    for b in covered(track, grid, held) {
492        let start = grid.beat_time(b).get();
493        if start < from - 1e-9 || start + period > to + 1e-9 {
494            continue;
495        }
496        if let Some(curve) = beat_curve(track, start, period, 0) {
497            for (s, c) in sum.iter_mut().zip(curve) {
498                *s += c;
499            }
500            count += 1.0;
501        }
502    }
503    if count == 0.0 {
504        return 0.0;
505    }
506    dip(&sum.map(|s| s / count))
507}
508
509/// How far a two-beat curve falls into the dip at bin `low`: its highest
510/// bin over the [`FALL_MAX`] bins before the [`PHASE_SLACK`] bins either
511/// side of `low`, minus its lowest bin within those.
512fn fall_into(curve: &[f64], low: usize) -> f64 {
513    let near = low - PHASE_SLACK..(low + PHASE_SLACK + 1).min(curve.len());
514    let top = curve[near.start - FALL_MAX..near.start]
515        .iter()
516        .copied()
517        .fold(f64::NEG_INFINITY, f64::max);
518    let bottom = curve[near].iter().copied().fold(f64::INFINITY, f64::min);
519    top - bottom
520}
521
522/// The mean level of each phase bin of the beat from `start` and of
523/// `before` beats before it, minus the [`PUMP_REFERENCE`] quantile of the
524/// beat's own bins. `None` when a bin holds no frame.
525fn beat_curve(track: &LevelTrack, start: f64, period: f64, before: usize) -> Option<Vec<f64>> {
526    let count = (before + 1) * PUMP_BINS;
527    let mut sums = vec![0.0; count];
528    let mut counts = vec![0_usize; count];
529    let bins = cast::usize_to_f64(PUMP_BINS);
530    let first = start - cast::usize_to_f64(before) * period;
531    for i in track.index(first.max(track.time(0)))..track.index(start + period) {
532        let phase = (track.time(i) - start) / period;
533        // A frame can read a rounding error before the first bin.
534        let bin = if phase >= 0.0 || before == 0 {
535            cast::floor_to_usize(phase * bins)
536                .unwrap_or(0)
537                .min(PUMP_BINS - 1)
538                + count
539                - PUMP_BINS
540        } else {
541            cast::floor_to_usize((phase + cast::usize_to_f64(before)) * bins)
542                .unwrap_or(0)
543                .min(count - PUMP_BINS - 1)
544        };
545        sums[bin] += track.db()[i];
546        counts[bin] += 1;
547    }
548    if counts.contains(&0) {
549        return None;
550    }
551    let means: Vec<f64> = sums
552        .iter()
553        .zip(counts)
554        .map(|(s, c)| s / cast::usize_to_f64(c))
555        .collect();
556    let mut sorted = means[count - PUMP_BINS..].to_vec();
557    sorted.sort_by(f64::total_cmp);
558    let reference = quantile(&sorted, PUMP_REFERENCE)?;
559    Some(means.into_iter().map(|m| m - reference).collect())
560}
561
562/// The median over beats of each bin.
563fn median_curve<B: AsRef<[f64]>>(beats: &[B]) -> Vec<f64> {
564    (0..beats.first().map_or(0, |b| b.as_ref().len()))
565        .map(|k| median(&beats.iter().map(|b| b.as_ref()[k]).collect::<Vec<_>>()).unwrap_or(0.0))
566        .collect()
567}
568
569/// How far a curve dips below 0 dB, at least 0.
570fn dip(curve: &[f64]) -> f64 {
571    -curve.iter().copied().fold(0.0, f64::min)
572}
573
574/// The bin of a curve's lowest value.
575fn dip_bin(curve: &[f64]) -> usize {
576    curve
577        .iter()
578        .enumerate()
579        .min_by(|a, b| a.1.total_cmp(b.1))
580        .map_or(0, |(k, _)| k)
581}
582
583/// The bins from the dip until the curve first comes back within
584/// [`PUMP_BACK`], going on over the beat's wrap and interpolated between
585/// bins; `None` when it does not within one beat. The grid starts at 0 s,
586/// so a stem whose duck sits late in the beat recovers after the wrap.
587fn recovery(curve: &[f64]) -> Option<f64> {
588    let low = dip_bin(curve);
589    let back = -PUMP_BACK.get();
590    let at = |k: usize| curve[(low + k) % PUMP_BINS];
591    (1..PUMP_BINS).find(|&k| at(k) >= back).map(|k| {
592        let (a, b) = (at(k - 1), at(k));
593        let w = if b > a { (back - a) / (b - a) } else { 1.0 };
594        cast::usize_to_f64(k - 1) + w.clamp(0.0, 1.0)
595    })
596}
597
598/// The bins from the last bin before bin `low` that lies within
599/// [`PUMP_BACK`] of the reference, to `low`, on a two-beat curve.
600fn fall_to(curve: &[f64], low: usize) -> usize {
601    (1..=low)
602        .find(|&k| curve[low - k] >= -PUMP_BACK.get())
603        .unwrap_or(PUMP_BINS)
604}
605
606/// The dips of a curve around the beat's wrap: a dip starts where the
607/// curve falls half its depth or more below the reference and ends where
608/// it comes back within a quarter of its depth. The gap between the two
609/// keeps the ripple of a chord's beating inside one dip from counting as
610/// two.
611fn dips(curve: &[f64], depth: f64) -> usize {
612    // Start the scan where the curve is up, so no dip straddles the wrap.
613    let Some(start) = (0..PUMP_BINS).find(|&k| curve[k] >= -0.25 * depth) else {
614        return 1;
615    };
616    let mut inside = false;
617    let mut count = 0;
618    for k in 0..PUMP_BINS {
619        let v = curve[(start + k) % PUMP_BINS];
620        if !inside && v <= -0.5 * depth {
621            inside = true;
622            count += 1;
623        } else if inside && v >= -0.25 * depth {
624            inside = false;
625        }
626    }
627    count
628}
629
630/// What a dip follows, tested in this order.
631///
632/// - `Note`: the beats with a note onset inside dip by [`PUMP_MIN`] or
633///   more, in their median curve, and the other beats by under half of
634///   it. Each group needs two beats.
635/// - `Free`: `free` finds a tremolo. It comes before the beat: a 5.3 Hz
636///   tremolo over 17 beats at 128 BPM left a 3.6 dB dip in the median
637///   curve that passed the beat test. A duck's strongest line lies on the
638///   beat rate, so it reads free only when that rate lies
639///   [`FREE_RESIDUAL`] or more from every division of the grid; `find` then
640///   tries the period search.
641/// - `Beat`: the curve dips by [`PUMP_MIN`] or more, once per beat, and
642///   falls into its dip within [`FALL_MAX`] bins, and at least
643///   [`PHASE_SHARE`] of the beats fall by [`PHASE_DEPTH`] of its depth
644///   into the curve's dip: `falls` holds each beat's fall from
645///   [`fall_into`].
646///
647/// `beats` and `wide` hold two-beat curves, the beat before and the beat;
648/// `wide` is their median.
649/// - `None` otherwise.
650fn lock(
651    beats: &[Vec<f64>],
652    onset_beats: &[bool],
653    odd: &[bool],
654    falls: &[f64],
655    wide: &[f64],
656    depth: f64,
657    free: bool,
658) -> PumpLock {
659    let curve = &wide[PUMP_BINS..];
660    let median_of = |flags: &[bool], value: bool| -> Option<Vec<f64>> {
661        let group: Vec<&Vec<f64>> = beats
662            .iter()
663            .zip(flags)
664            .filter(|&(_, &f)| f == value)
665            .map(|(b, _)| b)
666            .collect();
667        (group.len() >= 2).then(|| median_curve(&group))
668    };
669    let group = |with_onset: bool| median_of(onset_beats, with_onset).map(|c| dip(&c[PUMP_BINS..]));
670    if let (Some(with), Some(without)) = (group(true), group(false))
671        && with >= PUMP_MIN.get()
672        && without < 0.5 * PUMP_MIN.get()
673    {
674        return PumpLock::Note;
675    }
676    if free {
677        return PumpLock::Free;
678    }
679    let low = PUMP_BINS + dip_bin(curve);
680    let half_falls = |parity: bool| {
681        median_of(odd, parity).is_some_and(|half| {
682            fall_to(&half, low) <= FALL_MAX && half[low] <= -PHASE_DEPTH * depth
683        })
684    };
685    if depth >= PUMP_MIN.get()
686        && fall_to(wide, low) <= FALL_MAX
687        && dips(curve, depth) == 1
688        && half_falls(false)
689        && half_falls(true)
690    {
691        let near = falls.iter().filter(|&&f| f >= PHASE_DEPTH * depth).count();
692        if cast::usize_to_f64(near) >= PHASE_SHARE * cast::usize_to_f64(falls.len()) {
693            return PumpLock::Beat;
694        }
695    }
696    PumpLock::None
697}
698
699/// Whether a fold error beats another by [`FOLD_ERROR_SHARE`] and
700/// [`FOLD_ERROR_GAIN`].
701fn folds_better(error: f64, other: f64) -> bool {
702    error < FOLD_ERROR_SHARE * other && other - error >= FOLD_ERROR_GAIN.get()
703}
704
705/// The standard deviation of the level minus `curve` over the beats from
706/// `starts`, in dB, each beat about its own mean: a fade or a level step
707/// between beats is no misfit of the fold.
708fn fold_error(track: &LevelTrack, starts: &[f64], period: f64, curve: &[f64]) -> f64 {
709    let bins = cast::usize_to_f64(PUMP_BINS);
710    let mut squares = 0.0;
711    let mut count = 0.0;
712    for &start in starts {
713        let rests: Vec<f64> = (track.index(start)..track.index(start + period))
714            .map(|i| {
715                let phase = (track.time(i) - start) / period;
716                let bin = cast::floor_to_usize(phase * bins)
717                    .unwrap_or(0)
718                    .min(PUMP_BINS - 1);
719                track.db()[i] - curve[bin]
720            })
721            .collect();
722        let frames = cast::usize_to_f64(rests.len());
723        let mean = rests.iter().sum::<f64>() / frames;
724        squares += rests.iter().map(|r| (r - mean).powi(2)).sum::<f64>();
725        count += frames;
726    }
727    (squares / count).sqrt()
728}
729
730/// The cents from the nearest division to the strongest line of the
731/// modulation spectrum between [`FREE_RATE_MIN`] and 20 Hz, over a span of
732/// the level. `None` when that line swings by under [`FREE_SWING`].
733fn free_line(track: &LevelTrack, (start, end): (f64, f64), grid: BeatGrid) -> Option<f64> {
734    let (lo, hi) = (track.index(start), track.index(end));
735    let block = cast::round_to_usize(1.0 / (TRACK_RATE_HZ.get() * HOP.get())).unwrap_or(5);
736    // Each block averages in dB, as the curve does, so its mean is the
737    // geometric mean of the amplitude. The scan takes the natural log of
738    // the amplitude itself, so it reads that same mean level.
739    let values: Vec<f64> = track.db()[lo..hi]
740        .chunks_exact(block.max(1))
741        .map(|c| 10f64.powf(c.iter().sum::<f64>() / cast::usize_to_f64(c.len()) / 20.0))
742        .collect();
743    let track = Track::new(values, TRACK_RATE_HZ);
744    let spectrum = ModulationSpectrum::of(&track);
745    let rate = spectrum.rate(FREE_RATE_MIN, SCAN_TOP_HZ).ok()?;
746    let &rate = rate.value()?;
747    // A sine of amplitude `A` peaks at `A N / 4` under the Hann window.
748    let amplitude =
749        spectrum.magnitude_at(rate.get()) * 4.0 / (track.span().get() * TRACK_RATE_HZ.get());
750    // The amplitude is in natural-log units; 20 / ln 10 turns it into dB.
751    let swing = amplitude * 20.0 / std::f64::consts::LN_10;
752    (swing >= FREE_SWING.get()).then(|| snap(rate, grid.bpm()).residual.get().abs())
753}
754
755#[cfg(test)]
756mod tests {
757    use super::*;
758    use crate::units::Bpm;
759
760    #[test]
761    fn the_constants_pass_the_unit_check() {
762        assert_eq!(PUMP_BACK, Decibels::new(1.5).unwrap());
763        assert_eq!(PUMP_MIN, Decibels::new(3.0).unwrap());
764        assert_eq!(FREE_RESIDUAL, Cents::new(50.0).unwrap());
765        assert_eq!(FREE_SWING, Decibels::new(1.0).unwrap());
766        assert_eq!(FREE_RATE_MIN, Hz::new(0.5).unwrap());
767        assert_eq!(SUSTAIN_LEAD, Seconds::new(0.05).unwrap());
768    }
769
770    fn grid() -> BeatGrid {
771        BeatGrid::new(Bpm::new(120.0).unwrap(), Seconds::clamped(0.0))
772    }
773
774    #[test]
775    fn the_counted_beats_follow_the_grid_offset() {
776        let track = LevelTrack::new(Seconds::clamped(0.0), vec![0.0; 16_000]);
777        let grid = BeatGrid::new(Bpm::new(120.0).unwrap(), Seconds::clamped(0.6));
778        // Beat 1 starts at 1.1 s, 100 ms after the note and past the lead;
779        // beat 29 ends at 15.6 s, and beat 30 at 16.1 s, past the note.
780        assert_eq!(
781            covered(&track, grid, &[(1.0, 16.0)]),
782            (1..30).collect::<Vec<_>>()
783        );
784    }
785
786    #[test]
787    fn an_onset_up_to_the_lead_before_a_beat_belongs_to_it() {
788        let starts = |onset: f64| {
789            let note = crate::notes::Note::new(
790                Seconds::clamped(onset),
791                Seconds::clamped(onset + 5.0),
792                crate::units::MidiPitch::new(45.0).unwrap(),
793            )
794            .unwrap();
795            starts_in(&NoteList::new(vec![note]), 1.0, 1.5)
796        };
797        assert!(starts(0.97));
798        assert!(!starts(0.93), "70 ms before the beat");
799        assert!(starts(1.43), "70 ms before the next beat");
800        assert!(!starts(1.47), "30 ms before the next beat");
801    }
802
803    #[test]
804    fn the_folded_depth_is_the_mean_over_the_beats_whatever_their_count() {
805        // A 6 dB dip over the first 50 ms of each 500 ms beat.
806        let db: Vec<f64> = (0..4_000)
807            .map(|i| if i % 500 < 50 { -6.0 } else { 0.0 })
808            .collect();
809        let track = LevelTrack::new(Seconds::clamped(0.0), db);
810        for beats in [4.0, 7.0] {
811            let depth = folded_depth(&track, grid(), &[(-1.0, 4.0)], (0.0, 0.5 * beats));
812            assert!((depth - 6.0).abs() < 1e-9, "{beats}: {depth}");
813        }
814    }
815
816    #[test]
817    fn the_recovery_interpolates_between_bins() {
818        let mut curve = vec![0.0; PUMP_BINS];
819        curve[0] = -12.0;
820        curve[1] = -6.0;
821        curve[2] = -2.0;
822        curve[3] = -0.5;
823        // -1.5 lies a third of the way from bin 2 to bin 3.
824        assert!((recovery(&curve).unwrap() - (2.0 + 1.0 / 3.0)).abs() < 1e-12);
825        assert!(recovery(&vec![-4.0; PUMP_BINS]).is_none());
826        // The same dip moved to the last bin recovers over the wrap.
827        curve.rotate_left(1);
828        assert!((recovery(&curve).unwrap() - (2.0 + 1.0 / 3.0)).abs() < 1e-12);
829    }
830
831    /// A curve with one fast dip of `depth` dB at bin `at`.
832    fn dipped(at: usize, depth: f64) -> Vec<f64> {
833        let mut curve = vec![0.0; PUMP_BINS];
834        curve[at] = -depth;
835        curve[at + 1] = -0.5 * depth;
836        curve
837    }
838
839    /// The parity of 8 beats in a row.
840    const ALTERNATE: [bool; 8] = [false, true, false, true, false, true, false, true];
841
842    /// The two-beat curve of a flat beat before one with that dip.
843    fn wide(at: usize, depth: f64) -> Vec<f64> {
844        [vec![0.0; PUMP_BINS], dipped(at, depth)].concat()
845    }
846
847    #[test]
848    fn the_beat_lock_needs_half_the_beats_to_fall_into_the_dip() {
849        let curve = wide(10, 8.0);
850        let beats: Vec<Vec<f64>> = (0..8).map(|_| wide(10, 8.0)).collect();
851        // 4 of 8 beats fall 4 dB into the dip, half the curve's depth.
852        let mut falls = [4.0, 4.0, 4.0, 4.0, 3.9, 1.0, 1.0, 0.0];
853        assert_eq!(
854            lock(&beats, &[false; 8], &ALTERNATE, &falls, &curve, 8.0, false),
855            PumpLock::Beat
856        );
857        // 3 of 8: under half.
858        falls[3] = 3.9;
859        assert_eq!(
860            lock(&beats, &[false; 8], &ALTERNATE, &falls, &curve, 8.0, false),
861            PumpLock::None
862        );
863    }
864
865    #[test]
866    fn the_beat_lock_needs_the_curve_to_fall_from_the_beat_before() {
867        // A level that rises over each beat and drops back at the next:
868        // its curve wraps from 0 dB at bin 63 to -6 dB at bin 0, but the
869        // beat before ends where the beat starts.
870        let ramp: Vec<f64> = (0..PUMP_BINS)
871            .map(|k| -6.0 * (1.0 - cast::usize_to_f64(k) / 63.0))
872            .collect();
873        let rising = [ramp.iter().map(|v| v - 6.0).collect(), ramp.clone()].concat();
874        let beats = vec![rising.clone(); 8];
875        assert_eq!(
876            lock(
877                &beats,
878                &[false; 8],
879                &ALTERNATE,
880                &[6.0; 8],
881                &rising,
882                6.0,
883                false,
884            ),
885            PumpLock::None
886        );
887        // The same beat after a flat one falls into its dip at once.
888        let ducked = [vec![0.0; PUMP_BINS], ramp].concat();
889        let beats = vec![ducked.clone(); 8];
890        assert_eq!(
891            lock(
892                &beats,
893                &[false; 8],
894                &ALTERNATE,
895                &[6.0; 8],
896                &ducked,
897                6.0,
898                false,
899            ),
900            PumpLock::Beat
901        );
902    }
903
904    #[test]
905    fn the_beat_lock_needs_the_odd_and_the_even_beats_to_dip() {
906        // A dip on every second beat only: the even beats dip 8 dB, the odd
907        // ones are flat, and the median curve still dips 8 dB.
908        let beats: Vec<Vec<f64>> = (0..9)
909            .map(|i| {
910                if i % 2 == 0 {
911                    wide(10, 8.0)
912                } else {
913                    vec![0.0; 2 * PUMP_BINS]
914                }
915            })
916            .collect();
917        let odd: Vec<bool> = (0..9).map(|i| i % 2 == 1).collect();
918        let curve = median_curve(&beats);
919        assert!((dip(&curve[PUMP_BINS..]) - 8.0).abs() < 1e-12);
920        assert_eq!(
921            lock(&beats, &[false; 9], &odd, &[8.0; 9], &curve, 8.0, false),
922            PumpLock::None
923        );
924        // The odd beats dip, but under half the curve's depth at its dip.
925        let beats: Vec<Vec<f64>> = (0..9)
926            .map(|i| wide(10, if i % 2 == 0 { 8.0 } else { 3.9 }))
927            .collect();
928        assert_eq!(
929            lock(&beats, &[false; 9], &odd, &[8.0; 9], &curve, 8.0, false),
930            PumpLock::None
931        );
932        let beats = vec![wide(10, 8.0); 9];
933        assert_eq!(
934            lock(&beats, &[false; 9], &odd, &[8.0; 9], &curve, 8.0, false),
935            PumpLock::Beat
936        );
937    }
938
939    #[test]
940    fn a_beat_falls_from_the_bins_before_its_dip_to_the_lowest_near_it() {
941        // The dip at bin 10 of the beat: the 3 bins either side, 71 to 77,
942        // and the 8 bins before them, 63 to 70.
943        let mut curve = vec![-1.0; 2 * PUMP_BINS];
944        curve[63] = 0.5;
945        curve[77] = -9.0;
946        assert!((fall_into(&curve, 74) - 9.5).abs() < 1e-12);
947        let mut curve = vec![-1.0; 2 * PUMP_BINS];
948        curve[62] = 0.5;
949        curve[78] = -9.0;
950        assert!(fall_into(&curve, 74).abs() < 1e-12);
951        // A dip late in the beat looks no further than its last bin.
952        let mut curve = vec![-1.0; 2 * PUMP_BINS];
953        curve[126] = -9.0;
954        assert!((fall_into(&curve, 126) - 8.0).abs() < 1e-12);
955    }
956
957    #[test]
958    fn the_note_lock_needs_the_other_beats_under_half_the_floor() {
959        let curve = wide(10, 4.0);
960        let onsets = [true, true, false, false];
961        let with = |other: f64| {
962            let beats = vec![
963                wide(10, 4.0),
964                wide(10, 4.0),
965                wide(30, other),
966                wide(30, other),
967            ];
968            lock(
969                &beats,
970                &onsets,
971                &ALTERNATE[..4],
972                &[0.0; 4],
973                &curve,
974                4.0,
975                false,
976            )
977        };
978        assert_eq!(with(1.0), PumpLock::Note);
979        assert_ne!(with(1.6), PumpLock::Note);
980    }
981
982    #[test]
983    fn a_note_takes_the_strip_by_the_beats_after_the_grid_offset() {
984        let pump = Pump {
985            depth: Decibels::clamped(8.0),
986            back: None,
987            tau: None,
988            lock: PumpLock::Beat,
989            beats: 8,
990            curve: vec![0.0; PUMP_BINS],
991            grid: BeatGrid::new(Bpm::new(120.0).unwrap(), Seconds::clamped(0.6)),
992            falls: [0.0, 0.0, 8.0, 8.0, 8.0, 8.0, 8.0, 8.0].map(Some).to_vec(),
993            fold_error: 0.0,
994            free_line: None,
995        };
996        assert!(!pump.carries(0.6, 1.6), "beats 0 and 1 do not fall");
997        assert!(pump.carries(1.6, 2.6), "beats 2 and 3 fall");
998    }
999
1000    #[test]
1001    fn a_ducks_shape_recovers_slower_than_it_falls() {
1002        // A dip of 8 dB at bin 32, by the bins it takes each way.
1003        let shaped = |fall: f64, rise: f64, window: f64| {
1004            let curve: Vec<f64> = (0..PUMP_BINS)
1005                .map(|bin| {
1006                    let k = cast::usize_to_f64(bin) - 32.0;
1007                    let width = if k < 0.0 { fall } else { rise };
1008                    -8.0 * (1.0 - k.abs() / width).max(0.0)
1009                })
1010                .collect();
1011            Pump {
1012                depth: Decibels::clamped(8.0),
1013                back: None,
1014                tau: None,
1015                lock: PumpLock::Beat,
1016                beats: 8,
1017                curve,
1018                grid: grid(),
1019                falls: Vec::new(),
1020                fold_error: 0.0,
1021                free_line: None,
1022            }
1023            .has_a_ducks_shape(window)
1024        };
1025        // Beat 0.5 s: 128 bins to the second, so a 12 ms window spans 1.5 bins
1026        // and asks 3.77 bins of the tail. A rise of `r` bins has a tail of `r / 4`.
1027        assert!(shaped(4.0, 16.0, 0.012));
1028        assert!(!shaped(4.0, 12.0, 0.012), "3 bins under a window");
1029        assert!(shaped(4.0, 12.0, 0.0));
1030        assert!(!shaped(4.0, 8.0, 0.0), "2 bins without a window");
1031        assert!(!shaped(40.0, 24.0, 0.0), "falls slower than it recovers");
1032        assert!(!shaped(24.0, 24.0, 0.0), "falls as it recovers");
1033    }
1034
1035    #[test]
1036    fn a_better_fold_needs_both_a_share_and_a_gain() {
1037        assert!(folds_better(1.0, 1.2));
1038        assert!(!folds_better(1.0, 1.1), "0.1 dB");
1039        assert!(!folds_better(3.0, 3.2), "a fifteenth");
1040        assert!(folds_better(2.68, 3.0));
1041        assert!(!folds_better(2.72, 3.0));
1042        assert!(
1043            !folds_better(FOLD_ERROR_SHARE * 3.0, 3.0),
1044            "the share itself"
1045        );
1046        assert!(folds_better(0.0, FOLD_ERROR_GAIN.get()), "the gain itself");
1047    }
1048
1049    #[test]
1050    fn a_gained_strip_needs_a_lower_fold_error_and_a_lost_one_never_replaces() {
1051        let read = |beats: usize, fold_error: f64| Pump {
1052            depth: Decibels::clamped(8.0),
1053            back: None,
1054            tau: None,
1055            lock: PumpLock::Beat,
1056            beats,
1057            curve: vec![0.0; PUMP_BINS],
1058            grid: BeatGrid::new(Bpm::new(120.0).unwrap(), Seconds::clamped(0.0)),
1059            falls: Vec::new(),
1060            fold_error,
1061            free_line: None,
1062        };
1063        assert!(read(8, 1.99).replaces_a_beat_lock(&read(7, 2.0)));
1064        assert!(!read(8, 2.0).replaces_a_beat_lock(&read(7, 2.0)));
1065        assert!(!read(7, 1.0).replaces_a_beat_lock(&read(8, 2.0)));
1066        assert!(read(8, 1.0).replaces_a_beat_lock(&read(8, 2.0)));
1067        assert!(!read(8, 1.9).replaces_a_beat_lock(&read(8, 2.0)));
1068    }
1069
1070    #[test]
1071    fn the_curve_follows_the_grid_offset() {
1072        let mut curve = vec![0.0; PUMP_BINS];
1073        curve[0] = -8.0;
1074        let pump = Pump {
1075            depth: Decibels::clamped(8.0),
1076            back: None,
1077            tau: None,
1078            lock: PumpLock::Beat,
1079            beats: 8,
1080            curve,
1081            grid: BeatGrid::new(Bpm::new(120.0).unwrap(), Seconds::clamped(0.1)),
1082            falls: Vec::new(),
1083            fold_error: 0.0,
1084            free_line: None,
1085        };
1086        assert!((pump.at(0.1 + 0.5 / 128.0) + 8.0).abs() < 1e-9);
1087        assert!(pump.at(0.5 / 128.0).abs() < 1e-9);
1088    }
1089
1090    #[test]
1091    fn the_curve_wraps_between_the_last_and_the_first_bin() {
1092        let mut curve = vec![0.0; PUMP_BINS];
1093        curve[0] = -8.0;
1094        let pump = Pump {
1095            depth: Decibels::clamped(8.0),
1096            back: None,
1097            tau: None,
1098            lock: PumpLock::Beat,
1099            beats: 4,
1100            curve,
1101            grid: grid(),
1102            falls: Vec::new(),
1103            fold_error: 0.0,
1104            free_line: None,
1105        };
1106        // Beat 0.5 s; bin 0's centre at 0.5 / 128 s, the last bin's at
1107        // 0.5 - 0.5 / 128 s, and the beat start halfway between them.
1108        assert!((pump.at(0.5 / 128.0) + 8.0).abs() < 1e-9);
1109        assert!((pump.at(0.0) + 4.0).abs() < 1e-9);
1110        assert!((pump.at(1.0) + 4.0).abs() < 1e-9);
1111    }
1112
1113    proptest::proptest! {
1114        #[test]
1115        fn the_dips_and_the_recovery_do_not_depend_on_where_the_beat_starts(
1116            curve in proptest::collection::vec(-20.0..0.0f64, PUMP_BINS),
1117            shift in 0..PUMP_BINS,
1118        ) {
1119            let mut rotated = curve.clone();
1120            rotated.rotate_left(shift);
1121            let depth = dip(&curve);
1122            proptest::prop_assert_eq!(dips(&curve, depth), dips(&rotated, depth));
1123            proptest::prop_assert_eq!(recovery(&curve), recovery(&rotated));
1124        }
1125    }
1126}