Skip to main content

sound_to_patch/estimate/pitch/
glide.rs

1//! Glides: pitch moving from one stable pitch to another, read from the f0
2//! track, with the curve `s(x) = 1 - (1 - x)^p` fitted between the two.
3
4use super::fit::nelder_mead;
5use super::vibrato::{VIBRATO_EXTENT_MIN, VIBRATO_RATE_MIN};
6use crate::cast;
7use crate::dsp::ACTIVE_GATE_DB;
8use crate::notes::{MODEL_RATE, NoteList, PitchTrack, TRACK_FRAME, TRACK_HOP};
9use crate::units::{Cents, Hz, Seconds, Semitones};
10
11/// The slope above which the pitch moves, in cents per second: 0.861
12/// semitone per second, the floor of a published portamento detector.
13/// Untuned.
14pub const GLIDE_SLOPE_MIN: f64 = 86.0;
15/// The shortest glide over its fitted time, the minimum transition of a
16/// published portamento detector, untuned. A motion detected over a
17/// shorter span reads at low confidence when its fit passes every test,
18/// and is too short when the fit fails.
19pub(crate) const GLIDE_TIME_MIN: Seconds = Seconds::from_const(0.09);
20/// Motion shorter than this over its detected span or its fitted time is a
21/// step between two notes.
22///
23/// From here up to [`GLIDE_TIME_MIN`] of fitted time it is a glide too
24/// short to read. Six hops: the moving average alone spreads a jump over
25/// five.
26pub(crate) const GLIDE_STEP_MAX: Seconds = Seconds::from_const(0.035);
27/// Two stable pitches this far apart split a steady span, so the vibrato
28/// search never reads a step between two notes. A vibrato of ±60 cents
29/// moves its stable pitches 120 cents apart and stays whole.
30pub(crate) const STEADY_SPLIT: Cents = Cents::from_const(150.0);
31/// The smallest interval between the two stable pitches.
32pub(crate) const GLIDE_INTERVAL_MIN: Cents = Cents::from_const(100.0);
33/// A stable pitch lasts at least this long.
34pub(crate) const PLATEAU_MIN: Seconds = Seconds::from_const(0.05);
35/// A stable pitch stays within this many cents of its mean.
36pub(crate) const PLATEAU_RANGE: Cents = Cents::from_const(30.0);
37/// A stable pitch belongs to a note whose pitch lies within this many
38/// cents of it.
39pub(crate) const NOTE_MATCH: Cents = Cents::from_const(50.0);
40/// Frames of the centred moving average that the slope reads: 5 hops,
41/// 29 ms.
42pub(crate) const SMOOTH_FRAMES: usize = 5;
43/// The range of the curve power `p`.
44pub const CURVE_RANGE: (f64, f64) = (0.2, 5.0);
45/// A fitted `p` within this factor of a bound of [`CURVE_RANGE`] sits on
46/// the bound.
47pub(crate) const CURVE_EDGE: f64 = 1.05;
48/// The largest RMS residual of a glide whose `p` sits on the upper bound
49/// of [`CURVE_RANGE`]. Exponential portamento, `1 - exp(-t / tau)`, fits
50/// the bound within 20 cents; a smeared step on that bound fits worse.
51pub(crate) const BOUND_MAX_RESIDUAL: Cents = Cents::from_const(20.0);
52/// The longest detected span of a glide whose `p` sits on the upper bound
53/// of [`CURVE_RANGE`], as a multiple of its fitted time.
54///
55/// The track smears a step into a fast curve on that bound and keeps
56/// drifting after it, so the slope stays up long after the fitted motion
57/// ends: 6.2 times its fitted time at the semitone step on one 808
58/// oscillator of the tests, 2.9 and 9.1 at steps on real stems. The RC
59/// slides of the tests read 0.77, 0.80 and 2.27, and 26 small slides on
60/// real stems 0.44 to 2.28. The cut sits 10 % above the highest slide and
61/// 14 % below the lowest step.
62pub(crate) const BOUND_SPAN_MAX: f64 = 2.5;
63/// A glide from silence is none when the pitch swings back this share of
64/// its lead within half a period of the slowest vibrato after its first
65/// stable pitch.
66///
67/// The window is half a period of [`VIBRATO_RATE_MIN`] after the first
68/// stable pitch. It holds no frame when the motion is a step: the run's
69/// next stable pitch lies [`GLIDE_INTERVAL_MIN`] or more away and matches
70/// another note, and the pitch stays past half the way to it. On real stems a wobble of ±60 cents at 6 to 7 Hz on a
71/// bass read its first half-cycle as a fall from silence; the pitch came
72/// back 82 % of the lead. True falls from silence came back 0 % and 24 %.
73const SWING_BACK: f64 = 0.5;
74/// The largest RMS residual of a glide of 90 ms or more. The glides of the
75/// generated set fit within 22 cents.
76pub(crate) const GLIDE_MAX_RESIDUAL: Cents = Cents::from_const(40.0);
77/// The longest unvoiced gap between two voiced runs that the glide search
78/// bridges. A beating unison can leave the track unvoiced for 75 ms inside
79/// a glide.
80pub(crate) const GAP_BRIDGE_MAX: Seconds = Seconds::from_const(0.1);
81/// The smallest jump across a bridged gap, and the smallest motion over
82/// the two frames after it: two steps of the tracker's 10-cent grid.
83pub(crate) const GAP_MOTION_MIN: Cents = Cents::from_const(20.0);
84/// The fastest jump across a bridged gap, in cents per second.
85///
86/// A 50 ms glide of 7 semitones jumps 20 cents per millisecond across its two
87/// unvoiced frames, a unison whose voices start in phase 3.6 across its
88/// 75 ms gap; a note struck again 10 semitones lower jumps about 40 across
89/// three unvoiced frames.
90pub(crate) const GAP_SLOPE_MAX: f64 = 30_000.0;
91/// A gap whose jump carries more than this share of the interval between
92/// two stable pitches, or a jump against it, is a step: the track lands
93/// on the new pitch and the settle after it reads as motion.
94///
95/// Unison glides whose voices beat the track unvoiced for 75 ms jump 0.53
96/// and 0.54 of their interval across the gap; a step between two 808
97/// notes jumped 0.72 across 58 ms, and a fast-start unison 0.76.
98pub(crate) const GAP_SHARE_MAX: f64 = 0.6;
99/// A glide from silence needs a sound to start at most this long before
100/// its voiced run.
101pub(crate) const ONSET_LOOKBACK: Seconds = Seconds::from_const(0.1);
102/// The fit reads this much of each stable pitch next to the motion.
103const FIT_MARGIN: f64 = 0.05;
104
105/// How far the track reads behind the audio at `f0`.
106///
107/// The tracker compares the first half of each centred frame with the
108/// samples one period later,
109/// so a frame reads the audio from a quarter frame before its centre to one
110/// period after it: the lag is a quarter frame, 23.2 ms, less half a
111/// period, so 18.7 ms at 110 Hz and 14.1 ms at 55 Hz.
112#[must_use]
113pub(crate) fn track_lag(f0: Hz) -> Seconds {
114    let rate = MODEL_RATE.hz().get();
115    let quarter = cast::usize_to_f64(TRACK_FRAME / 4) / rate;
116    let half_period = if f0.get() > 0.0 { 0.5 / f0.get() } else { 0.0 };
117    Seconds::clamped(quarter - half_period)
118}
119
120/// [`track_lag`] at a pitch in cents.
121fn lag_at(cents: f64) -> f64 {
122    track_lag(hz(cents)).get()
123}
124
125/// The frequency of a pitch in cents (MIDI pitch times 100).
126fn hz(cents: f64) -> Hz {
127    Hz::clamped(440.0 * 2f64.powf((cents - 6900.0) / 1200.0))
128}
129
130/// The convention of a glide, as `layers.json` names it.
131pub const GLIDE_CONVENTION: &str = "pitch in cents of the voiced f0 frames; a stable pitch is a \
132span of 50 ms or more within 30 cents of its running mean whose least-squares slope stays at 86 \
133cents per second or less; a glide joins two stable pitches 100 cents or more apart that match \
134notes of different pitch within 50 cents, where the slope of the 5-frame moving average stays \
135at 86 cents per second or more, in the direction of the motion, over 35 ms or more; s(x) = 1 - \
136(1 - x)^p is fitted between the two stable pitches by least squares over the motion and 50 ms of \
137each; start_s is the fitted start less the track's lag of a quarter frame, 23.2 ms, less half \
138a period; time_s is the full 0 to 100 % time; from_silence marks a glide into the first stable \
139pitch of a voiced run whose sound starts, the full-band RMS rising 20 dB, up to 100 ms before \
140it, when that stable pitch has a median level within 40 dB of the loudest frame and the smoothed \
141pitch does not swing back 50 % of the lead within half a period of 3 Hz after it, unless the \
142run's next stable pitch lies 100 cents or more away and matches another note, the nearest match \
143counting, and the pitch, once past half the way to it, stays there up to that stable pitch and \
144to the window's end, and the fit also finds its start pitch; a voiced run that starts within 100 ms of the one \
145before joins it when the full-band RMS does not rise 6 dB in the gap, its 10 ms RMS stays within \
14620 dB of the 20 ms before or after the gap and within 40 dB of the loudest frame of the stem, \
147and the pitch keeps moving after the gap the way it jumped, at 30 cents per millisecond or \
148slower; two stable pitches with a bridged gap between them whose jump goes against the interval \
149by 20 cents or more, or carries more than 60 % of it, join no glide; motion of 35 to 90 ms over \
150the fitted time, or over the detected span when the fit fails, counts as too short; a fit of 90 ms or more with p within \
1515 % of 0.2, with p within 5 % of 5 and an RMS residual above 20 cents, a detected span under 90 \
152ms or over 2.5 times the fitted time, or a start from silence, or with an RMS residual above 40 \
153cents, is no glide; a glide with p within 5 % of 5 reads at low \
154confidence with the reason curve_bound, and so does one whose detected span lasts under 90 ms";
155
156/// A fitted glide.
157#[derive(Clone, Copy, Debug, PartialEq)]
158pub struct GlideFit {
159    /// The start of the motion in the audio.
160    pub start: Seconds,
161    /// The interval from the start pitch to the end pitch.
162    pub interval: Semitones,
163    /// The full 0 to 100 % time.
164    pub time: Seconds,
165    /// The curve power `p`: above 1 starts fast, below 1 starts slow.
166    pub curve: f64,
167    /// The glide starts a voiced run, so its start pitch comes from the fit.
168    pub from_silence: bool,
169    /// The span where the slope stayed above [`GLIDE_SLOPE_MIN`].
170    pub detected: Seconds,
171    /// The RMS residual of the fit.
172    pub residual: Cents,
173    /// The RMS deviation of the stable pitches from their medians.
174    pub jitter: Cents,
175    /// The fitted `p` sits on the upper bound of [`CURVE_RANGE`]: a glide
176    /// that approaches its target exponentially, as an RC portamento does,
177    /// runs past the curve family.
178    pub on_bound: bool,
179}
180
181impl GlideFit {
182    /// The span, in seconds of audio, where the fitted curve lies more than
183    /// [`VIBRATO_EXTENT_MIN`] from both its pitches. Outside it the glide
184    /// holds within the smallest vibrato of a stable pitch: a curve on the
185    /// upper bound of `p` comes within 3 % of its interval of its end
186    /// halfway through its time.
187    pub(crate) fn moving(&self) -> (f64, f64) {
188        let (p, cents) = (self.curve, 100.0 * self.interval.get().abs());
189        let near = (VIBRATO_EXTENT_MIN.get() / cents).min(1.0);
190        let from = 1.0 - (1.0 - near).powf(1.0 / p);
191        let to = 1.0 - near.powf(1.0 / p);
192        let at = |x: f64| self.start.get() + x * self.time.get();
193        (at(from), at(to))
194    }
195}
196
197/// What the search found.
198#[derive(Clone, Debug, Default, PartialEq)]
199pub(crate) struct GlideRead {
200    /// The glides, in time order.
201    pub glides: Vec<GlideFit>,
202    /// Motions between two stable pitches that lasted from
203    /// [`GLIDE_STEP_MAX`] up to [`GLIDE_TIME_MIN`].
204    pub too_short: usize,
205}
206
207/// One voiced frame of a run: time and pitch in cents (MIDI pitch times
208/// 100).
209#[derive(Clone, Copy, Debug)]
210struct Frame {
211    time: f64,
212    cents: f64,
213    /// The level against the loudest frame of the stem, in dB.
214    level: f64,
215}
216
217/// A stable pitch: frames `first..=last` of a run.
218#[derive(Clone, Copy, Debug)]
219struct Plateau {
220    first: usize,
221    last: usize,
222    median: f64,
223}
224
225/// Finds the glides of a monophonic line.
226///
227/// `breaks` tells whether the sound breaks inside a span of the audio, as
228/// a strike or a dip of the level does, `from_silence` whether a glide
229/// from silence may start a voiced run whose sound starts inside a span.
230/// A voiced run that starts within [`GAP_BRIDGE_MAX`] of the
231/// previous one joins it when the sound does not break in the gap and the
232/// pitch keeps moving after the gap in the direction of its jump across
233/// it: the track can go unvoiced inside a glide, and at a step between two
234/// notes.
235#[must_use]
236pub(crate) fn find_glides(
237    track: &PitchTrack,
238    notes: &NoteList,
239    breaks: impl Fn(Seconds, Seconds) -> bool,
240    from_silence: impl Fn(Seconds, Seconds) -> bool,
241) -> GlideRead {
242    let mut read = GlideRead::default();
243    let audio = |frame: &Frame| frame.time - lag_at(frame.cents);
244    let mut runs: Vec<Vec<Frame>> = Vec::new();
245    for run in voiced_runs(track) {
246        if let Some(previous) = runs.last_mut()
247            && let Some(last) = previous.last()
248            && run[0].time - last.time <= GAP_BRIDGE_MAX.get()
249            && moves_on(last, &run)
250            && !breaks(
251                Seconds::clamped(audio(last)),
252                Seconds::clamped(audio(&run[0])),
253            )
254        {
255            previous.extend(run);
256        } else {
257            runs.push(run);
258        }
259    }
260    for run in runs {
261        let smooth = moving_average(&run);
262        let slope = slopes(&run, &smooth);
263        let plateaus = plateaus(&run, &smooth);
264        let note_of = |plateau: &Plateau| {
265            let middle = f64::midpoint(run[plateau.first].time, run[plateau.last].time);
266            let audio = Seconds::clamped(middle - lag_at(plateau.median));
267            notes
268                .notes()
269                .iter()
270                .filter(|note| note.sounds_at(audio))
271                .map(|note| note.pitch().get())
272                .map(|pitch| (pitch, (100.0 * pitch - plateau.median).abs()))
273                .filter(|&(_, off)| off <= NOTE_MATCH.get())
274                .min_by(|a, b| a.1.total_cmp(&b.1))
275                .map(|(pitch, _)| pitch.round())
276        };
277        if let Some(first) = plateaus.first()
278            && first.first > 0
279            && from_silence(
280                Seconds::clamped(audio(&run[0]) - ONSET_LOOKBACK.get()),
281                Seconds::clamped(audio(&run[0])),
282            )
283            && let Some(settled) = note_of(first)
284        {
285            let lead = run[0].cents - first.median;
286            // The motion after the first stable pitch is a step, and the
287            // window holds no frame, when the next stable pitch lies 100
288            // cents or more away and matches another note, and the pitch,
289            // once past half the way to it, stays there up to that stable
290            // pitch and to the window's end. A wobble's
291            // crest can match a neighbouring note but comes back. The
292            // transcribed note's end does not cut the window: the transcriber
293            // can end a wobbling note inside its first trough.
294            let until = run[first.last].time + 0.5 / VIBRATO_RATE_MIN.get();
295            let steps = plateaus.get(1).is_some_and(|next| {
296                let step = next.median - first.median;
297                let past = |k: usize| (smooth[k] - first.median) * step.signum() > 0.5 * step.abs();
298                step.abs() >= GLIDE_INTERVAL_MIN.get()
299                    && note_of(next).is_some_and(|pitch| (pitch - settled).abs() >= 0.5)
300                    && (first.last + 1..run.len())
301                        .take_while(|&k| k <= next.first || run[k].time <= until)
302                        .skip_while(|&k| !past(k))
303                        .all(past)
304            });
305            let stop = if steps { first.last + 1 } else { run.len() };
306            let signed: Vec<f64> = slope.iter().map(|s| -s * lead.signum()).collect();
307            let motion = detected(
308                &run,
309                &signed,
310                0,
311                first.first + (first.last - first.first) / 2,
312            );
313            if lead.abs() >= GLIDE_INTERVAL_MIN.get()
314                && settles(&run[..stop], &smooth, *first, lead, until)
315                && let Some(span) = motion
316            {
317                // `too_short` counts motions between two stable pitches only:
318                // a fall from silence is mostly a strike's pitch drop.
319                let mut from_silence = GlideRead::default();
320                classify(
321                    &mut from_silence,
322                    span,
323                    || fit_from_silence(&run, *first, span),
324                    false,
325                );
326                read.glides.extend(from_silence.glides);
327            }
328        }
329        for pair in plateaus.windows(2) {
330            let (a, b) = (pair[0], pair[1]);
331            let interval = b.median - a.median;
332            if interval.abs() < GLIDE_INTERVAL_MIN.get() {
333                continue;
334            }
335            let (Some(from), Some(to)) = (note_of(&a), note_of(&b)) else {
336                continue;
337            };
338            if (from - to).abs() < 0.5 {
339                continue;
340            }
341            let stepped = steps_across_gap(&run[a.last..=b.first], interval);
342            let mid = |p: Plateau| p.first + (p.last - p.first) / 2;
343            let signed: Vec<f64> = slope.iter().map(|s| s * interval.signum()).collect();
344            if let Some(span) = detected(&run, &signed, mid(a), mid(b)) {
345                classify(&mut read, span, || fit_between(&run, a, b, span), stepped);
346            }
347        }
348    }
349    read.glides
350        .sort_by(|x, y| x.start.get().total_cmp(&y.start.get()));
351    read
352}
353
354/// The steady spans of the track: its voiced runs, split where two stable
355/// pitches lie [`STEADY_SPLIT`] or more apart and without the frames where
356/// a glide moves, as `(time, f0)` frames in track time.
357#[must_use]
358pub(crate) fn steady_spans(track: &PitchTrack, glides: &[GlideFit]) -> Vec<Vec<(Seconds, Hz)>> {
359    let moving: Vec<(f64, f64)> = glides.iter().map(GlideFit::moving).collect();
360    let in_glide = |frame: &Frame| {
361        let audio = frame.time - lag_at(frame.cents);
362        moving.iter().any(|span| (span.0..=span.1).contains(&audio))
363    };
364    let mut spans = Vec::new();
365    for run in voiced_runs(track) {
366        let smooth = moving_average(&run);
367        let mut cuts = vec![0];
368        for pair in plateaus(&run, &smooth).windows(2) {
369            if (pair[1].median - pair[0].median).abs() >= STEADY_SPLIT.get() {
370                cuts.push(pair[0].last + 1);
371                cuts.push(pair[1].first);
372            }
373        }
374        cuts.push(run.len());
375        // The cuts come in pairs: each split ends one span and starts the
376        // next, between 0 and the end of the run.
377        for &[from, to] in cuts.as_chunks::<2>().0 {
378            let mut span = Vec::new();
379            for frame in &run[from..to] {
380                if in_glide(frame) {
381                    if !span.is_empty() {
382                        spans.push(std::mem::take(&mut span));
383                    }
384                } else {
385                    span.push((Seconds::clamped(frame.time), hz(frame.cents)));
386                }
387            }
388            if !span.is_empty() {
389                spans.push(span);
390            }
391        }
392    }
393    spans
394}
395
396/// Counts a motion as a step, a glide too short, or a glide.
397///
398/// A motion whose detected span is shorter than [`GLIDE_STEP_MAX`] is a
399/// step. Then the fitted time decides, with the same bounds: the track
400/// smears a step between two 808 notes over a detected span of 100 to
401/// 450 ms, while the fit finds the motion short. A fit off that bound that
402/// follows the curve is a glide over a detected span under
403/// [`GLIDE_TIME_MIN`] too, and the pitch layer reads it at low confidence;
404/// a motion whose fit fails over such a span is too short. A fit of
405/// [`GLIDE_TIME_MIN`] or more whose `p` sits on the lower bound of
406/// [`CURVE_RANGE`], or whose residual exceeds [`GLIDE_MAX_RESIDUAL`], does
407/// not follow the curve, so the motion is no glide. On the upper bound the
408/// residual must stay within [`BOUND_MAX_RESIDUAL`], the detected span
409/// must last [`GLIDE_TIME_MIN`] or more and at most [`BOUND_SPAN_MAX`]
410/// times the fitted time, the glide must not come from silence, and it is
411/// marked: a melody step on a sine, a driven drop and a smeared step read
412/// as glides on that bound otherwise. A `stepped` motion, a step across a
413/// gap, is no glide either.
414fn classify(
415    read: &mut GlideRead,
416    span: (f64, f64),
417    fit: impl FnOnce() -> Option<GlideFit>,
418    stepped: bool,
419) {
420    let length = span.1 - span.0;
421    if length < GLIDE_STEP_MAX.get() {
422        return;
423    }
424    let Some(glide) = fit() else {
425        if length < GLIDE_TIME_MIN.get() {
426            read.too_short += 1;
427        }
428        return;
429    };
430    if glide.time < GLIDE_STEP_MAX {
431        return;
432    }
433    if glide.time < GLIDE_TIME_MIN {
434        read.too_short += 1;
435        return;
436    }
437    let (lo, hi) = CURVE_RANGE;
438    let on_bound = glide.curve >= hi / CURVE_EDGE;
439    let follows = if on_bound {
440        glide.residual <= BOUND_MAX_RESIDUAL
441            && !glide.from_silence
442            && length >= GLIDE_TIME_MIN.get()
443            && length <= BOUND_SPAN_MAX * glide.time.get()
444    } else {
445        glide.residual <= GLIDE_MAX_RESIDUAL
446    };
447    let glide = GlideFit { on_bound, ..glide };
448    if glide.curve > CURVE_EDGE * lo && follows && !stepped {
449        read.glides.push(glide);
450    }
451}
452
453/// Whether the pitch keeps moving after an unvoiced gap: the jump from
454/// `last` across the gap and the motion over the next two frames of `run`
455/// go the same way, each by [`GAP_MOTION_MIN`] or more, and the jump is no
456/// faster than [`GAP_SLOPE_MAX`]. At a step the track lands on the new
457/// pitch and holds it, or swings back.
458fn moves_on(last: &Frame, run: &[Frame]) -> bool {
459    let [first, _, third, ..] = run else {
460        return false;
461    };
462    let (jump, then) = (first.cents - last.cents, third.cents - first.cents);
463    jump.abs() >= GAP_MOTION_MIN.get()
464        && jump.abs() <= GAP_SLOPE_MAX * (first.time - last.time)
465        && then.abs() >= GAP_MOTION_MIN.get()
466        && jump.signum() == then.signum()
467}
468
469/// Whether the pitch steps across a bridged gap among `frames`, in time
470/// order: it jumps against `interval` by [`GAP_MOTION_MIN`] or more, or by
471/// more than [`GAP_SHARE_MAX`] of it.
472fn steps_across_gap(frames: &[Frame], interval: f64) -> bool {
473    let hop = cast::usize_to_f64(TRACK_HOP) / MODEL_RATE.hz().get();
474    frames.windows(2).any(|pair| {
475        let jump = pair[1].cents - pair[0].cents;
476        pair[1].time - pair[0].time > 1.5 * hop
477            && (jump * interval < 0.0 && jump.abs() >= GAP_MOTION_MIN.get()
478                || jump.abs() > GAP_SHARE_MAX * interval.abs())
479    })
480}
481
482/// Splits the track into runs of consecutive voiced frames.
483fn voiced_runs(track: &PitchTrack) -> Vec<Vec<Frame>> {
484    let mut runs = Vec::new();
485    let mut run = Vec::new();
486    for frame in track.frames() {
487        if let Some(f0) = frame.f0.filter(|f0| f0.get() > 0.0) {
488            run.push(Frame {
489                time: frame.time.get(),
490                cents: 6900.0 + 1200.0 * (f0.get() / 440.0).log2(),
491                level: frame.level.get(),
492            });
493        } else if !run.is_empty() {
494            runs.push(std::mem::take(&mut run));
495        }
496    }
497    if !run.is_empty() {
498        runs.push(run);
499    }
500    runs
501}
502
503/// Whether a run that leads into its first stable pitch `first` by `lead`
504/// cents settles there. The stable pitch must be active, with its median
505/// level within [`ACTIVE_GATE_DB`] of the loudest frame: on real stems a
506/// quiet onset after digital silence read a false fall into a stable pitch
507/// at -41.9 dB. And the smoothed pitch must not swing back [`SWING_BACK`]
508/// of the lead from its end up to `until`, in track time, or the end of
509/// `run`.
510fn settles(run: &[Frame], smooth: &[f64], first: Plateau, lead: f64, until: f64) -> bool {
511    let mut levels: Vec<f64> = run[first.first..=first.last]
512        .iter()
513        .map(|f| f.level)
514        .collect();
515    levels.sort_by(f64::total_cmp);
516    let active = levels[levels.len() / 2] >= -ACTIVE_GATE_DB.get();
517    let back = (first.last + 1..run.len())
518        .take_while(|&k| run[k].time <= until)
519        .map(|k| (smooth[k] - first.median) * lead.signum())
520        .fold(0.0, f64::max);
521    active && back < SWING_BACK * lead.abs()
522}
523
524/// The centred moving average over [`SMOOTH_FRAMES`], shorter at the ends.
525fn moving_average(run: &[Frame]) -> Vec<f64> {
526    let half = SMOOTH_FRAMES / 2;
527    (0..run.len())
528        .map(|i| {
529            let window = &run[i.saturating_sub(half)..(i + half + 1).min(run.len())];
530            window.iter().map(|f| f.cents).sum::<f64>() / cast::usize_to_f64(window.len())
531        })
532        .collect()
533}
534
535/// The central difference of the smoothed pitch, in cents per second.
536fn slopes(run: &[Frame], smooth: &[f64]) -> Vec<f64> {
537    (0..run.len())
538        .map(|i| {
539            let (a, b) = (i.saturating_sub(1), (i + 1).min(run.len() - 1));
540            let dt = run[b].time - run[a].time;
541            if dt > 0.0 {
542                (smooth[b] - smooth[a]) / dt
543            } else {
544                0.0
545            }
546        })
547        .collect()
548}
549
550/// The stable pitches of a run, in time order.
551fn plateaus(run: &[Frame], smooth: &[f64]) -> Vec<Plateau> {
552    let mut found = Vec::new();
553    let mut i = 0;
554    while i < run.len() {
555        let mut fit = LineSums::default();
556        fit.add(run[i].time, smooth[i]);
557        let mut j = i;
558        while j + 1 < run.len() {
559            let mut next = fit;
560            next.add(run[j + 1].time, smooth[j + 1]);
561            let within = (smooth[j + 1] - fit.mean()).abs() <= PLATEAU_RANGE.get();
562            let long = run[j + 1].time - run[i].time >= PLATEAU_MIN.get();
563            if !within || (long && next.slope().abs() > GLIDE_SLOPE_MIN) {
564                break;
565            }
566            fit = next;
567            j += 1;
568        }
569        if run[j].time - run[i].time >= PLATEAU_MIN.get() {
570            let mut cents: Vec<f64> = run[i..=j].iter().map(|f| f.cents).collect();
571            cents.sort_by(f64::total_cmp);
572            found.push(Plateau {
573                first: i,
574                last: j,
575                median: cents[cents.len() / 2],
576            });
577            i = j + 1;
578        } else {
579            i += 1;
580        }
581    }
582    found
583}
584
585/// Running sums of a least-squares line.
586#[derive(Clone, Copy, Debug, Default)]
587struct LineSums {
588    n: f64,
589    t: f64,
590    y: f64,
591    tt: f64,
592    ty: f64,
593}
594
595impl LineSums {
596    fn add(&mut self, t: f64, y: f64) {
597        self.n += 1.0;
598        self.t += t;
599        self.y += y;
600        self.tt += t * t;
601        self.ty += t * y;
602    }
603
604    fn mean(&self) -> f64 {
605        self.y / self.n
606    }
607
608    fn slope(&self) -> f64 {
609        let det = self.n * self.tt - self.t * self.t;
610        if det <= 0.0 {
611            0.0
612        } else {
613            (self.n * self.ty - self.t * self.y) / det
614        }
615    }
616}
617
618/// The first and last time between frames `from` and `to` whose signed
619/// slope reaches [`GLIDE_SLOPE_MIN`], or `None` when none does.
620fn detected(run: &[Frame], signed: &[f64], from: usize, to: usize) -> Option<(f64, f64)> {
621    let moving = |&k: &usize| signed[k] >= GLIDE_SLOPE_MIN;
622    let mut span = from..=to.min(run.len() - 1);
623    Some((
624        run[span.clone().find(moving)?].time,
625        run[span.rfind(moving)?].time,
626    ))
627}
628
629/// The curve between `c0` and `c1` at time `t` for a start `t0`, a full
630/// time `time` and a power `p`.
631fn model(t: f64, c0: f64, c1: f64, t0: f64, time: f64, p: f64) -> f64 {
632    let x = ((t - t0) / time).clamp(0.0, 1.0);
633    c0 + (c1 - c0) * (1.0 - (1.0 - x).powf(p))
634}
635
636/// The squared error of the curve over `frames`, and a large value outside
637/// the bounds of `p` and the time.
638fn sse(frames: &[Frame], c0: f64, c1: f64, t0: f64, log_time: f64, log_p: f64) -> f64 {
639    let p = log_p.exp();
640    if !(CURVE_RANGE.0..=CURVE_RANGE.1).contains(&p) || !(-5.0..=1.0).contains(&log_time) {
641        return f64::INFINITY;
642    }
643    let time = log_time.exp();
644    frames
645        .iter()
646        .map(|f| (f.cents - model(f.time, c0, c1, t0, time, p)).powi(2))
647        .sum()
648}
649
650/// The coarse grid of curve powers, times (as shares of the detected span)
651/// and start shifts that the simplex starts from.
652const P_GRID: [f64; 9] = [0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0, 2.8, 4.0];
653const TIME_GRID: [f64; 8] = [0.7, 0.85, 1.0, 1.2, 1.45, 1.75, 2.1, 2.5];
654
655/// Fits a glide between two stable pitches.
656fn fit_between(run: &[Frame], a: Plateau, b: Plateau, span: (f64, f64)) -> Option<GlideFit> {
657    let window = window(
658        run,
659        run[a.last].time - FIT_MARGIN,
660        run[b.first].time + FIT_MARGIN,
661    );
662    let (c0, c1) = (a.median, b.median);
663    let length = span.1 - span.0;
664    let grid = P_GRID.iter().flat_map(|&p| {
665        TIME_GRID.iter().flat_map(move |&share| {
666            (-8..=4).map(move |shift| {
667                [
668                    span.0 + 0.005 * f64::from(shift),
669                    (length * share).ln(),
670                    p.ln(),
671                ]
672            })
673        })
674    });
675    let ([t0, log_time, log_p], value) = seeded_fit(
676        |x: &[f64; 3]| sse(window, c0, c1, x[0], x[1], x[2]),
677        [span.0, length.ln(), 0.0],
678        grid,
679        [0.005, 0.1, 0.1],
680    );
681    let jitter = jitter(&run[a.first..=a.last], c0, &run[b.first..=b.last], c1);
682    glide(
683        t0,
684        c0,
685        c1 - c0,
686        log_time,
687        log_p,
688        value,
689        window.len(),
690        false,
691        length,
692        jitter,
693    )
694}
695
696/// Fits a glide from the start of a voiced run into its first stable
697/// pitch: the run's first frame fixes the start, and the start pitch is a
698/// free parameter.
699fn fit_from_silence(run: &[Frame], b: Plateau, span: (f64, f64)) -> Option<GlideFit> {
700    let window = window(run, run[0].time, run[b.first].time + FIT_MARGIN);
701    let c1 = b.median;
702    let t0 = run[0].time;
703    let length = span.1 - t0;
704    let grid = P_GRID.iter().flat_map(|&p| {
705        TIME_GRID
706            .iter()
707            .map(move |&share| [run[0].cents, (length * share).ln(), p.ln()])
708    });
709    let ([c0, log_time, log_p], value) = seeded_fit(
710        |x: &[f64; 3]| sse(window, x[0], c1, t0, x[1], x[2]),
711        [run[0].cents, length.ln(), 0.0],
712        grid,
713        [10.0, 0.1, 0.1],
714    );
715    let jitter = jitter(&[], c1, &run[b.first..=b.last], c1);
716    glide(
717        t0,
718        c0,
719        c1 - c0,
720        log_time,
721        log_p,
722        value,
723        window.len(),
724        true,
725        span.1 - span.0,
726        jitter,
727    )
728}
729
730/// Runs a Nelder-Mead simplex from the first point of `grid` with the
731/// smallest `objective`, or from `fallback` when none is below infinity,
732/// with initial steps `step`.
733fn seeded_fit(
734    objective: impl Fn(&[f64; 3]) -> f64,
735    fallback: [f64; 3],
736    grid: impl IntoIterator<Item = [f64; 3]>,
737    step: [f64; 3],
738) -> ([f64; 3], f64) {
739    let mut start = (f64::INFINITY, fallback);
740    for point in grid {
741        let value = objective(&point);
742        if value < start.0 {
743            start = (value, point);
744        }
745    }
746    nelder_mead(&objective, start.1, step, 2_000, 1e-10)
747}
748
749/// The frames of `run` from `from` to `to`.
750fn window(run: &[Frame], from: f64, to: f64) -> &[Frame] {
751    let start = run.partition_point(|f| f.time < from);
752    let end = run.partition_point(|f| f.time <= to);
753    &run[start..end.max(start)]
754}
755
756/// The RMS deviation of two stable pitches from their medians.
757fn jitter(a: &[Frame], ca: f64, b: &[Frame], cb: f64) -> f64 {
758    let squares: f64 = a.iter().map(|f| (f.cents - ca).powi(2)).sum::<f64>()
759        + b.iter().map(|f| (f.cents - cb).powi(2)).sum::<f64>();
760    (squares / cast::usize_to_f64((a.len() + b.len()).max(1))).sqrt()
761}
762
763#[expect(clippy::too_many_arguments, reason = "the fields of one record")]
764fn glide(
765    t0: f64,
766    c0: f64,
767    interval: f64,
768    log_time: f64,
769    log_p: f64,
770    sse: f64,
771    frames: usize,
772    from_silence: bool,
773    detected: f64,
774    jitter: f64,
775) -> Option<GlideFit> {
776    if frames == 0 || !sse.is_finite() {
777        return None;
778    }
779    Some(GlideFit {
780        start: Seconds::new(t0 - lag_at(c0)).ok()?,
781        interval: Semitones::new(interval / 100.0).ok()?,
782        time: Seconds::new(log_time.exp()).ok()?,
783        curve: log_p.exp(),
784        from_silence,
785        detected: Seconds::new(detected).ok()?,
786        residual: Cents::new((sse / cast::usize_to_f64(frames)).sqrt()).ok()?,
787        jitter: Cents::new(jitter).ok()?,
788        on_bound: false,
789    })
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795    use crate::notes::{Note, TrackFrame};
796    use crate::units::{Decibels, MidiPitch};
797
798    #[test]
799    fn the_convention_states_the_constants() {
800        use super::super::{
801            DIP_SIDE, DIP_WINDOW, GAP_DIP_DB, RUN_START_RISE, STRIKE_RISE, num, ratio_db, says,
802        };
803        let ms = |s: f64| num(1000.0 * s);
804        let quarter = 1000.0 * cast::usize_to_f64(TRACK_FRAME / 4) / MODEL_RATE.hz().get();
805        says(
806            GLIDE_CONVENTION,
807            &[
808                format!(
809                    "a span of {} ms or more within {} cents of its running mean whose \
810                     least-squares slope stays at {} cents per second or less",
811                    ms(PLATEAU_MIN.get()),
812                    num(PLATEAU_RANGE.get()),
813                    num(GLIDE_SLOPE_MIN)
814                ),
815                format!(
816                    "{} cents or more apart that match notes of different pitch within {} cents, \
817                     where the slope of the {}-frame moving average stays at {} cents per second",
818                    num(GLIDE_INTERVAL_MIN.get()),
819                    num(NOTE_MATCH.get()),
820                    SMOOTH_FRAMES,
821                    num(GLIDE_SLOPE_MIN)
822                ),
823                format!("over {} ms or more;", ms(GLIDE_STEP_MAX.get())),
824                format!(
825                    "one whose detected span lasts under {} ms",
826                    ms(GLIDE_TIME_MIN.get())
827                ),
828                format!("over the motion and {} ms of each", ms(FIT_MARGIN)),
829                format!("a quarter frame, {quarter:.1} ms"),
830                format!(
831                    "the full-band RMS rising {}, up to {} ms before it",
832                    ratio_db(RUN_START_RISE),
833                    ms(ONSET_LOOKBACK.get())
834                ),
835                format!(
836                    "a median level within {} dB of the loudest frame",
837                    num(ACTIVE_GATE_DB.get())
838                ),
839                format!(
840                    "swing back {} % of the lead within half a period of {} Hz after it",
841                    num(100.0 * SWING_BACK),
842                    num(VIBRATO_RATE_MIN.get())
843                ),
844                format!(
845                    "within {} ms of the one before joins it when the full-band RMS does not \
846                     rise {} in the gap, its {} ms RMS stays within {} dB of the {} ms before or \
847                     after the gap and within {} dB of the loudest frame",
848                    ms(GAP_BRIDGE_MAX.get()),
849                    ratio_db(STRIKE_RISE),
850                    ms(DIP_WINDOW),
851                    num(GAP_DIP_DB.get()),
852                    ms(DIP_SIDE),
853                    num(ACTIVE_GATE_DB.get())
854                ),
855                format!(
856                    "at {} cents per millisecond or slower",
857                    num(GAP_SLOPE_MAX / 1000.0)
858                ),
859                format!(
860                    "against the interval by {} cents or more, or carries more than {} % of it",
861                    num(GAP_MOTION_MIN.get()),
862                    num(100.0 * GAP_SHARE_MAX)
863                ),
864                format!(
865                    "motion of {} to {} ms",
866                    ms(GLIDE_STEP_MAX.get()),
867                    ms(GLIDE_TIME_MIN.get())
868                ),
869                format!(
870                    "a fit of {} ms or more with p within {} % of {}",
871                    ms(GLIDE_TIME_MIN.get()),
872                    num(100.0 * (CURVE_EDGE - 1.0)),
873                    num(CURVE_RANGE.0)
874                ),
875                format!(
876                    "with p within {} % of {} and an RMS residual above {} cents, a detected \
877                     span under {} ms or over {} times the fitted time",
878                    num(100.0 * (CURVE_EDGE - 1.0)),
879                    num(CURVE_RANGE.1),
880                    num(BOUND_MAX_RESIDUAL.get()),
881                    ms(GLIDE_TIME_MIN.get()),
882                    num(BOUND_SPAN_MAX)
883                ),
884                format!(
885                    "an RMS residual above {} cents, is no glide",
886                    num(GLIDE_MAX_RESIDUAL.get())
887                ),
888            ],
889        );
890    }
891
892    const HOP: f64 = 128.0 / 22_050.0;
893
894    /// A track whose pitch follows `cents(t)` from 0 to `length`, voiced
895    /// where `cents` returns a value, read a quarter frame late and on the
896    /// tracker's grid of 10 cents.
897    fn track(length: f64, cents: impl Fn(f64) -> Option<f64>) -> PitchTrack {
898        let count = cast::floor_to_usize(length / HOP).unwrap();
899        PitchTrack::from_frames(
900            (0..count)
901                .map(|k| {
902                    let t = cast::usize_to_f64(k) * HOP;
903                    let guess = t - track_lag(Hz::new(150.0).unwrap()).get();
904                    let lag = cents(guess).map_or(0.0, lag_at);
905                    let audio = t - lag;
906                    TrackFrame {
907                        time: Seconds::new(t).unwrap(),
908                        f0: cents(audio).map(|c| {
909                            let c = (c / 10.0).round() * 10.0;
910                            Hz::new(440.0 * 2f64.powf((c - 6900.0) / 1200.0)).unwrap()
911                        }),
912                        level: Decibels::new(0.0).unwrap(),
913                    }
914                })
915                .collect(),
916        )
917    }
918
919    fn notes(list: &[(f64, f64, f64)]) -> NoteList {
920        NoteList::new(
921            list.iter()
922                .map(|&(on, off, pitch)| {
923                    Note::new(
924                        Seconds::new(on).unwrap(),
925                        Seconds::new(off).unwrap(),
926                        MidiPitch::new(pitch).unwrap(),
927                    )
928                    .unwrap()
929                })
930                .collect(),
931        )
932    }
933
934    #[test]
935    fn a_jump_across_a_gap_faster_than_30_cents_per_ms_is_no_glide_motion() {
936        let frame = |time: f64, cents: f64| Frame {
937            time,
938            cents,
939            level: 0.0,
940        };
941        let last = frame(0.0, 0.0);
942        // Four hops apart: 1000 cents is 43 per ms, 300 cents 13.
943        for (jump, bridged) in [(-1000.0, false), (-300.0, true)] {
944            let run = [
945                frame(4.0 * HOP, jump),
946                frame(5.0 * HOP, jump - 30.0),
947                frame(6.0 * HOP, jump - 60.0),
948            ];
949            assert_eq!(moves_on(&last, &run), bridged, "{jump}");
950        }
951    }
952
953    #[test]
954    fn a_jump_across_a_gap_that_carries_most_of_the_interval_is_a_step() {
955        let frame = |time: f64, cents: f64| Frame {
956            time,
957            cents,
958            level: 0.0,
959        };
960        let gap = [
961            frame(0.0, 6900.0),
962            frame(HOP, 6890.0),
963            frame(5.0 * HOP, 6440.0),
964            frame(6.0 * HOP, 6200.0),
965        ];
966        // 450 cents is 0.64 of a fall of 700 and 0.5 of 900; against a
967        // rise it is a step at any share.
968        assert!(steps_across_gap(&gap, -700.0));
969        assert!(!steps_across_gap(&gap, -900.0));
970        assert!(steps_across_gap(&gap, 900.0));
971        // One hop apart the frames hold no gap.
972        let close = [frame(0.0, 6900.0), frame(HOP, 6440.0)];
973        assert!(!steps_across_gap(&close, -700.0));
974    }
975
976    fn glide_track(full: f64, p: f64) -> PitchTrack {
977        track(1.6, |t| {
978            (0.0..1.5 + full).contains(&t).then(|| {
979                let x = ((t - 0.5) / full).clamp(0.0, 1.0);
980                4500.0 + 700.0 * (1.0 - (1.0 - x).powf(p))
981            })
982        })
983    }
984
985    #[test]
986    fn a_curve_on_the_tracker_grid_reads_its_time_and_power() {
987        let line = notes(&[(0.0, 0.55, 45.0), (0.55, 1.6, 52.0)]);
988        for (full, p) in [(0.15, 1.0), (0.15, 2.5), (0.4, 1.0), (0.4, 2.5), (0.3, 0.5)] {
989            let read = find_glides(&glide_track(full, p), &line, |_, _| false, |_, _| true);
990            assert_eq!(read.glides.len(), 1, "{full} {p}: {read:?}");
991            let glide = read.glides[0];
992            assert!(
993                (glide.time.get() - full).abs() < 0.05 * full,
994                "{full} {p}: {glide:?}"
995            );
996            assert!((glide.curve - p).abs() < 0.2 * p, "{full} {p}: {glide:?}");
997            assert!(
998                (glide.start.get() - 0.5).abs() < 0.006,
999                "{full} {p}: {glide:?}"
1000            );
1001            assert!((glide.interval.get() - 7.0).abs() < 0.1, "{glide:?}");
1002            assert!(!glide.from_silence);
1003        }
1004    }
1005
1006    #[test]
1007    fn a_step_reports_nothing_and_a_short_glide_is_too_short() {
1008        let line = notes(&[(0.0, 0.5, 45.0), (0.5, 1.6, 52.0)]);
1009        let step = find_glides(&glide_track(0.001, 1.0), &line, |_, _| false, |_, _| true);
1010        assert_eq!(step, GlideRead::default());
1011        let short = find_glides(&glide_track(0.05, 1.0), &line, |_, _| false, |_, _| true);
1012        assert!(short.glides.is_empty(), "{short:?}");
1013        assert_eq!(short.too_short, 1);
1014    }
1015
1016    #[test]
1017    fn motion_inside_one_note_is_not_a_glide() {
1018        let vibrato = track(1.6, |t| {
1019            Some(5700.0 + 60.0 * (std::f64::consts::TAU * 3.0 * t).sin())
1020        });
1021        let read = find_glides(
1022            &vibrato,
1023            &notes(&[(0.0, 1.6, 57.0)]),
1024            |_, _| false,
1025            |_, _| true,
1026        );
1027        assert_eq!(read, GlideRead::default());
1028    }
1029
1030    #[test]
1031    fn a_glide_on_the_bound_reads_up_to_its_span_ratio() {
1032        let glide = GlideFit {
1033            start: Seconds::from_const(0.5),
1034            interval: Semitones::from_const(1.2),
1035            time: Seconds::from_const(0.1),
1036            curve: CURVE_RANGE.1,
1037            from_silence: false,
1038            detected: Seconds::from_const(0.1),
1039            residual: Cents::from_const(5.0),
1040            jitter: Cents::from_const(1.0),
1041            on_bound: false,
1042        };
1043        let read = |ratio: f64| {
1044            let mut read = GlideRead::default();
1045            classify(&mut read, (0.5, 0.5 + ratio * 0.1), || Some(glide), false);
1046            read.glides
1047        };
1048        assert_eq!(
1049            read(2.4),
1050            [GlideFit {
1051                on_bound: true,
1052                ..glide
1053            }]
1054        );
1055        assert_eq!(read(2.6), []);
1056    }
1057
1058    #[test]
1059    fn a_short_detected_span_reads_when_the_fit_follows_the_curve() {
1060        let glide = GlideFit {
1061            start: Seconds::from_const(0.5),
1062            interval: Semitones::from_const(2.0),
1063            time: Seconds::from_const(0.12),
1064            curve: 1.0,
1065            from_silence: false,
1066            detected: Seconds::from_const(0.06),
1067            residual: Cents::from_const(5.0),
1068            jitter: Cents::from_const(1.0),
1069            on_bound: false,
1070        };
1071        // A 60 ms span whose fit lasts 120 ms reads; on the curve bound it
1072        // does not, and a failed fit over it is too short.
1073        for (span, curve, fits, glides, too_short) in [
1074            (0.06, 1.0, true, 1, 0),
1075            (0.06, CURVE_RANGE.1, true, 0, 0),
1076            (0.06, 1.0, false, 0, 1),
1077            (0.1, 1.0, true, 1, 0),
1078        ] {
1079            let mut read = GlideRead::default();
1080            let fit = GlideFit { curve, ..glide };
1081            classify(&mut read, (0.5, 0.5 + span), || fits.then_some(fit), false);
1082            assert_eq!(read.glides.len(), glides, "{span} {curve} {fits}");
1083            assert_eq!(read.too_short, too_short, "{span} {curve} {fits}");
1084        }
1085    }
1086
1087    #[test]
1088    fn a_glide_into_the_first_note_comes_from_silence() {
1089        let rising = track(1.0, |t| {
1090            (0.2..1.0).contains(&t).then(|| {
1091                let x = ((t - 0.2) / 0.2).clamp(0.0, 1.0);
1092                4500.0 + 500.0 * x
1093            })
1094        });
1095        let line = notes(&[(0.2, 1.0, 50.0)]);
1096        let read = find_glides(&rising, &line, |_, _| false, |_, _| true);
1097        assert_eq!(read.glides.len(), 1, "{read:?}");
1098        let glide = read.glides[0];
1099        assert!(glide.from_silence);
1100        assert!((glide.interval.get() - 5.0).abs() < 0.3, "{glide:?}");
1101        let skipped = find_glides(&rising, &line, |_, _| false, |_, _| false);
1102        assert_eq!(skipped.glides, []);
1103        let falling = track(1.0, |t| {
1104            (0.2..1.0).contains(&t).then(|| {
1105                let x = ((t - 0.2) / 0.2).clamp(0.0, 1.0);
1106                5500.0 - 500.0 * x
1107            })
1108        });
1109        let read = find_glides(&falling, &line, |_, _| false, |_, _| true);
1110        assert_eq!(read.glides.len(), 1, "{read:?}");
1111        assert!(read.glides[0].from_silence);
1112        assert!(
1113            (read.glides[0].interval.get() + 5.0).abs() < 0.3,
1114            "{read:?}"
1115        );
1116        // A rise from silence over 60 ms is too short to read, and it is no
1117        // motion between two notes, so it is not counted either.
1118        let short = track(1.0, |t| {
1119            (0.2..1.0).contains(&t).then(|| {
1120                let x = ((t - 0.2) / 0.06).clamp(0.0, 1.0);
1121                4500.0 + 500.0 * x
1122            })
1123        });
1124        let read = find_glides(&short, &line, |_, _| false, |_, _| true);
1125        assert_eq!(read, GlideRead::default());
1126    }
1127
1128    /// From silence at 0.2 s, 120 cents above E1, a fall over 90 ms into
1129    /// E1, held 60 ms; from 0.35 s `after` adds its cents.
1130    fn fall_into_e1(after: impl Fn(f64) -> f64) -> PitchTrack {
1131        track(1.2, move |t| {
1132            (0.2..1.1).contains(&t).then(|| {
1133                let x = ((t - 0.2) / 0.09).clamp(0.0, 1.0);
1134                4220.0 - 120.0 * x + if t > 0.35 { after(t - 0.35) } else { 0.0 }
1135            })
1136        })
1137    }
1138
1139    #[test]
1140    fn a_fall_from_silence_that_swings_back_is_no_glide() {
1141        // A ±55-cent wobble at 6.5 Hz comes back 110 of the 120 cents: its
1142        // first half-cycle is no fall into a note. A rebound of 30 cents,
1143        // a quarter of the lead as on a true fall on real stems, reads.
1144        let line = notes(&[(0.2, 1.1, 41.0)]);
1145        let reads = |after: fn(f64) -> f64| {
1146            find_glides(&fall_into_e1(after), &line, |_, _| false, |_, _| true).glides
1147        };
1148        let held = reads(|_| 0.0);
1149        assert_eq!(held.len(), 1, "{held:?}");
1150        assert!(held[0].from_silence);
1151        assert_eq!(reads(|x| 30.0 * (x / 0.05).min(1.0)).len(), 1);
1152        assert_eq!(reads(|x| 54.0 * (x / 0.05).min(1.0)).len(), 1, "45 % reads");
1153        let wobble = reads(|x| 55.0 - 55.0 * (std::f64::consts::TAU * 6.5 * x).cos());
1154        assert_eq!(wobble, [], "{wobble:?}");
1155        // The transcriber can end the note inside the first stable pitch,
1156        // which runs to about 0.35 s, and start another on the same pitch;
1157        // the wobble still swings back.
1158        let wobble = fall_into_e1(|x| 55.0 - 55.0 * (std::f64::consts::TAU * 6.5 * x).cos());
1159        for end in [0.32, 0.34, 0.36] {
1160            let split = notes(&[(0.2, end, 41.0), (end, 1.1, 41.0)]);
1161            let read = find_glides(&wobble, &split, |_, _| false, |_, _| true);
1162            assert_eq!(read.glides, [], "{end}: {read:?}");
1163        }
1164        // A neighbouring note can sound at the crest; the pitch comes back
1165        // from it, so it is no step.
1166        let neighbour = notes(&[(0.2, 1.3, 41.0), (0.3, 0.8, 42.0)]);
1167        for (extent, rate) in [
1168            (55.0, 6.5),
1169            (60.0, 6.0),
1170            (45.0, 6.0),
1171            (55.0, 7.0),
1172            (60.0, 6.5),
1173        ] {
1174            let wobble = fall_into_e1(move |x: f64| {
1175                extent - extent * (std::f64::consts::TAU * rate * x).cos()
1176            });
1177            let read = find_glides(&wobble, &neighbour, |_, _| false, |_, _| true);
1178            assert_eq!(read.glides, [], "±{extent} at {rate} Hz: {read:?}");
1179        }
1180    }
1181
1182    #[test]
1183    fn a_fall_from_silence_then_a_step_to_another_note_reads() {
1184        // A step up 200 cents into G1 starts 30 ms after the stable E1, well
1185        // inside the swing window; it belongs to the next note.
1186        // The first note can end at the step's start, 20 ms into it or
1187        // after it.
1188        let step = |x: f64| 200.0 * ((x - 0.03) / 0.03).clamp(0.0, 1.0);
1189        for end in [0.38, 0.4, 0.45] {
1190            let line = notes(&[(0.2, end, 41.0), (end, 1.1, 43.0)]);
1191            let read = find_glides(&fall_into_e1(step), &line, |_, _| false, |_, _| true);
1192            assert!(
1193                read.glides.iter().any(|g| g.from_silence),
1194                "{end}: {read:?}"
1195            );
1196        }
1197        // A slide to G1 of up to 400 ms, past the window, is a step too.
1198        for slide in [0.08, 0.16, 0.25, 0.4] {
1199            let step = move |x: f64| 200.0 * ((x - 0.03) / slide).clamp(0.0, 1.0);
1200            let end = 0.38 + slide / 2.0;
1201            let line = notes(&[(0.2, end, 41.0), (end, 1.1, 43.0)]);
1202            let read = find_glides(&fall_into_e1(step), &line, |_, _| false, |_, _| true);
1203            assert!(
1204                read.glides.iter().any(|g| g.from_silence),
1205                "{slide}: {read:?}"
1206            );
1207        }
1208    }
1209
1210    /// Cents through the given points, linear between them and held
1211    /// outside.
1212    fn path(points: &[(f64, f64)]) -> impl Fn(f64) -> f64 + use<> {
1213        let points = points.to_vec();
1214        move |x| {
1215            let next = points.partition_point(|&(at, _)| at <= x);
1216            match (next.checked_sub(1).map(|k| points[k]), points.get(next)) {
1217                (Some((a, ca)), Some(&(b, cb))) => ca + (cb - ca) * (x - a) / (b - a),
1218                (Some((_, c)), None) | (None, Some(&(_, c))) => c,
1219                (None, None) => 0.0,
1220            }
1221        }
1222    }
1223
1224    /// Whether a fall into E1 then `after` reads from silence, with E1
1225    /// sounding to `end` and G1 from there.
1226    fn falls_before_g1(after: impl Fn(f64) -> f64, end: f64) -> bool {
1227        let line = notes(&[(0.2, end, 41.0), (end, 1.1, 43.0)]);
1228        let read = find_glides(&fall_into_e1(after), &line, |_, _| false, |_, _| true);
1229        read.glides.iter().any(|g| g.from_silence)
1230    }
1231
1232    #[test]
1233    fn a_step_holds_past_half_the_way() {
1234        // The pitch peaks, dips toward 90 cents and climbs to G1. Smoothed,
1235        // the dip after a peak of 120 falls under half the 200-cent step,
1236        // so the motion is no step and swings back; after 160 it does not.
1237        let to_g1 = |peak: f64| path(&[(0.03, 0.0), (0.06, peak), (0.1, 90.0), (0.13, 200.0)]);
1238        assert!(!falls_before_g1(to_g1(120.0), 0.5));
1239        assert!(falls_before_g1(to_g1(160.0), 0.5));
1240    }
1241
1242    #[test]
1243    fn a_step_must_hold_up_to_its_stable_pitch() {
1244        // The pitch reaches G1 at once, then sinks to 40 cents over 440 ms,
1245        // past the window, before it comes back: no step, so the swing back
1246        // counts.
1247        let sag = path(&[(0.03, 0.0), (0.06, 200.0), (0.5, 40.0), (0.55, 200.0)]);
1248        assert!(!falls_before_g1(sag, 0.9));
1249    }
1250
1251    #[test]
1252    fn a_scoop_away_from_the_step_still_steps() {
1253        // The pitch scoops 80 cents back toward the start, past half the
1254        // lead, then steps down to D1: a step, so the scoop does not count.
1255        let scoop = path(&[(0.03, 0.0), (0.05, 80.0), (0.07, 80.0), (0.11, -200.0)]);
1256        let line = notes(&[(0.2, 0.5, 41.0), (0.5, 1.1, 39.0)]);
1257        let read = find_glides(&fall_into_e1(scoop), &line, |_, _| false, |_, _| true);
1258        assert!(read.glides.iter().any(|g| g.from_silence), "{read:?}");
1259    }
1260
1261    #[test]
1262    fn the_nearest_note_names_a_stable_pitch() {
1263        // The fall lands at 4140 cents, nearest the note at 41.6, which
1264        // rounds to the same note as the hold at 4245: no step, and the
1265        // pitch swings back 105 of the 120 cents. The note at 41.0, first
1266        // in the list, would make it a step.
1267        let track = track(1.2, |t| {
1268            (0.2..1.1).contains(&t).then(|| {
1269                let x = ((t - 0.2) / 0.09).clamp(0.0, 1.0);
1270                4260.0 - 120.0 * x + path(&[(0.38, 0.0), (0.41, 105.0)])(t)
1271            })
1272        });
1273        let line = notes(&[(0.2, 1.1, 41.0), (0.2, 1.1, 41.6), (0.2, 1.1, 42.0)]);
1274        let read = find_glides(&track, &line, |_, _| false, |_, _| true);
1275        assert_eq!(read.glides, [], "{read:?}");
1276    }
1277
1278    #[test]
1279    fn a_portamento_that_stalls_under_half_the_way_is_lost() {
1280        // A stall of 100 ms at 80 cents forms its own stable pitch, which no
1281        // note matches, so the motion is no step and swings back; one of
1282        // 60 ms does not.
1283        let stall = |hold: f64| {
1284            path(&[
1285                (0.03, 0.0),
1286                (0.05, 80.0),
1287                (0.05 + hold, 80.0),
1288                (0.08 + hold, 200.0),
1289            ])
1290        };
1291        assert!(falls_before_g1(stall(0.06), 0.5));
1292        assert!(!falls_before_g1(stall(0.1), 0.5));
1293    }
1294
1295    #[test]
1296    fn a_move_under_the_glide_interval_is_no_step() {
1297        // A 70-cent move matches F1 within 30 cents but is no step, so it
1298        // swings back past half the 120-cent lead.
1299        let line = notes(&[(0.2, 1.1, 41.0), (0.4, 1.1, 42.0)]);
1300        let after = path(&[(0.03, 0.0), (0.06, 70.0)]);
1301        let read = find_glides(&fall_into_e1(after), &line, |_, _| false, |_, _| true);
1302        assert_eq!(read.glides, [], "{read:?}");
1303    }
1304
1305    #[test]
1306    fn a_fall_from_silence_into_an_inactive_pitch_is_no_glide() {
1307        let line = notes(&[(0.2, 1.1, 41.0)]);
1308        let at = |level: f64| {
1309            let frames = fall_into_e1(|_| 0.0)
1310                .frames()
1311                .iter()
1312                .map(|f| TrackFrame {
1313                    level: Decibels::new(level).unwrap(),
1314                    ..*f
1315                })
1316                .collect();
1317            find_glides(
1318                &PitchTrack::from_frames(frames),
1319                &line,
1320                |_, _| false,
1321                |_, _| true,
1322            )
1323            .glides
1324            .len()
1325        };
1326        assert_eq!(at(-38.0), 1);
1327        assert_eq!(at(-42.0), 0);
1328    }
1329}