1use math_audio_iir_fir::filtfilt;
16use rayon::prelude::*;
17
18use crate::metrics::{Iso3382Metrics, analyze_iso3382};
19
20pub 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
27pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum BandWidth {
36 Octave,
38 ThirdOctave,
40}
41
42impl BandWidth {
43 fn bandedges(self, fc: f64) -> (f64, f64) {
44 match self {
45 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
54pub 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 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 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 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, §ions);
93 filtered.into_iter().map(|s| s as f32).collect()
94}
95
96pub 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
128pub 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
139pub 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 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 let sr = 48000.0;
187 let mut sig = vec![1.0f32; (sr * 0.2) as usize];
188 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 assert!(
197 out_rms < in_rms * 0.01,
198 "DC bandpass leakage too high: in_rms={in_rms} out_rms={out_rms}"
199 );
200 sig.clear();
202 }
203
204 #[test]
205 fn bandpass_passes_in_band_signal() {
206 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 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 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 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 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}