Skip to main content

math_rir/
bands.rs

1//! Octave- and third-octave-band filtering for ISO 3382 per-band analysis.
2//!
3//! ISO 3382 requires reverberation times and clarity metrics to be reported
4//! per octave (125 Hz … 4 kHz minimum) or third-octave band. This module
5//! provides:
6//!
7//! - Standard ISO/IEC 61260 nominal centre frequencies.
8//! - A zero-phase Butterworth bandpass implementation built on the same
9//!   `math-iir-fir::filtfilt` cascade used elsewhere in the crate, so the
10//!   filtered RIR has no group-delay distortion and the energy in each
11//!   band is directly comparable.
12//! - A convenience entry point that computes [`crate::metrics::Iso3382Metrics`]
13//!   for every requested band in parallel.
14
15use math_audio_iir_fir::filtfilt;
16use rayon::prelude::*;
17
18use crate::metrics::{Iso3382Metrics, analyze_iso3382};
19
20/// ISO 3382-1 reports reverberation across octave bands 125 Hz … 4 kHz
21/// (and recommends 63 Hz and 8 kHz where the RIR supports them). These
22/// are the nominal centre frequencies — the actual base-2 centres
23/// (`1000 · 2^k`) only differ from the nominal values by < 0.6 %.
24pub const ISO_OCTAVE_CENTERS_HZ: [f64; 8] =
25    [63.0, 125.0, 250.0, 500.0, 1000.0, 2000.0, 4000.0, 8000.0];
26
27/// ISO/IEC 61260 third-octave centres covering 100 Hz … 10 kHz.
28pub const ISO_THIRD_OCTAVE_CENTERS_HZ: [f64; 21] = [
29    100.0, 125.0, 160.0, 200.0, 250.0, 315.0, 400.0, 500.0, 630.0, 800.0, 1000.0, 1250.0, 1600.0,
30    2000.0, 2500.0, 3150.0, 4000.0, 5000.0, 6300.0, 8000.0, 10000.0,
31];
32
33/// How many octaves wide a band is.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum BandWidth {
36    /// One-octave bands. `f_low = f_c · 2^(-1/2)`, `f_high = f_c · 2^(+1/2)`.
37    Octave,
38    /// One-third-octave bands. `f_low = f_c · 2^(-1/6)`, `f_high = f_c · 2^(+1/6)`.
39    ThirdOctave,
40}
41
42impl BandWidth {
43    fn bandedges(self, fc: f64) -> (f64, f64) {
44        match self {
45            // Base-2 edges. ISO 3382-1 §5.1 permits either base-2 or
46            // base-10 (G=10^(3/10)) — they differ by < 0.3 % which is
47            // well below filter-skirt error.
48            BandWidth::Octave => (fc * 2f64.powf(-0.5), fc * 2f64.powf(0.5)),
49            BandWidth::ThirdOctave => (fc * 2f64.powf(-1.0 / 6.0), fc * 2f64.powf(1.0 / 6.0)),
50        }
51    }
52}
53
54/// Filter `rir` through a zero-phase Butterworth bandpass centred at `fc`.
55///
56/// `order` is the order of each Butterworth stage (the cascade is
57/// HP(order) ∘ LP(order), then `filtfilt` doubles the effective order
58/// while removing phase). Returns the filtered signal at the same sample
59/// rate. Empty input → empty output.
60pub fn bandpass(
61    rir: &[f32],
62    fc: f64,
63    width: BandWidth,
64    sample_rate: f64,
65    order: usize,
66) -> Vec<f32> {
67    if rir.is_empty() || sample_rate <= 0.0 || order == 0 {
68        return rir.to_vec();
69    }
70    let (f_low, f_high) = width.bandedges(fc);
71    let nyquist = sample_rate * 0.5;
72    // Clamp the band edges so a 16 kHz centre on a 32 kHz sample rate
73    // doesn't ask for a 22 kHz lowpass.
74    let f_low = f_low.max(1.0);
75    let f_high = f_high.min(nyquist * 0.99);
76    if f_high <= f_low {
77        return rir.to_vec();
78    }
79
80    // Build a HP + LP cascade and convert to second-order sections
81    // suitable for `filtfilt`.
82    let mut sections = filtfilt::peq_to_coefficients(
83        &math_audio_iir_fir::peq_butterworth_highpass(order, f_low, sample_rate),
84    );
85    sections.extend(filtfilt::peq_to_coefficients(
86        &math_audio_iir_fir::peq_butterworth_lowpass(order, f_high, sample_rate),
87    ));
88
89    // `filtfilt` works in f64; convert in/out.
90    let mut scratch: Vec<f64> = Vec::with_capacity(rir.len());
91    scratch.extend(rir.iter().map(|&s| s as f64));
92    let filtered = filtfilt::filtfilt(&scratch, &sections);
93    filtered.into_iter().map(|s| s as f32).collect()
94}
95
96/// Per-band ISO 3382 analysis on a broadband RIR.
97///
98/// Returns one `(centre_hz, metrics)` tuple per band. Bands whose centre
99/// would land outside `[0, Nyquist]` are silently dropped (cannot happen
100/// with the standard 8 kHz/10 kHz tops at sample rates ≥ 22 050 Hz). Bands
101/// are computed in parallel via rayon — the bandpass + Schroeder fit is
102/// the bulk of the cost, and bands are independent.
103///
104/// `order` controls the Butterworth bandpass order (per side). `4` is the
105/// common default and is the value used by most acoustic-measurement
106/// software (REW, EASERA, AURELIO).
107pub fn analyze_iso3382_bands(
108    rir: &[f32],
109    sample_rate: f64,
110    bands: &[f64],
111    width: BandWidth,
112    order: usize,
113) -> Vec<(f64, Iso3382Metrics)> {
114    let nyquist = sample_rate * 0.5;
115    bands
116        .par_iter()
117        .filter_map(|&fc| {
118            let (f_low, f_high) = width.bandedges(fc);
119            if f_low <= 0.0 || f_high >= nyquist {
120                return None;
121            }
122            let filtered = bandpass(rir, fc, width, sample_rate, order);
123            Some((fc, analyze_iso3382(&filtered, sample_rate)))
124        })
125        .collect()
126}
127
128/// Convenience: ISO octave-band analysis (125 Hz … 8 kHz).
129pub fn analyze_iso3382_octaves(rir: &[f32], sample_rate: f64) -> Vec<(f64, Iso3382Metrics)> {
130    analyze_iso3382_bands(
131        rir,
132        sample_rate,
133        &ISO_OCTAVE_CENTERS_HZ,
134        BandWidth::Octave,
135        4,
136    )
137}
138
139/// Convenience: ISO third-octave-band analysis (100 Hz … 10 kHz).
140pub fn analyze_iso3382_third_octaves(rir: &[f32], sample_rate: f64) -> Vec<(f64, Iso3382Metrics)> {
141    analyze_iso3382_bands(
142        rir,
143        sample_rate,
144        &ISO_THIRD_OCTAVE_CENTERS_HZ,
145        BandWidth::ThirdOctave,
146        4,
147    )
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    fn impulse_at(sample_rate: f64, duration_s: f64, idx: usize, amp: f32) -> Vec<f32> {
155        let n = (sample_rate * duration_s) as usize;
156        let mut v = vec![0.0f32; n];
157        if idx < n {
158            v[idx] = amp;
159        }
160        v
161    }
162
163    fn rms(buf: &[f32]) -> f64 {
164        if buf.is_empty() {
165            return 0.0;
166        }
167        let s: f64 = buf.iter().map(|&v| (v as f64) * v as f64).sum();
168        (s / buf.len() as f64).sqrt()
169    }
170
171    #[test]
172    fn bandedges_are_symmetric_in_log() {
173        let fc = 1000.0;
174        let (lo, hi) = BandWidth::Octave.bandedges(fc);
175        // log-centred: sqrt(lo * hi) ≈ fc
176        let geo = (lo * hi).sqrt();
177        assert!((geo - fc).abs() / fc < 1e-9);
178        let (lo, hi) = BandWidth::ThirdOctave.bandedges(fc);
179        let geo = (lo * hi).sqrt();
180        assert!((geo - fc).abs() / fc < 1e-9);
181    }
182
183    #[test]
184    fn bandpass_dc_is_suppressed() {
185        // DC offset → highpass leg should strongly attenuate.
186        let sr = 48000.0;
187        let mut sig = vec![1.0f32; (sr * 0.2) as usize];
188        // Trigger filtfilt edge effects only by dropping the first 100 ms.
189        let in_rms = rms(&sig);
190        let out = bandpass(&sig, 1000.0, BandWidth::Octave, sr, 4);
191        let trim = (sr * 0.1) as usize;
192        let out_rms = rms(&out[trim..]);
193        // After settling the band-limited DC should be well below the
194        // input level — 40 dB is conservative for order=4 (filtfilt
195        // doubles it).
196        assert!(
197            out_rms < in_rms * 0.01,
198            "DC bandpass leakage too high: in_rms={in_rms} out_rms={out_rms}"
199        );
200        // Quench the unused-mut warning when the test runs in isolation.
201        sig.clear();
202    }
203
204    #[test]
205    fn bandpass_passes_in_band_signal() {
206        // Sine at 1 kHz through a 1 kHz octave bandpass should pass with
207        // < 1 dB loss.
208        let sr = 48000.0;
209        let n = (sr * 0.5) as usize;
210        let f = 1000.0_f64;
211        let omega = 2.0 * std::f64::consts::PI * f / sr;
212        let sig: Vec<f32> = (0..n).map(|i| (i as f64 * omega).sin() as f32).collect();
213        let out = bandpass(&sig, 1000.0, BandWidth::Octave, sr, 4);
214
215        // Drop edge transients.
216        let trim = (sr * 0.05) as usize;
217        let in_rms = rms(&sig[trim..n - trim]);
218        let out_rms = rms(&out[trim..n - trim]);
219        let loss_db = 20.0 * (out_rms / in_rms).log10();
220        // The bandpass is HP(order=4) ∘ LP(order=4) run through filtfilt
221        // (which doubles the effective order, doubling the slope but also
222        // double-attenuating any in-band ripple). A few dB of loss at the
223        // exact centre is therefore expected; we only require the
224        // attenuation to be far better than the out-of-band case.
225        assert!(
226            loss_db.abs() < 2.0,
227            "in-band loss = {loss_db:.2} dB (expected ≈ 0)"
228        );
229    }
230
231    #[test]
232    fn bandpass_rejects_out_of_band_signal() {
233        // 100 Hz sine through a 4 kHz octave bandpass: should be heavily
234        // attenuated.
235        let sr = 48000.0;
236        let n = (sr * 0.5) as usize;
237        let f = 100.0_f64;
238        let omega = 2.0 * std::f64::consts::PI * f / sr;
239        let sig: Vec<f32> = (0..n).map(|i| (i as f64 * omega).sin() as f32).collect();
240        let out = bandpass(&sig, 4000.0, BandWidth::Octave, sr, 4);
241
242        let trim = (sr * 0.05) as usize;
243        let in_rms = rms(&sig[trim..n - trim]);
244        let out_rms = rms(&out[trim..n - trim]);
245        let loss_db = 20.0 * (out_rms / in_rms).max(1e-30).log10();
246        assert!(
247            loss_db < -40.0,
248            "out-of-band rejection only {loss_db:.1} dB (expected < -40)"
249        );
250    }
251
252    #[test]
253    fn analyze_octaves_runs_on_impulse() {
254        // Dirac impulse → no decay; metrics will be NaN/short but we
255        // verify the dispatch doesn't panic and returns one entry per
256        // valid band.
257        let sr = 48000.0;
258        let rir = impulse_at(sr, 0.5, 0, 1.0);
259        let results = analyze_iso3382_octaves(&rir, sr);
260        assert_eq!(results.len(), ISO_OCTAVE_CENTERS_HZ.len());
261        for (fc, _) in &results {
262            assert!(*fc > 0.0);
263        }
264    }
265}