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