Skip to main content

sound_to_patch/estimate/pitch/
drop.rs

1//! The pitch drop of a low note, the "thump" of an 808 or a pluck: the fit
2//! `f(t) = f_end + df exp(-t / tau)` on the sub-band line.
3
4use super::FAST_TAU;
5use super::fit::golden_section;
6use super::line::SubBandLine;
7use crate::audio::SampleRate;
8use crate::cast;
9use crate::units::{Hz, Seconds, Semitones};
10
11/// The fit starts this long after the onset. The line of a tone switched
12/// on from silence overshoots by about 11 Hz at 3 ms and by 1 to 4 Hz at
13/// 7 ms, at 30 to 150 Hz, and settles within 1 Hz by 30 ms.
14pub const DROP_FIT_START: Seconds = Seconds::from_const(0.007);
15/// The fit ends this long after the onset, or at the end of the note.
16pub(crate) const DROP_FIT_END: Seconds = Seconds::from_const(0.4);
17/// The smallest drop that counts.
18pub const DROP_MIN: Semitones = Semitones::from_const(2.0);
19/// The largest drop that counts: three octaves. The line reads up to
20/// 200 Hz, so a larger fall starts far outside the band and its `df` is
21/// extrapolated from the tail alone.
22pub const DROP_MAX: Semitones = Semitones::from_const(36.0);
23/// The smallest `tau` of a fit, about 3 ms.
24///
25/// A tenth of the fall is still left at [`DROP_FIT_START`],
26/// `exp(-7 ms / tau) >= 0.1`. A faster fall ends before the span starts,
27/// and its `df` is extrapolated from the swing of the line at a
28/// retriggered or switched-on tone.
29pub const DROP_TAU_MIN: Seconds = Seconds::from_const(0.007 / std::f64::consts::LN_10);
30/// The largest RMS residual of an accepted fit, as a share of the fall the
31/// fit span shows.
32pub const DROP_MAX_RESIDUAL: f64 = 0.15;
33/// The onset of a strike: the first sample whose band envelope reaches
34/// this share of its peak.
35pub const DROP_ONSET_SHARE: f64 = 0.5;
36/// The attack of a strike starts where the band envelope reaches this
37/// share of its peak.
38pub(crate) const ATTACK_FROM: f64 = 0.1;
39/// The attack ends where the band envelope reaches this share of its peak.
40pub(crate) const ATTACK_TO: f64 = 0.9;
41/// The centred average of the band envelope that the onset search reads.
42pub const ONSET_SMOOTHING: Seconds = Seconds::from_const(0.01);
43/// The span, in periods of the note, of the band envelope average that
44/// tells a strike found again from a strike of its own.
45///
46/// A search that starts where the previous one ended, inside the rise of
47/// the strike read last, is lowest at its first sample. A new strike dips
48/// first. A second harmonic in the band beats with the fundamental, and
49/// the beat dips a 10 ms average by up to 12 dB. On 456 generated sweeps
50/// with the border 20 ms before to 70 ms into the strike, a 10 ms average
51/// read a second drop 21 times. One period read 5, 1.5 periods 1 and two
52/// periods none. Over three periods, 90 runs of 808 hits struck every
53/// 115 ms lost 2 of their drops.
54pub const RISE_PERIODS: f64 = 2.0;
55/// The largest `tau` the search covers.
56const TAU_MAX: f64 = 1.0;
57/// A fit whose `tau` lies within this factor of [`DROP_TAU_MIN`] sits on
58/// the bound of the search.
59const TAU_EDGE: f64 = 1.01;
60/// A fit whose `tau` spans this many periods of `f_end` or more has its
61/// error averaged over a full period.
62const FULL_PERIODS: f64 = 1.5;
63/// Points of the log grid over `tau` before the golden-section refinement.
64const TAU_GRID: usize = 240;
65
66/// The convention of a drop, as `layers.json` names it.
67pub const DROP_CONVENTION: &str = "f(t) = f_end + df exp(-t / tau) fitted by least squares to \
68the sub-band line from 7 ms to 400 ms after the onset, or to the end of the note; tau by a log \
69grid and golden-section search from 3.04 ms, where exp(-7 ms / tau) = 0.1, to 1 s, f_end and \
70df linear for each tau; a fit with tau under 10 ms, or after a strike whose full-band RMS rises \
7120 dB, is fitted again on the line with its high-pass at 10 Hz, and the refit counts; \
72df_st = 12 log2((f_end + df) / f_end); onset_s is the first sample after the lowest point of \
73the onset search where the 20 to 200 Hz envelope, averaged over a centred 10 ms, reaches half \
74the peak that follows; a strike whose full-band RMS over the 20 ms after the onset lies more \
75than 40 dB below the loudest 25 ms frame of the stem, or whose 20 to 200 Hz RMS over those \
7620 ms lies more than 18 dB below the loudest 25 ms frame of that band, is no candidate; a drop \
77with tau under 10 ms needs an envelope that takes 30 ms or less from 10 to 90 % of its peak; a \
78drop with tau under 10 ms, one that starts at 200 Hz or above, or one whose envelope takes over \
7930 ms from 10 to 90 % of its peak needs the full-band RMS over the 20 ms after the onset 6 dB or \
80more above the 20 ms that end 25 ms before it; residual is the RMS of the fit's error, averaged \
81over half a period of f_end when tau is 10 ms or more and over a full period when tau spans 1.5 \
82periods or more, over the fitted fall from 7 ms on, df exp(-7 ms / tau); a drop counts when \
83df_st is above 2 and at most 36, that fall is above 2 semitones too, tau is above the bound of \
84the search, residual is below 0.15, and f_end lies within 100 cents of a note that starts 150 \
85ms or less before the onset and sounds at some point from 5 tau after the onset, or from the end \
86of the fit span if that comes first, to the end of the fit span; a fit after a 6 dB strike that is no \
87drop is fitted again on the note line, whose low-pass sits at 1.5 times the note's pitch, when \
88that lies under 200 Hz, with its span ending one period of the note before the note line's \
89frequency first falls to 0 Hz, and the refit counts with tau of 10 ms or more and an RMS \
90envelope over its fall, from 7 ms to 5 tau, at most 6 dB below the line the first fit read; a \
91search that starts where the previous note's search ended, before the drop read last has \
92settled, 5 tau after its onset, finds that strike again when its 20 to 200 Hz envelope, \
93averaged over a centred 2 periods of the note, is lowest at its first sample";
94
95/// A fitted drop.
96#[derive(Clone, Copy, Debug, PartialEq)]
97pub struct DropFit {
98    /// The onset the fit counts `t` from.
99    pub onset: Seconds,
100    /// The settled frequency.
101    pub f_end: Hz,
102    /// The frequency above `f_end` at the onset, in hertz; negative for a
103    /// rise.
104    pub df_hz: f64,
105    /// The time constant.
106    pub tau: Seconds,
107    /// The RMS residual over the fall the fit span shows,
108    /// `|df_hz| exp(-DROP_FIT_START / tau)`. Over the extrapolated `df_hz`
109    /// a fast swing at the onset, fitted with a `tau` under a millisecond,
110    /// would read a tiny share.
111    pub residual: f64,
112    /// The number of line values the fit read.
113    pub points: usize,
114}
115
116impl DropFit {
117    /// Five time constants after the onset, where 99.3 % of the fall is
118    /// done: the drop has settled.
119    #[must_use]
120    pub fn settled(&self) -> Seconds {
121        Seconds::clamped(self.onset.get() + 5.0 * self.tau.get())
122    }
123
124    /// The drop in semitones, `12 log2((f_end + df) / f_end)`, or `None`
125    /// when `f_end + df` is not above zero.
126    #[must_use]
127    pub fn df(&self) -> Option<Semitones> {
128        self.above_end(self.f_end.get() + self.df_hz)
129    }
130
131    /// The drop the line shows inside the fit span, in semitones: from the
132    /// fitted frequency at [`DROP_FIT_START`] down to `f_end`. A fast fall
133    /// that ends before the span starts is extrapolated, not seen, such as
134    /// the overshoot of the line at a tone's onset.
135    #[must_use]
136    pub fn visible(&self) -> Option<Semitones> {
137        self.above_end(
138            self.f_end.get() + self.df_hz * (-DROP_FIT_START.get() / self.tau.get()).exp(),
139        )
140    }
141
142    /// `start` above `f_end` in semitones, `12 log2(start / f_end)`, or
143    /// `None` when either is not above zero.
144    fn above_end(&self, start: f64) -> Option<Semitones> {
145        (start > 0.0 && self.f_end.get() > 0.0)
146            .then(|| Semitones::clamped(12.0 * (start / self.f_end.get()).log2()))
147    }
148
149    /// Whether the fit counts as a drop: `df` from above [`DROP_MIN`] up
150    /// to [`DROP_MAX`], the visible drop above [`DROP_MIN`], `tau` above
151    /// [`DROP_TAU_MIN`] and the residual below [`DROP_MAX_RESIDUAL`]. A
152    /// `tau` on the bound means the best fit lies below it.
153    #[must_use]
154    pub fn accepted(&self) -> bool {
155        self.miss().is_none()
156    }
157
158    /// The first condition of [`Self::accepted`] the fit misses, or `None`
159    /// when it counts as a drop.
160    #[must_use]
161    pub fn miss(&self) -> Option<DropMiss> {
162        if !(self.df().is_some_and(|df| df > DROP_MIN && df <= DROP_MAX)
163            && self.visible().is_some_and(|visible| visible > DROP_MIN))
164        {
165            Some(DropMiss::Size)
166        } else if self.tau.get() <= TAU_EDGE * DROP_TAU_MIN.get() {
167            Some(DropMiss::TauBound)
168        } else if self.residual >= DROP_MAX_RESIDUAL {
169            Some(DropMiss::Residual)
170        } else {
171            None
172        }
173    }
174}
175
176/// Why a fit does not count as a drop.
177#[derive(Clone, Copy, Debug, PartialEq, Eq)]
178pub enum DropMiss {
179    /// The drop, in full or inside the fit span, lies outside
180    /// [`DROP_MIN`] to [`DROP_MAX`].
181    Size,
182    /// `tau` sits on the bound of the search.
183    TauBound,
184    /// The residual reaches [`DROP_MAX_RESIDUAL`].
185    Residual,
186}
187
188/// The onset of a strike inside `from..to`, from the band envelope.
189///
190/// The envelope is averaged over a centred [`ONSET_SMOOTHING`]. The onset
191/// is the first sample after the lowest point of the span where the
192/// average reaches [`DROP_ONSET_SHARE`] of the peak that follows. `None`
193/// when the span is empty or its lowest point already stands above that
194/// share, so no attack lies inside it.
195///
196/// The average keeps a held note from reading as a strike: several
197/// partials of a saw share the band and beat, so its raw envelope dips at
198/// every period. A centred average leaves the midpoint of a step in place.
199#[must_use]
200pub fn band_onset(line: &SubBandLine, from: Seconds, to: Seconds) -> Option<Seconds> {
201    let (start, smooth) = smoothed_envelope(line, from, to, ONSET_SMOOTHING.get())?;
202    let (low, peak) = low_and_peak(&smooth)?;
203    let first = smooth[low..]
204        .iter()
205        .position(|&e| e >= DROP_ONSET_SHARE * peak)?;
206    Some(line.sample_time(start + low + first))
207}
208
209/// Whether the band envelope inside `from..to`, averaged over a centred
210/// [`RISE_PERIODS`] periods of `pitch`, is lowest at its first sample: the
211/// span starts inside a rise that began before it.
212#[must_use]
213pub(crate) fn rises_into(line: &SubBandLine, from: Seconds, to: Seconds, pitch: Hz) -> bool {
214    smoothed_envelope(line, from, to, RISE_PERIODS / pitch.get()).is_some_and(|(_, smooth)| {
215        (0..smooth.len()).min_by(|&a, &b| smooth[a].total_cmp(&smooth[b])) == Some(0)
216    })
217}
218
219/// The attack of a strike inside `from..to`.
220///
221/// It is the time the band envelope, averaged as for [`band_onset`], takes
222/// from [`ATTACK_FROM`] to [`ATTACK_TO`] of the peak that follows its
223/// lowest point. `None` when no attack lies inside the span.
224#[must_use]
225pub(crate) fn band_attack(line: &SubBandLine, from: Seconds, to: Seconds) -> Option<Seconds> {
226    let (_, smooth) = smoothed_envelope(line, from, to, ONSET_SMOOTHING.get())?;
227    let (low, peak) = low_and_peak(&smooth)?;
228    let after = &smooth[low..];
229    let first = after.iter().position(|&e| e >= ATTACK_FROM * peak)?;
230    let last = after.iter().position(|&e| e >= ATTACK_TO * peak)?;
231    Some(Seconds::clamped(
232        cast::usize_to_f64(last.saturating_sub(first)) / line.sample_rate().hz().get(),
233    ))
234}
235
236/// The band envelope inside `from..to`, averaged over a centred
237/// [`ONSET_SMOOTHING`], with the index of its first sample.
238fn smoothed_envelope(
239    line: &SubBandLine,
240    from: Seconds,
241    to: Seconds,
242    span: f64,
243) -> Option<(usize, Vec<f64>)> {
244    let rate = line.sample_rate().hz().get();
245    let half = cast::round_to_usize(0.5 * span * rate).unwrap_or(0);
246    let envelope = line.envelope();
247    let (start, end) = (line.index_at(from), line.index_at(to).min(envelope.len()));
248    if start >= end {
249        return None;
250    }
251    let mut sums = Vec::with_capacity(envelope.len() + 1);
252    sums.push(0.0);
253    for e in envelope {
254        sums.push(sums[sums.len() - 1] + e);
255    }
256    let smooth = (start..end)
257        .map(|n| {
258            let (a, b) = (n.saturating_sub(half), (n + half + 1).min(envelope.len()));
259            (sums[b] - sums[a]) / cast::usize_to_f64(b - a)
260        })
261        .collect();
262    Some((start, smooth))
263}
264
265/// The lowest point of a smoothed envelope and the peak after it, or
266/// `None` when the lowest point already stands above
267/// [`DROP_ONSET_SHARE`] of that peak.
268fn low_and_peak(smooth: &[f64]) -> Option<(usize, f64)> {
269    let low = (0..smooth.len()).min_by(|&a, &b| smooth[a].total_cmp(&smooth[b]))?;
270    let peak = smooth[low..].iter().copied().fold(0.0, f64::max);
271    (peak > 0.0 && smooth[low] <= DROP_ONSET_SHARE * peak).then_some((low, peak))
272}
273
274/// Fits the drop model to the line from `onset` on, up to `end` at most.
275/// `None` when fewer than three line values fall inside the fit span.
276#[must_use]
277pub fn fit_drop(line: &SubBandLine, onset: Seconds, end: Seconds) -> Option<DropFit> {
278    let last = (onset.get() + DROP_FIT_END.get()).min(end.get());
279    let from = line.index_at(Seconds::clamped(onset.get() + DROP_FIT_START.get()));
280    let to = line.index_at(Seconds::clamped(last)).min(line.hz().len());
281    let points: Vec<(f64, f64)> = (from..to)
282        .map(|n| (line.time_of(n).get() - onset.get(), line.hz()[n]))
283        .collect();
284    if points.len() < 3 {
285        return None;
286    }
287    let (lo, hi) = (DROP_TAU_MIN.get().ln(), TAU_MAX.ln());
288    let step = (hi - lo) / cast::usize_to_f64(TAU_GRID - 1);
289    let sse = |log_tau: f64| solve(&points, log_tau.exp()).2;
290    let (best, _) = (0..TAU_GRID)
291        .map(|k| lo + step * cast::usize_to_f64(k))
292        .map(|log_tau| (log_tau, sse(log_tau)))
293        .min_by(|a, b| a.1.total_cmp(&b.1))?;
294    let (log_tau, _) = golden_section(sse, (best - step).max(lo), (best + step).min(hi), 1e-9);
295    let tau = log_tau.exp();
296    let (f_end, df, sse) = solve(&points, tau);
297    // A fall faster than FAST_TAU ends within a period of a low note, and
298    // the average would spread its misfit.
299    let rms = if tau >= FAST_TAU.get() {
300        period_rms(&points, f_end, df, tau, line.sample_rate())
301    } else {
302        (sse / cast::usize_to_f64(points.len())).sqrt()
303    };
304    Some(DropFit {
305        onset,
306        f_end: Hz::new(f_end).ok()?,
307        df_hz: df,
308        tau: Seconds::new(tau).ok()?,
309        residual: {
310            let seen = df.abs() * (-DROP_FIT_START.get() / tau).exp();
311            if seen > 0.0 {
312                rms / seen
313            } else {
314                f64::INFINITY
315            }
316        },
317        points: points.len(),
318    })
319}
320
321/// The RMS of the fit's error averaged over half a period of `f_end`, or
322/// over a full period when `tau` spans [`FULL_PERIODS`] periods or more,
323/// centred and shortened at the ends.
324///
325/// The third harmonic of a driven note beats with the fundamental and
326/// swings the line at twice the note: a driven 808 left a raw residual of
327/// 23 % of its fall. Half a period removes that swing and keeps a slower
328/// error, from a wrong model or from the phase restart of a retrigger,
329/// which a full period hid: two same-pitch retriggers then read falls of
330/// 4 and 11 semitones with a `tau` of 12 ms. A second harmonic swings the
331/// line once per period, and a sweep with one at half the fundamental
332/// failed on the residual, at a `tau` of 50 to 150 ms.
333fn period_rms(points: &[(f64, f64)], f_end: f64, df: f64, tau: f64, rate: SampleRate) -> f64 {
334    let errors: Vec<f64> = points
335        .iter()
336        .map(|&(t, y)| y - f_end - df * (-t / tau).exp())
337        .collect();
338    let period = if f_end > 0.0 {
339        cast::round_to_usize(rate.hz().get() / f_end).unwrap_or(1)
340    } else {
341        1
342    };
343    // A fall over FULL_PERIODS periods or longer changes little within
344    // one, so a full period removes the swing of the second harmonic too.
345    let half = if tau * f_end >= FULL_PERIODS {
346        period.max(1) / 2
347    } else {
348        period.max(1) / 4
349    };
350    let mut sums = vec![0.0; errors.len() + 1];
351    for (k, e) in errors.iter().enumerate() {
352        sums[k + 1] = sums[k] + e;
353    }
354    let squares: f64 = (0..errors.len())
355        .map(|k| {
356            let (a, b) = (k.saturating_sub(half), (k + half + 1).min(errors.len()));
357            ((sums[b] - sums[a]) / cast::usize_to_f64(b - a)).powi(2)
358        })
359        .sum();
360    (squares / cast::usize_to_f64(errors.len())).sqrt()
361}
362
363/// The least-squares `f_end` and `df` for one `tau`, and the sum of the
364/// squared residuals.
365fn solve(points: &[(f64, f64)], tau: f64) -> (f64, f64, f64) {
366    let n = cast::usize_to_f64(points.len());
367    let g: Vec<f64> = points.iter().map(|&(t, _)| (-t / tau).exp()).collect();
368    let (mut sg, mut sgg, mut sy, mut sgy) = (0.0, 0.0, 0.0, 0.0);
369    for (&(_, y), &g) in points.iter().zip(&g) {
370        sg += g;
371        sgg += g * g;
372        sy += y;
373        sgy += g * y;
374    }
375    let det = n * sgg - sg * sg;
376    let (a, b) = if det.abs() <= f64::EPSILON * n * sgg {
377        (sy / n, 0.0)
378    } else {
379        ((sgg * sy - sg * sgy) / det, (n * sgy - sg * sy) / det)
380    };
381    let sse = points
382        .iter()
383        .zip(&g)
384        .map(|(&(_, y), &g)| (y - a - b * g).powi(2))
385        .sum();
386    (a, b, sse)
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::audio::SampleRate;
393
394    #[test]
395    fn the_convention_states_the_constants() {
396        use super::super::line::{LINE_HI, LINE_LO, LINE_LO_FAST};
397        use super::super::{
398            DROP_BAND_GATE, DROP_NOTE_LEAD, DROP_NOTE_MATCH, RUN_START_RISE, STRIKE_ATTACK,
399            STRIKE_RISE, STRIKE_SPANS, num, ratio_db, says,
400        };
401        use crate::dsp::ACTIVE_GATE_DB;
402        use crate::notes::QUIET_FRAME;
403        let ms = |s: f64| num(1000.0 * s);
404        let [before, after] = STRIKE_SPANS;
405        assert!((DROP_ONSET_SHARE - 0.5).abs() < 1e-12, "half the peak");
406        says(
407            DROP_CONVENTION,
408            &[
409                format!(
410                    "from {} ms to {} ms after the onset",
411                    ms(DROP_FIT_START.get()),
412                    ms(DROP_FIT_END.get())
413                ),
414                format!(
415                    "search from {} ms, where exp(-{} ms / tau) = 0.1, to {} s",
416                    ms(DROP_TAU_MIN.get()),
417                    ms(DROP_FIT_START.get()),
418                    num(TAU_MAX)
419                ),
420                format!(
421                    "a fit with tau under {} ms, or after a strike whose full-band RMS rises {}, \
422                     is fitted again on the line with its high-pass at {} Hz",
423                    ms(FAST_TAU.get()),
424                    ratio_db(RUN_START_RISE),
425                    num(LINE_LO_FAST.get())
426                ),
427                format!(
428                    "where the {} to {} Hz envelope, averaged over a centred {} ms, reaches half",
429                    num(LINE_LO.get()),
430                    num(LINE_HI.get()),
431                    ms(ONSET_SMOOTHING.get())
432                ),
433                format!(
434                    "RMS over the {} ms after the onset lies more than {} dB below the loudest \
435                     {} ms frame of the stem",
436                    ms(after.1 - after.0),
437                    num(ACTIVE_GATE_DB.get()),
438                    ms(QUIET_FRAME.get())
439                ),
440                format!(
441                    "lies more than {} dB below the loudest {} ms frame of that band",
442                    num(DROP_BAND_GATE.get()),
443                    ms(QUIET_FRAME.get())
444                ),
445                format!(
446                    "a drop with tau under {} ms needs an envelope that takes {} ms or less from \
447                     {} to {} % of its peak",
448                    ms(FAST_TAU.get()),
449                    ms(STRIKE_ATTACK.get()),
450                    num(100.0 * ATTACK_FROM),
451                    num(100.0 * ATTACK_TO)
452                ),
453                format!("one that starts at {} Hz or above", num(LINE_HI.get())),
454                format!(
455                    "the {} ms after the onset {} or more above the {} ms that end {} ms before it",
456                    ms(after.1 - after.0),
457                    ratio_db(STRIKE_RISE),
458                    ms(before.1 - before.0),
459                    ms(-before.1)
460                ),
461                format!(
462                    "half a period of f_end when tau is {} ms or more and over a full period \
463                     when tau spans {} periods or more",
464                    ms(FAST_TAU.get()),
465                    num(FULL_PERIODS)
466                ),
467                format!(
468                    "df_st is above {} and at most {}, that fall is above {} semitones too",
469                    num(DROP_MIN.get()),
470                    num(DROP_MAX.get()),
471                    num(DROP_MIN.get())
472                ),
473                format!("residual is below {}", num(DROP_MAX_RESIDUAL)),
474                format!(
475                    "within {} cents of a note that starts {} ms or less before the onset",
476                    num(DROP_NOTE_MATCH.get()),
477                    num(1000.0 * DROP_NOTE_LEAD.get())
478                ),
479            ],
480        );
481    }
482
483    #[test]
484    fn the_convention_states_the_note_line_constants() {
485        use super::super::line::LINE_HI;
486        use super::super::{FAST_TAU, NOTE_LINE_EDGE, NOTE_LINE_LOSS, STRIKE_RISE};
487        use super::super::{num, ratio_db, says};
488        let ms = |s: f64| num(1000.0 * s);
489        says(
490            DROP_CONVENTION,
491            &[
492                format!(
493                    "a fit after a {} strike that is no drop is fitted again on the note line, \
494                     whose low-pass sits at {} times the note's pitch, when that lies under {} Hz",
495                    ratio_db(STRIKE_RISE),
496                    num(NOTE_LINE_EDGE),
497                    num(LINE_HI.get())
498                ),
499                format!(
500                    "the refit counts with tau of {} ms or more and an RMS envelope over its \
501                     fall, from {} ms to 5 tau, at most {} dB below",
502                    ms(FAST_TAU.get()),
503                    ms(DROP_FIT_START.get()),
504                    num(NOTE_LINE_LOSS.get())
505                ),
506                format!(
507                    "averaged over a centred {} periods of the note, is lowest at its first \
508                     sample",
509                    num(RISE_PERIODS)
510                ),
511            ],
512        );
513    }
514
515    #[test]
516    fn the_fit_recovers_an_exact_exponential() {
517        let points: Vec<(f64, f64)> = (0..400)
518            .map(|k| {
519                let t = 0.007 + cast::usize_to_f64(k) * 0.001;
520                (t, 41.2 + 41.2 * (-t / 0.02).exp())
521            })
522            .collect();
523        let (a, b, sse) = solve(&points, 0.02);
524        assert!((a - 41.2).abs() < 1e-9 && (b - 41.2).abs() < 1e-9 && sse < 1e-18);
525    }
526
527    #[test]
528    fn a_constant_line_has_no_drop() {
529        let rate = SampleRate::new(22_050).unwrap();
530        let x: Vec<f64> = (0..22_050)
531            .map(|n| (std::f64::consts::TAU * 55.0 * cast::usize_to_f64(n) / 22_050.0).sin())
532            .collect();
533        let line = SubBandLine::of(&x, rate, Seconds::new(0.0).unwrap()).unwrap();
534        let fit = fit_drop(
535            &line,
536            Seconds::new(0.3).unwrap(),
537            Seconds::new(0.9).unwrap(),
538        )
539        .unwrap();
540        assert!((fit.f_end.get() - 55.0).abs() < 0.05, "{fit:?}");
541        assert!(!fit.accepted(), "{fit:?}");
542    }
543
544    #[test]
545    fn the_drop_in_semitones_follows_its_definition() {
546        let fit = DropFit {
547            onset: Seconds::new(0.0).unwrap(),
548            f_end: Hz::new(41.2).unwrap(),
549            df_hz: 41.2,
550            tau: Seconds::new(0.02).unwrap(),
551            residual: 0.05,
552            points: 100,
553        };
554        assert!((fit.df().unwrap().get() - 12.0).abs() < 1e-12);
555        assert!(fit.accepted());
556        // A fall with tau 2 ms is mostly over at 7 ms.
557        let fast = DropFit {
558            tau: Seconds::new(0.002).unwrap(),
559            ..fit
560        };
561        assert!(fast.df().unwrap() > DROP_MIN && fast.visible().unwrap() < DROP_MIN);
562        assert!(!fast.accepted());
563        // A tau on the bound of the search, with the fall still visible.
564        let bound = DropFit {
565            tau: DROP_TAU_MIN,
566            df_hz: 41.2 * 3.0,
567            ..fit
568        };
569        assert!(bound.visible().unwrap() > DROP_MIN && !bound.accepted());
570        assert!(
571            DropFit {
572                tau: Seconds::new(1.02 * DROP_TAU_MIN.get()).unwrap(),
573                ..bound
574            }
575            .accepted()
576        );
577        assert!(((-DROP_FIT_START.get() / DROP_TAU_MIN.get()).exp() - 0.1).abs() < 1e-12);
578        // Three octaves count; more do not.
579        let octaves = |n: f64| DropFit {
580            df_hz: 41.2 * (2f64.powf(n) - 1.0),
581            ..fit
582        };
583        assert!(octaves(3.0).accepted() && !octaves(3.01).accepted());
584        assert!(
585            !DropFit {
586                residual: 0.15,
587                ..fit
588            }
589            .accepted()
590        );
591        assert!(
592            !DropFit {
593                df_hz: 41.2 * (2f64.powf(1.99 / 12.0) - 1.0),
594                ..fit
595            }
596            .accepted()
597        );
598        assert_eq!(
599            DropFit {
600                df_hz: -50.0,
601                ..fit
602            }
603            .df(),
604            None
605        );
606    }
607}