Skip to main content

sound_to_patch/dsp/
peak.rs

1//! Spectral peak picking with parabolic interpolation on the log magnitude.
2
3use super::StftParams;
4use crate::audio::SampleRate;
5use crate::cast;
6use crate::units::{Decibels, Hz};
7
8/// A spectral peak with its interpolated position.
9#[derive(Clone, Copy, Debug, PartialEq)]
10pub struct Peak {
11    bin: usize,
12    offset: f64,
13    log_magnitude: f64,
14    neighbour_ratio: Option<Decibels>,
15}
16
17impl Peak {
18    /// Returns the bin of the local maximum.
19    #[must_use]
20    pub fn bin(self) -> usize {
21        self.bin
22    }
23
24    /// Returns the interpolated position in bins, `k + delta`, with `delta`
25    /// in -0.5 to 0.5.
26    #[must_use]
27    pub fn position(self) -> f64 {
28        cast::usize_to_f64(self.bin) + self.offset
29    }
30
31    /// Returns the interpolated frequency, `(k + delta) fs / fft_len`.
32    #[must_use]
33    pub fn frequency(self, params: StftParams, rate: SampleRate) -> Hz {
34        Hz::clamped(self.position() * params.bin_width(rate).get())
35    }
36
37    /// Returns the interpolated magnitude at the peak, the exponential of
38    /// the parabola's top on `ln |X|`.
39    #[must_use]
40    pub fn magnitude(self) -> f64 {
41        self.log_magnitude.exp()
42    }
43
44    /// Returns how far the peak stands above its strongest neighbouring
45    /// peak within the search span: `20 log10(|X_peak| / |X_neighbour|)`.
46    /// `None` when no other local maximum lies in the span.
47    #[must_use]
48    pub fn neighbour_ratio(self) -> Option<Decibels> {
49        self.neighbour_ratio
50    }
51}
52
53/// Interpolates the local maximum at bin `k` of a magnitude spectrum.
54///
55/// With `a, b, c = ln|X[k-1]|, ln|X[k]|, ln|X[k+1]|` the parabola's top
56/// sits at `delta = 0.5 (a - c) / (a - 2b + c)`. Returns `None` when `k`
57/// has no neighbour on both sides, is not a strict local maximum, or a
58/// magnitude in the three bins is zero. `span` bins on each side are
59/// searched for a neighbouring peak.
60#[must_use]
61pub fn interpolate(magnitudes: &[f64], bin: usize, span: usize) -> Option<Peak> {
62    let left = magnitudes.get(bin.checked_sub(1)?)?;
63    let centre = magnitudes.get(bin)?;
64    let right = magnitudes.get(bin + 1)?;
65    if !(centre > left && centre >= right) || *left <= 0.0 || *right <= 0.0 {
66        return None;
67    }
68    let (a, b, c) = (left.ln(), centre.ln(), right.ln());
69    let curvature = a - 2.0 * b + c;
70    let offset = if curvature < 0.0 {
71        (0.5 * (a - c) / curvature).clamp(-0.5, 0.5)
72    } else {
73        0.0
74    };
75    let log_magnitude = b - 0.25 * (a - c) * offset;
76    let lo = bin.saturating_sub(span);
77    let hi = bin
78        .saturating_add(span)
79        .min(magnitudes.len().saturating_sub(1));
80    let neighbour = (lo..=hi)
81        .filter(|&k| k != bin && is_local_max(magnitudes, k))
82        .map(|k| magnitudes[k])
83        .fold(None, |best: Option<f64>, m| {
84            Some(best.map_or(m, |b| b.max(m)))
85        });
86    let neighbour_ratio = neighbour
87        .filter(|&m| m > 0.0)
88        .and_then(|m| Decibels::from_amplitude_gain(centre / m).ok());
89    Some(Peak {
90        bin,
91        offset,
92        log_magnitude,
93        neighbour_ratio,
94    })
95}
96
97fn is_local_max(magnitudes: &[f64], bin: usize) -> bool {
98    match (
99        bin.checked_sub(1).and_then(|k| magnitudes.get(k)),
100        magnitudes.get(bin),
101        magnitudes.get(bin + 1),
102    ) {
103        (Some(left), Some(centre), Some(right)) => centre > left && centre >= right,
104        _ => false,
105    }
106}
107
108/// Returns the bins of the strict local maxima of a magnitude spectrum,
109/// skipping the first and the last bin.
110#[must_use]
111pub fn local_maxima(magnitudes: &[f64]) -> Vec<usize> {
112    (1..magnitudes.len().saturating_sub(1))
113        .filter(|&bin| is_local_max(magnitudes, bin))
114        .collect()
115}
116
117/// Returns the interpolated peak with the largest magnitude between bins
118/// `lo` and `hi` inclusive, or `None` when the range holds no local
119/// maximum.
120#[must_use]
121pub fn strongest_peak(magnitudes: &[f64], lo: usize, hi: usize, span: usize) -> Option<Peak> {
122    let hi = hi.min(magnitudes.len().saturating_sub(2));
123    (lo.max(1)..=hi)
124        .filter(|&bin| is_local_max(magnitudes, bin))
125        .max_by(|&a, &b| magnitudes[a].total_cmp(&magnitudes[b]))
126        .and_then(|bin| interpolate(magnitudes, bin, span))
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn a_symmetric_peak_has_no_offset() {
135        let peak = interpolate(&[1.0, 4.0, 1.0], 1, 1).unwrap();
136        assert_eq!(peak.position(), 1.0);
137        assert!((peak.magnitude() - 4.0).abs() < 1e-12);
138        assert_eq!(peak.neighbour_ratio(), None);
139    }
140
141    #[test]
142    fn an_unbounded_span_searches_the_whole_spectrum() {
143        let mags = [1.0, 4.0, 1.0, 1.0, 2.0, 1.0];
144        let peak = interpolate(&mags, 1, usize::MAX).unwrap();
145        // The neighbour at bin 4 lies 6 dB below the peak.
146        let ratio = peak.neighbour_ratio().unwrap().get();
147        assert!((ratio - 20.0 * 2f64.log10()).abs() < 1e-12, "{ratio}");
148    }
149
150    #[test]
151    fn a_gaussian_peak_is_located_exactly() {
152        // ln of a Gaussian is a parabola, so the fit is exact.
153        let centre = 10.3;
154        let magnitudes: Vec<f64> = (0..20)
155            .map(|k| (-(f64::from(k) - centre).powi(2) / 8.0).exp())
156            .collect();
157        let peak = interpolate(&magnitudes, 10, 2).unwrap();
158        assert!(
159            (peak.position() - centre).abs() < 1e-12,
160            "{}",
161            peak.position()
162        );
163        assert!((peak.magnitude() - 1.0).abs() < 1e-12);
164    }
165
166    #[test]
167    fn non_maxima_edges_and_zeros_give_no_peak() {
168        assert!(interpolate(&[1.0, 2.0, 3.0], 1, 1).is_none());
169        assert!(interpolate(&[3.0, 2.0], 0, 1).is_none());
170        assert!(interpolate(&[0.0, 2.0, 1.0], 1, 1).is_none());
171    }
172
173    #[test]
174    fn neighbour_ratio_compares_with_the_next_peak_in_the_span() {
175        let magnitudes = [0.1, 1.0, 0.1, 0.05, 0.1, 0.01];
176        let peak = interpolate(&magnitudes, 1, 4).unwrap();
177        assert!((peak.neighbour_ratio().unwrap().get() - 20.0).abs() < 1e-9);
178        assert_eq!(local_maxima(&magnitudes), vec![1, 4]);
179        assert_eq!(strongest_peak(&magnitudes, 2, 5, 4).unwrap().bin(), 4);
180    }
181}