Skip to main content

sound_to_patch/estimate/
pitch.rs

1//! The pitch layer of a monophonic line: glides between notes, pitch drops
2//! at the strike of a low note, and vibrato.
3//!
4//! The glides and the vibrato read the f0 track; the drops read the
5//! sub-band line of the stem, because the track smears a drop that settles
6//! in tens of milliseconds. Every record carries its own confidence. A
7//! line that plays chords reports nothing, since no method here separates
8//! the voices.
9
10mod drop;
11pub(crate) mod fit;
12mod glide;
13mod line;
14mod vibrato;
15
16pub use drop::{
17    DROP_CONVENTION, DROP_FIT_START, DROP_MAX, DROP_MAX_RESIDUAL, DROP_MIN, DROP_ONSET_SHARE,
18    DROP_TAU_MIN, DropFit, DropMiss, ONSET_SMOOTHING, RISE_PERIODS, band_onset, fit_drop,
19};
20pub use glide::{CURVE_RANGE, GLIDE_CONVENTION, GLIDE_SLOPE_MIN, GlideFit};
21pub use line::{LINE_CONVENTION, SubBandLine};
22pub use vibrato::{EXTENT_SHARE, VIBRATO_CONVENTION, VIBRATO_SPAN_MIN, VibratoFit, fit_vibrato};
23
24use drop::{DROP_FIT_END, band_attack, rises_into};
25use glide::{GLIDE_TIME_MIN as SPAN_MIN, GlideRead, find_glides, steady_spans, track_lag};
26use line::{LINE_HI, LINE_LO, LINE_LO_FAST};
27
28use crate::audio::SampleRate;
29use crate::cast;
30use crate::confidence::{Confidence, ConfidenceError, Level, NoSpread, Reason, ReasonCode, Signal};
31use crate::dsp::{
32    ACTIVE_GATE_DB, FilterError, StftParams, band_magnitude, band_pass_zero_phase, frame_rms,
33};
34use crate::notes::{NoteList, PitchTrack, QUIET_FRAME, Voicing};
35use crate::units::{Cents, Decibels, Hz, MidiPitch, Seconds, Semitones};
36
37/// The onset search of a strike starts at most this long before the note
38/// onset, because the transcriber places the onset of a falling note late.
39pub const STRIKE_LOOKBACK: Seconds = Seconds::from_const(0.5);
40/// The onset search ends this long after the note onset.
41pub const STRIKE_LOOKAHEAD: Seconds = Seconds::from_const(0.1);
42/// The vibrato search skips this much at each end of a steady span.
43pub const RUN_TRIM: Seconds = Seconds::from_const(0.05);
44/// The vibrato search skips at least this much after a drop's onset, and
45/// five time constants when that is longer.
46const SETTLE_MIN: f64 = 0.1;
47/// A glide from silence is a drop when its run starts this close to the
48/// onset of an accepted drop.
49pub const DROP_GLIDE_DISTANCE: Seconds = Seconds::from_const(0.2);
50/// A drop settles on a note whose pitch lies within this many cents of
51/// `f_end`.
52pub const DROP_NOTE_MATCH: Cents = Cents::from_const(100.0);
53/// The note a drop settles on starts at most this long before the onset.
54///
55/// A note that started earlier belongs to a sound before the strike. On
56/// real stems a fit on the lead-in to a step settled on the note that
57/// had sounded for 790 ms, and a fit inside a chord on a note an octave
58/// below that had started 210 ms before. A generated fall into C1 settles
59/// on a C1 note that started 100 ms before its strike and on none that
60/// started 200 ms before.
61pub const DROP_NOTE_LEAD: Seconds = Seconds::from_const(0.15);
62/// The harmonic check of a strike reads the bands from this long after the
63/// onset.
64pub const WEAK_FROM: Seconds = Seconds::from_const(0.05);
65/// A fundamental is weak when the band around twice the pitch exceeds the
66/// band around the pitch by this factor, 6 dB.
67///
68/// White noise reads a factor of 1.41, since the upper band is twice as
69/// wide. Below this factor a fit that reads the harmonic settles 1200
70/// cents off the note and fails.
71pub const WEAK_RATIO: f64 = 2.0;
72/// A strike raises the full-band RMS by this factor, 6 dB. The glide
73/// search reads it as a new note, and a drop that is fast or starts at the
74/// top of the band needs it.
75pub const STRIKE_RISE: f64 = 2.0;
76/// A drop with `tau` under this needs a strike of [`STRIKE_RISE`]. The swing
77/// of the line where a ringing tone restarts its phase fits a fast fall:
78/// a plain retriggered 808 read 32 semitones with a `tau` of 4 ms.
79pub const FAST_TAU: Seconds = Seconds::from_const(0.01);
80/// The low-pass of the note line, as a multiple of the note's pitch.
81///
82/// A fit after a strike of [`STRIKE_RISE`] that is no drop is fitted
83/// again on a line that keeps the band below this edge. That needs the
84/// edge under 200 Hz, the top of the band, so the note sits under 133 Hz.
85/// Below 66 Hz the second and third harmonics of a note lie inside the
86/// band. At -5 to -14 dB they swing the line by more than the note's
87/// pitch: a real sweep into C1 fitted a residual of 2.5. At 1.5 times the
88/// note the second harmonic falls 10 dB more. A fall that starts higher
89/// then stays the strongest component on its way down. At 1.2 a generated
90/// sweep into C1 with strong overtones read tau 24 % long, and one struck
91/// again inside its note read none. At 3 both read none. Without a
92/// strike, the note line smooths the swing where a retriggered tone
93/// restarts its phase into a fall. Three generated retriggers that rose
94/// 0.6 to 1.6 dB read one.
95pub const NOTE_LINE_EDGE: f64 = 1.5;
96/// A fit on the note line counts when the note line keeps the fall: its
97/// RMS envelope over the fall lies at most this far below the band's.
98///
99/// Over the fall of four true drops on real stems the note line lay 2.2
100/// to 3.8 dB below the band, and generated sweeps 2.2 to 3.6 dB. A tone an
101/// octave above a real note, with the note's own tone entering 80 ms after
102/// the strike, lay 10.8 dB below: the note line held the upper tone 18 dB
103/// down, and its line fell from it to the lower one as that entered.
104/// Generated tones of that shape lay 4.8 to 7.6 dB below, so the ones
105/// under this edge still read as drops. The margins are thin: the true
106/// drops lie 2.2 dB under the edge at most, and the false tones of the
107/// tests 0.6 and 1.3 dB over it.
108pub const NOTE_LINE_LOSS: Decibels = Decibels::from_const(6.0);
109/// The spans, in seconds from a band onset, of the RMS before and after
110/// it that a strike compares. A pitch falling into the band from above
111/// raises the band envelope with no strike. The span before ends 25 ms
112/// early, since the band onset of a broadband sound such as noise lags
113/// its start by about 15 ms.
114const STRIKE_SPANS: [(f64, f64); 2] = [(-0.045, -0.025), (0.0, 0.02)];
115/// The spans, in seconds from a time, of the RMS before and after it that
116/// tell whether a sound starts there, for the glide search.
117const RUN_START_SPANS: [(f64, f64); 2] = [(-0.06, -0.03), (0.0, 0.03)];
118/// A sound starts where the full-band RMS rises by this factor, 20 dB:
119/// from silence, not from a dip of a beating unison.
120pub const RUN_START_RISE: f64 = 10.0;
121/// The step at which the glide search looks for the start of a sound.
122const ONSET_STEP: f64 = 0.005;
123/// A gap of the track whose full-band RMS, over 10 ms, falls this far
124/// below the 20 ms before or after it is not bridged.
125///
126/// The dip breaks the sound, and the glide search reads the runs apart. So
127/// does a gap that falls more than [`ACTIVE_GATE_DB`] below the loudest
128/// frame of the stem.
129pub const GAP_DIP_DB: Decibels = Decibels::from_const(20.0);
130/// The RMS window of the dip check.
131const DIP_WINDOW: f64 = 0.01;
132/// The span before and after a gap that the dip check compares with.
133const DIP_SIDE: f64 = 0.02;
134/// The line keeps this much signal after the fit span, so the cut end of
135/// the analytic signal stays far from it.
136const LINE_TAIL: f64 = 0.5;
137/// The line starts this long before the onset search, for the same reason.
138const LINE_LEAD: f64 = 0.1;
139
140/// A glide whose fit residual stays at or below this many cents, whose
141/// stable pitches jitter at most [`JITTER_HIGH`] and whose motion lasts
142/// [`SPAN_HIGH`] or more reads at high confidence.
143pub const RESIDUAL_HIGH: f64 = 15.0;
144/// The largest jitter of a high-confidence glide, in cents. A unison
145/// beats, and the track then wanders on the stable pitches.
146pub const JITTER_HIGH: f64 = 3.0;
147/// The largest curve power of a high-confidence glide. A faster start
148/// reads its rise less well: up to 11 % off at `p` 4.4 on the generated
149/// sets, within 7.1 % at 3 or less.
150pub const CURVE_HIGH: f64 = 3.0;
151/// The shortest motion of a high-confidence glide.
152pub const SPAN_HIGH: Seconds = Seconds::from_const(0.15);
153/// The jitter above which a glide reads at low confidence.
154///
155/// The unison glides of a larger generated set jitter 3.77 to 7.07 cents
156/// at 22.05 kHz and 3.45 to 6.93 at 44.1 kHz, the mono ones 0.66 to 8.03
157/// at both rates, so the jitter alone cannot tell the two apart: a mono
158/// glide above it reads low too. From [`JITTER_HIGH`] to here only mono
159/// glides fall, and they read medium. One generated unison sets the 3.45,
160/// a margin of 0.05 cents. In the committed fixtures no glide jitters
161/// from 3 to 3.4 cents, and the unison glides jitter 3.54 or more, a
162/// margin of 0.14.
163pub const JITTER_LOW: f64 = 3.4;
164/// A drop without a full-band rise needs an attack this short or shorter.
165/// Struck 808 notes read 9 to 13 ms; a unison falling into the band from
166/// above reads 190 ms.
167pub const STRIKE_ATTACK: Seconds = Seconds::from_const(0.03);
168/// A strike whose RMS in the band of the line, over the 20 ms after the
169/// onset, lies more than this below the loudest [`QUIET_FRAME`] of that
170/// band in the stem is no candidate.
171///
172/// Checked against an independent IF on real stems, the drops of a line's
173/// own low notes read 3 to 12 dB below the loudest passage of the band.
174/// Quiet falls under a louder line in the band, and a fall in a noise
175/// floor, read 22 to 33 dB below it.
176pub const DROP_BAND_GATE: Decibels = Decibels::from_const(18.0);
177/// The longest attack of a high-confidence drop.
178///
179/// The pitch falls during a slower attack, before the fit span starts, and
180/// `df` reads low. The 10 ms average of the envelope spreads a sharp strike
181/// to 9 to 11 ms; a 20 ms ramp reads about 17.5 ms.
182pub const ATTACK_HIGH: Seconds = Seconds::from_const(0.012);
183/// A drop whose residual share stays below this reads at high confidence.
184pub const DROP_RESIDUAL_HIGH: f64 = 0.05;
185/// A vibrato over this many cycles or more reads at high confidence.
186///
187/// Checked against an independent f0 on real stems, every vibrato above
188/// 100 Hz of 4 cycles or more matched it, at a share of 0.51 to 0.64: the
189/// share did not separate the real ones from the rest.
190pub const CYCLES_HIGH: f64 = 4.0;
191/// A vibrato on a pitch below this reads at low confidence.
192///
193/// Checked against an independent f0 on real stems, few vibratos below it
194/// matched, and their extent read up to 5 times too wide. A generated
195/// low tone with amplitude modulation or two beating voices reads no
196/// vibrato, so the cause lies elsewhere.
197pub const VIBRATO_LOW_PITCH: Hz = Hz::from_const(100.0);
198/// A vibrato whose contour has a kurtosis below this reads at low
199/// confidence: it jumps between two levels, as a trill does. The sines of
200/// the generated set read 1.36 to 1.66, a semitone trill 1.06.
201pub const KURTOSIS_LOW: f64 = 1.25;
202/// The layer reads at low confidence below this share of note frames.
203///
204/// The share counts the track frames inside the notes that are voiced
205/// within [`NOTE_FRAME_MATCH`] of a sounding note. The tones of the
206/// generated set read 0.80 or more.
207pub const NOTE_FRAMES_LOW: f64 = 0.5;
208/// A voiced frame belongs to a sounding note within this many cents.
209pub const NOTE_FRAME_MATCH: Cents = Cents::from_const(100.0);
210
211/// What the pitch layer reads.
212#[derive(Clone, Copy, Debug)]
213pub struct PitchInput<'a> {
214    /// The mono mix at the native rate.
215    pub mono: &'a [f64],
216    /// The native rate.
217    pub rate: SampleRate,
218    /// The f0 track of the stem.
219    pub track: &'a PitchTrack,
220    /// The note list, transcribed or given.
221    pub notes: &'a NoteList,
222    /// Whether the line plays one note at a time.
223    pub voicing: Voicing,
224}
225
226/// A record with its confidence.
227#[derive(Clone, Debug, PartialEq)]
228pub struct Rated<T> {
229    /// The record.
230    pub value: T,
231    /// How far the layer trusts it.
232    pub confidence: Confidence<NoSpread>,
233}
234
235/// Flags of the pitch layer.
236#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
237#[expect(clippy::struct_excessive_bools, reason = "independent report flags")]
238pub struct PitchFlags {
239    /// The line plays chords, so no record was searched for.
240    pub chord_skipped: bool,
241    /// A motion between two notes lasted 35 to 90 ms, over its detected
242    /// span or its fitted time: a glide too short to read.
243    pub glide_too_short: bool,
244    /// A low note whose second harmonic exceeds its fundamental by 6 dB or
245    /// more had its drop fit dropped, since the band-pass then reads the
246    /// wrong partial.
247    pub weak_fundamental: bool,
248    /// The stem gave no note list, so no record was searched for.
249    pub no_notes: bool,
250}
251
252/// The pitch layer of a stem.
253#[derive(Clone, Debug, PartialEq)]
254pub struct PitchLayer {
255    /// The glides, in time order.
256    pub glides: Vec<Rated<GlideFit>>,
257    /// The accepted pitch drops, in time order.
258    pub drops: Vec<Rated<DropFit>>,
259    /// The accepted vibratos, in time order.
260    pub vibrato: Vec<Rated<VibratoFit>>,
261    /// The flags.
262    pub flags: PitchFlags,
263    /// The confidence of the layer as a whole; no estimate when the line
264    /// plays chords or the stem has no notes.
265    pub confidence: Confidence<NoSpread>,
266    /// The counts behind the summary.
267    pub counts: PitchCounts,
268}
269
270/// How many candidates each search read.
271#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
272pub struct PitchCounts {
273    /// Motions between two notes shorter than a glide.
274    pub too_short: usize,
275    /// Low notes whose strike the drop fit read.
276    pub drop_candidates: usize,
277    /// Why the drop search read no drop at a low note, per gate.
278    pub drop_skips: DropSkips,
279    /// Steady spans long enough for the vibrato search.
280    pub vibrato_candidates: usize,
281    /// Voiced frames of the track.
282    pub voiced_frames: usize,
283}
284
285/// The low notes the drop search read no drop at, by the first gate that
286/// stopped each.
287#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
288pub struct DropSkips {
289    /// No attack inside the onset search.
290    pub no_onset: usize,
291    /// The onset lies within 20 ms of a search that starts where the
292    /// previous note's search ended, before the drop read last has
293    /// settled: that search found the same strike again inside its rise.
294    pub settling: usize,
295    /// The strike lies more than [`ACTIVE_GATE_DB`] below the loudest
296    /// frame of the stem, or more than [`DROP_BAND_GATE`] below the
297    /// loudest frame of the band.
298    pub quiet: usize,
299    /// The full-band level does not rise as the fall needs.
300    pub no_strike: usize,
301    /// The second harmonic exceeds the fundamental.
302    pub weak_fundamental: usize,
303    /// The fit failed, or its drop lies outside [`DROP_MIN`] to
304    /// [`DROP_MAX`] in full or inside its span.
305    pub no_fall: usize,
306    /// The fit's `tau` sits on the bound of the search.
307    pub tau_bound: usize,
308    /// The fit's residual reaches [`DROP_MAX_RESIDUAL`].
309    pub residual: usize,
310    /// No note sounds at `f_end` while the drop is settled.
311    pub off_note: usize,
312}
313
314impl DropSkips {
315    /// The gates that skipped a note, as `count gate` items.
316    #[must_use]
317    pub fn items(&self) -> Vec<String> {
318        [
319            (self.no_onset, "no onset"),
320            (self.settling, "settling"),
321            (self.quiet, "quiet"),
322            (self.no_strike, "no strike"),
323            (self.weak_fundamental, "weak fundamental"),
324            (self.no_fall, "no fall"),
325            (self.tau_bound, "tau on bound"),
326            (self.residual, "residual"),
327            (self.off_note, "off note"),
328        ]
329        .into_iter()
330        .filter(|(n, _)| *n > 0)
331        .map(|(n, gate)| format!("{n} {gate}"))
332        .collect()
333    }
334}
335
336impl PitchLayer {
337    /// Reads the pitch layer of a stem.
338    ///
339    /// # Errors
340    ///
341    /// Returns a [`PitchError`] when the stem's rate is too low for the
342    /// 200 Hz band of the sub-band line, or a confidence cannot be built.
343    pub fn of(input: &PitchInput<'_>) -> Result<Self, PitchError> {
344        let voiced_frames = input
345            .track
346            .frames()
347            .iter()
348            .filter(|f| f.f0.is_some())
349            .count();
350        let counts = PitchCounts {
351            voiced_frames,
352            ..PitchCounts::default()
353        };
354        let skip = |flags: PitchFlags, reason: Reason| -> Result<Self, PitchError> {
355            Ok(Self {
356                glides: Vec::new(),
357                drops: Vec::new(),
358                vibrato: Vec::new(),
359                flags,
360                confidence: Confidence::no_estimate(vec![reason])?,
361                counts,
362            })
363        };
364        if input.notes.is_empty() {
365            let flags = PitchFlags {
366                no_notes: true,
367                ..PitchFlags::default()
368            };
369            return skip(
370                flags,
371                Reason::new(ReasonCode::NoNoteList, Signal::Flag(true))?,
372            );
373        }
374        if input.voicing == Voicing::Polyphonic {
375            let flags = PitchFlags {
376                chord_skipped: true,
377                ..PitchFlags::default()
378            };
379            return skip(
380                flags,
381                Reason::new(ReasonCode::PolyphonicLine, Signal::Flag(true))?,
382            );
383        }
384        let note_frames = note_frames(input.track, input.notes);
385        let mut flags = PitchFlags::default();
386        let mut counts = counts;
387        let loudest = loudest_frame(input.mono, input.rate);
388        let struck = strikes(input, loudest, &mut flags, &mut counts)?;
389        let drops: Vec<DropFit> = struck.iter().map(|s| s.fit).collect();
390        let read = glides(input, loudest, &drops);
391        counts.too_short = read.too_short;
392        flags.glide_too_short = read.too_short > 0;
393        let vibrato = vibratos(input.track, &read.glides, &drops, &mut counts);
394        Ok(Self {
395            glides: read
396                .glides
397                .into_iter()
398                .map(rate_glide)
399                .collect::<Result<_, _>>()?,
400            drops: struck
401                .into_iter()
402                .map(rate_drop)
403                .collect::<Result<_, _>>()?,
404            vibrato: vibrato
405                .into_iter()
406                .map(rate_vibrato)
407                .collect::<Result<_, _>>()?,
408            flags,
409            confidence: Confidence::estimate(
410                if note_frames < NOTE_FRAMES_LOW {
411                    Level::Low
412                } else {
413                    Level::Medium
414                },
415                None,
416                vec![
417                    Reason::new(
418                        ReasonCode::VoicedFrames,
419                        Signal::Count(count(voiced_frames)),
420                    )?,
421                    Reason::new(ReasonCode::NoteFrames, Signal::Number(note_frames))?,
422                ],
423            )?,
424            counts,
425        })
426    }
427
428    /// Returns the lines that tell what the layer found.
429    #[must_use]
430    pub fn summary(&self) -> Vec<String> {
431        if self.flags.no_notes {
432            return vec!["pitch: no notes, so no glides, drops or vibrato".to_owned()];
433        }
434        if self.flags.chord_skipped {
435            return vec!["pitch: the line plays chords, so no glides, drops or vibrato".to_owned()];
436        }
437        let glides = self
438            .glides
439            .iter()
440            .map(|g| {
441                format!(
442                    "{:+.1} st in {:.0} ms at {:.2} s, curve {:.2}{}",
443                    g.value.interval.get(),
444                    1000.0 * g.value.time.get(),
445                    g.value.start.get(),
446                    g.value.curve,
447                    if g.value.from_silence {
448                        ", from silence"
449                    } else {
450                        ""
451                    }
452                )
453            })
454            .collect::<Vec<_>>();
455        let drops = self
456            .drops
457            .iter()
458            .map(|d| {
459                format!(
460                    "{:.1} st, tau {:.1} ms, to {:.1} Hz at {:.2} s",
461                    d.value.df().map_or(0.0, Semitones::get),
462                    1000.0 * d.value.tau.get(),
463                    d.value.f_end.get(),
464                    d.value.onset.get()
465                )
466            })
467            .collect::<Vec<_>>();
468        let vibrato = self
469            .vibrato
470            .iter()
471            .map(|v| {
472                format!(
473                    "{:.2} Hz, {:.0} ct at {:.2} s",
474                    v.value.rate.get(),
475                    v.value.extent().get(),
476                    v.value.start.get()
477                )
478            })
479            .collect::<Vec<_>>();
480        let list = |items: &[String]| {
481            if items.is_empty() {
482                String::new()
483            } else {
484                format!(": {}", items.join("; "))
485            }
486        };
487        vec![
488            format!(
489                "glides: {}, {} too short{}",
490                self.glides.len(),
491                self.counts.too_short,
492                list(&glides)
493            ),
494            format!(
495                "pitch drops: {} of {} low strikes{}",
496                self.drops.len(),
497                self.counts.drop_candidates,
498                list(&drops)
499            ),
500            format!(
501                "drop skips: {}",
502                match self.counts.drop_skips.items() {
503                    items if items.is_empty() => "none".to_owned(),
504                    items => items.join(", "),
505                }
506            ),
507            format!(
508                "vibrato: {} of {} steady spans{}",
509                self.vibrato.len(),
510                self.counts.vibrato_candidates,
511                list(&vibrato)
512            ),
513        ]
514    }
515}
516
517/// Finds the glides, with the audio telling where a sound starts, breaks
518/// or jumps in level, and the drops where a glide from silence is a drop.
519/// `loudest` is the RMS of the loudest frame of the stem.
520fn glides(input: &PitchInput<'_>, loudest: f64, drops: &[DropFit]) -> GlideRead {
521    let near_drop = |time: Seconds| {
522        drops
523            .iter()
524            .any(|d| (d.onset.get() - time.get()).abs() <= DROP_GLIDE_DISTANCE.get())
525    };
526    let starts = |from: Seconds, to: Seconds, spans: [(f64, f64); 2], rise: f64| {
527        scan(from.get(), to.get(), ONSET_STEP).any(|at| {
528            rise_ratio(
529                input.mono,
530                input.rate,
531                Seconds::clamped(0.0),
532                Seconds::clamped(at),
533                spans,
534            ) >= rise
535        })
536    };
537    let rms = |at: f64, span: (f64, f64)| {
538        span_rms(
539            input.mono,
540            input.rate,
541            Seconds::clamped(0.0),
542            Seconds::clamped(at),
543            span,
544        )
545    };
546    let floor = loudest * gain_below(ACTIVE_GATE_DB);
547    let dips = |from: Seconds, to: Seconds| {
548        let window = (-0.5 * DIP_WINDOW, 0.5 * DIP_WINDOW);
549        let side = rms(from.get(), (-DIP_SIDE, 0.0)).max(rms(to.get(), (0.0, DIP_SIDE)));
550        let low = scan(from.get(), to.get(), ONSET_STEP)
551            .map(|at| rms(at, window))
552            .fold(rms(f64::midpoint(from.get(), to.get()), window), f64::min);
553        low < side * gain_below(GAP_DIP_DB) || low < floor
554    };
555    find_glides(
556        input.track,
557        input.notes,
558        |from, to| starts(from, to, STRIKE_SPANS, STRIKE_RISE) || dips(from, to),
559        |from, to| starts(from, to, RUN_START_SPANS, RUN_START_RISE) && !near_drop(to),
560    )
561}
562
563/// The times from `from` to `to`, both included, `step` apart, each the
564/// previous plus `step`.
565fn scan(from: f64, to: f64, step: f64) -> impl Iterator<Item = f64> {
566    std::iter::successors(Some(from), move |at| Some(at + step)).take_while(move |&at| at <= to)
567}
568
569/// The amplitude gain of `gate` below a level.
570fn gain_below(gate: Decibels) -> f64 {
571    Decibels::clamped(-gate.get())
572        .to_amplitude_gain()
573        .unwrap_or(0.0)
574}
575
576/// The share of the track frames inside the notes that are voiced within
577/// [`NOTE_FRAME_MATCH`] of a note sounding at the frame, in track time.
578fn note_frames(track: &PitchTrack, notes: &NoteList) -> f64 {
579    let (mut inside, mut matched) = (0_usize, 0_usize);
580    for frame in track.frames() {
581        let sounding: Vec<f64> = notes
582            .notes()
583            .iter()
584            .filter(|note| note.sounds_at(frame.time))
585            .map(|note| note.pitch().get())
586            .collect();
587        if sounding.is_empty() {
588            continue;
589        }
590        inside += 1;
591        let Some(cents) = frame
592            .f0
593            .and_then(|f0| MidiPitch::try_from(f0).ok())
594            .map(|pitch| 100.0 * pitch.get())
595        else {
596            continue;
597        };
598        if sounding
599            .iter()
600            .any(|pitch| (100.0 * pitch - cents).abs() <= NOTE_FRAME_MATCH.get())
601        {
602            matched += 1;
603        }
604    }
605    if inside == 0 {
606        0.0
607    } else {
608        cast::usize_to_f64(matched) / cast::usize_to_f64(inside)
609    }
610}
611
612fn count(n: usize) -> u64 {
613    u64::try_from(n).unwrap_or(u64::MAX)
614}
615
616/// Fits a drop at the strike of each low note. A note that continues the
617/// one before it, with no fall of the band envelope between them, has no
618/// onset and so no fit; nor has a band onset where the full-band level
619/// does not rise as [`struck_enough`] asks. `loudest` is the RMS of the
620/// loudest frame of the stem.
621fn strikes(
622    input: &PitchInput<'_>,
623    loudest: f64,
624    flags: &mut PitchFlags,
625    counts: &mut PitchCounts,
626) -> Result<Vec<Strike>, PitchError> {
627    let rate = input.rate.hz().get();
628    let mut drops: Vec<Strike> = Vec::new();
629    // The search of each note starts after the previous note's search
630    // ended.
631    let mut previous_to = 0.0_f64;
632    let floor = loudest * gain_below(ACTIVE_GATE_DB);
633    let band = band_pass_zero_phase(input.mono, input.rate, LINE_LO, LINE_HI)?;
634    let band_floor = loudest_frame(&band, input.rate) * gain_below(DROP_BAND_GATE);
635    let origin = Seconds::clamped(0.0);
636    let skips = &mut counts.drop_skips;
637    for note in input.notes.notes() {
638        let (note_on, note_off) = (note.onset().get(), note.offset().get());
639        let settled = Hz::from(note.pitch());
640        let border = previous_to;
641        let search_from = border.max(note_on - STRIKE_LOOKBACK.get()).max(0.0);
642        let search_to = note_on + STRIKE_LOOKAHEAD.get();
643        previous_to = previous_to.max(search_to);
644        if !(LINE_LO..=LINE_HI).contains(&settled) || search_from >= search_to {
645            continue;
646        }
647        let first = cast::floor_to_usize((search_from - LINE_LEAD).max(0.0) * rate).unwrap_or(0);
648        let last = cast::ceil_to_usize((search_to + DROP_FIT_END.get() + LINE_TAIL) * rate)
649            .unwrap_or(usize::MAX)
650            .min(input.mono.len());
651        if first >= last {
652            continue;
653        }
654        let segment = &input.mono[first..last];
655        let start = Seconds::clamped(cast::usize_to_f64(first) / rate);
656        let line = SubBandLine::of(segment, input.rate, start)?;
657        let Some(onset) = band_onset(
658            &line,
659            Seconds::clamped(search_from),
660            Seconds::clamped(search_to),
661        ) else {
662            skips.no_onset += 1;
663            continue;
664        };
665        if search_from <= border
666            && drops.last().is_some_and(|last| onset < last.fit.settled())
667            && rises_into(
668                &line,
669                Seconds::clamped(search_from),
670                Seconds::clamped(search_to),
671                settled,
672            )
673        {
674            skips.settling += 1;
675            continue;
676        }
677        let rise = rise_ratio(segment, input.rate, start, onset, STRIKE_SPANS);
678        if span_rms(segment, input.rate, start, onset, STRIKE_SPANS[1]) < floor
679            || span_rms(&band, input.rate, origin, onset, STRIKE_SPANS[1]) < band_floor
680        {
681            skips.quiet += 1;
682            continue;
683        }
684        let attack = band_attack(
685            &line,
686            Seconds::clamped(search_from),
687            Seconds::clamped(search_to),
688        )
689        .unwrap_or(Seconds::clamped(0.0));
690        counts.drop_candidates += 1;
691        let span_end = Seconds::clamped(note_off.max(onset.get()));
692        let (fit, fit_end) = fit_strike(
693            input,
694            (segment, start),
695            line,
696            (onset, span_end),
697            rise,
698            settled,
699        )?;
700        let Some(fit) = fit else {
701            skips.no_fall += 1;
702            continue;
703        };
704        if !struck_enough(&fit, rise, attack) {
705            skips.no_strike += 1;
706            continue;
707        }
708        let window = (
709            Seconds::clamped(
710                (onset.get().max(note_on) + WEAK_FROM.get()).max(onset.get() + in_band(&fit)),
711            ),
712            Seconds::clamped((onset.get() + DROP_FIT_END.get()).min(note_off)),
713        );
714        if weak_fundamental(segment, input.rate, start, window, settled)? {
715            flags.weak_fundamental = true;
716            skips.weak_fundamental += 1;
717            continue;
718        }
719        match fit.miss() {
720            Some(DropMiss::Size) => skips.no_fall += 1,
721            Some(DropMiss::TauBound) => skips.tau_bound += 1,
722            Some(DropMiss::Residual) => skips.residual += 1,
723            None if !settles_on_a_note(input.notes, &fit, fit_end) => skips.off_note += 1,
724            None => drops.push(Strike { fit, attack }),
725        }
726    }
727    Ok(drops)
728}
729
730/// The fit of a strike, with the end of its span.
731///
732/// The fit reads `line` from the onset to `span_end`. It reads a line with
733/// the high-pass at [`LINE_LO_FAST`] instead when that fit is faster than
734/// [`FAST_TAU`] or the strike rises [`RUN_START_RISE`]. When that fit is
735/// no drop after a strike of [`STRIKE_RISE`], the fit reads the note line:
736/// the band up to [`NOTE_LINE_EDGE`] times the note's `pitch`, when that
737/// edge lies under [`LINE_HI`]. Its span ends at a break of the note line.
738/// Its fit counts with a `tau` of [`FAST_TAU`] or more, since a line
739/// low-passed under 70 Hz cannot follow a fall that ends within a period.
740/// It also needs a level within [`NOTE_LINE_LOSS`] of the band's.
741/// `segment` starts at `start`.
742fn fit_strike(
743    input: &PitchInput<'_>,
744    (segment, start): (&[f64], Seconds),
745    line: SubBandLine,
746    (onset, span_end): (Seconds, Seconds),
747    rise: f64,
748    pitch: Hz,
749) -> Result<(Option<DropFit>, Seconds), PitchError> {
750    let mut fit = fit_drop(&line, onset, span_end);
751    let (lo, wide) = if fit.is_some_and(|fit| fit.tau < FAST_TAU) || rise >= RUN_START_RISE {
752        let low = SubBandLine::with_low_edge(segment, input.rate, start, LINE_LO_FAST)?;
753        fit = fit_drop(&low, onset, span_end);
754        (LINE_LO_FAST, low)
755    } else {
756        (LINE_LO, line)
757    };
758    let note_hi = Hz::clamped(NOTE_LINE_EDGE * pitch.get());
759    if !fit
760        .is_some_and(|fit| fit.miss().is_some() || !settles_on_a_note(input.notes, &fit, span_end))
761        || rise < STRIKE_RISE
762        || note_hi >= LINE_HI
763    {
764        return Ok((fit, span_end));
765    }
766    let note_line = SubBandLine::with_edges(segment, input.rate, start, lo, note_hi)?;
767    let end = break_in(&note_line, onset, span_end, pitch);
768    Ok(fit_drop(&note_line, onset, end)
769        .filter(|note_fit| {
770            note_fit.miss().is_none()
771                && note_fit.tau >= FAST_TAU
772                && line_loss(&note_line, &wide, note_fit, end)
773                    .is_some_and(|loss| loss <= NOTE_LINE_LOSS)
774        })
775        .map_or((fit, span_end), |note_fit| (Some(note_fit), end)))
776}
777
778/// Whether the strike under a drop rises enough for its fit.
779///
780/// A fall with `tau` under [`FAST_TAU`] needs [`STRIKE_RISE`], since the
781/// line swings where a ringing tone restarts its phase, and an attack of
782/// [`STRIKE_ATTACK`] or less, since it ends inside a slower attack, where
783/// the line wanders on a rising level. So does a fall
784/// that starts at [`LINE_HI`] or above, since a pitch falling into the band
785/// from above raises the band envelope with no strike. A slower fall that
786/// starts inside the band, with an attack of [`STRIKE_ATTACK`] or less,
787/// needs no rise: an 808 note struck while the one before still rings
788/// rises by that note's decay alone, under 3 dB with 0.4 s notes and a 1 s
789/// decay.
790fn struck_enough(fit: &DropFit, rise: f64, attack: Seconds) -> bool {
791    if fit.tau < FAST_TAU {
792        return rise >= STRIKE_RISE && attack <= STRIKE_ATTACK;
793    }
794    rise >= STRIKE_RISE || (fit.f_end.get() + fit.df_hz < LINE_HI.get() && attack <= STRIKE_ATTACK)
795}
796
797/// Whether a drop settles on a note: a note sounds at some point while the
798/// drop is settled, from five time constants after the onset to the end of the fit
799/// span at `span_end` or [`DROP_FIT_END`], with its pitch within
800/// [`DROP_NOTE_MATCH`] of `f_end`, that starts [`DROP_NOTE_LEAD`] or less
801/// before the onset. A slow fall that is not settled by then needs the
802/// note at the end of the span. Several partials inside the band make the
803/// line swing at a saw's onset into a fall that settles off the note.
804fn settles_on_a_note(notes: &NoteList, fit: &DropFit, span_end: Seconds) -> bool {
805    let to = (fit.onset.get() + DROP_FIT_END.get()).min(span_end.get());
806    let from = fit.settled().get().min(to);
807    notes.notes().iter().any(|note| {
808        note.onset().get() <= to
809            && note.onset().get() >= fit.onset.get() - DROP_NOTE_LEAD.get()
810            && note.offset().get() >= from
811            && (100.0 * note.pitch().get()
812                - 100.0 * MidiPitch::try_from(fit.f_end).map_or(f64::NAN, MidiPitch::get))
813            .abs()
814                <= DROP_NOTE_MATCH.get()
815    })
816}
817
818/// The end of a fit span on the note line: one period of the note's
819/// `pitch` before the first point after [`DROP_FIT_START`] where the
820/// line's frequency falls to 0 Hz or below, or `end`. The phase of a line
821/// that holds one tone never runs backwards; it does where that tone
822/// breaks off and a second sound starts, and the low-pass smears the break
823/// over about a period.
824fn break_in(line: &SubBandLine, onset: Seconds, end: Seconds, pitch: Hz) -> Seconds {
825    let from = line.index_at(Seconds::clamped(onset.get() + DROP_FIT_START.get()));
826    let to = line.index_at(end).min(line.hz().len());
827    (from..to).find(|&n| line.hz()[n] <= 0.0).map_or(end, |n| {
828        Seconds::clamped(line.time_of(n).get() - 1.0 / pitch.get())
829    })
830}
831
832/// How far the power of `note_line` lies below that of `wide` over the
833/// fall of `fit`: from [`DROP_FIT_START`] to five time constants after the
834/// onset, or to `end`.
835fn line_loss(
836    note_line: &SubBandLine,
837    wide: &SubBandLine,
838    fit: &DropFit,
839    end: Seconds,
840) -> Option<Decibels> {
841    let from = fit.onset.get() + DROP_FIT_START.get();
842    let to = fit.settled().get().min(end.get()).max(from);
843    Decibels::new(20.0 * (envelope_rms(wide, from, to) / envelope_rms(note_line, from, to)).log10())
844        .ok()
845}
846
847/// The RMS of the envelope of `line` from `from` to `to`, in seconds.
848fn envelope_rms(line: &SubBandLine, from: f64, to: f64) -> f64 {
849    let envelope = line.envelope();
850    let first = line.index_at(Seconds::clamped(from));
851    let last = line.index_at(Seconds::clamped(to)).min(envelope.len());
852    if first >= last {
853        return 0.0;
854    }
855    (envelope[first..last].iter().map(|e| e * e).sum::<f64>() / cast::usize_to_f64(last - first))
856        .sqrt()
857}
858
859/// The full-band RMS over `after` over the RMS over `before`, both spans in
860/// seconds from `at`; infinite when the span before is silent. `samples`
861/// start at `start`.
862fn rise_ratio(
863    samples: &[f64],
864    rate: SampleRate,
865    start: Seconds,
866    at: Seconds,
867    [before, after]: [(f64, f64); 2],
868) -> f64 {
869    let (a, b) = (
870        span_rms(samples, rate, start, at, after),
871        span_rms(samples, rate, start, at, before),
872    );
873    if b > 0.0 {
874        a / b
875    } else if a > 0.0 {
876        f64::INFINITY
877    } else {
878        0.0
879    }
880}
881
882/// The full-band RMS over `(from, to)`, in seconds from `at`. `samples`
883/// start at `start`.
884fn span_rms(
885    samples: &[f64],
886    rate: SampleRate,
887    start: Seconds,
888    at: Seconds,
889    (from, to): (f64, f64),
890) -> f64 {
891    let r = rate.hz().get();
892    let index = |t: f64| {
893        cast::round_to_usize(((at.get() + t - start.get()) * r).max(0.0))
894            .unwrap_or(0)
895            .min(samples.len())
896    };
897    let (a, b) = (index(from), index(to));
898    if a >= b {
899        return 0.0;
900    }
901    (samples[a..b].iter().map(|x| x * x).sum::<f64>() / cast::usize_to_f64(b - a)).sqrt()
902}
903
904/// The RMS of the loudest [`QUIET_FRAME`] of the stem, with a quarter
905/// frame hop.
906fn loudest_frame(mono: &[f64], rate: SampleRate) -> f64 {
907    let frame = cast::round_to_usize(QUIET_FRAME.get() * rate.hz().get())
908        .unwrap_or(4)
909        .max(4);
910    frame_rms(mono, StftParams::fixed(frame))
911        .into_iter()
912        .fold(0.0, f64::max)
913}
914
915/// The time after the onset at which a fit's pitch falls inside the
916/// half-octave band around `f_end`, so a slow fall no longer reads as the
917/// band around twice the note.
918fn in_band(fit: &DropFit) -> f64 {
919    let edge = fit.f_end.get() * (2f64.powf(0.25) - 1.0);
920    if fit.df_hz > edge {
921        fit.tau.get() * (fit.df_hz / edge).ln()
922    } else {
923        0.0
924    }
925}
926
927/// Whether the fundamental of a struck note is weaker than its second
928/// harmonic: the median of the half-octave band around twice the note's
929/// pitch exceeds [`WEAK_RATIO`] times the band around the pitch, over
930/// `window`: from 50 ms after the onset or the note's start, whichever is
931/// later, and not before the fit's pitch lies inside the band around the
932/// note, to the end of the fit span or of the note. The line follows the
933/// strongest partial of its band, so it would read the second harmonic as
934/// the note. Each band stops at 0.99 of the Nyquist frequency. When twice
935/// the pitch lies above that, at rates under about 4 times the pitch, the
936/// harmonic lies outside its band and the test passes. Short of that the
937/// cut band would narrow towards nothing, and the filter's padding grows as
938/// the inverse of its width.
939fn weak_fundamental(
940    segment: &[f64],
941    rate: SampleRate,
942    start: Seconds,
943    window: (Seconds, Seconds),
944    pitch: Hz,
945) -> Result<bool, PitchError> {
946    let r = rate.hz().get();
947    let index = |t: f64| cast::round_to_usize(((t - start.get()) * r).max(0.0)).unwrap_or(0);
948    let from = index(window.0.get());
949    let to = index(window.1.get()).min(segment.len());
950    let nyquist = rate.nyquist().get();
951    if from >= to || 2.0 * pitch.get() >= 0.99 * nyquist {
952        return Ok(false);
953    }
954    let band = |centre: f64| -> Result<f64, PitchError> {
955        let (lo, hi) = (
956            centre * 2f64.powf(-0.25),
957            (centre * 2f64.powf(0.25)).min(0.99 * nyquist),
958        );
959        let mut values =
960            band_magnitude(segment, rate, Hz::clamped(lo), Hz::clamped(hi))?[from..to].to_vec();
961        values.sort_by(f64::total_cmp);
962        Ok(values[values.len() / 2])
963    };
964    Ok(band(2.0 * pitch.get())? > WEAK_RATIO * band(pitch.get())?)
965}
966
967/// Fits a vibrato to each steady span of the track, without 50 ms at each
968/// end. A drop inside a span splits it: the part before ends 50 ms before
969/// the onset, and the part after starts once the drop has settled.
970fn vibratos(
971    track: &PitchTrack,
972    glides: &[GlideFit],
973    drops: &[DropFit],
974    counts: &mut PitchCounts,
975) -> Vec<VibratoFit> {
976    let mut found = Vec::new();
977    for span in steady_spans(track, glides) {
978        let (Some(&(first, _)), Some(&(last, _))) = (span.first(), span.last()) else {
979            continue;
980        };
981        let mut pitches: Vec<f64> = span.iter().map(|(_, f0)| f0.get()).collect();
982        pitches.sort_by(f64::total_cmp);
983        let lag = track_lag(Hz::clamped(pitches[pitches.len() / 2])).get();
984        let mut parts = vec![(first.get() + RUN_TRIM.get(), last.get() - RUN_TRIM.get())];
985        for drop in drops {
986            let strike = drop.onset.get() + lag - RUN_TRIM.get();
987            let settled = drop.settled().get().max(drop.onset.get() + SETTLE_MIN) + lag;
988            parts = parts
989                .into_iter()
990                .flat_map(|(from, to)| {
991                    if strike > to || settled <= from {
992                        vec![(from, to)]
993                    } else {
994                        vec![(from, strike), (settled, to)]
995                    }
996                })
997                .filter(|(from, to)| from < to)
998                .collect();
999        }
1000        for (from, to) in parts {
1001            let frames: Vec<(Seconds, Hz)> = span
1002                .iter()
1003                .copied()
1004                .filter(|(time, _)| (from..=to).contains(&time.get()))
1005                .collect();
1006            let Some(fit) = fit_vibrato(&frames) else {
1007                continue;
1008            };
1009            counts.vibrato_candidates += 1;
1010            if fit.accepted() {
1011                found.push(VibratoFit {
1012                    start: Seconds::clamped(fit.start.get() - lag),
1013                    end: Seconds::clamped(fit.end.get() - lag),
1014                    ..fit
1015                });
1016            }
1017        }
1018    }
1019    found
1020}
1021
1022fn rate_glide(glide: GlideFit) -> Result<Rated<GlideFit>, ConfidenceError> {
1023    let (residual, jitter) = (glide.residual.get(), glide.jitter.get());
1024    // A motion under the 90 ms floor of the published detector reads low: on
1025    // real stems such a slide of a semitone was clear in the spectrum.
1026    let level = if jitter > JITTER_LOW || glide.on_bound || glide.detected < SPAN_MIN {
1027        Level::Low
1028    } else if residual <= RESIDUAL_HIGH
1029        && jitter <= JITTER_HIGH
1030        && glide.detected >= SPAN_HIGH
1031        && glide.curve <= CURVE_HIGH
1032    {
1033        Level::High
1034    } else {
1035        Level::Medium
1036    };
1037    Ok(Rated {
1038        confidence: Confidence::estimate(
1039            level,
1040            None,
1041            vec![
1042                Reason::new(ReasonCode::MotionSpan, Signal::Number(glide.detected.get()))?,
1043                Reason::new(ReasonCode::FitResidual, Signal::Number(residual))?,
1044                Reason::new(ReasonCode::PitchJitter, Signal::Number(jitter))?,
1045                Reason::new(ReasonCode::CurveBound, Signal::Flag(glide.on_bound))?,
1046            ],
1047        )?,
1048        value: glide,
1049    })
1050}
1051
1052/// An accepted drop with the attack of its strike.
1053#[derive(Clone, Copy, Debug)]
1054struct Strike {
1055    fit: DropFit,
1056    attack: Seconds,
1057}
1058
1059fn rate_drop(strike: Strike) -> Result<Rated<DropFit>, ConfidenceError> {
1060    let Strike { fit, attack } = strike;
1061    let level = if attack > STRIKE_ATTACK {
1062        Level::Low
1063    } else if fit.residual < DROP_RESIDUAL_HIGH && fit.tau >= FAST_TAU && attack <= ATTACK_HIGH {
1064        Level::High
1065    } else {
1066        Level::Medium
1067    };
1068    Ok(Rated {
1069        confidence: Confidence::estimate(
1070            level,
1071            None,
1072            vec![
1073                Reason::new(ReasonCode::FitResidual, Signal::Number(fit.residual))?,
1074                Reason::new(ReasonCode::AttackTime, Signal::Number(attack.get()))?,
1075            ],
1076        )?,
1077        value: fit,
1078    })
1079}
1080
1081fn rate_vibrato(vibrato: VibratoFit) -> Result<Rated<VibratoFit>, ConfidenceError> {
1082    let cycles = vibrato.cycles();
1083    let level = if vibrato.kurtosis < KURTOSIS_LOW || vibrato.pitch < VIBRATO_LOW_PITCH {
1084        Level::Low
1085    } else if cycles >= CYCLES_HIGH {
1086        Level::High
1087    } else {
1088        Level::Medium
1089    };
1090    Ok(Rated {
1091        confidence: Confidence::estimate(
1092            level,
1093            None,
1094            vec![
1095                Reason::new(ReasonCode::SineShare, Signal::Number(vibrato.share))?,
1096                Reason::new(ReasonCode::CyclesInSpan, Signal::Number(cycles))?,
1097                Reason::new(
1098                    ReasonCode::ContourKurtosis,
1099                    Signal::Number(vibrato.kurtosis),
1100                )?,
1101            ],
1102        )?,
1103        value: vibrato,
1104    })
1105}
1106
1107/// The pitch layer failed.
1108#[derive(Debug)]
1109pub enum PitchError {
1110    /// The rate puts the band of the sub-band line at or above the Nyquist
1111    /// frequency.
1112    Filter(FilterError),
1113    /// A confidence could not be built.
1114    Confidence(ConfidenceError),
1115}
1116
1117impl std::fmt::Display for PitchError {
1118    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1119        match self {
1120            Self::Filter(error) => write!(f, "cannot read the pitch layer: {error}"),
1121            Self::Confidence(error) => write!(f, "cannot rate the pitch layer: {error}"),
1122        }
1123    }
1124}
1125
1126impl std::error::Error for PitchError {
1127    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1128        match self {
1129            Self::Filter(error) => Some(error),
1130            Self::Confidence(error) => Some(error),
1131        }
1132    }
1133}
1134
1135impl From<FilterError> for PitchError {
1136    fn from(error: FilterError) -> Self {
1137        Self::Filter(error)
1138    }
1139}
1140
1141impl From<ConfidenceError> for PitchError {
1142    fn from(error: ConfidenceError) -> Self {
1143        Self::Confidence(error)
1144    }
1145}
1146
1147/// `x` with at most two decimals and no trailing zeros, as the convention
1148/// strings print their numbers.
1149#[cfg(test)]
1150fn num(x: f64) -> String {
1151    let text = format!("{x:.2}");
1152    text.trim_end_matches('0').trim_end_matches('.').to_owned()
1153}
1154
1155/// The convention `text` holds each phrase of `phrases`.
1156#[cfg(test)]
1157fn says(text: &str, phrases: &[String]) {
1158    for phrase in phrases {
1159        assert!(
1160            text.contains(phrase.as_str()),
1161            "missing {phrase:?} in {text:?}"
1162        );
1163    }
1164}
1165
1166/// A full-band RMS ratio in whole decibels.
1167#[cfg(test)]
1168fn ratio_db(ratio: f64) -> String {
1169    format!("{:.0} dB", 20.0 * ratio.log10())
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174    use super::*;
1175    use crate::notes::Note;
1176
1177    #[test]
1178    fn a_second_harmonic_above_nyquist_leaves_the_fundamental_alone() {
1179        // A 190 Hz note's second harmonic band starts at 319.5 Hz. At 600 Hz
1180        // it lies above the 300 Hz Nyquist frequency. At 646 Hz it is cut
1181        // to 0.24 Hz under 0.99 of Nyquist, and at 767 Hz to 60 Hz, both
1182        // below the 380 Hz harmonic, so a strong tone inside either passes.
1183        // At 800 Hz the band holds the harmonic, and one four times the
1184        // fundamental reads weak.
1185        let weak = |rate: u32, upper: f64| {
1186            let r = f64::from(rate);
1187            let segment: Vec<f64> = (0..rate)
1188                .map(|n| {
1189                    let t = f64::from(n) / r;
1190                    (std::f64::consts::TAU * 190.0 * t).sin()
1191                        + 4.0 * (std::f64::consts::TAU * upper * t).sin()
1192                })
1193                .collect();
1194            let window = (Seconds::clamped(0.1), Seconds::clamped(0.9));
1195            let rate = SampleRate::new(rate).unwrap();
1196            weak_fundamental(
1197                &segment,
1198                rate,
1199                Seconds::clamped(0.0),
1200                window,
1201                Hz::clamped(190.0),
1202            )
1203            .unwrap()
1204        };
1205        assert!(!weak(600, 250.0));
1206        assert!(!weak(646, 319.65));
1207        assert!(!weak(767, 379.6));
1208        assert!(weak(800, 380.0));
1209    }
1210
1211    #[test]
1212    fn a_drop_settles_on_a_note_of_its_own_strike() {
1213        let fit = DropFit {
1214            onset: Seconds::clamped(1.0),
1215            f_end: Hz::clamped(69.3),
1216            df_hz: 100.0,
1217            tau: Seconds::clamped(0.03),
1218            residual: 0.05,
1219            points: 1000,
1220        };
1221        let settles = |on: f64| {
1222            let note = Note::new(
1223                Seconds::clamped(on),
1224                Seconds::clamped(1.6),
1225                MidiPitch::new(37.0).unwrap(),
1226            )
1227            .unwrap();
1228            settles_on_a_note(&NoteList::new(vec![note]), &fit, Seconds::clamped(1.5))
1229        };
1230        assert!(settles(1.05));
1231        assert!(settles(0.86));
1232        assert!(!settles(0.84));
1233        assert!(!settles(0.2));
1234    }
1235
1236    #[test]
1237    fn the_least_jitter_of_a_unison_reads_low() {
1238        let glide = GlideFit {
1239            start: Seconds::clamped(0.5),
1240            interval: Semitones::clamped(7.0),
1241            time: Seconds::clamped(0.2),
1242            curve: 1.0,
1243            from_silence: false,
1244            detected: Seconds::clamped(0.2),
1245            residual: Cents::clamped(5.0),
1246            jitter: Cents::clamped(3.45),
1247            on_bound: false,
1248        };
1249        let level = |jitter: f64| {
1250            rate_glide(GlideFit {
1251                jitter: Cents::clamped(jitter),
1252                ..glide
1253            })
1254            .unwrap()
1255            .confidence
1256            .level()
1257        };
1258        // A unison of the generated sets jittered 3.45 cents at 44.1 kHz;
1259        // mono glides from 3.06 to 3.40 read medium.
1260        assert_eq!(level(3.45), Some(Level::Low));
1261        assert_eq!(level(3.4), Some(Level::Medium));
1262        assert_eq!(level(JITTER_HIGH), Some(Level::High));
1263        // A clean glide reads low only when its motion lasts under 90 ms.
1264        let span = |detected: f64| {
1265            rate_glide(GlideFit {
1266                detected: Seconds::clamped(detected),
1267                jitter: Cents::clamped(1.0),
1268                ..glide
1269            })
1270            .unwrap()
1271            .confidence
1272            .level()
1273        };
1274        assert_eq!(span(0.06), Some(Level::Low));
1275        assert_eq!(span(0.1), Some(Level::Medium));
1276    }
1277}