sound_to_patch/dsp/
peak.rs1use super::StftParams;
4use crate::audio::SampleRate;
5use crate::cast;
6use crate::units::{Decibels, Hz};
7
8#[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 #[must_use]
20 pub fn bin(self) -> usize {
21 self.bin
22 }
23
24 #[must_use]
27 pub fn position(self) -> f64 {
28 cast::usize_to_f64(self.bin) + self.offset
29 }
30
31 #[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 #[must_use]
40 pub fn magnitude(self) -> f64 {
41 self.log_magnitude.exp()
42 }
43
44 #[must_use]
48 pub fn neighbour_ratio(self) -> Option<Decibels> {
49 self.neighbour_ratio
50 }
51}
52
53#[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#[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#[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 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 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}