pitch_estimate/lib.rs
1//! Realtime monophonic pitch detection with the McLeod pitch method (MPM)
2//! and YIN.
3//!
4//! Both detectors take one frame of `f32` or `f64` samples and return the
5//! fundamental frequency with a clarity score in `[0, 1]`, or `None` when the
6//! frame has no pitch that clears the configured gates. Estimation is per
7//! frame and stateless: a detector keeps no history between calls and applies
8//! no smoothing across frames.
9//!
10//! ```
11//! use pitch_estimate::{McLeodDetector, PitchDetector, YinDetector};
12//!
13//! let sample_rate = 44_100;
14//! let frame: Vec<f64> = (0..4096)
15//! .map(|i| (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sample_rate as f64).sin())
16//! .collect();
17//!
18//! let mut mpm = McLeodDetector::<f64>::new(4096, 2048)?;
19//! let pitch = mpm.detect(&frame, sample_rate).expect("a clean sine has a pitch");
20//! assert!((pitch.frequency - 440.0).abs() < 0.5);
21//! assert!(pitch.clarity > 0.99);
22//!
23//! let mut yin = YinDetector::<f64>::new(4096, 2048)?;
24//! let pitch = yin.detect(&frame, sample_rate).expect("a clean sine has a pitch");
25//! assert!((pitch.frequency - 440.0).abs() < 0.5);
26//! # Ok::<(), pitch_estimate::ConfigError>(())
27//! ```
28//!
29//! # Contract
30//!
31//! For every finite frame, `detect` returns either `None` or a `Pitch` whose
32//! `frequency` is finite and whose `clarity` is finite and in `[0, 1]`. That
33//! holds when the frame's own arithmetic overflows inside the FFT: a
34//! non-finite autocorrelation, difference, or CMNDF entry is discarded before
35//! any candidate is chosen. Frames with a non-finite sample, an empty or too
36//! short frame, an all-zero frame, and a constant (DC) frame return `None`
37//! and never panic.
38//!
39//! The detectors allocate their scratch buffers once, in the constructor.
40//! `detect` performs no heap allocation.
41//!
42//! The crate uses the standard library (the FFT layer needs it) and contains
43//! no `unsafe` code.
44//!
45//! # Algorithms
46//!
47//! [`McLeodDetector`] computes the linear autocorrelation `r(tau)` through a
48//! zero-padded real FFT, divides the product by the transform length so the
49//! values are in time-domain units, seeds the two-segment squared-sum term
50//! `m'(tau)` from the time-domain sum of squares, and forms the NSDF
51//! `n(tau) = 2 r(tau) / m'(tau)`, which lies in `[-1, 1]`. Key maxima between
52//! zero crossings are refined by parabolic interpolation. The first one at or
53//! above `k` times the largest is the period, and its height is the clarity.
54//!
55//! [`YinDetector`] computes the difference function `d(tau)` over a fixed
56//! integration window through a cross-correlation FFT, normalizes it to the
57//! CMNDF `d'(tau) = d(tau) * tau / (d(1) + ... + d(tau))` (so `d'(1) = 1`),
58//! takes the first lag at which `d'` drops below the absolute threshold,
59//! descends to the local minimum, refines it parabolically, and reports
60//! `clarity = 1 - d'_min` clamped to `[0, 1]`.
61//!
62//! Neither detector applies a window function. YIN subtracts the frame mean
63//! before its transform because its difference function is DC-invariant. Remove
64//! DC before using MPM. A constant offset pushes the NSDF toward 1 at every lag
65//! and hides the zero crossings MPM needs.
66
67#![forbid(unsafe_code)]
68#![warn(missing_docs)]
69
70use std::fmt;
71
72mod error;
73mod fft;
74mod float;
75mod interp;
76mod mpm;
77mod stats;
78mod yin;
79
80#[cfg(test)]
81mod reference;
82#[cfg(test)]
83mod unit_tests;
84
85pub use error::{ConfigError, MAX_FRAME_LEN};
86pub use float::Float;
87pub use mpm::McLeodDetector;
88pub use yin::YinDetector;
89
90/// Result of a detection pass over one frame.
91#[derive(Debug, Clone, Copy, PartialEq)]
92pub struct Pitch<T> {
93 /// Estimated fundamental, Hz.
94 pub frequency: T,
95 /// Confidence in `[0, 1]`. Never NaN, never outside `[0, 1]`.
96 pub clarity: T,
97}
98
99impl<T: fmt::Display> fmt::Display for Pitch<T> {
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 write!(f, "{} Hz, clarity {}", self.frequency, self.clarity)
102 }
103}
104
105/// A frame-by-frame pitch detector.
106pub trait PitchDetector<T: Float> {
107 /// Detect the pitch of `frame` sampled at `sample_rate` Hz.
108 ///
109 /// Returns `None` when no pitch clears the configured gates (power floor,
110 /// clarity or absolute threshold). Degenerate input returns `None` without
111 /// a panic: an empty frame, a frame shorter than the configured frame
112 /// length, an all-zero frame, a constant (DC) frame, or a frame containing
113 /// a non-finite sample. A frame longer than the
114 /// configured length is analysed over its first `frame_len` samples.
115 ///
116 /// `sample_rate == 0` returns a finite 0.0 Hz pitch, which is in
117 /// contract (finite, clarity in `[0, 1]`) but odd.
118 fn detect(&mut self, frame: &[T], sample_rate: u32) -> Option<Pitch<T>>;
119}