Skip to main content

harmos_signal/spectrum/
harmonic.rs

1//! Peaks, the fundamental beneath a taper's DC leakage, and the distortion the
2//! rest of the spectrum accounts for.
3
4/// The bins that are local maxima, strongest first.
5///
6/// A bin is a peak when it stands above both neighbours; the first and last
7/// bins qualify on their one neighbour alone. Equal peaks keep bin order, so
8/// the answer is stable.
9///
10/// Indices rather than values, like every reducer in this crate: the caller
11/// reads its own columns at the bins it is handed.
12#[must_use]
13pub fn peaks(magnitude: &[f64]) -> Vec<usize> {
14    let mut found: Vec<usize> = Vec::new();
15
16    if magnitude.len() >= 2 {
17        if magnitude[0] > magnitude[1] {
18            found.push(0);
19        }
20        found.extend(
21            magnitude
22                .windows(3)
23                .enumerate()
24                .filter(|(_, window)| window[1] > window[0] && window[1] > window[2])
25                .map(|(index, _)| index + 1),
26        );
27        let last = magnitude.len() - 1;
28        if magnitude[last] > magnitude[last - 1] {
29            found.push(last);
30        }
31    }
32
33    found.sort_by(|left, right| magnitude[*right].total_cmp(&magnitude[*left]));
34    found
35}
36
37/// The peaks at or above `fraction` of the strongest one, in bin order.
38///
39/// `fraction` is a share of the largest magnitude in the spectrum, so `0.1`
40/// keeps everything within 20 dB of the peak.
41#[must_use]
42pub fn harmonics(magnitude: &[f64], fraction: f64) -> Vec<usize> {
43    let strongest = magnitude.iter().copied().fold(0.0_f64, f64::max);
44    let floor = fraction * strongest;
45
46    let mut found: Vec<usize> = peaks(magnitude)
47        .into_iter()
48        .filter(|bin| magnitude[*bin] >= floor)
49        .collect();
50
51    found.sort_unstable();
52    found
53}
54
55/// The bin of the fundamental: the strongest peak that is not DC.
56///
57/// Peaks, not raw magnitudes, and that is the whole point. A taper leaks DC
58/// into the bins beside it hard enough that the leakage can out-measure the
59/// signal — but leakage decays away from DC and so is never a local maximum,
60/// while the signal is. Anything within half a bin of DC is skipped outright.
61#[must_use]
62pub fn fundamental(frequency: &[f64], magnitude: &[f64]) -> Option<usize> {
63    let guard = step_of(frequency) * 0.5;
64
65    peaks(magnitude)
66        .into_iter()
67        .find(|bin| frequency.get(*bin).is_some_and(|at| *at > guard))
68}
69
70/// Total harmonic distortion as a percentage of the fundamental.
71///
72/// Every bin that is neither DC nor the fundamental counts as distortion:
73/// `100 · sqrt(Σ m²) / m_fundamental`. A spectrum with no fundamental, or one
74/// whose fundamental is zero, has no answer.
75#[must_use]
76pub fn thd(frequency: &[f64], magnitude: &[f64]) -> Option<f64> {
77    let bin = fundamental(frequency, magnitude)?;
78    let strongest = magnitude[bin];
79    if strongest < f64::EPSILON {
80        return None;
81    }
82
83    let tolerance = step_of(frequency) * 0.5;
84    let distortion: f64 = magnitude
85        .iter()
86        .zip(frequency)
87        .filter(|(_, at)| **at > tolerance && (**at - frequency[bin]).abs() > tolerance)
88        .map(|(value, _)| value * value)
89        .sum();
90
91    Some(distortion.sqrt() / strongest * 100.0)
92}
93
94/// The bin step an axis implies, or one hertz when it implies nothing.
95fn step_of(frequency: &[f64]) -> f64 {
96    match frequency {
97        [first, second, ..] => second - first,
98        _ => 1.0,
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    /// A spectrum of `length` empty bins with the named ones filled.
107    fn spectrum(length: usize, filled: &[(usize, f64)]) -> Vec<f64> {
108        let mut magnitude = vec![0.0; length];
109        for (bin, value) in filled {
110            magnitude[*bin] = *value;
111        }
112        magnitude
113    }
114
115    #[test]
116    fn peaks_come_back_strongest_first() {
117        let magnitude = spectrum(101, &[(10, 5.0), (30, 10.0), (50, 7.0), (70, 2.0)]);
118
119        assert_eq!(peaks(&magnitude), vec![30, 50, 10, 70]);
120    }
121
122    #[test]
123    fn a_run_with_no_local_maximum_has_no_peaks() {
124        assert!(peaks(&[1.0, 1.0, 1.0, 1.0]).is_empty());
125        assert!(peaks(&[]).is_empty());
126        assert!(peaks(&[1.0]).is_empty());
127    }
128
129    #[test]
130    fn the_edges_qualify_on_their_one_neighbour() {
131        assert_eq!(peaks(&[5.0, 1.0, 0.0]), vec![0]);
132        assert_eq!(peaks(&[0.0, 1.0, 5.0]), vec![2]);
133    }
134
135    #[test]
136    fn harmonics_keep_what_clears_the_fraction_in_bin_order() {
137        let magnitude = spectrum(201, &[(0, 5.0), (50, 10.0), (150, 3.0)]);
138
139        assert_eq!(harmonics(&magnitude, 0.1), vec![0, 50, 150]);
140        assert_eq!(harmonics(&magnitude, 0.4), vec![0, 50]);
141        assert_eq!(harmonics(&magnitude, 0.9), vec![50]);
142    }
143
144    #[test]
145    fn the_fundamental_is_the_strongest_peak_off_dc() {
146        let frequency = vec![0.0, 50.0, 100.0, 150.0];
147        let magnitude = vec![5.0, 20.0, 10.0, 5.0];
148
149        assert_eq!(fundamental(&frequency, &magnitude), Some(1));
150    }
151
152    #[test]
153    fn dc_leakage_as_strong_as_dc_is_still_not_the_fundamental() {
154        // A Hann taper can leak DC into bin 1 at DC's own apparent amplitude.
155        // Bin 1 is a local maximum there, so only the DC guard rejects it.
156        let frequency: Vec<f64> = (0..5001).map(|bin| f64::from(bin) * 1000.0).collect();
157        let magnitude = spectrum(5001, &[(0, 75.0), (1, 75.0), (50, 5.0)]);
158
159        assert_eq!(fundamental(&frequency, &magnitude), Some(50));
160    }
161
162    #[test]
163    fn dc_leakage_that_merely_decays_is_never_a_peak() {
164        let frequency: Vec<f64> = (0..100).map(|bin| f64::from(bin) * 100.0).collect();
165        let magnitude = spectrum(100, &[(0, 50.0), (1, 30.0), (2, 10.0), (40, 8.0)]);
166
167        assert_eq!(fundamental(&frequency, &magnitude), Some(40));
168    }
169
170    #[test]
171    fn a_pure_tone_distorts_by_nothing() {
172        let frequency: Vec<f64> = (0..100).map(|bin| f64::from(bin) * 10.0).collect();
173        let magnitude = spectrum(100, &[(10, 4.0)]);
174
175        assert!(thd(&frequency, &magnitude).is_some_and(|thd| thd.abs() < 1e-12));
176    }
177
178    #[test]
179    fn one_harmonic_at_half_the_fundamental_is_fifty_percent() {
180        let frequency: Vec<f64> = (0..100).map(|bin| f64::from(bin) * 10.0).collect();
181        let magnitude = spectrum(100, &[(10, 4.0), (20, 2.0)]);
182
183        assert!(thd(&frequency, &magnitude).is_some_and(|thd| (thd - 50.0).abs() < 1e-9));
184    }
185
186    #[test]
187    fn a_spectrum_with_no_peak_has_no_distortion_to_report() {
188        assert!(thd(&[0.0, 10.0, 20.0], &[0.0, 0.0, 0.0]).is_none());
189    }
190}