Skip to main content

audio_lab/dsp/
filter_bank.rs

1use ndarray::prelude::*;
2use ndrustfft::{R2cFftHandler, ndfft_r2c, ndifft_r2c};
3use num::complex::Complex64;
4
5use crate::signal::{SignalError, TimeSignal};
6
7use crate::dsp::window::{WindowFn, generate_window};
8
9#[derive(Debug, thiserror::Error)]
10pub enum FilterError {
11    #[error("overlap must be between 0 and 1, got {0}")]
12    InvalidOverlap(f64),
13    #[error("n_samples must be at least 2")]
14    InvalidNSamples,
15    #[error("no valid frequency bands found in range {0}..{1} Hz")]
16    NoBandsInRange(f64, f64),
17    #[error(transparent)]
18    Signal(#[from] SignalError),
19}
20
21/// Compute fractional octave center and cut-off frequencies (IEC 61260).
22///
23/// Returns `(centers, lower_cutoffs, upper_cutoffs)` for all bands whose
24/// center frequency falls within `frequency_range`.
25pub fn fractional_octave_frequencies(
26    num_fractions: usize,
27    frequency_range: (f64, f64),
28) -> (Vec<f64>, Vec<f64>, Vec<f64>) {
29    let (f_min, f_max) = frequency_range;
30    let nf = num_fractions as f64;
31
32    // Generous k range – filtered to frequency_range below
33    let k_min = (nf * (f_min / 1000.0).log2()).floor() as i32 - 1;
34    let k_max = (nf * (f_max / 1000.0).log2()).ceil() as i32 + 1;
35
36    let mut centers = Vec::new();
37    let mut lowers = Vec::new();
38    let mut uppers = Vec::new();
39
40    for k in k_min..=k_max {
41        let f_m = 1000.0 * 2.0_f64.powf(k as f64 / nf);
42        if f_m >= f_min * (1.0 - 1e-6) && f_m <= f_max * (1.0 + 1e-6) {
43            centers.push(f_m);
44            lowers.push(f_m * 2.0_f64.powf(-0.5 / nf));
45            uppers.push(f_m * 2.0_f64.powf(0.5 / nf));
46        }
47    }
48
49    (centers, lowers, uppers)
50}
51
52#[derive(Debug, Clone, Copy)]
53pub struct OctaveBandsConfig {
54    /// Hz range `(low, high)` for the band centres.
55    pub frequency_range: (f64, f64),
56    /// Transition overlap in `[0, 1]`; smaller values give wider pass-bands and steeper slopes.
57    pub overlap: f64,
58    /// Steepness iterations ≥ 0; each step applies one sine recursion for a steeper roll-off.
59    pub slope: usize,
60    /// FIR filter length in samples; longer → more accurate.
61    pub n_samples: usize,
62}
63
64impl Default for OctaveBandsConfig {
65    fn default() -> Self {
66        Self {
67            frequency_range: (63.0, 16_000.0),
68            overlap: 1.0,
69            slope: 0,
70            n_samples: 4096,
71        }
72    }
73}
74
75/// A reconstructing linear-phase fractional octave filter bank.
76///
77/// # Quick start
78///
79/// ```rust,ignore
80/// // Design a third-octave bank and apply it to a signal
81/// let fb = OctaveBands::design(3, OctaveBandsConfig::default(), signal.sample_rate())?;
82/// let bands: Vec<TimeSignal> = fb.apply(&signal)?;
83/// let freqs: &[f64] = &fb.center_frequencies;
84/// ```
85///
86/// The sum of all `bands` approximately equals the input delayed by
87/// `n_samples / 2` samples. The final Hann window improves FIR sidelobes but
88/// makes reconstruction approximate rather than exact.
89pub struct OctaveBands {
90    /// One FIR impulse response per band: shape `[num_bands, n_samples]`.
91    pub impulse_responses: TimeSignal,
92    /// Center frequency of each band in Hz.
93    pub center_frequencies: Vec<f64>,
94}
95
96impl OctaveBands {
97    /// Design a reconstructing fractional octave filter bank.
98    ///
99    /// Filters are windowed FIR filters designed in the frequency domain
100    /// following Antoni (2010), doi: 10.1121/1.3273888. The first band acts as
101    /// a low-pass below the lowest centre frequency; the last as a high-pass
102    /// above the highest.
103    pub fn design(
104        num_fractions: usize,
105        config: &OctaveBandsConfig,
106        sample_rate: f64,
107    ) -> Result<Self, FilterError> {
108        let OctaveBandsConfig {
109            frequency_range,
110            overlap,
111            slope,
112            n_samples,
113        } = *config;
114
115        if !(0.0..=1.0).contains(&overlap) {
116            return Err(FilterError::InvalidOverlap(overlap));
117        }
118        if n_samples < 2 {
119            return Err(FilterError::InvalidNSamples);
120        }
121
122        let n_bins = n_samples / 2 + 1;
123        let (f_center, f_lower, f_upper) =
124            fractional_octave_frequencies(num_fractions, frequency_range);
125
126        // Drop bands above Nyquist
127        let nyquist = sample_rate / 2.0;
128        let mut f_m = Vec::new();
129        let mut f_l = Vec::new();
130        let mut f_u = Vec::new();
131        for ((fc, fl), fu) in f_center.iter().zip(&f_lower).zip(&f_upper) {
132            if *fc < nyquist {
133                f_m.push(*fc);
134                f_l.push(*fl);
135                f_u.push(*fu);
136            }
137        }
138
139        let num_bands = f_m.len();
140        if num_bands == 0 {
141            return Err(FilterError::NoBandsInRange(
142                frequency_range.0,
143                frequency_range.1,
144            ));
145        }
146
147        // Frequency → nearest FFT bin
148        let freq_to_bin = |f: f64| -> usize {
149            ((n_samples as f64 * f / sample_rate).round() as usize).min(n_bins - 1)
150        };
151
152        let k_1: Vec<usize> = f_l.iter().map(|&f| freq_to_bin(f)).collect(); // lower cut-off
153        let k_m: Vec<usize> = f_m.iter().map(|&f| freq_to_bin(f)).collect(); // centre
154        let k_2: Vec<usize> = f_u.iter().map(|&f| freq_to_bin(f)).collect(); // upper cut-off
155
156        // Half-width of each transition region (bins).
157        // Based on the upper half-bandwidth of band b (centre → upper cut-off).
158        let p: Vec<usize> = (0..num_bands)
159            .map(|b| ((overlap / 2.0) * (k_2[b] as f64 - k_m[b] as f64)).round() as usize)
160            .collect();
161
162        // Magnitude responses: start all-pass (ones)
163        let mut g = Array2::<f64>::ones((num_bands, n_bins));
164
165        // Build transitions between adjacent bands (start at b=1: first band is low-pass)
166        for b in 1..num_bands {
167            if p[b] > 0 {
168                let pb = p[b] as isize;
169                let k1b = k_1[b] as isize;
170
171                for pp in -pb..=pb {
172                    let bin = k1b + pp;
173                    if bin < 0 || bin as usize >= n_bins {
174                        continue;
175                    }
176                    let idx = bin as usize;
177
178                    // φ ∈ [-1, 1], optionally refined by sine recursion
179                    let mut phi = pp as f64 / pb as f64;
180                    for _ in 0..slope {
181                        phi = (std::f64::consts::PI / 2.0 * phi).sin();
182                    }
183                    phi = 0.5 * (phi + 1.0); // shift to [0, 1]
184
185                    // Fade out band b-1, fade in band b
186                    g[[b - 1, idx]] = (std::f64::consts::PI / 2.0 * phi).cos();
187                    g[[b, idx]] = (std::f64::consts::PI / 2.0 * phi).sin();
188                }
189            }
190
191            // Zero outside each band's support
192            let high = (k_1[b] + p[b]).min(n_bins - 1) + 1;
193            g.slice_mut(s![b - 1, high..]).fill(0.0);
194
195            let low = k_1[b].saturating_sub(p[b]);
196            g.slice_mut(s![b, ..low]).fill(0.0);
197        }
198
199        // Square magnitudes → -6 dB at cut-off frequencies
200        g.mapv_inplace(|v| v * v);
201
202        // Multiply by linear phase term: exp(-j·2π·f·T_d), T_d = n_samples/2 / sr.
203        // With f = k·sr/n_samples the phase is exactly -π·k, so the term is (-1)^k.
204        let g_complex = Array2::from_shape_fn((num_bands, n_bins), |(b, k)| {
205            let sign = if k % 2 == 0 { 1.0 } else { -1.0 };
206            Complex64::new(g[[b, k]] * sign, 0.0)
207        });
208
209        // IFFT → impulse responses
210        let fft_handler = R2cFftHandler::<f64>::new(n_samples);
211        let mut ir = Array2::<f64>::zeros((num_bands, n_samples));
212        ndifft_r2c(&g_complex.view(), &mut ir.view_mut(), &fft_handler, 1);
213
214        // Hanning window (suppresses side lobes of the finite-length FIR)
215        let window = generate_window(WindowFn::Hann, n_samples);
216        for b in 0..num_bands {
217            ir.slice_mut(s![b, ..])
218                .zip_mut_with(&window, |x, &w| *x *= w);
219        }
220
221        let impulse_responses = TimeSignal::new(ir, sample_rate)?;
222        Ok(Self {
223            impulse_responses,
224            center_frequencies: f_m,
225        })
226    }
227
228    /// Apply the filter bank to `signal` via FFT-based linear convolution.
229    ///
230    /// Returns one [`TimeSignal`] per band (same channel count as the input).
231    /// Each output is `signal.num_time_steps() + n_samples - 1` samples long
232    /// and is delayed by `n_samples / 2` samples relative to the input.
233    pub fn apply(&self, signal: &TimeSignal) -> Result<Vec<TimeSignal>, FilterError> {
234        let n_sig = signal.num_time_steps();
235        let n_filt = self.impulse_responses.num_time_steps();
236        let n_out = n_sig + n_filt - 1;
237        let fft_size = n_out.next_power_of_two();
238        let n_freq = fft_size / 2 + 1;
239        let num_bands = self.impulse_responses.num_channels();
240        let num_ch = signal.num_channels();
241
242        let fft_handler = R2cFftHandler::<f64>::new(fft_size);
243
244        // Pre-compute spectra of all filter impulse responses in one batch FFT
245        let mut filter_spectra = Array2::<Complex64>::zeros((num_bands, n_freq));
246        {
247            let mut padded = Array2::<f64>::zeros((num_bands, fft_size));
248            padded
249                .slice_mut(s![.., ..n_filt])
250                .assign(&self.impulse_responses.time_data());
251            ndfft_r2c(
252                &padded.view(),
253                &mut filter_spectra.view_mut(),
254                &fft_handler,
255                1,
256            );
257        }
258
259        // Pre-compute spectra of all input channels in one batch FFT
260        let mut sig_spectra = Array2::<Complex64>::zeros((num_ch, n_freq));
261        {
262            let mut padded = Array2::<f64>::zeros((num_ch, fft_size));
263            padded
264                .slice_mut(s![.., ..n_sig])
265                .assign(&signal.time_data());
266            ndfft_r2c(&padded.view(), &mut sig_spectra.view_mut(), &fft_handler, 1);
267        }
268
269        // Convolve each band with every channel
270        let mut product = Array2::<Complex64>::zeros((num_ch, n_freq));
271        let mut ifft_buf = Array2::<f64>::zeros((num_ch, fft_size));
272
273        let mut bands = Vec::with_capacity(num_bands);
274        for b in 0..num_bands {
275            let h = filter_spectra.slice(s![b, ..]);
276            for ch in 0..num_ch {
277                for k in 0..n_freq {
278                    product[[ch, k]] = sig_spectra[[ch, k]] * h[k];
279                }
280            }
281
282            ndifft_r2c(&product.view(), &mut ifft_buf.view_mut(), &fft_handler, 1);
283
284            let band_data = ifft_buf.slice(s![.., ..n_out]).to_owned();
285            bands.push(TimeSignal::new(band_data, signal.sample_rate())?);
286        }
287
288        Ok(bands)
289    }
290}
291
292// ─── Convenience function ────────────────────────────────────────────────────
293
294/// Design and apply a reconstructing linear-phase fractional octave filter bank.
295///
296/// Returns one filtered [`TimeSignal`] per band together with the centre
297/// frequencies in Hz. The sum of all bands approximately equals the input
298/// delayed by `n_samples / 2` samples; the final Hann window trades exact
299/// reconstruction for improved FIR sidelobes.
300///
301/// Internally this calls [`OctaveBands::design`] followed by
302/// [`OctaveBands::apply`]. Use those directly if you need to apply the same
303/// filter bank to multiple signals.
304pub fn reconstructing_fractional_octave_bands(
305    signal: &TimeSignal,
306    num_fractions: usize,
307    config: &OctaveBandsConfig,
308) -> Result<(Vec<TimeSignal>, Vec<f64>), FilterError> {
309    let fb = OctaveBands::design(num_fractions, config, signal.sample_rate())?;
310    let center_frequencies = fb.center_frequencies.clone();
311    let bands = fb.apply(signal)?;
312    Ok((bands, center_frequencies))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use approx::assert_abs_diff_eq;
319
320    fn impulse(n: usize, sample_rate: f64) -> TimeSignal {
321        let mut data = Array2::<f64>::zeros((1, n));
322        data[[0, 0]] = 1.0;
323        TimeSignal::new(data, sample_rate).unwrap()
324    }
325
326    #[test]
327    fn fractional_octave_frequencies_octave() {
328        let (c, l, u) = fractional_octave_frequencies(1, (63.0, 16000.0));
329        assert!(!c.is_empty());
330        for &f in &c {
331            assert!((63.0 * (1.0 - 1e-5)..=16000.0 * (1.0 + 1e-5)).contains(&f));
332        }
333        for ((&fc, &fl), &fu) in c.iter().zip(&l).zip(&u) {
334            assert!(fl < fc && fc < fu);
335        }
336    }
337
338    #[test]
339    fn design_output_shape() {
340        let n_samples = 256;
341        let sample_rate = 44100.0;
342        let fb = OctaveBands::design(
343            1,
344            &OctaveBandsConfig {
345                frequency_range: (125.0, 8000.0),
346                n_samples,
347                ..Default::default()
348            },
349            sample_rate,
350        )
351        .unwrap();
352
353        let (c, _, _) = fractional_octave_frequencies(1, (125.0, 8000.0));
354        let expected_bands = c.iter().filter(|&&f| f < sample_rate / 2.0).count();
355
356        assert_eq!(fb.impulse_responses.num_channels(), expected_bands);
357        assert_eq!(fb.impulse_responses.num_time_steps(), n_samples);
358        assert_eq!(fb.center_frequencies.len(), expected_bands);
359    }
360
361    #[test]
362    fn reconstruction_impulse() {
363        // Sum of all band outputs should equal the delayed impulse.
364        let n = 512;
365        let sr = 44100.0;
366        let n_filt = 256_usize;
367
368        let x = impulse(n, sr);
369        let (bands, _) = reconstructing_fractional_octave_bands(
370            &x,
371            1,
372            &OctaveBandsConfig {
373                frequency_range: (125.0, 8000.0),
374                n_samples: n_filt,
375                ..Default::default()
376            },
377        )
378        .unwrap();
379
380        let n_out = n + n_filt - 1;
381        let mut sum = Array1::<f64>::zeros(n_out);
382        for band in &bands {
383            sum += &band.channel(0);
384        }
385
386        // Peak should sit at n_filt/2 with amplitude ≈ 1
387        let peak_idx = sum
388            .iter()
389            .enumerate()
390            .max_by(|(_, a), (_, b)| a.abs().partial_cmp(&b.abs()).unwrap())
391            .map(|(i, _)| i)
392            .unwrap();
393
394        assert_eq!(peak_idx, n_filt / 2);
395        assert_abs_diff_eq!(sum[peak_idx], 1.0, epsilon = 0.05);
396    }
397}