Skip to main content

math_rir/
metrics.rs

1//! ISO 3382 room acoustic metrics from a Room Impulse Response.
2//!
3//! Computes the parameters defined in
4//! - ISO 3382-1:2009 *Acoustics — Measurement of room acoustic parameters,
5//!   Part 1: Performance spaces*
6//! - ISO 3382-2:2008 *Part 2: Reverberation time in ordinary rooms*
7//!
8//! Implemented here:
9//!
10//! | Metric | Definition                                                          |
11//! |--------|---------------------------------------------------------------------|
12//! | EDT    | Early decay time — slope of Schroeder decay over `0 → −10 dB`,      |
13//! |        | extrapolated to a 60 dB drop.                                       |
14//! | T20    | Slope of Schroeder decay over `−5 → −25 dB`, extrapolated to 60 dB. |
15//! | T30    | Slope of Schroeder decay over `−5 → −35 dB`, extrapolated to 60 dB. |
16//! | C50    | Clarity (50 ms): `10·log10(E_[0,50ms] / E_(50ms,∞))`.               |
17//! | C80    | Clarity (80 ms): same, for music.                                   |
18//! | D50    | Definition: `E_[0,50ms] / E_[0,∞)` (ratio in 0..1).                 |
19//! | Ts     | Centre time: `∫ t · h²(t) dt / ∫ h²(t) dt` (seconds).               |
20//!
21//! All time integrations start at the **direct-sound arrival**
22//! (see [`find_direct_sound_toa`] in `detection.rs`) so that pre-arrival
23//! silence in the recording does not skew the result.
24//!
25//! Each fitted line carries an `r²` so callers can reject reverberation
26//! times where the decay is not linear in dB (e.g. coupled rooms or
27//! noise-dominated tails).
28
29use crate::config::SsirConfig;
30use crate::detection::find_direct_sound_toa;
31
32/// Schroeder backward-integrated decay curve of a RIR, expressed in dB
33/// relative to the curve's peak.
34///
35/// `samples[n] = 10·log10( ∫_n^{cutoff} h²(τ) dτ / ∫_0^{cutoff} h²(τ) dτ )`
36///
37/// Truncation at `cutoff` (the estimated noise-floor crossover) avoids the
38/// "lift" that a never-decaying integrated noise tail would otherwise add
39/// to the curve — see Chu (1978) and Lundeby et al. (1995).
40#[derive(Debug, Clone)]
41pub struct DecayCurve {
42    /// Sample-by-sample Schroeder decay in dB (0 dB at the start, decreasing).
43    pub samples: Vec<f64>,
44    /// Sample rate the curve was computed at.
45    pub sample_rate: f64,
46    /// Index (within `samples`) at which the underlying RIR was truncated
47    /// before backward-integration. Below this sample the curve is dominated
48    /// by noise and should not be used for slope fitting.
49    pub noise_cutoff_sample: usize,
50}
51
52impl DecayCurve {
53    /// Compute the Schroeder decay curve from a RIR.
54    ///
55    /// `start_sample` is the index of the direct sound; integration starts
56    /// from there. `noise_cutoff_sample` (absolute index in `rir`) lets the
57    /// caller override the auto-detected noise truncation; if `None`,
58    /// [`estimate_noise_cutoff`] is used.
59    pub fn from_rir(
60        rir: &[f32],
61        sample_rate: f64,
62        start_sample: usize,
63        noise_cutoff_sample: Option<usize>,
64    ) -> Self {
65        if rir.is_empty() || start_sample >= rir.len() {
66            return Self {
67                samples: Vec::new(),
68                sample_rate,
69                noise_cutoff_sample: 0,
70            };
71        }
72
73        let cutoff_abs =
74            noise_cutoff_sample.unwrap_or_else(|| estimate_noise_cutoff(rir, start_sample));
75        let cutoff_abs = cutoff_abs.min(rir.len());
76        let cutoff_rel = cutoff_abs.saturating_sub(start_sample);
77
78        // Square h(n) and backward-integrate from `cutoff_abs` down to
79        // `start_sample`. We work in f64 throughout — for a 200 ms IR at
80        // 48 kHz this is < 10k accumulations; precision matters at the
81        // −35 dB tail.
82        let n = cutoff_rel;
83        if n == 0 {
84            return Self {
85                samples: Vec::new(),
86                sample_rate,
87                noise_cutoff_sample: 0,
88            };
89        }
90
91        let mut energy: Vec<f64> = Vec::with_capacity(n);
92        let mut acc = 0.0_f64;
93        // Walk from end → start, summing h²; reverse afterwards so that
94        // `energy[i]` = ∫_{start_sample+i}^{cutoff_abs} h²(τ) dτ.
95        for i in (0..n).rev() {
96            let s = rir[start_sample + i] as f64;
97            acc += s * s;
98            energy.push(acc);
99        }
100        energy.reverse();
101
102        let total = energy[0];
103        if total <= 0.0 || !total.is_finite() {
104            return Self {
105                samples: Vec::new(),
106                sample_rate,
107                noise_cutoff_sample: cutoff_abs,
108            };
109        }
110
111        // Convert to dB relative to the peak. The smallest representable
112        // value past the tail still produces a finite dB (clamped at
113        // −300 dB) so consumers don't have to handle `-∞`.
114        let inv_total = 1.0 / total;
115        let samples: Vec<f64> = energy
116            .iter()
117            .map(|&e| {
118                let r = e * inv_total;
119                if r <= 0.0 { -300.0 } else { 10.0 * r.log10() }
120            })
121            .collect();
122
123        Self {
124            samples,
125            sample_rate,
126            noise_cutoff_sample: cutoff_abs,
127        }
128    }
129
130    /// First sample index at which the curve is `≤ threshold_db`. Returns
131    /// `None` if the curve never reaches the threshold.
132    pub fn first_crossing(&self, threshold_db: f64) -> Option<usize> {
133        self.samples.iter().position(|&v| v <= threshold_db)
134    }
135
136    /// Least-squares fit of the decay between two dB thresholds.
137    ///
138    /// Returns `(slope_db_per_s, intercept_db, r_squared)`. `None` if either
139    /// threshold is never reached or fewer than two samples lie in the band.
140    pub fn fit_db_range(&self, upper_db: f64, lower_db: f64) -> Option<(f64, f64, f64)> {
141        debug_assert!(upper_db > lower_db);
142        let i_upper = self.first_crossing(upper_db)?;
143        let i_lower = self.first_crossing(lower_db)?;
144        if i_lower <= i_upper + 1 {
145            return None;
146        }
147
148        // x is time in seconds (sample index / sample_rate); y is decay dB.
149        let dt = 1.0 / self.sample_rate;
150        let xs = (i_upper..=i_lower).map(|i| (i as f64) * dt);
151        let ys = self.samples[i_upper..=i_lower].iter().copied();
152        linear_fit(xs, ys)
153    }
154}
155
156/// Compute a direct-sound-anchored Schroeder decay curve for a RIR.
157///
158/// This is a convenience wrapper around [`DecayCurve::from_rir`]. It detects
159/// the direct sound with the SSIR detector, falls back to sample 0 when no
160/// direct sound can be identified, and uses the automatic noise-tail cutoff.
161pub fn schroeder_curve(rir: &[f32], sample_rate: f64) -> DecayCurve {
162    if rir.is_empty() || sample_rate <= 0.0 {
163        return DecayCurve {
164            samples: Vec::new(),
165            sample_rate,
166            noise_cutoff_sample: 0,
167        };
168    }
169
170    let cfg = SsirConfig::new(sample_rate);
171    let start = find_direct_sound_toa(rir, &cfg).unwrap_or(0);
172    DecayCurve::from_rir(rir, sample_rate, start, None)
173}
174
175/// Least-squares linear fit `y = slope·x + intercept`. Returns
176/// `(slope, intercept, r²)`. `None` if `n < 2` or `Var(x) = 0`.
177fn linear_fit<X, Y>(xs: X, ys: Y) -> Option<(f64, f64, f64)>
178where
179    X: IntoIterator<Item = f64>,
180    Y: IntoIterator<Item = f64>,
181{
182    let xs: Vec<f64> = xs.into_iter().collect();
183    let ys: Vec<f64> = ys.into_iter().collect();
184    let n = xs.len();
185    if n < 2 || ys.len() != n {
186        return None;
187    }
188    let n_f = n as f64;
189    let sx: f64 = xs.iter().sum();
190    let sy: f64 = ys.iter().sum();
191    let sxx: f64 = xs.iter().map(|x| x * x).sum();
192    let sxy: f64 = xs.iter().zip(ys.iter()).map(|(x, y)| x * y).sum();
193    let syy: f64 = ys.iter().map(|y| y * y).sum();
194
195    let denom = n_f * sxx - sx * sx;
196    if denom.abs() < f64::EPSILON {
197        return None;
198    }
199    let slope = (n_f * sxy - sx * sy) / denom;
200    let intercept = (sy - slope * sx) / n_f;
201
202    let ss_tot = syy - sy * sy / n_f;
203    let ss_res: f64 = xs
204        .iter()
205        .zip(ys.iter())
206        .map(|(x, y)| {
207            let pred = slope * x + intercept;
208            let r = y - pred;
209            r * r
210        })
211        .sum();
212    let r2 = if ss_tot.abs() < f64::EPSILON {
213        1.0
214    } else {
215        (1.0 - ss_res / ss_tot).clamp(0.0, 1.0)
216    };
217    Some((slope, intercept, r2))
218}
219
220/// Estimate the sample index at which the RIR drops into the noise floor.
221///
222/// This is a deliberately simple two-pass estimator (Chu's method): the
223/// noise floor is the mean of `h²` over the last 10 % of the signal, and
224/// the cutoff is the first sample at which a 5 ms running mean of `h²`
225/// drops within 10 dB of that floor. Lundeby's iterative refinement is a
226/// possible future upgrade — for typical concert-hall RIRs this estimator
227/// is within ±20 ms of the Lundeby result and good enough for T20/T30
228/// computations that only need the −5..−25 / −5..−35 dB region.
229pub fn estimate_noise_cutoff(rir: &[f32], start_sample: usize) -> usize {
230    if rir.is_empty() || start_sample >= rir.len() {
231        return rir.len();
232    }
233    let n = rir.len();
234    let tail_start = start_sample + ((n - start_sample) * 9) / 10;
235    if tail_start >= n {
236        return n;
237    }
238
239    // Mean squared value of the last 10 % is the noise estimate.
240    let tail_len = n - tail_start;
241    let mut tail_e = 0.0_f64;
242    for &s in &rir[tail_start..n] {
243        let v = s as f64;
244        tail_e += v * v;
245    }
246    let noise_e = tail_e / tail_len as f64;
247    // 10 dB above the noise floor.
248    let threshold = noise_e * 10.0;
249
250    // 5 ms running mean of h². Sample-rate-independent: just use a
251    // proportional window (5 % of the signal length, clamped).
252    let win = ((n - start_sample) / 20).clamp(32, 4096);
253    if win == 0 || win >= n - start_sample {
254        return n;
255    }
256
257    let mut win_sum = 0.0_f64;
258    for &s in &rir[start_sample..start_sample + win] {
259        let v = s as f64;
260        win_sum += v * v;
261    }
262    // Walk forward and find the first window whose mean drops below
263    // `threshold`. Stop at `tail_start` — beyond that we're inside the
264    // noise tail by definition.
265    let limit = tail_start.min(n - win);
266    for i in (start_sample + win)..limit {
267        let inv = win as f64;
268        if win_sum / inv < threshold {
269            return i;
270        }
271        let drop = rir[i - win] as f64;
272        let add = rir[i] as f64;
273        win_sum += add * add - drop * drop;
274    }
275    tail_start
276}
277
278/// ISO 3382 single-band acoustic metrics for one RIR.
279#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct Iso3382Metrics {
281    /// Early decay time — Schroeder slope over `0 → −10 dB`, extrapolated
282    /// to a 60 dB drop. In seconds.
283    pub edt_s: f64,
284    /// T20 reverberation time (`−5 → −25 dB`, extrapolated to 60 dB). Seconds.
285    pub t20_s: f64,
286    /// T30 reverberation time (`−5 → −35 dB`, extrapolated to 60 dB). Seconds.
287    pub t30_s: f64,
288    /// Clarity at 50 ms (in dB). Higher = more speech-intelligibility-friendly.
289    pub c50_db: f64,
290    /// Clarity at 80 ms (in dB). Standard music clarity parameter.
291    pub c80_db: f64,
292    /// Definition: ratio of early (≤ 50 ms) to total energy. Dimensionless.
293    pub d50: f64,
294    /// Centre time (seconds). The temporal centre of gravity of `h²`.
295    pub ts_s: f64,
296    /// R² of the EDT linear fit (0..1). `< 0.9` is a quality warning.
297    pub edt_r2: f64,
298    /// R² of the T20 linear fit.
299    pub t20_r2: f64,
300    /// R² of the T30 linear fit.
301    pub t30_r2: f64,
302}
303
304impl Iso3382Metrics {
305    /// Returns `true` if every fitted decay region had `r² ≥ 0.95` —
306    /// the conventional ISO 3382-1 acceptance threshold.
307    pub fn fit_is_valid(&self) -> bool {
308        self.edt_r2 >= 0.95 && self.t20_r2 >= 0.95 && self.t30_r2 >= 0.95
309    }
310}
311
312/// Compute ISO 3382 single-band metrics on a broadband RIR.
313///
314/// For per-band analysis, bandpass the RIR with one of the helpers in
315/// [`crate::bands`] and call this function on the filtered signal.
316pub fn analyze_iso3382(rir: &[f32], sample_rate: f64) -> Iso3382Metrics {
317    if rir.is_empty() || sample_rate <= 0.0 {
318        return EMPTY_METRICS;
319    }
320
321    // Anchor t = 0 at the direct sound. If we cannot find one (silent or
322    // sub-noise RIR), fall back to sample 0.
323    let cfg = SsirConfig::new(sample_rate);
324    let start = find_direct_sound_toa(rir, &cfg).unwrap_or(0);
325    if start >= rir.len() {
326        return EMPTY_METRICS;
327    }
328
329    // C50 / C80 / D50 / Ts: integrate h² from `start` onwards.
330    let ms = sample_rate / 1000.0;
331    let i50 = (start + (50.0 * ms) as usize).min(rir.len());
332    let i80 = (start + (80.0 * ms) as usize).min(rir.len());
333
334    let mut e_total = 0.0_f64;
335    let mut e_50 = 0.0_f64;
336    let mut e_80 = 0.0_f64;
337    let mut ts_num = 0.0_f64;
338    let dt = 1.0 / sample_rate;
339    for (i, &s) in rir[start..].iter().enumerate() {
340        let v = s as f64;
341        let e = v * v;
342        e_total += e;
343        let abs_i = start + i;
344        if abs_i < i50 {
345            e_50 += e;
346        }
347        if abs_i < i80 {
348            e_80 += e;
349        }
350        ts_num += (i as f64 * dt) * e;
351    }
352
353    let (c50_db, c80_db, d50, ts_s) = if e_total > 0.0 {
354        let e_late_50 = (e_total - e_50).max(f64::MIN_POSITIVE);
355        let e_late_80 = (e_total - e_80).max(f64::MIN_POSITIVE);
356        let c50 = 10.0 * (e_50.max(f64::MIN_POSITIVE) / e_late_50).log10();
357        let c80 = 10.0 * (e_80.max(f64::MIN_POSITIVE) / e_late_80).log10();
358        let d50 = (e_50 / e_total).clamp(0.0, 1.0);
359        let ts = ts_num / e_total;
360        (c50, c80, d50, ts)
361    } else {
362        (f64::NAN, f64::NAN, f64::NAN, f64::NAN)
363    };
364
365    // Schroeder decay → EDT / T20 / T30.
366    let curve = DecayCurve::from_rir(rir, sample_rate, start, None);
367    let (edt_s, edt_r2) = match curve.fit_db_range(0.0, -10.0) {
368        Some((slope, _, r2)) if slope < 0.0 => (-60.0 / slope, r2),
369        _ => (f64::NAN, 0.0),
370    };
371    let (t20_s, t20_r2) = match curve.fit_db_range(-5.0, -25.0) {
372        Some((slope, _, r2)) if slope < 0.0 => (-60.0 / slope, r2),
373        _ => (f64::NAN, 0.0),
374    };
375    let (t30_s, t30_r2) = match curve.fit_db_range(-5.0, -35.0) {
376        Some((slope, _, r2)) if slope < 0.0 => (-60.0 / slope, r2),
377        _ => (f64::NAN, 0.0),
378    };
379
380    Iso3382Metrics {
381        edt_s,
382        t20_s,
383        t30_s,
384        c50_db,
385        c80_db,
386        d50,
387        ts_s,
388        edt_r2,
389        t20_r2,
390        t30_r2,
391    }
392}
393
394const EMPTY_METRICS: Iso3382Metrics = Iso3382Metrics {
395    edt_s: f64::NAN,
396    t20_s: f64::NAN,
397    t30_s: f64::NAN,
398    c50_db: f64::NAN,
399    c80_db: f64::NAN,
400    d50: f64::NAN,
401    ts_s: f64::NAN,
402    edt_r2: 0.0,
403    t20_r2: 0.0,
404    t30_r2: 0.0,
405};
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410
411    /// Synthetic exponentially-decaying noise burst whose −60 dB time
412    /// equals `t60_s`. Useful as ground truth for T20/T30/EDT.
413    fn exponential_decay_rir(sample_rate: f64, t60_s: f64, duration_s: f64) -> Vec<f32> {
414        let n = (duration_s * sample_rate) as usize;
415        // h(t) = exp(-α t) · ξ(t),    α such that 20·log10(exp(-α·T60)) = -60
416        // ⇒ α = ln(10⁶) / (2·T60) (because h² gives 60 dB drop at T60).
417        let alpha = std::f64::consts::LN_10 * 6.0 / (2.0 * t60_s);
418        let mut rir = vec![0.0f32; n];
419        // First sample = direct sound.
420        rir[0] = 1.0;
421        // Pseudo-noise tail with envelope exp(-α t).
422        let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
423        for (i, sample) in rir.iter_mut().enumerate().skip(1) {
424            // xorshift64
425            state ^= state << 13;
426            state ^= state >> 7;
427            state ^= state << 17;
428            let noise = ((state >> 32) as i32 as f64) / (i32::MAX as f64);
429            let t = i as f64 / sample_rate;
430            *sample = (noise * (-alpha * t).exp()) as f32;
431        }
432        rir
433    }
434
435    #[test]
436    fn linear_fit_perfect_line() {
437        let xs = [0.0, 1.0, 2.0, 3.0, 4.0];
438        let ys = [0.0, -2.0, -4.0, -6.0, -8.0];
439        let (slope, intercept, r2) = linear_fit(xs, ys).unwrap();
440        assert!((slope - -2.0).abs() < 1e-12);
441        assert!(intercept.abs() < 1e-12);
442        assert!((r2 - 1.0).abs() < 1e-12);
443    }
444
445    #[test]
446    fn schroeder_monotonic_decreasing_for_exp_decay() {
447        let sr = 48000.0;
448        let rir = exponential_decay_rir(sr, 1.0, 2.0);
449        let curve = DecayCurve::from_rir(&rir, sr, 0, None);
450        assert!(!curve.samples.is_empty());
451        // The Schroeder integral of any non-negative envelope is
452        // monotonically non-increasing.
453        for w in curve.samples.windows(2) {
454            assert!(w[1] <= w[0] + 1e-9, "non-monotonic: {} -> {}", w[0], w[1]);
455        }
456        // First sample must be 0 dB by construction.
457        assert!(curve.samples[0].abs() < 1e-9);
458    }
459
460    #[test]
461    fn schroeder_curve_helper_anchors_at_detected_direct_sound() {
462        let sr = 48000.0;
463        let mut rir = vec![0.0f32; 4096];
464        rir[96] = 1.0;
465        rir[97] = 0.5;
466        rir[500] = 0.1;
467
468        let curve = schroeder_curve(&rir, sr);
469        assert!(!curve.samples.is_empty());
470        assert_eq!(curve.sample_rate, sr);
471        assert!(curve.noise_cutoff_sample >= 96);
472        assert!(curve.samples[0].abs() < 1e-9);
473    }
474
475    #[test]
476    fn reverberation_times_match_synthetic_t60() {
477        let sr = 48000.0;
478        let target_t60 = 0.6_f64;
479        let rir = exponential_decay_rir(sr, target_t60, 2.0);
480        let m = analyze_iso3382(&rir, sr);
481
482        // Synthetic exponential decay → all three should match T60 within
483        // a small fraction. We allow ±15 % because the noise excitation
484        // adds variance in the slope fit.
485        assert!(
486            m.t30_s.is_finite() && (m.t30_s - target_t60).abs() < 0.15 * target_t60,
487            "T30 = {:.3}s, expected ≈ {:.3}s",
488            m.t30_s,
489            target_t60
490        );
491        assert!(
492            m.t20_s.is_finite() && (m.t20_s - target_t60).abs() < 0.20 * target_t60,
493            "T20 = {:.3}s, expected ≈ {:.3}s",
494            m.t20_s,
495            target_t60
496        );
497        // EDT on a pure exponential equals T60. Loose tolerance because
498        // only the first 10 dB are used.
499        assert!(
500            m.edt_s.is_finite() && (m.edt_s - target_t60).abs() < 0.40 * target_t60,
501            "EDT = {:.3}s, expected ≈ {:.3}s",
502            m.edt_s,
503            target_t60
504        );
505
506        // r² should be high on a clean exponential decay.
507        assert!(m.t20_r2 > 0.9, "T20 r² = {:.3}", m.t20_r2);
508        assert!(m.t30_r2 > 0.9, "T30 r² = {:.3}", m.t30_r2);
509    }
510
511    #[test]
512    fn definition_and_clarity_for_anechoic_ir_max_out() {
513        // Single direct sound, no reverberation.
514        let sr = 48000.0;
515        let mut rir = vec![0.0f32; (sr as usize) / 10]; // 100 ms
516        rir[0] = 1.0;
517        let m = analyze_iso3382(&rir, sr);
518        // All energy is in the first sample → D50 = 1, C50/C80 are large.
519        assert!((m.d50 - 1.0).abs() < 1e-6, "D50 = {}", m.d50);
520        assert!(m.c50_db > 100.0, "C50 = {}", m.c50_db);
521        assert!(m.c80_db > 100.0, "C80 = {}", m.c80_db);
522        // Center time → 0 because all energy is at t = 0.
523        assert!(m.ts_s.abs() < 1e-9, "Ts = {}", m.ts_s);
524    }
525
526    #[test]
527    fn clarity_for_uniform_energy_rir() {
528        // Uniform energy across 100 ms: C80 should be exactly
529        // 10·log10(80 / 20) ≈ 6.02 dB; D50 = 0.5; Ts = 50 ms.
530        let sr = 48000.0;
531        let n = (sr * 0.1) as usize;
532        let rir = vec![1.0f32; n];
533        let m = analyze_iso3382(&rir, sr);
534        let expected_c80 = 10.0 * (80.0_f64 / 20.0).log10();
535        assert!(
536            (m.c80_db - expected_c80).abs() < 0.05,
537            "C80 = {}, expected {}",
538            m.c80_db,
539            expected_c80
540        );
541        assert!((m.d50 - 0.5).abs() < 0.005, "D50 = {}", m.d50);
542        assert!((m.ts_s - 0.050).abs() < 0.001, "Ts = {}s", m.ts_s);
543    }
544
545    #[test]
546    fn empty_rir_returns_nan() {
547        let m = analyze_iso3382(&[], 48000.0);
548        assert!(m.t30_s.is_nan());
549        assert!(m.c80_db.is_nan());
550    }
551
552    #[test]
553    fn fit_is_valid_threshold() {
554        let mut m = EMPTY_METRICS;
555        m.edt_r2 = 0.96;
556        m.t20_r2 = 0.97;
557        m.t30_r2 = 0.95;
558        assert!(m.fit_is_valid());
559        m.t30_r2 = 0.94;
560        assert!(!m.fit_is_valid());
561    }
562}