1mod drop;
11pub(crate) mod fit;
12mod glide;
13mod line;
14mod vibrato;
15
16pub use drop::{
17 DROP_CONVENTION, DROP_FIT_START, DROP_MAX, DROP_MAX_RESIDUAL, DROP_MIN, DROP_ONSET_SHARE,
18 DROP_TAU_MIN, DropFit, DropMiss, ONSET_SMOOTHING, RISE_PERIODS, band_onset, fit_drop,
19};
20pub use glide::{CURVE_RANGE, GLIDE_CONVENTION, GLIDE_SLOPE_MIN, GlideFit};
21pub use line::{LINE_CONVENTION, SubBandLine};
22pub use vibrato::{EXTENT_SHARE, VIBRATO_CONVENTION, VIBRATO_SPAN_MIN, VibratoFit, fit_vibrato};
23
24use drop::{DROP_FIT_END, band_attack, rises_into};
25use glide::{GLIDE_TIME_MIN as SPAN_MIN, GlideRead, find_glides, steady_spans, track_lag};
26use line::{LINE_HI, LINE_LO, LINE_LO_FAST};
27
28use crate::audio::SampleRate;
29use crate::cast;
30use crate::confidence::{Confidence, ConfidenceError, Level, NoSpread, Reason, ReasonCode, Signal};
31use crate::dsp::{
32 ACTIVE_GATE_DB, FilterError, StftParams, band_magnitude, band_pass_zero_phase, frame_rms,
33};
34use crate::notes::{NoteList, PitchTrack, QUIET_FRAME, Voicing};
35use crate::units::{Cents, Decibels, Hz, MidiPitch, Seconds, Semitones};
36
37pub const STRIKE_LOOKBACK: Seconds = Seconds::from_const(0.5);
40pub const STRIKE_LOOKAHEAD: Seconds = Seconds::from_const(0.1);
42pub const RUN_TRIM: Seconds = Seconds::from_const(0.05);
44const SETTLE_MIN: f64 = 0.1;
47pub const DROP_GLIDE_DISTANCE: Seconds = Seconds::from_const(0.2);
50pub const DROP_NOTE_MATCH: Cents = Cents::from_const(100.0);
53pub const DROP_NOTE_LEAD: Seconds = Seconds::from_const(0.15);
62pub const WEAK_FROM: Seconds = Seconds::from_const(0.05);
65pub const WEAK_RATIO: f64 = 2.0;
72pub const STRIKE_RISE: f64 = 2.0;
76pub const FAST_TAU: Seconds = Seconds::from_const(0.01);
80pub const NOTE_LINE_EDGE: f64 = 1.5;
96pub const NOTE_LINE_LOSS: Decibels = Decibels::from_const(6.0);
109const STRIKE_SPANS: [(f64, f64); 2] = [(-0.045, -0.025), (0.0, 0.02)];
115const RUN_START_SPANS: [(f64, f64); 2] = [(-0.06, -0.03), (0.0, 0.03)];
118pub const RUN_START_RISE: f64 = 10.0;
121const ONSET_STEP: f64 = 0.005;
123pub const GAP_DIP_DB: Decibels = Decibels::from_const(20.0);
130const DIP_WINDOW: f64 = 0.01;
132const DIP_SIDE: f64 = 0.02;
134const LINE_TAIL: f64 = 0.5;
137const LINE_LEAD: f64 = 0.1;
139
140pub const RESIDUAL_HIGH: f64 = 15.0;
144pub const JITTER_HIGH: f64 = 3.0;
147pub const CURVE_HIGH: f64 = 3.0;
151pub const SPAN_HIGH: Seconds = Seconds::from_const(0.15);
153pub const JITTER_LOW: f64 = 3.4;
164pub const STRIKE_ATTACK: Seconds = Seconds::from_const(0.03);
168pub const DROP_BAND_GATE: Decibels = Decibels::from_const(18.0);
177pub const ATTACK_HIGH: Seconds = Seconds::from_const(0.012);
183pub const DROP_RESIDUAL_HIGH: f64 = 0.05;
185pub const CYCLES_HIGH: f64 = 4.0;
191pub const VIBRATO_LOW_PITCH: Hz = Hz::from_const(100.0);
198pub const KURTOSIS_LOW: f64 = 1.25;
202pub const NOTE_FRAMES_LOW: f64 = 0.5;
208pub const NOTE_FRAME_MATCH: Cents = Cents::from_const(100.0);
210
211#[derive(Clone, Copy, Debug)]
213pub struct PitchInput<'a> {
214 pub mono: &'a [f64],
216 pub rate: SampleRate,
218 pub track: &'a PitchTrack,
220 pub notes: &'a NoteList,
222 pub voicing: Voicing,
224}
225
226#[derive(Clone, Debug, PartialEq)]
228pub struct Rated<T> {
229 pub value: T,
231 pub confidence: Confidence<NoSpread>,
233}
234
235#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize)]
237#[expect(clippy::struct_excessive_bools, reason = "independent report flags")]
238pub struct PitchFlags {
239 pub chord_skipped: bool,
241 pub glide_too_short: bool,
244 pub weak_fundamental: bool,
248 pub no_notes: bool,
250}
251
252#[derive(Clone, Debug, PartialEq)]
254pub struct PitchLayer {
255 pub glides: Vec<Rated<GlideFit>>,
257 pub drops: Vec<Rated<DropFit>>,
259 pub vibrato: Vec<Rated<VibratoFit>>,
261 pub flags: PitchFlags,
263 pub confidence: Confidence<NoSpread>,
266 pub counts: PitchCounts,
268}
269
270#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
272pub struct PitchCounts {
273 pub too_short: usize,
275 pub drop_candidates: usize,
277 pub drop_skips: DropSkips,
279 pub vibrato_candidates: usize,
281 pub voiced_frames: usize,
283}
284
285#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
288pub struct DropSkips {
289 pub no_onset: usize,
291 pub settling: usize,
295 pub quiet: usize,
299 pub no_strike: usize,
301 pub weak_fundamental: usize,
303 pub no_fall: usize,
306 pub tau_bound: usize,
308 pub residual: usize,
310 pub off_note: usize,
312}
313
314impl DropSkips {
315 #[must_use]
317 pub fn items(&self) -> Vec<String> {
318 [
319 (self.no_onset, "no onset"),
320 (self.settling, "settling"),
321 (self.quiet, "quiet"),
322 (self.no_strike, "no strike"),
323 (self.weak_fundamental, "weak fundamental"),
324 (self.no_fall, "no fall"),
325 (self.tau_bound, "tau on bound"),
326 (self.residual, "residual"),
327 (self.off_note, "off note"),
328 ]
329 .into_iter()
330 .filter(|(n, _)| *n > 0)
331 .map(|(n, gate)| format!("{n} {gate}"))
332 .collect()
333 }
334}
335
336impl PitchLayer {
337 pub fn of(input: &PitchInput<'_>) -> Result<Self, PitchError> {
344 let voiced_frames = input
345 .track
346 .frames()
347 .iter()
348 .filter(|f| f.f0.is_some())
349 .count();
350 let counts = PitchCounts {
351 voiced_frames,
352 ..PitchCounts::default()
353 };
354 let skip = |flags: PitchFlags, reason: Reason| -> Result<Self, PitchError> {
355 Ok(Self {
356 glides: Vec::new(),
357 drops: Vec::new(),
358 vibrato: Vec::new(),
359 flags,
360 confidence: Confidence::no_estimate(vec![reason])?,
361 counts,
362 })
363 };
364 if input.notes.is_empty() {
365 let flags = PitchFlags {
366 no_notes: true,
367 ..PitchFlags::default()
368 };
369 return skip(
370 flags,
371 Reason::new(ReasonCode::NoNoteList, Signal::Flag(true))?,
372 );
373 }
374 if input.voicing == Voicing::Polyphonic {
375 let flags = PitchFlags {
376 chord_skipped: true,
377 ..PitchFlags::default()
378 };
379 return skip(
380 flags,
381 Reason::new(ReasonCode::PolyphonicLine, Signal::Flag(true))?,
382 );
383 }
384 let note_frames = note_frames(input.track, input.notes);
385 let mut flags = PitchFlags::default();
386 let mut counts = counts;
387 let loudest = loudest_frame(input.mono, input.rate);
388 let struck = strikes(input, loudest, &mut flags, &mut counts)?;
389 let drops: Vec<DropFit> = struck.iter().map(|s| s.fit).collect();
390 let read = glides(input, loudest, &drops);
391 counts.too_short = read.too_short;
392 flags.glide_too_short = read.too_short > 0;
393 let vibrato = vibratos(input.track, &read.glides, &drops, &mut counts);
394 Ok(Self {
395 glides: read
396 .glides
397 .into_iter()
398 .map(rate_glide)
399 .collect::<Result<_, _>>()?,
400 drops: struck
401 .into_iter()
402 .map(rate_drop)
403 .collect::<Result<_, _>>()?,
404 vibrato: vibrato
405 .into_iter()
406 .map(rate_vibrato)
407 .collect::<Result<_, _>>()?,
408 flags,
409 confidence: Confidence::estimate(
410 if note_frames < NOTE_FRAMES_LOW {
411 Level::Low
412 } else {
413 Level::Medium
414 },
415 None,
416 vec![
417 Reason::new(
418 ReasonCode::VoicedFrames,
419 Signal::Count(count(voiced_frames)),
420 )?,
421 Reason::new(ReasonCode::NoteFrames, Signal::Number(note_frames))?,
422 ],
423 )?,
424 counts,
425 })
426 }
427
428 #[must_use]
430 pub fn summary(&self) -> Vec<String> {
431 if self.flags.no_notes {
432 return vec!["pitch: no notes, so no glides, drops or vibrato".to_owned()];
433 }
434 if self.flags.chord_skipped {
435 return vec!["pitch: the line plays chords, so no glides, drops or vibrato".to_owned()];
436 }
437 let glides = self
438 .glides
439 .iter()
440 .map(|g| {
441 format!(
442 "{:+.1} st in {:.0} ms at {:.2} s, curve {:.2}{}",
443 g.value.interval.get(),
444 1000.0 * g.value.time.get(),
445 g.value.start.get(),
446 g.value.curve,
447 if g.value.from_silence {
448 ", from silence"
449 } else {
450 ""
451 }
452 )
453 })
454 .collect::<Vec<_>>();
455 let drops = self
456 .drops
457 .iter()
458 .map(|d| {
459 format!(
460 "{:.1} st, tau {:.1} ms, to {:.1} Hz at {:.2} s",
461 d.value.df().map_or(0.0, Semitones::get),
462 1000.0 * d.value.tau.get(),
463 d.value.f_end.get(),
464 d.value.onset.get()
465 )
466 })
467 .collect::<Vec<_>>();
468 let vibrato = self
469 .vibrato
470 .iter()
471 .map(|v| {
472 format!(
473 "{:.2} Hz, {:.0} ct at {:.2} s",
474 v.value.rate.get(),
475 v.value.extent().get(),
476 v.value.start.get()
477 )
478 })
479 .collect::<Vec<_>>();
480 let list = |items: &[String]| {
481 if items.is_empty() {
482 String::new()
483 } else {
484 format!(": {}", items.join("; "))
485 }
486 };
487 vec![
488 format!(
489 "glides: {}, {} too short{}",
490 self.glides.len(),
491 self.counts.too_short,
492 list(&glides)
493 ),
494 format!(
495 "pitch drops: {} of {} low strikes{}",
496 self.drops.len(),
497 self.counts.drop_candidates,
498 list(&drops)
499 ),
500 format!(
501 "drop skips: {}",
502 match self.counts.drop_skips.items() {
503 items if items.is_empty() => "none".to_owned(),
504 items => items.join(", "),
505 }
506 ),
507 format!(
508 "vibrato: {} of {} steady spans{}",
509 self.vibrato.len(),
510 self.counts.vibrato_candidates,
511 list(&vibrato)
512 ),
513 ]
514 }
515}
516
517fn glides(input: &PitchInput<'_>, loudest: f64, drops: &[DropFit]) -> GlideRead {
521 let near_drop = |time: Seconds| {
522 drops
523 .iter()
524 .any(|d| (d.onset.get() - time.get()).abs() <= DROP_GLIDE_DISTANCE.get())
525 };
526 let starts = |from: Seconds, to: Seconds, spans: [(f64, f64); 2], rise: f64| {
527 scan(from.get(), to.get(), ONSET_STEP).any(|at| {
528 rise_ratio(
529 input.mono,
530 input.rate,
531 Seconds::clamped(0.0),
532 Seconds::clamped(at),
533 spans,
534 ) >= rise
535 })
536 };
537 let rms = |at: f64, span: (f64, f64)| {
538 span_rms(
539 input.mono,
540 input.rate,
541 Seconds::clamped(0.0),
542 Seconds::clamped(at),
543 span,
544 )
545 };
546 let floor = loudest * gain_below(ACTIVE_GATE_DB);
547 let dips = |from: Seconds, to: Seconds| {
548 let window = (-0.5 * DIP_WINDOW, 0.5 * DIP_WINDOW);
549 let side = rms(from.get(), (-DIP_SIDE, 0.0)).max(rms(to.get(), (0.0, DIP_SIDE)));
550 let low = scan(from.get(), to.get(), ONSET_STEP)
551 .map(|at| rms(at, window))
552 .fold(rms(f64::midpoint(from.get(), to.get()), window), f64::min);
553 low < side * gain_below(GAP_DIP_DB) || low < floor
554 };
555 find_glides(
556 input.track,
557 input.notes,
558 |from, to| starts(from, to, STRIKE_SPANS, STRIKE_RISE) || dips(from, to),
559 |from, to| starts(from, to, RUN_START_SPANS, RUN_START_RISE) && !near_drop(to),
560 )
561}
562
563fn scan(from: f64, to: f64, step: f64) -> impl Iterator<Item = f64> {
566 std::iter::successors(Some(from), move |at| Some(at + step)).take_while(move |&at| at <= to)
567}
568
569fn gain_below(gate: Decibels) -> f64 {
571 Decibels::clamped(-gate.get())
572 .to_amplitude_gain()
573 .unwrap_or(0.0)
574}
575
576fn note_frames(track: &PitchTrack, notes: &NoteList) -> f64 {
579 let (mut inside, mut matched) = (0_usize, 0_usize);
580 for frame in track.frames() {
581 let sounding: Vec<f64> = notes
582 .notes()
583 .iter()
584 .filter(|note| note.sounds_at(frame.time))
585 .map(|note| note.pitch().get())
586 .collect();
587 if sounding.is_empty() {
588 continue;
589 }
590 inside += 1;
591 let Some(cents) = frame
592 .f0
593 .and_then(|f0| MidiPitch::try_from(f0).ok())
594 .map(|pitch| 100.0 * pitch.get())
595 else {
596 continue;
597 };
598 if sounding
599 .iter()
600 .any(|pitch| (100.0 * pitch - cents).abs() <= NOTE_FRAME_MATCH.get())
601 {
602 matched += 1;
603 }
604 }
605 if inside == 0 {
606 0.0
607 } else {
608 cast::usize_to_f64(matched) / cast::usize_to_f64(inside)
609 }
610}
611
612fn count(n: usize) -> u64 {
613 u64::try_from(n).unwrap_or(u64::MAX)
614}
615
616fn strikes(
622 input: &PitchInput<'_>,
623 loudest: f64,
624 flags: &mut PitchFlags,
625 counts: &mut PitchCounts,
626) -> Result<Vec<Strike>, PitchError> {
627 let rate = input.rate.hz().get();
628 let mut drops: Vec<Strike> = Vec::new();
629 let mut previous_to = 0.0_f64;
632 let floor = loudest * gain_below(ACTIVE_GATE_DB);
633 let band = band_pass_zero_phase(input.mono, input.rate, LINE_LO, LINE_HI)?;
634 let band_floor = loudest_frame(&band, input.rate) * gain_below(DROP_BAND_GATE);
635 let origin = Seconds::clamped(0.0);
636 let skips = &mut counts.drop_skips;
637 for note in input.notes.notes() {
638 let (note_on, note_off) = (note.onset().get(), note.offset().get());
639 let settled = Hz::from(note.pitch());
640 let border = previous_to;
641 let search_from = border.max(note_on - STRIKE_LOOKBACK.get()).max(0.0);
642 let search_to = note_on + STRIKE_LOOKAHEAD.get();
643 previous_to = previous_to.max(search_to);
644 if !(LINE_LO..=LINE_HI).contains(&settled) || search_from >= search_to {
645 continue;
646 }
647 let first = cast::floor_to_usize((search_from - LINE_LEAD).max(0.0) * rate).unwrap_or(0);
648 let last = cast::ceil_to_usize((search_to + DROP_FIT_END.get() + LINE_TAIL) * rate)
649 .unwrap_or(usize::MAX)
650 .min(input.mono.len());
651 if first >= last {
652 continue;
653 }
654 let segment = &input.mono[first..last];
655 let start = Seconds::clamped(cast::usize_to_f64(first) / rate);
656 let line = SubBandLine::of(segment, input.rate, start)?;
657 let Some(onset) = band_onset(
658 &line,
659 Seconds::clamped(search_from),
660 Seconds::clamped(search_to),
661 ) else {
662 skips.no_onset += 1;
663 continue;
664 };
665 if search_from <= border
666 && drops.last().is_some_and(|last| onset < last.fit.settled())
667 && rises_into(
668 &line,
669 Seconds::clamped(search_from),
670 Seconds::clamped(search_to),
671 settled,
672 )
673 {
674 skips.settling += 1;
675 continue;
676 }
677 let rise = rise_ratio(segment, input.rate, start, onset, STRIKE_SPANS);
678 if span_rms(segment, input.rate, start, onset, STRIKE_SPANS[1]) < floor
679 || span_rms(&band, input.rate, origin, onset, STRIKE_SPANS[1]) < band_floor
680 {
681 skips.quiet += 1;
682 continue;
683 }
684 let attack = band_attack(
685 &line,
686 Seconds::clamped(search_from),
687 Seconds::clamped(search_to),
688 )
689 .unwrap_or(Seconds::clamped(0.0));
690 counts.drop_candidates += 1;
691 let span_end = Seconds::clamped(note_off.max(onset.get()));
692 let (fit, fit_end) = fit_strike(
693 input,
694 (segment, start),
695 line,
696 (onset, span_end),
697 rise,
698 settled,
699 )?;
700 let Some(fit) = fit else {
701 skips.no_fall += 1;
702 continue;
703 };
704 if !struck_enough(&fit, rise, attack) {
705 skips.no_strike += 1;
706 continue;
707 }
708 let window = (
709 Seconds::clamped(
710 (onset.get().max(note_on) + WEAK_FROM.get()).max(onset.get() + in_band(&fit)),
711 ),
712 Seconds::clamped((onset.get() + DROP_FIT_END.get()).min(note_off)),
713 );
714 if weak_fundamental(segment, input.rate, start, window, settled)? {
715 flags.weak_fundamental = true;
716 skips.weak_fundamental += 1;
717 continue;
718 }
719 match fit.miss() {
720 Some(DropMiss::Size) => skips.no_fall += 1,
721 Some(DropMiss::TauBound) => skips.tau_bound += 1,
722 Some(DropMiss::Residual) => skips.residual += 1,
723 None if !settles_on_a_note(input.notes, &fit, fit_end) => skips.off_note += 1,
724 None => drops.push(Strike { fit, attack }),
725 }
726 }
727 Ok(drops)
728}
729
730fn fit_strike(
743 input: &PitchInput<'_>,
744 (segment, start): (&[f64], Seconds),
745 line: SubBandLine,
746 (onset, span_end): (Seconds, Seconds),
747 rise: f64,
748 pitch: Hz,
749) -> Result<(Option<DropFit>, Seconds), PitchError> {
750 let mut fit = fit_drop(&line, onset, span_end);
751 let (lo, wide) = if fit.is_some_and(|fit| fit.tau < FAST_TAU) || rise >= RUN_START_RISE {
752 let low = SubBandLine::with_low_edge(segment, input.rate, start, LINE_LO_FAST)?;
753 fit = fit_drop(&low, onset, span_end);
754 (LINE_LO_FAST, low)
755 } else {
756 (LINE_LO, line)
757 };
758 let note_hi = Hz::clamped(NOTE_LINE_EDGE * pitch.get());
759 if !fit
760 .is_some_and(|fit| fit.miss().is_some() || !settles_on_a_note(input.notes, &fit, span_end))
761 || rise < STRIKE_RISE
762 || note_hi >= LINE_HI
763 {
764 return Ok((fit, span_end));
765 }
766 let note_line = SubBandLine::with_edges(segment, input.rate, start, lo, note_hi)?;
767 let end = break_in(¬e_line, onset, span_end, pitch);
768 Ok(fit_drop(¬e_line, onset, end)
769 .filter(|note_fit| {
770 note_fit.miss().is_none()
771 && note_fit.tau >= FAST_TAU
772 && line_loss(¬e_line, &wide, note_fit, end)
773 .is_some_and(|loss| loss <= NOTE_LINE_LOSS)
774 })
775 .map_or((fit, span_end), |note_fit| (Some(note_fit), end)))
776}
777
778fn struck_enough(fit: &DropFit, rise: f64, attack: Seconds) -> bool {
791 if fit.tau < FAST_TAU {
792 return rise >= STRIKE_RISE && attack <= STRIKE_ATTACK;
793 }
794 rise >= STRIKE_RISE || (fit.f_end.get() + fit.df_hz < LINE_HI.get() && attack <= STRIKE_ATTACK)
795}
796
797fn settles_on_a_note(notes: &NoteList, fit: &DropFit, span_end: Seconds) -> bool {
805 let to = (fit.onset.get() + DROP_FIT_END.get()).min(span_end.get());
806 let from = fit.settled().get().min(to);
807 notes.notes().iter().any(|note| {
808 note.onset().get() <= to
809 && note.onset().get() >= fit.onset.get() - DROP_NOTE_LEAD.get()
810 && note.offset().get() >= from
811 && (100.0 * note.pitch().get()
812 - 100.0 * MidiPitch::try_from(fit.f_end).map_or(f64::NAN, MidiPitch::get))
813 .abs()
814 <= DROP_NOTE_MATCH.get()
815 })
816}
817
818fn break_in(line: &SubBandLine, onset: Seconds, end: Seconds, pitch: Hz) -> Seconds {
825 let from = line.index_at(Seconds::clamped(onset.get() + DROP_FIT_START.get()));
826 let to = line.index_at(end).min(line.hz().len());
827 (from..to).find(|&n| line.hz()[n] <= 0.0).map_or(end, |n| {
828 Seconds::clamped(line.time_of(n).get() - 1.0 / pitch.get())
829 })
830}
831
832fn line_loss(
836 note_line: &SubBandLine,
837 wide: &SubBandLine,
838 fit: &DropFit,
839 end: Seconds,
840) -> Option<Decibels> {
841 let from = fit.onset.get() + DROP_FIT_START.get();
842 let to = fit.settled().get().min(end.get()).max(from);
843 Decibels::new(20.0 * (envelope_rms(wide, from, to) / envelope_rms(note_line, from, to)).log10())
844 .ok()
845}
846
847fn envelope_rms(line: &SubBandLine, from: f64, to: f64) -> f64 {
849 let envelope = line.envelope();
850 let first = line.index_at(Seconds::clamped(from));
851 let last = line.index_at(Seconds::clamped(to)).min(envelope.len());
852 if first >= last {
853 return 0.0;
854 }
855 (envelope[first..last].iter().map(|e| e * e).sum::<f64>() / cast::usize_to_f64(last - first))
856 .sqrt()
857}
858
859fn rise_ratio(
863 samples: &[f64],
864 rate: SampleRate,
865 start: Seconds,
866 at: Seconds,
867 [before, after]: [(f64, f64); 2],
868) -> f64 {
869 let (a, b) = (
870 span_rms(samples, rate, start, at, after),
871 span_rms(samples, rate, start, at, before),
872 );
873 if b > 0.0 {
874 a / b
875 } else if a > 0.0 {
876 f64::INFINITY
877 } else {
878 0.0
879 }
880}
881
882fn span_rms(
885 samples: &[f64],
886 rate: SampleRate,
887 start: Seconds,
888 at: Seconds,
889 (from, to): (f64, f64),
890) -> f64 {
891 let r = rate.hz().get();
892 let index = |t: f64| {
893 cast::round_to_usize(((at.get() + t - start.get()) * r).max(0.0))
894 .unwrap_or(0)
895 .min(samples.len())
896 };
897 let (a, b) = (index(from), index(to));
898 if a >= b {
899 return 0.0;
900 }
901 (samples[a..b].iter().map(|x| x * x).sum::<f64>() / cast::usize_to_f64(b - a)).sqrt()
902}
903
904fn loudest_frame(mono: &[f64], rate: SampleRate) -> f64 {
907 let frame = cast::round_to_usize(QUIET_FRAME.get() * rate.hz().get())
908 .unwrap_or(4)
909 .max(4);
910 frame_rms(mono, StftParams::fixed(frame))
911 .into_iter()
912 .fold(0.0, f64::max)
913}
914
915fn in_band(fit: &DropFit) -> f64 {
919 let edge = fit.f_end.get() * (2f64.powf(0.25) - 1.0);
920 if fit.df_hz > edge {
921 fit.tau.get() * (fit.df_hz / edge).ln()
922 } else {
923 0.0
924 }
925}
926
927fn weak_fundamental(
940 segment: &[f64],
941 rate: SampleRate,
942 start: Seconds,
943 window: (Seconds, Seconds),
944 pitch: Hz,
945) -> Result<bool, PitchError> {
946 let r = rate.hz().get();
947 let index = |t: f64| cast::round_to_usize(((t - start.get()) * r).max(0.0)).unwrap_or(0);
948 let from = index(window.0.get());
949 let to = index(window.1.get()).min(segment.len());
950 let nyquist = rate.nyquist().get();
951 if from >= to || 2.0 * pitch.get() >= 0.99 * nyquist {
952 return Ok(false);
953 }
954 let band = |centre: f64| -> Result<f64, PitchError> {
955 let (lo, hi) = (
956 centre * 2f64.powf(-0.25),
957 (centre * 2f64.powf(0.25)).min(0.99 * nyquist),
958 );
959 let mut values =
960 band_magnitude(segment, rate, Hz::clamped(lo), Hz::clamped(hi))?[from..to].to_vec();
961 values.sort_by(f64::total_cmp);
962 Ok(values[values.len() / 2])
963 };
964 Ok(band(2.0 * pitch.get())? > WEAK_RATIO * band(pitch.get())?)
965}
966
967fn vibratos(
971 track: &PitchTrack,
972 glides: &[GlideFit],
973 drops: &[DropFit],
974 counts: &mut PitchCounts,
975) -> Vec<VibratoFit> {
976 let mut found = Vec::new();
977 for span in steady_spans(track, glides) {
978 let (Some(&(first, _)), Some(&(last, _))) = (span.first(), span.last()) else {
979 continue;
980 };
981 let mut pitches: Vec<f64> = span.iter().map(|(_, f0)| f0.get()).collect();
982 pitches.sort_by(f64::total_cmp);
983 let lag = track_lag(Hz::clamped(pitches[pitches.len() / 2])).get();
984 let mut parts = vec![(first.get() + RUN_TRIM.get(), last.get() - RUN_TRIM.get())];
985 for drop in drops {
986 let strike = drop.onset.get() + lag - RUN_TRIM.get();
987 let settled = drop.settled().get().max(drop.onset.get() + SETTLE_MIN) + lag;
988 parts = parts
989 .into_iter()
990 .flat_map(|(from, to)| {
991 if strike > to || settled <= from {
992 vec![(from, to)]
993 } else {
994 vec![(from, strike), (settled, to)]
995 }
996 })
997 .filter(|(from, to)| from < to)
998 .collect();
999 }
1000 for (from, to) in parts {
1001 let frames: Vec<(Seconds, Hz)> = span
1002 .iter()
1003 .copied()
1004 .filter(|(time, _)| (from..=to).contains(&time.get()))
1005 .collect();
1006 let Some(fit) = fit_vibrato(&frames) else {
1007 continue;
1008 };
1009 counts.vibrato_candidates += 1;
1010 if fit.accepted() {
1011 found.push(VibratoFit {
1012 start: Seconds::clamped(fit.start.get() - lag),
1013 end: Seconds::clamped(fit.end.get() - lag),
1014 ..fit
1015 });
1016 }
1017 }
1018 }
1019 found
1020}
1021
1022fn rate_glide(glide: GlideFit) -> Result<Rated<GlideFit>, ConfidenceError> {
1023 let (residual, jitter) = (glide.residual.get(), glide.jitter.get());
1024 let level = if jitter > JITTER_LOW || glide.on_bound || glide.detected < SPAN_MIN {
1027 Level::Low
1028 } else if residual <= RESIDUAL_HIGH
1029 && jitter <= JITTER_HIGH
1030 && glide.detected >= SPAN_HIGH
1031 && glide.curve <= CURVE_HIGH
1032 {
1033 Level::High
1034 } else {
1035 Level::Medium
1036 };
1037 Ok(Rated {
1038 confidence: Confidence::estimate(
1039 level,
1040 None,
1041 vec![
1042 Reason::new(ReasonCode::MotionSpan, Signal::Number(glide.detected.get()))?,
1043 Reason::new(ReasonCode::FitResidual, Signal::Number(residual))?,
1044 Reason::new(ReasonCode::PitchJitter, Signal::Number(jitter))?,
1045 Reason::new(ReasonCode::CurveBound, Signal::Flag(glide.on_bound))?,
1046 ],
1047 )?,
1048 value: glide,
1049 })
1050}
1051
1052#[derive(Clone, Copy, Debug)]
1054struct Strike {
1055 fit: DropFit,
1056 attack: Seconds,
1057}
1058
1059fn rate_drop(strike: Strike) -> Result<Rated<DropFit>, ConfidenceError> {
1060 let Strike { fit, attack } = strike;
1061 let level = if attack > STRIKE_ATTACK {
1062 Level::Low
1063 } else if fit.residual < DROP_RESIDUAL_HIGH && fit.tau >= FAST_TAU && attack <= ATTACK_HIGH {
1064 Level::High
1065 } else {
1066 Level::Medium
1067 };
1068 Ok(Rated {
1069 confidence: Confidence::estimate(
1070 level,
1071 None,
1072 vec![
1073 Reason::new(ReasonCode::FitResidual, Signal::Number(fit.residual))?,
1074 Reason::new(ReasonCode::AttackTime, Signal::Number(attack.get()))?,
1075 ],
1076 )?,
1077 value: fit,
1078 })
1079}
1080
1081fn rate_vibrato(vibrato: VibratoFit) -> Result<Rated<VibratoFit>, ConfidenceError> {
1082 let cycles = vibrato.cycles();
1083 let level = if vibrato.kurtosis < KURTOSIS_LOW || vibrato.pitch < VIBRATO_LOW_PITCH {
1084 Level::Low
1085 } else if cycles >= CYCLES_HIGH {
1086 Level::High
1087 } else {
1088 Level::Medium
1089 };
1090 Ok(Rated {
1091 confidence: Confidence::estimate(
1092 level,
1093 None,
1094 vec![
1095 Reason::new(ReasonCode::SineShare, Signal::Number(vibrato.share))?,
1096 Reason::new(ReasonCode::CyclesInSpan, Signal::Number(cycles))?,
1097 Reason::new(
1098 ReasonCode::ContourKurtosis,
1099 Signal::Number(vibrato.kurtosis),
1100 )?,
1101 ],
1102 )?,
1103 value: vibrato,
1104 })
1105}
1106
1107#[derive(Debug)]
1109pub enum PitchError {
1110 Filter(FilterError),
1113 Confidence(ConfidenceError),
1115}
1116
1117impl std::fmt::Display for PitchError {
1118 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1119 match self {
1120 Self::Filter(error) => write!(f, "cannot read the pitch layer: {error}"),
1121 Self::Confidence(error) => write!(f, "cannot rate the pitch layer: {error}"),
1122 }
1123 }
1124}
1125
1126impl std::error::Error for PitchError {
1127 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1128 match self {
1129 Self::Filter(error) => Some(error),
1130 Self::Confidence(error) => Some(error),
1131 }
1132 }
1133}
1134
1135impl From<FilterError> for PitchError {
1136 fn from(error: FilterError) -> Self {
1137 Self::Filter(error)
1138 }
1139}
1140
1141impl From<ConfidenceError> for PitchError {
1142 fn from(error: ConfidenceError) -> Self {
1143 Self::Confidence(error)
1144 }
1145}
1146
1147#[cfg(test)]
1150fn num(x: f64) -> String {
1151 let text = format!("{x:.2}");
1152 text.trim_end_matches('0').trim_end_matches('.').to_owned()
1153}
1154
1155#[cfg(test)]
1157fn says(text: &str, phrases: &[String]) {
1158 for phrase in phrases {
1159 assert!(
1160 text.contains(phrase.as_str()),
1161 "missing {phrase:?} in {text:?}"
1162 );
1163 }
1164}
1165
1166#[cfg(test)]
1168fn ratio_db(ratio: f64) -> String {
1169 format!("{:.0} dB", 20.0 * ratio.log10())
1170}
1171
1172#[cfg(test)]
1173mod tests {
1174 use super::*;
1175 use crate::notes::Note;
1176
1177 #[test]
1178 fn a_second_harmonic_above_nyquist_leaves_the_fundamental_alone() {
1179 let weak = |rate: u32, upper: f64| {
1186 let r = f64::from(rate);
1187 let segment: Vec<f64> = (0..rate)
1188 .map(|n| {
1189 let t = f64::from(n) / r;
1190 (std::f64::consts::TAU * 190.0 * t).sin()
1191 + 4.0 * (std::f64::consts::TAU * upper * t).sin()
1192 })
1193 .collect();
1194 let window = (Seconds::clamped(0.1), Seconds::clamped(0.9));
1195 let rate = SampleRate::new(rate).unwrap();
1196 weak_fundamental(
1197 &segment,
1198 rate,
1199 Seconds::clamped(0.0),
1200 window,
1201 Hz::clamped(190.0),
1202 )
1203 .unwrap()
1204 };
1205 assert!(!weak(600, 250.0));
1206 assert!(!weak(646, 319.65));
1207 assert!(!weak(767, 379.6));
1208 assert!(weak(800, 380.0));
1209 }
1210
1211 #[test]
1212 fn a_drop_settles_on_a_note_of_its_own_strike() {
1213 let fit = DropFit {
1214 onset: Seconds::clamped(1.0),
1215 f_end: Hz::clamped(69.3),
1216 df_hz: 100.0,
1217 tau: Seconds::clamped(0.03),
1218 residual: 0.05,
1219 points: 1000,
1220 };
1221 let settles = |on: f64| {
1222 let note = Note::new(
1223 Seconds::clamped(on),
1224 Seconds::clamped(1.6),
1225 MidiPitch::new(37.0).unwrap(),
1226 )
1227 .unwrap();
1228 settles_on_a_note(&NoteList::new(vec![note]), &fit, Seconds::clamped(1.5))
1229 };
1230 assert!(settles(1.05));
1231 assert!(settles(0.86));
1232 assert!(!settles(0.84));
1233 assert!(!settles(0.2));
1234 }
1235
1236 #[test]
1237 fn the_least_jitter_of_a_unison_reads_low() {
1238 let glide = GlideFit {
1239 start: Seconds::clamped(0.5),
1240 interval: Semitones::clamped(7.0),
1241 time: Seconds::clamped(0.2),
1242 curve: 1.0,
1243 from_silence: false,
1244 detected: Seconds::clamped(0.2),
1245 residual: Cents::clamped(5.0),
1246 jitter: Cents::clamped(3.45),
1247 on_bound: false,
1248 };
1249 let level = |jitter: f64| {
1250 rate_glide(GlideFit {
1251 jitter: Cents::clamped(jitter),
1252 ..glide
1253 })
1254 .unwrap()
1255 .confidence
1256 .level()
1257 };
1258 assert_eq!(level(3.45), Some(Level::Low));
1261 assert_eq!(level(3.4), Some(Level::Medium));
1262 assert_eq!(level(JITTER_HIGH), Some(Level::High));
1263 let span = |detected: f64| {
1265 rate_glide(GlideFit {
1266 detected: Seconds::clamped(detected),
1267 jitter: Cents::clamped(1.0),
1268 ..glide
1269 })
1270 .unwrap()
1271 .confidence
1272 .level()
1273 };
1274 assert_eq!(span(0.06), Some(Level::Low));
1275 assert_eq!(span(0.1), Some(Level::Medium));
1276 }
1277}