Skip to main content

harmos_signal/spectrum/
indicator.rs

1//! What one band of a spectrum measures: how much energy is in it, where that
2//! energy sits, and how evenly it is spread.
3//!
4//! Every reading here takes a frequency axis and a *linear* magnitude column.
5//! A caller holding decibels converts first with [`Decibel::to_linear`]; a
6//! reading that silently converted would be guessing which reference it was
7//! handed.
8//!
9//! [`Decibel::to_linear`]: super::Decibel::to_linear
10
11use std::ops::Range;
12
13/// The energy of one band, by Parseval: `Σ |X|² · df`.
14///
15/// The band is inclusive at both ends. A band holding no bin has no energy.
16#[must_use]
17pub fn band_energy(frequency: &[f64], magnitude: &[f64], low: f64, high: f64) -> f64 {
18    let band = band(frequency, low, high);
19    if band.is_empty() {
20        return 0.0;
21    }
22
23    let step = step_of(frequency, &band);
24    magnitude[band]
25        .iter()
26        .map(|value| value * value * step)
27        .sum()
28}
29
30/// The RMS amplitude of one band: `sqrt(energy / bandwidth)`.
31///
32/// The effective amplitude every component in the band adds up to, in the same
33/// unit the magnitudes are in.
34#[must_use]
35pub fn band_rms(frequency: &[f64], magnitude: &[f64], low: f64, high: f64) -> f64 {
36    let bandwidth = high - low;
37    if bandwidth <= 0.0 {
38        return 0.0;
39    }
40
41    (band_energy(frequency, magnitude, low, high) / bandwidth).sqrt()
42}
43
44/// The spectral centroid of one band: its centre of energy, in Hz.
45///
46/// `Σ f · |X|² / Σ |X|²` — where the band would balance if energy were weight.
47/// A band with no bins, or no energy in them, has no centroid.
48#[must_use]
49pub fn centroid(frequency: &[f64], magnitude: &[f64], low: f64, high: f64) -> Option<f64> {
50    let band = band(frequency, low, high);
51    if band.is_empty() {
52        return None;
53    }
54
55    let (weighted, total) = frequency[band.clone()].iter().zip(&magnitude[band]).fold(
56        (0.0, 0.0),
57        |(weighted, total), (at, value)| {
58            let power = value * value;
59            (weighted + at * power, total + power)
60        },
61    );
62
63    (total > 0.0).then_some(weighted / total)
64}
65
66/// The peak-to-average power ratio of one band, as a linear ratio.
67///
68/// High when a few components dominate, near one when the band is flat. In
69/// decibels it is `10 · log10` of this.
70#[must_use]
71pub fn papr(frequency: &[f64], magnitude: &[f64], low: f64, high: f64) -> Option<f64> {
72    let band = &magnitude[band(frequency, low, high)];
73    if band.is_empty() {
74        return None;
75    }
76
77    let peak = band.iter().copied().fold(0.0_f64, f64::max);
78    let mean = band.iter().sum::<f64>() / band.len() as f64;
79
80    (mean > 0.0).then(|| (peak * peak) / (mean * mean))
81}
82
83/// The frequency beneath which `fraction` of the band's energy lies, in Hz.
84///
85/// The roll-off point: `0.95` answers where ninety-five percent of the energy
86/// has accumulated, walking upwards from the bottom of the band.
87#[must_use]
88pub fn rolloff(
89    frequency: &[f64],
90    magnitude: &[f64],
91    low: f64,
92    high: f64,
93    fraction: f64,
94) -> Option<f64> {
95    let band = band(frequency, low, high);
96    if band.is_empty() {
97        return None;
98    }
99
100    let step = step_of(frequency, &band);
101    let total: f64 = magnitude[band.clone()]
102        .iter()
103        .map(|value| value * value * step)
104        .sum();
105    if total <= 0.0 {
106        return None;
107    }
108
109    let target = total * fraction.clamp(0.0, 1.0);
110    let mut accumulated = 0.0;
111
112    for (at, value) in frequency[band.clone()].iter().zip(&magnitude[band.clone()]) {
113        accumulated += value * value * step;
114        if accumulated >= target {
115            return Some(*at);
116        }
117    }
118
119    frequency.get(band.end - 1).copied()
120}
121
122/// The narrowest span of the band holding `fraction` of its energy.
123///
124/// Answers `(lower, upper, bandwidth)` in Hz, where the bandwidth includes the
125/// width of the bins at both ends. Unlike [`rolloff`], which always starts at
126/// the bottom, this slides a window: a signal sitting high in the band is
127/// measured where it is.
128#[must_use]
129pub fn occupied_bandwidth(
130    frequency: &[f64],
131    magnitude: &[f64],
132    low: f64,
133    high: f64,
134    fraction: f64,
135) -> Option<(f64, f64, f64)> {
136    let band = band(frequency, low, high);
137    if band.is_empty() {
138        return None;
139    }
140
141    let step = step_of(frequency, &band);
142    let energies: Vec<f64> = magnitude[band.clone()]
143        .iter()
144        .map(|value| value * value * step)
145        .collect();
146    let total: f64 = energies.iter().sum();
147    if total <= 0.0 {
148        return None;
149    }
150
151    let target = total * fraction.clamp(0.0, 1.0);
152    // Prefix sums, so the energy of any window is one subtraction.
153    let mut prefix = vec![0.0; energies.len() + 1];
154    for (index, energy) in energies.iter().enumerate() {
155        prefix[index + 1] = prefix[index] + energy;
156    }
157
158    let (mut lower, mut upper, mut width) = (0, energies.len() - 1, energies.len());
159    let mut left = 0;
160
161    for right in 0..energies.len() {
162        // Shrink from the left while the window still clears the target, so
163        // what is checked below is the narrowest window ending here.
164        while left < right && prefix[right + 1] - prefix[left + 1] >= target {
165            left += 1;
166        }
167
168        if prefix[right + 1] - prefix[left] >= target && right - left + 1 < width {
169            width = right - left + 1;
170            lower = left;
171            upper = right;
172        }
173    }
174
175    let lower = frequency[band.start + lower];
176    let upper = frequency[band.start + upper];
177
178    Some((lower, upper, upper - lower + step))
179}
180
181/// The bins of a sorted frequency axis inside `low..=high`.
182fn band(frequency: &[f64], low: f64, high: f64) -> Range<usize> {
183    let start = frequency.partition_point(|at| *at < low);
184    let end = frequency.partition_point(|at| *at <= high);
185
186    start..end.max(start)
187}
188
189/// The bin width to integrate a band with.
190///
191/// A uniform axis states it outright; a band of one bin borrows a neighbour's
192/// spacing; anything else averages the band's own.
193fn step_of(frequency: &[f64], band: &Range<usize>) -> f64 {
194    match band.len() {
195        0 => 1.0,
196        1 => match (band.start.checked_sub(1), frequency.get(band.end)) {
197            (Some(before), _) => frequency[band.start] - frequency[before],
198            (None, Some(after)) => after - frequency[band.start],
199            (None, None) => 1.0,
200        },
201        length => (frequency[band.end - 1] - frequency[band.start]) / (length - 1) as f64,
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    /// A uniform axis of `count` bins `step` Hz apart, starting at `start`.
210    fn axis(start: f64, step: f64, count: usize) -> Vec<f64> {
211        (0..count).map(|bin| start + bin as f64 * step).collect()
212    }
213
214    #[test]
215    fn one_bin_of_energy_is_its_square_times_the_bin_width() {
216        let frequency = axis(0.0, 10.0, 3);
217
218        assert!((band_energy(&frequency, &[5.0, 0.0, 0.0], 0.0, 5.0) - 250.0).abs() < 1e-9);
219    }
220
221    #[test]
222    fn band_energy_adds_the_bins_it_covers() {
223        let frequency = axis(0.0, 10.0, 3);
224
225        assert!((band_energy(&frequency, &[1.0, 2.0, 3.0], 0.0, 30.0) - 140.0).abs() < 1e-9);
226    }
227
228    #[test]
229    fn a_band_with_no_bins_has_no_energy() {
230        let frequency = axis(100.0, 10.0, 3);
231
232        assert_eq!(band_energy(&frequency, &[1.0, 2.0, 3.0], 0.0, 50.0), 0.0);
233    }
234
235    #[test]
236    fn a_flat_band_rms_is_the_flat_amplitude() {
237        let frequency = axis(0.0, 10.0, 5);
238
239        assert!((band_rms(&frequency, &[5.0; 5], 0.0, 50.0) - 5.0).abs() < 1e-9);
240        assert_eq!(band_rms(&frequency, &[5.0; 5], 50.0, 50.0), 0.0);
241    }
242
243    #[test]
244    fn a_symmetric_band_balances_at_its_middle() {
245        let frequency = vec![50.0, 100.0, 150.0];
246
247        let centre = centroid(&frequency, &[1.0, 2.0, 1.0], 0.0, 200.0);
248        assert!(centre.is_some_and(|centre| (centre - 100.0).abs() < 1e-9));
249    }
250
251    #[test]
252    fn energy_pulls_the_centroid_towards_itself() {
253        let frequency = vec![100.0, 200.0];
254
255        // (100·1 + 200·9) / 10 = 190.
256        let centre = centroid(&frequency, &[1.0, 3.0], 0.0, 300.0);
257        assert!(centre.is_some_and(|centre| (centre - 190.0).abs() < 1e-9));
258    }
259
260    #[test]
261    fn a_band_with_no_energy_has_no_centroid() {
262        assert!(centroid(&[100.0, 200.0], &[0.0, 0.0], 0.0, 300.0).is_none());
263        assert!(centroid(&[100.0], &[1.0], 200.0, 300.0).is_none());
264    }
265
266    #[test]
267    fn a_flat_band_peaks_at_its_own_average() {
268        let frequency = vec![50.0, 100.0, 150.0];
269
270        assert!(
271            papr(&frequency, &[5.0; 3], 0.0, 200.0).is_some_and(|papr| (papr - 1.0).abs() < 1e-9)
272        );
273        assert!(papr(&frequency, &[0.1, 10.0, 0.1], 0.0, 200.0).is_some_and(|papr| papr > 5.0));
274        assert!(papr(&frequency, &[5.0; 3], 300.0, 400.0).is_none());
275    }
276
277    #[test]
278    fn rolloff_walks_up_until_the_share_is_met() {
279        let frequency = axis(10.0, 10.0, 5);
280
281        let half = rolloff(&frequency, &[1.0; 5], 0.0, 60.0, 0.5);
282        assert!(half.is_some_and(|at| (20.0..=30.0).contains(&at)));
283
284        let all = rolloff(&frequency, &[1.0; 5], 0.0, 60.0, 1.0);
285        assert!(all.is_some_and(|at| (at - 50.0).abs() < 1e-9));
286    }
287
288    #[test]
289    fn energy_concentrated_low_rolls_off_immediately() {
290        let frequency = axis(10.0, 10.0, 5);
291
292        let at = rolloff(&frequency, &[10.0, 1.0, 1.0, 1.0, 1.0], 0.0, 60.0, 0.95);
293        assert!(at.is_some_and(|at| (at - 10.0).abs() < 1e-9));
294    }
295
296    #[test]
297    fn occupied_bandwidth_finds_the_peak_rather_than_starting_low() {
298        let frequency = axis(10.0, 10.0, 5);
299
300        let (lower, upper, width) =
301            occupied_bandwidth(&frequency, &[0.1, 0.1, 10.0, 0.1, 0.1], 0.0, 60.0, 0.95)
302                .expect("a band with energy has an occupied width");
303
304        assert!((lower - 30.0).abs() < 1e-9);
305        assert!((upper - 30.0).abs() < 1e-9);
306        assert!((width - 10.0).abs() < 1e-9);
307    }
308
309    #[test]
310    fn spread_energy_occupies_the_whole_band() {
311        let frequency = axis(10.0, 10.0, 5);
312
313        let (_, _, width) = occupied_bandwidth(&frequency, &[1.0; 5], 0.0, 60.0, 0.99)
314            .expect("a band with energy has an occupied width");
315
316        assert!(width >= 40.0);
317    }
318
319    #[test]
320    fn a_silent_band_occupies_nothing() {
321        assert!(occupied_bandwidth(&[10.0, 20.0], &[0.0, 0.0], 0.0, 30.0, 0.99).is_none());
322        assert!(occupied_bandwidth(&[10.0, 20.0], &[1.0, 1.0], 100.0, 200.0, 0.99).is_none());
323    }
324
325    #[test]
326    fn a_single_bin_band_borrows_a_neighbours_spacing() {
327        let frequency = axis(0.0, 10.0, 3);
328
329        // Only bin 1 is inside, and its width is the axis step either way.
330        assert!((band_energy(&frequency, &[1.0, 2.0, 3.0], 6.0, 14.0) - 40.0).abs() < 1e-9);
331        assert!((band_energy(&frequency, &[1.0, 2.0, 3.0], 0.0, 4.0) - 10.0).abs() < 1e-9);
332    }
333}