1use crate::config::SsirConfig;
30use crate::detection::find_direct_sound_toa;
31
32#[derive(Debug, Clone)]
41pub struct DecayCurve {
42 pub samples: Vec<f64>,
44 pub sample_rate: f64,
46 pub noise_cutoff_sample: usize,
50}
51
52impl DecayCurve {
53 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 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 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 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 pub fn first_crossing(&self, threshold_db: f64) -> Option<usize> {
133 self.samples.iter().position(|&v| v <= threshold_db)
134 }
135
136 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 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
156pub 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
175fn 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
220pub 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 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 let threshold = noise_e * 10.0;
249
250 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 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#[derive(Debug, Clone, Copy, PartialEq)]
280pub struct Iso3382Metrics {
281 pub edt_s: f64,
284 pub t20_s: f64,
286 pub t30_s: f64,
288 pub c50_db: f64,
290 pub c80_db: f64,
292 pub d50: f64,
294 pub ts_s: f64,
296 pub edt_r2: f64,
298 pub t20_r2: f64,
300 pub t30_r2: f64,
302}
303
304impl Iso3382Metrics {
305 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
312pub 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 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 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 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 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 let alpha = std::f64::consts::LN_10 * 6.0 / (2.0 * t60_s);
418 let mut rir = vec![0.0f32; n];
419 rir[0] = 1.0;
421 let mut state: u64 = 0xDEAD_BEEF_CAFE_BABE;
423 for (i, sample) in rir.iter_mut().enumerate().skip(1) {
424 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 for w in curve.samples.windows(2) {
454 assert!(w[1] <= w[0] + 1e-9, "non-monotonic: {} -> {}", w[0], w[1]);
455 }
456 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 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 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 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 let sr = 48000.0;
515 let mut rir = vec![0.0f32; (sr as usize) / 10]; rir[0] = 1.0;
517 let m = analyze_iso3382(&rir, sr);
518 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 assert!(m.ts_s.abs() < 1e-9, "Ts = {}", m.ts_s);
524 }
525
526 #[test]
527 fn clarity_for_uniform_energy_rir() {
528 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}