1use 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
11pub const GLIDE_SLOPE_MIN: f64 = 86.0;
15pub(crate) const GLIDE_TIME_MIN: Seconds = Seconds::from_const(0.09);
20pub(crate) const GLIDE_STEP_MAX: Seconds = Seconds::from_const(0.035);
27pub(crate) const STEADY_SPLIT: Cents = Cents::from_const(150.0);
31pub(crate) const GLIDE_INTERVAL_MIN: Cents = Cents::from_const(100.0);
33pub(crate) const PLATEAU_MIN: Seconds = Seconds::from_const(0.05);
35pub(crate) const PLATEAU_RANGE: Cents = Cents::from_const(30.0);
37pub(crate) const NOTE_MATCH: Cents = Cents::from_const(50.0);
40pub(crate) const SMOOTH_FRAMES: usize = 5;
43pub const CURVE_RANGE: (f64, f64) = (0.2, 5.0);
45pub(crate) const CURVE_EDGE: f64 = 1.05;
48pub(crate) const BOUND_MAX_RESIDUAL: Cents = Cents::from_const(20.0);
52pub(crate) const BOUND_SPAN_MAX: f64 = 2.5;
63const SWING_BACK: f64 = 0.5;
74pub(crate) const GLIDE_MAX_RESIDUAL: Cents = Cents::from_const(40.0);
77pub(crate) const GAP_BRIDGE_MAX: Seconds = Seconds::from_const(0.1);
81pub(crate) const GAP_MOTION_MIN: Cents = Cents::from_const(20.0);
84pub(crate) const GAP_SLOPE_MAX: f64 = 30_000.0;
91pub(crate) const GAP_SHARE_MAX: f64 = 0.6;
99pub(crate) const ONSET_LOOKBACK: Seconds = Seconds::from_const(0.1);
102const FIT_MARGIN: f64 = 0.05;
104
105#[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
120fn lag_at(cents: f64) -> f64 {
122 track_lag(hz(cents)).get()
123}
124
125fn hz(cents: f64) -> Hz {
127 Hz::clamped(440.0 * 2f64.powf((cents - 6900.0) / 1200.0))
128}
129
130pub 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#[derive(Clone, Copy, Debug, PartialEq)]
158pub struct GlideFit {
159 pub start: Seconds,
161 pub interval: Semitones,
163 pub time: Seconds,
165 pub curve: f64,
167 pub from_silence: bool,
169 pub detected: Seconds,
171 pub residual: Cents,
173 pub jitter: Cents,
175 pub on_bound: bool,
179}
180
181impl GlideFit {
182 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#[derive(Clone, Debug, Default, PartialEq)]
199pub(crate) struct GlideRead {
200 pub glides: Vec<GlideFit>,
202 pub too_short: usize,
205}
206
207#[derive(Clone, Copy, Debug)]
210struct Frame {
211 time: f64,
212 cents: f64,
213 level: f64,
215}
216
217#[derive(Clone, Copy, Debug)]
219struct Plateau {
220 first: usize,
221 last: usize,
222 median: f64,
223}
224
225#[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 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 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#[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 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
396fn 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
453fn 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
469fn 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
482fn 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
503fn 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
524fn 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
535fn 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
550fn 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#[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
618fn 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
629fn 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
636fn 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
650const 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
655fn 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
696fn 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
730fn 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
749fn 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
756fn 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 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 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 assert!(steps_across_gap(&gap, -700.0));
969 assert!(!steps_across_gap(&gap, -900.0));
970 assert!(steps_across_gap(&gap, 900.0));
971 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 ¬es(&[(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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}