Skip to main content

math_rir/
lib.rs

1//! # math-rir: Room Impulse Response Analysis
2//!
3//! SSIR (Spatial Segmentation of Impulse Response) implementation for detecting,
4//! segmenting, and analyzing early reflections in measured room impulse responses.
5//!
6//! Based on: Pawlak & Lee, "Spatial segmentation of impulse response for room
7//! reflection analysis and auralization", Applied Acoustics 249 (2026).
8//!
9//! ## Overview
10//!
11//! The SSIR method segments a Room Impulse Response (RIR) into consecutive,
12//! variable-length sound events (direct sound + early reflections), each with
13//! a constant direction of arrival (DOA). This preserves the full temporal
14//! energy profile while enabling per-reflection manipulation.
15//!
16//! ## Usage
17//!
18//! ```rust
19//! use math_rir::{analyze_rir, SsirConfig};
20//!
21//! let rir: Vec<f32> = load_impulse_response(); // your RIR data
22//! let config = SsirConfig::new(48000.0);
23//! let result = analyze_rir(&rir, &config);
24//!
25//! println!("Detected {} events ({} reflections)",
26//!     result.num_events(), result.num_reflections());
27//! println!("Mixing time: {:.1}ms", result.mixing_time_ms());
28//!
29//! for seg in result.reflections() {
30//!     println!("  Reflection at {:.1}ms, duration {:.1}ms",
31//!         seg.toa_ms(48000.0), seg.duration_ms(48000.0));
32//! }
33//! # fn load_impulse_response() -> Vec<f32> { vec![0.0; 4800] }
34//! ```
35
36mod config;
37mod detection;
38mod mixing_time;
39mod segmentation;
40mod types;
41
42pub use config::SsirConfig;
43pub use math_audio_iir_fir::filtfilt;
44pub use types::{RirSegment, SsirResult};
45
46use detection::{detect_reflections, find_direct_sound_toa};
47use mixing_time::estimate_mixing_time;
48use rayon::prelude::*;
49use segmentation::build_segments;
50
51/// Analyze a mono room impulse response using the SSIR method.
52///
53/// Detects the direct sound, identifies early reflections via Local Energy Ratio,
54/// and segments the early RIR into consecutive sound events.
55///
56/// For mono input, DOA validation is not available — only energy-based and
57/// temporal distance criteria are used for reflection detection.
58///
59/// Returns an [`SsirResult`] with the detected segments and mixing time.
60pub fn analyze_rir(rir: &[f32], config: &SsirConfig) -> SsirResult {
61    if rir.is_empty() {
62        return SsirResult {
63            segments: Vec::new(),
64            mixing_time_samples: 0,
65            sample_rate: config.sample_rate,
66        };
67    }
68
69    // Step 1: Estimate mixing time (or use configured value)
70    let mixing_time_samples = if config.mixing_time_ms.is_some() {
71        config.mixing_time_samples()
72    } else {
73        estimate_mixing_time(rir, config.sample_rate)
74    };
75
76    // Step 2: Find direct sound TOA
77    let direct_sound_toa = match find_direct_sound_toa(rir, config) {
78        Some(toa) => toa,
79        None => {
80            // No direct sound detected — return empty result
81            return SsirResult {
82                segments: Vec::new(),
83                mixing_time_samples,
84                sample_rate: config.sample_rate,
85            };
86        }
87    };
88
89    // Step 3: Detect early reflections (no DOA data for mono)
90    let reflections = detect_reflections(rir, direct_sound_toa, None, config);
91
92    // Step 4: Build segments with onset refinement
93    let segments = build_segments(
94        rir,
95        direct_sound_toa,
96        None,
97        &reflections,
98        mixing_time_samples,
99        config,
100    );
101
102    SsirResult {
103        segments,
104        mixing_time_samples,
105        sample_rate: config.sample_rate,
106    }
107}
108
109/// Analyze a multi-channel Spatial Room Impulse Response (SRIR) using the full SSIR method.
110///
111/// Uses the first channel as the omnidirectional pressure signal for energy-based
112/// detection, and derives DOA from all channels using the intensity vector method.
113///
114/// `channels` should contain at least 4 channels (B-format: W, X, Y, Z) for
115/// meaningful DOA estimation. The first channel (W) is used as the omnidirectional
116/// signal for reflection detection.
117///
118/// Falls back to mono analysis if fewer than 4 channels are provided.
119pub fn analyze_srir(channels: &[&[f32]], config: &SsirConfig) -> SsirResult {
120    if channels.is_empty() || channels[0].is_empty() {
121        return SsirResult {
122            segments: Vec::new(),
123            mixing_time_samples: 0,
124            sample_rate: config.sample_rate,
125        };
126    }
127
128    // Use first channel as omnidirectional pressure
129    let omni = channels[0];
130
131    // Need at least W, X, Y, Z (4 channels) for DOA estimation
132    if channels.len() < 4 {
133        return analyze_rir(omni, config);
134    }
135
136    // Verify all channels have the same length
137    let len = omni.len();
138    if channels.iter().any(|ch| ch.len() != len) {
139        return analyze_rir(omni, config);
140    }
141
142    // Step 1: Estimate mixing time
143    let mixing_time_samples = if config.mixing_time_ms.is_some() {
144        config.mixing_time_samples()
145    } else {
146        estimate_mixing_time(omni, config.sample_rate)
147    };
148
149    // Step 2: Find direct sound TOA
150    let direct_sound_toa = match find_direct_sound_toa(omni, config) {
151        Some(toa) => toa,
152        None => {
153            return SsirResult {
154                segments: Vec::new(),
155                mixing_time_samples,
156                sample_rate: config.sample_rate,
157            };
158        }
159    };
160
161    // Step 3: Compute DOA vectors from band-limited B-format channels
162    // B-format: W (omni), X (front-back), Y (left-right), Z (up-down)
163    let doa_vectors = compute_bformat_doa(channels, len, config);
164
165    // Step 4: Detect reflections with DOA validation
166    let reflections = detect_reflections(omni, direct_sound_toa, Some(&doa_vectors), config);
167
168    // Step 5: Build segments (pass direct sound DOA from the DOA vector at its TOA)
169    let ds_doa = doa_vectors.get(direct_sound_toa).copied();
170    let segments = build_segments(
171        omni,
172        direct_sound_toa,
173        ds_doa,
174        &reflections,
175        mixing_time_samples,
176        config,
177    );
178
179    SsirResult {
180        segments,
181        mixing_time_samples,
182        sample_rate: config.sample_rate,
183    }
184}
185
186/// Compute per-sample DOA unit vectors from B-format (Ambisonics) channels.
187///
188/// The channels are band-limited with a zero-phase Butterworth bandpass filter
189/// before computing the pseudo-intensity vector. This improves DOA reliability
190/// by excluding low frequencies (poor spatial resolution) and high frequencies
191/// (spatial aliasing).
192///
193/// Uses the pseudo-intensity vector: I = P * V, where P = W and V = [X, Y, Z].
194/// The DOA is the normalized intensity vector direction.
195fn compute_bformat_doa(channels: &[&[f32]], len: usize, config: &SsirConfig) -> Vec<[f32; 3]> {
196    let (low_hz, high_hz) = config.doa_bandpass_hz;
197    let order = config.doa_bandpass_order;
198    let nyquist = config.sample_rate / 2.0;
199
200    // Band-limit all 4 B-format channels with zero-phase filtering.
201    // Skip filtering if the band covers the full spectrum or the signal is too short.
202    let needs_filtering = low_hz > 0.0 && high_hz < nyquist && len >= 4 && order >= 1;
203
204    let (w, x, y, z): (Vec<f32>, Vec<f32>, Vec<f32>, Vec<f32>) = if needs_filtering {
205        let mut sections =
206            filtfilt::peq_to_coefficients(&math_audio_iir_fir::peq_butterworth_highpass(
207                order as usize,
208                low_hz,
209                config.sample_rate,
210            ));
211        sections.extend(filtfilt::peq_to_coefficients(
212            &math_audio_iir_fir::peq_butterworth_lowpass(
213                order as usize,
214                high_hz,
215                config.sample_rate,
216            ),
217        ));
218        // Convert f32 channels to f64, filter, convert back
219        let filter_channel = |ch: &[f32]| -> Vec<f32> {
220            let ch_f64: Vec<f64> = ch.iter().map(|&s| s as f64).collect();
221            filtfilt::filtfilt(&ch_f64, &sections)
222                .into_iter()
223                .map(|s| s as f32)
224                .collect()
225        };
226        let ((w, x), (y, z)) = rayon::join(
227            || {
228                rayon::join(
229                    || filter_channel(channels[0]),
230                    || filter_channel(channels[1]),
231                )
232            },
233            || {
234                rayon::join(
235                    || filter_channel(channels[2]),
236                    || filter_channel(channels[3]),
237                )
238            },
239        );
240        (w, x, y, z)
241    } else {
242        (
243            channels[0].to_vec(),
244            channels[1].to_vec(),
245            channels[2].to_vec(),
246            channels[3].to_vec(),
247        )
248    };
249
250    (0..len)
251        .into_par_iter()
252        .map(|i| {
253            let p = w[i] as f64;
254            // Intensity vector components
255            let ix = p * x[i] as f64;
256            let iy = p * y[i] as f64;
257            let iz = p * z[i] as f64;
258
259            let mag = (ix * ix + iy * iy + iz * iz).sqrt();
260            if mag < 1e-12 {
261                [0.0f32, 0.0, 0.0]
262            } else {
263                [(ix / mag) as f32, (iy / mag) as f32, (iz / mag) as f32]
264            }
265        })
266        .collect()
267}
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    /// Helper: create a synthetic RIR with known reflections
274    fn make_synthetic_rir(
275        sample_rate: f64,
276        reflection_times_ms: &[f64],
277        reflection_gains: &[f32],
278    ) -> Vec<f32> {
279        let duration_ms = 100.0;
280        let len = (duration_ms * sample_rate / 1000.0) as usize;
281        let mut rir = vec![0.0001f32; len]; // low noise floor
282
283        // Direct sound at 1ms
284        let ds_sample = (1.0 * sample_rate / 1000.0) as usize;
285        rir[ds_sample] = 1.0;
286
287        // Add reflections
288        for (&time_ms, &gain) in reflection_times_ms.iter().zip(reflection_gains.iter()) {
289            let sample = (time_ms * sample_rate / 1000.0) as usize;
290            if sample < len {
291                rir[sample] = gain;
292            }
293        }
294
295        rir
296    }
297
298    #[test]
299    fn test_analyze_rir_basic() {
300        let rir = make_synthetic_rir(48000.0, &[6.0, 10.0, 15.0, 22.0], &[0.5, 0.3, 0.25, 0.15]);
301
302        let config = SsirConfig {
303            sample_rate: 48000.0,
304            mixing_time_ms: Some(40.0),
305            ..SsirConfig::default()
306        };
307
308        let result = analyze_rir(&rir, &config);
309
310        // Should detect direct sound + reflections
311        assert!(
312            result.num_events() >= 3,
313            "expected >= 3 events, got {}",
314            result.num_events()
315        );
316        assert!(result.segments[0].is_direct_sound);
317
318        // Segments should be consecutive
319        for i in 0..result.segments.len() - 1 {
320            assert_eq!(
321                result.segments[i].end_sample,
322                result.segments[i + 1].onset_sample,
323                "segments {} and {} are not consecutive",
324                i,
325                i + 1
326            );
327        }
328
329        // All reflection TOAs should be within the early RIR
330        for seg in result.reflections() {
331            let toa_ms = seg.toa_ms(48000.0);
332            assert!(
333                toa_ms > 1.0 && toa_ms < 40.0,
334                "reflection TOA {toa_ms:.1}ms outside expected range"
335            );
336        }
337    }
338
339    #[test]
340    fn test_analyze_rir_empty() {
341        let config = SsirConfig::new(48000.0);
342        let result = analyze_rir(&[], &config);
343        assert_eq!(result.num_events(), 0);
344    }
345
346    #[test]
347    fn test_analyze_rir_single_impulse() {
348        // Anechoic: only direct sound, no reflections
349        let mut rir = vec![0.0001f32; 4800]; // 100ms
350        rir[48] = 1.0;
351
352        let config = SsirConfig {
353            sample_rate: 48000.0,
354            mixing_time_ms: Some(40.0),
355            ..SsirConfig::default()
356        };
357
358        let result = analyze_rir(&rir, &config);
359
360        // Should have at least the direct sound
361        assert!(result.num_events() >= 1);
362        assert!(result.segments[0].is_direct_sound);
363    }
364
365    #[test]
366    fn test_analyze_srir_fallback_to_mono() {
367        let rir = make_synthetic_rir(48000.0, &[6.0, 10.0], &[0.5, 0.3]);
368
369        let config = SsirConfig {
370            sample_rate: 48000.0,
371            mixing_time_ms: Some(40.0),
372            ..SsirConfig::default()
373        };
374
375        // Only 2 channels — should fall back to mono
376        let result = analyze_srir(&[&rir, &rir], &config);
377        assert!(result.num_events() >= 2);
378    }
379
380    #[test]
381    fn test_analyze_srir_bformat() {
382        let len = 4800;
383        let mut w = vec![0.0001f32; len]; // omni
384        let mut x = vec![0.0f32; len]; // front-back
385        let mut y = vec![0.0f32; len]; // left-right
386        let z = vec![0.0f32; len]; // up-down
387
388        // Direct sound from front (positive X)
389        w[48] = 1.0;
390        x[48] = 1.0;
391        y[48] = 0.0;
392
393        // Reflection from left at 6ms (positive Y)
394        w[288] = 0.5;
395        x[288] = 0.0;
396        y[288] = 0.5;
397
398        // Reflection from right at 10ms (negative Y)
399        w[480] = 0.3;
400        x[480] = 0.0;
401        y[480] = -0.3;
402
403        let config = SsirConfig {
404            sample_rate: 48000.0,
405            mixing_time_ms: Some(40.0),
406            ..SsirConfig::default()
407        };
408
409        let result = analyze_srir(&[&w, &x, &y, &z], &config);
410
411        assert!(
412            result.num_events() >= 2,
413            "expected >= 2 events, got {}",
414            result.num_events()
415        );
416
417        // Check that DOA is present on segments
418        for seg in &result.segments {
419            assert!(seg.doa.is_some(), "SRIR segments should have DOA data");
420        }
421    }
422
423    #[test]
424    fn test_segments_cover_early_rir() {
425        let rir = make_synthetic_rir(48000.0, &[6.0, 12.0, 20.0], &[0.5, 0.3, 0.2]);
426
427        let config = SsirConfig {
428            sample_rate: 48000.0,
429            mixing_time_ms: Some(40.0),
430            ..SsirConfig::default()
431        };
432
433        let result = analyze_rir(&rir, &config);
434
435        // First segment should start at 0
436        assert_eq!(result.segments[0].onset_sample, 0);
437
438        // Segments should be non-empty
439        for seg in &result.segments {
440            assert!(!seg.is_empty(), "segment should have non-zero length");
441        }
442    }
443
444    #[test]
445    fn test_mixing_time_auto_estimation() {
446        // Create a RIR with sparse reflections then dense reverb
447        let sample_rate = 48000.0;
448        let len = (0.200 * sample_rate) as usize;
449        let mut rir = vec![0.0f32; len];
450
451        // Direct sound
452        rir[48] = 1.0;
453        // Sparse reflections
454        rir[240] = 0.5;
455        rir[480] = 0.3;
456
457        // Dense reverb starting at ~30ms
458        let reverb_start = (0.030 * sample_rate) as usize;
459        let mut amp = 0.08f32;
460        let mut rng: u32 = 12345;
461        for sample in rir.iter_mut().take(len).skip(reverb_start) {
462            rng = rng.wrapping_mul(1103515245).wrapping_add(12345);
463            let noise = ((rng >> 16) as f32 / 32768.0) - 1.0;
464            *sample += noise * amp;
465            amp *= 0.9997;
466        }
467
468        let config = SsirConfig {
469            sample_rate,
470            mixing_time_ms: None, // auto-estimate
471            ..SsirConfig::default()
472        };
473
474        let result = analyze_rir(&rir, &config);
475
476        // Mixing time should be in reasonable range
477        let mt_ms = result.mixing_time_ms();
478        assert!(
479            (10.0..=80.0).contains(&mt_ms),
480            "auto mixing time {mt_ms:.1}ms outside expected range"
481        );
482    }
483
484    #[test]
485    fn test_analyze_rir_very_short() {
486        // RIR shorter than one LER window (48 samples at 48kHz = 1ms)
487        let rir = vec![0.5f32; 10];
488        let config = SsirConfig::new(48000.0);
489        let result = analyze_rir(&rir, &config);
490        // Should not panic, may find 0 or 1 events
491        assert!(result.num_events() <= 1);
492    }
493
494    #[test]
495    fn test_analyze_rir_all_zeros() {
496        let rir = vec![0.0f32; 4800];
497        let config = SsirConfig {
498            sample_rate: 48000.0,
499            mixing_time_ms: Some(40.0),
500            ..SsirConfig::default()
501        };
502        let result = analyze_rir(&rir, &config);
503        // All-zero RIR: no detectable direct sound
504        assert_eq!(result.num_events(), 0);
505    }
506
507    #[test]
508    fn test_analyze_rir_dc_offset() {
509        // RIR with DC offset — should still detect the impulse
510        let mut rir = vec![0.1f32; 4800];
511        rir[48] = 1.0;
512        rir[288] = 0.6;
513
514        let config = SsirConfig {
515            sample_rate: 48000.0,
516            mixing_time_ms: Some(40.0),
517            ..SsirConfig::default()
518        };
519        let result = analyze_rir(&rir, &config);
520        assert!(result.num_events() >= 1);
521    }
522
523    #[test]
524    fn test_segment_duration_ms_accuracy() {
525        let seg = RirSegment {
526            onset_sample: 0,
527            end_sample: 480,
528            toa_sample: 48,
529            doa: None,
530            peak_energy: 1.0,
531            is_direct_sound: true,
532        };
533        let dur = seg.duration_ms(48000.0);
534        assert!((dur - 10.0).abs() < 0.01, "expected 10ms, got {dur}ms");
535    }
536
537    #[test]
538    fn test_direct_sound_toa_at_rir_boundary() {
539        // Direct sound at the very start
540        let mut rir = vec![0.0001f32; 2400];
541        rir[0] = 1.0;
542        rir[288] = 0.3;
543
544        let config = SsirConfig {
545            sample_rate: 48000.0,
546            mixing_time_ms: Some(40.0),
547            ..SsirConfig::default()
548        };
549        let result = analyze_rir(&rir, &config);
550        assert!(result.num_events() >= 1);
551        assert!(result.segments[0].is_direct_sound);
552    }
553}