Skip to main content

ferraus_analysis/
spectrum.rs

1//! Long-term average spectrum (Welch), fractional-octave band levels, per-bin spectra,
2//! spectral descriptors, and tonal balance against a reference spectrum or curve.
3
4use crate::util::{self, pow_db};
5use ferraus_core::AudioBuffer;
6use serde::Serialize;
7
8/// Output resolution of [`spectrum`].
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
10pub enum Resolution {
11    #[serde(rename = "1/3oct")]
12    ThirdOctave,
13    #[serde(rename = "1/6oct")]
14    SixthOctave,
15    #[serde(rename = "bins")]
16    Bins,
17}
18
19impl Resolution {
20    /// `1/3oct`, `1/6oct`, `bins` (also `third`, `sixth`, `1/3`, `1/6`).
21    pub fn parse(s: &str) -> Option<Resolution> {
22        match s.trim().to_ascii_lowercase().as_str() {
23            "1/3oct" | "1/3" | "third" | "1/3-oct" => Some(Resolution::ThirdOctave),
24            "1/6oct" | "1/6" | "sixth" | "1/6-oct" => Some(Resolution::SixthOctave),
25            "bins" | "bin" | "fft" => Some(Resolution::Bins),
26            _ => None,
27        }
28    }
29}
30
31/// Level of one frequency band. `db` is the band's mean-square power in dB relative to a
32/// full-scale sine (a 0 dBFS sine inside the band reads 0 dB). Floored at -150 dB.
33#[derive(Clone, Debug, Serialize, PartialEq)]
34pub struct BandLevel {
35    pub center_hz: f64,
36    pub low_hz: f64,
37    pub high_hz: f64,
38    pub db: f64,
39}
40
41/// A per-bin spectrum: `db[k]` is the level at `k * bin_hz`, scaled so a full-scale sine
42/// centred on a bin reads 0 dB.
43#[derive(Clone, Debug, Serialize)]
44pub struct BinSpectrum {
45    pub bin_hz: f64,
46    pub fft_size: usize,
47    pub db: Vec<f64>,
48}
49
50/// Result of [`spectrum`].
51#[derive(Clone, Debug, Serialize)]
52pub struct SpectrumReport {
53    pub resolution: Resolution,
54    #[serde(skip_serializing_if = "Vec::is_empty")]
55    pub bands: Vec<BandLevel>,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub bins: Option<BinSpectrum>,
58    /// Power-weighted mean frequency.
59    pub centroid_hz: f64,
60    /// Frequency below which 85% of the power lies.
61    pub rolloff_hz: f64,
62    /// Spectral flatness 0..1 (1 = white noise, 0 = pure tone).
63    pub flatness: f64,
64    /// Frequency of the strongest bin.
65    pub peak_hz: f64,
66}
67
68/// Long-term average spectrum: mean-square power per bin (summed bins give the signal's
69/// mean square), averaged over channels.
70#[derive(Clone, Debug)]
71pub struct Ltas {
72    pub bin_hz: f64,
73    pub fft_size: usize,
74    /// Mean-square contribution of each bin (one-sided, already doubled).
75    pub power: Vec<f64>,
76}
77
78/// Compute the long-term average spectrum of all channels (averaged).
79pub fn ltas(buf: &AudioBuffer, n_fft: usize) -> Ltas {
80    let chans: Vec<&[f32]> = buf.data.iter().map(|c| c.as_slice()).collect();
81    let (p, scale) = util::welch_power(&chans, n_fft);
82    Ltas {
83        bin_hz: buf.sample_rate as f64 / n_fft as f64,
84        fft_size: n_fft,
85        power: p.into_iter().map(|v| v * scale).collect(),
86    }
87}
88
89/// FFT size giving roughly 3 Hz resolution (needed for 20 Hz third-octave bands).
90fn band_fft_size(sr: u32) -> usize {
91    util::next_pow2((sr as f64 / 3.0) as usize).clamp(1024, 65536)
92}
93
94/// Fractional-octave band centres (base-2, referenced to 1 kHz) from 20 Hz up to the
95/// Nyquist frequency. `fraction` is 3 for third-octave, 6 for sixth-octave, 1 for octave.
96pub fn band_centers(fraction: u32, sample_rate: u32) -> Vec<(f64, f64, f64)> {
97    let f = fraction.max(1) as f64;
98    let nyq = sample_rate as f64 / 2.0;
99    let kmin = (f * (20.0f64 / 1000.0).log2()).round() as i32;
100    let kmax = (f * (20000.0f64 / 1000.0).log2()).round() as i32;
101    (kmin..=kmax)
102        .map(|k| {
103            let c = 1000.0 * 2f64.powf(k as f64 / f);
104            (c, c * 2f64.powf(-0.5 / f), c * 2f64.powf(0.5 / f))
105        })
106        .filter(|(c, _, _)| *c < nyq)
107        .map(|(c, lo, hi)| (c, lo, hi.min(nyq)))
108        .collect()
109}
110
111/// Nominal (rounded) label for a band centre, e.g. 31.5, 63, 1250.
112pub fn nominal_hz(c: f64) -> f64 {
113    let digits = if c < 100.0 { 2 } else { 3 };
114    let mag = 10f64.powi(c.log10().floor() as i32 - (digits - 1));
115    let r = (c / mag).round() * mag;
116    // conventional names
117    match r as i64 {
118        31 | 32 => 31.5,
119        _ => {
120            if (r - 3150.0).abs() < 60.0 {
121                3150.0
122            } else if (r - 315.0).abs() < 6.0 {
123                315.0
124            } else {
125                r
126            }
127        }
128    }
129}
130
131/// Band levels from an LTAS: each bin's power is shared between bands in proportion to
132/// the overlap of `[f - df/2, f + df/2]` with the band, so narrow low bands still work.
133pub fn bands_from_ltas(l: &Ltas, fraction: u32, sample_rate: u32) -> Vec<BandLevel> {
134    let df = l.bin_hz;
135    band_centers(fraction, sample_rate)
136        .into_iter()
137        .map(|(c, lo, hi)| {
138            let k0 = ((lo / df) - 0.5).floor().max(1.0) as usize;
139            let k1 = (((hi / df) + 0.5).ceil() as usize).min(l.power.len() - 1);
140            let mut ms = 0.0;
141            for k in k0..=k1 {
142                let (a, b) = ((k as f64 - 0.5) * df, (k as f64 + 0.5) * df);
143                let ov = (b.min(hi) - a.max(lo)).max(0.0) / df;
144                ms += l.power[k] * ov;
145            }
146            BandLevel { center_hz: nominal_hz(c), low_hz: lo, high_hz: hi, db: (pow_db(ms) + 3.0103).max(-150.0) }
147        })
148        .collect()
149}
150
151/// Fractional-octave band levels of a buffer (all channels averaged).
152pub fn band_levels(buf: &AudioBuffer, fraction: u32) -> Vec<BandLevel> {
153    let l = ltas(buf, band_fft_size(buf.sample_rate));
154    bands_from_ltas(&l, fraction, buf.sample_rate)
155}
156
157/// Third-octave band levels (20 Hz – 20 kHz, limited by Nyquist).
158pub fn third_octave_levels(buf: &AudioBuffer) -> Vec<BandLevel> {
159    band_levels(buf, 3)
160}
161
162fn descriptors(l: &Ltas) -> (f64, f64, f64, f64) {
163    let p = &l.power;
164    let k0 = ((20.0 / l.bin_hz).ceil() as usize).max(1).min(p.len());
165    let total: f64 = p[k0..].iter().sum();
166    if total <= 1e-30 {
167        return (0.0, 0.0, 0.0, 0.0);
168    }
169    let centroid = p[k0..].iter().enumerate().map(|(i, v)| (i + k0) as f64 * l.bin_hz * v).sum::<f64>() / total;
170    let mut acc = 0.0;
171    let mut rolloff = 0.0;
172    for (i, v) in p[k0..].iter().enumerate() {
173        acc += v;
174        if acc >= 0.85 * total {
175            rolloff = (i + k0) as f64 * l.bin_hz;
176            break;
177        }
178    }
179    let n = (p.len() - k0) as f64;
180    let geo = (p[k0..].iter().map(|v| (v + 1e-30).ln()).sum::<f64>() / n).exp();
181    let flat = (geo / (total / n)).clamp(0.0, 1.0);
182    let (kmax, _) = p.iter().enumerate().skip(k0).fold((0, 0.0), |m, (i, v)| if *v > m.1 { (i, *v) } else { m });
183    (centroid, rolloff, flat, kmax as f64 * l.bin_hz)
184}
185
186/// Long-term average spectrum at the requested resolution, with descriptors.
187pub fn spectrum(buf: &AudioBuffer, resolution: Resolution) -> SpectrumReport {
188    match resolution {
189        Resolution::Bins => {
190            let n_fft = 4096.min(util::next_pow2(buf.frames().max(256)));
191            let l = ltas(buf, n_fft);
192            let (centroid, rolloff, flatness, peak) = descriptors(&l);
193            // mean square per bin → sine-peak scaling (one-sided x Hann ENBW of 1.5 bins)
194            let db = l.power.iter().map(|p| pow_db(p * 3.0).max(-150.0)).collect();
195            SpectrumReport {
196                resolution,
197                bands: Vec::new(),
198                bins: Some(BinSpectrum { bin_hz: l.bin_hz, fft_size: n_fft, db }),
199                centroid_hz: centroid,
200                rolloff_hz: rolloff,
201                flatness,
202                peak_hz: peak,
203            }
204        }
205        _ => {
206            let fraction = if resolution == Resolution::ThirdOctave { 3 } else { 6 };
207            let l = ltas(buf, band_fft_size(buf.sample_rate));
208            let (centroid, rolloff, flatness, peak) = descriptors(&l);
209            SpectrumReport {
210                resolution,
211                bands: bands_from_ltas(&l, fraction, buf.sample_rate),
212                bins: None,
213                centroid_hz: centroid,
214                rolloff_hz: rolloff,
215                flatness,
216                peak_hz: peak,
217            }
218        }
219    }
220}
221
222/// One band of a tonal-balance comparison.
223#[derive(Clone, Debug, Serialize)]
224pub struct BandDelta {
225    pub center_hz: f64,
226    pub level_db: f64,
227    pub reference_db: f64,
228    /// Level minus reference after removing the overall offset (positive = too much).
229    pub delta_db: f64,
230}
231
232/// Tonal balance against a reference: per-band deltas with the overall level difference
233/// removed (the mean delta over 60 Hz – 12 kHz), and the three largest deviations.
234#[derive(Clone, Debug, Serialize)]
235pub struct ToneBalance {
236    /// Mean level difference (input minus reference) that was removed before the deltas.
237    pub offset_db: f64,
238    pub bands: Vec<BandDelta>,
239    pub largest_deviations: Vec<BandDelta>,
240    /// RMS of the deltas over 60 Hz – 12 kHz.
241    pub rms_deviation_db: f64,
242}
243
244/// Compare band levels with reference band levels (matched by centre frequency).
245pub fn tone_balance(levels: &[BandLevel], reference: &[BandLevel]) -> ToneBalance {
246    let curve: Vec<(f64, f64)> = reference.iter().map(|b| (b.center_hz, b.db)).collect();
247    tone_balance_vs_curve(levels, &curve)
248}
249
250/// Compare band levels with a reference curve of `(frequency_hz, db)` points, interpolated
251/// linearly in log frequency (a genre target curve, for instance).
252pub fn tone_balance_vs_curve(levels: &[BandLevel], curve: &[(f64, f64)]) -> ToneBalance {
253    let mut pts: Vec<(f64, f64)> = curve.iter().copied().filter(|p| p.0 > 0.0).collect();
254    pts.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
255    let at = |f: f64| -> f64 {
256        if pts.is_empty() {
257            return 0.0;
258        }
259        if f <= pts[0].0 {
260            return pts[0].1;
261        }
262        for w in pts.windows(2) {
263            if f <= w[1].0 {
264                let t = (f.ln() - w[0].0.ln()) / (w[1].0.ln() - w[0].0.ln());
265                return w[0].1 + t * (w[1].1 - w[0].1);
266            }
267        }
268        pts.last().unwrap().1
269    };
270    let raw: Vec<(f64, f64, f64)> = levels.iter().map(|b| (b.center_hz, b.db, at(b.center_hz))).collect();
271    let mid: Vec<f64> = raw
272        .iter()
273        .filter(|(c, l, r)| *c >= 60.0 && *c <= 12500.0 && *l > -140.0 && *r > -140.0)
274        .map(|(_, l, r)| l - r)
275        .collect();
276    let offset = util::mean(&mid);
277    let bands: Vec<BandDelta> = raw
278        .iter()
279        .map(|(c, l, r)| BandDelta { center_hz: *c, level_db: *l, reference_db: *r, delta_db: l - r - offset })
280        .collect();
281    let in_range: Vec<&BandDelta> = bands.iter().filter(|b| b.center_hz >= 60.0 && b.center_hz <= 12500.0).collect();
282    let rms = if in_range.is_empty() {
283        0.0
284    } else {
285        (in_range.iter().map(|b| b.delta_db.powi(2)).sum::<f64>() / in_range.len() as f64).sqrt()
286    };
287    let mut largest: Vec<BandDelta> = in_range.into_iter().cloned().collect();
288    largest.sort_by(|a, b| b.delta_db.abs().partial_cmp(&a.delta_db.abs()).unwrap_or(std::cmp::Ordering::Equal));
289    largest.truncate(3);
290    ToneBalance { offset_db: offset, bands, largest_deviations: largest, rms_deviation_db: rms }
291}
292
293/// Fraction of the signal power (0..1, all channels) between `lo` and `hi` Hz.
294pub fn band_power_fraction(l: &Ltas, lo: f64, hi: f64) -> f64 {
295    let total: f64 = l.power.iter().skip(1).sum();
296    if total <= 1e-30 {
297        return 0.0;
298    }
299    let k0 = ((lo / l.bin_hz).round() as usize).max(1);
300    let k1 = ((hi / l.bin_hz).round() as usize).min(l.power.len() - 1);
301    if k1 < k0 {
302        return 0.0;
303    }
304    l.power[k0..=k1].iter().sum::<f64>() / total
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use ferraus_core::testing::{add, noise, sine};
311
312    #[test]
313    fn third_octave_sine_lands_in_its_band() {
314        let b = sine(48000, 1000.0, 2.0, 0.5);
315        let bands = third_octave_levels(&b);
316        let k = bands.iter().position(|x| x.center_hz == 1000.0).unwrap();
317        assert!((bands[k].db - 20.0 * 0.5f64.log10()).abs() < 0.3, "{}", bands[k].db);
318        assert!(bands[k - 2].db < bands[k].db - 40.0);
319        assert_eq!(bands[0].center_hz, 20.0);
320        assert!(bands.iter().any(|b| b.center_hz == 31.5));
321        assert!(bands.iter().any(|b| b.center_hz == 3150.0));
322    }
323
324    #[test]
325    fn low_band_tone_is_measured() {
326        let b = sine(44100, 25.0, 3.0, 0.5);
327        let bands = third_octave_levels(&b);
328        let k = bands.iter().position(|x| x.center_hz == 25.0).unwrap();
329        assert!((bands[k].db + 6.02).abs() < 1.0, "{}", bands[k].db);
330    }
331
332    #[test]
333    fn bins_and_descriptors() {
334        let b = sine(48000, 3000.0, 1.0, 1.0);
335        let r = spectrum(&b, Resolution::Bins);
336        let bins = r.bins.unwrap();
337        let k = (3000.0 / bins.bin_hz).round() as usize;
338        assert!(bins.db[k] > -2.0 && bins.db[k] < 0.5, "{}", bins.db[k]);
339        assert!((r.peak_hz - 3000.0).abs() < bins.bin_hz);
340        assert!(r.flatness < 0.01);
341        let n = noise(48000, 1.0, 0.5, 1);
342        let r = spectrum(&n, Resolution::SixthOctave);
343        assert!(r.flatness > 0.5, "{}", r.flatness);
344        assert!(r.centroid_hz > 9000.0);
345        assert!(r.bands.len() > 55);
346    }
347
348    #[test]
349    fn tone_balance_finds_boost() {
350        let a = add(&noise(48000, 2.0, 0.3, 1), &sine(48000, 2000.0, 2.0, 0.3));
351        let r = noise(48000, 2.0, 0.15, 2);
352        let tb = tone_balance(&third_octave_levels(&a), &third_octave_levels(&r));
353        assert_eq!(tb.largest_deviations[0].center_hz, 2000.0);
354        assert!(tb.largest_deviations[0].delta_db > 6.0);
355        assert!(tb.offset_db > 4.0);
356    }
357
358    #[test]
359    fn silence_is_floored() {
360        let b = AudioBuffer::new(48000, 2, 4800);
361        let r = spectrum(&b, Resolution::ThirdOctave);
362        assert!(r.bands.iter().all(|b| b.db == -150.0));
363    }
364}