1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
//! First-reflection cancellation using LP-filtered echo subtraction.
//!
//! Based on Johnston (AES): applies partial cancellation of the first
//! strong reflection below ~500Hz, removing "boxiness" without creating
//! artifacts outside the sweet spot.
//!
//! Algorithm: `y[n] = x[n] - g * LP(x[n - d])`
//! where g = reflection gain, d = reflection delay, LP = Butterworth lowpass.
use math_audio_iir_fir::{Biquad, BiquadFilterType};
/// Configuration for first-reflection cancellation.
#[derive(Debug, Clone)]
pub struct ReflectionCancellationConfig {
/// Enable/disable reflection cancellation.
pub enabled: bool,
/// Maximum frequency for cancellation (Hz). Above this, reflections are left alone.
/// Per Johnston: ~500 Hz gives ~0.5ft sweet spot radius.
pub max_freq_hz: f64,
/// Maximum attenuation of the reflection (dB). Partial cancellation avoids
/// bizarre artifacts outside the sweet spot.
pub max_attenuation_db: f64,
/// Butterworth lowpass filter order (typically 4).
pub lp_order: usize,
}
impl Default for ReflectionCancellationConfig {
fn default() -> Self {
Self {
enabled: true,
max_freq_hz: 500.0,
max_attenuation_db: 6.0,
lp_order: 4,
}
}
}
/// Result of reflection cancellation analysis for one channel.
#[derive(Debug, Clone)]
pub struct ReflectionCancellationResult {
/// Delay of the first reflection in samples.
pub delay_samples: usize,
/// Delay in milliseconds.
pub delay_ms: f64,
/// Attenuation to apply (linear, 0..1).
pub attenuation_linear: f64,
/// LP filter cutoff frequency.
pub lp_cutoff_hz: f64,
/// Biquad cascade for the lowpass filter.
pub lp_biquads: Vec<Biquad>,
}
/// Compute reflection cancellation parameters from SSIR analysis.
///
/// Finds the first strong reflection, computes its delay and relative gain,
/// and designs the LP filter for the cancellation path.
///
/// Returns `None` if cancellation is disabled, no direct sound is found,
/// or no suitable reflection exists.
pub fn compute_reflection_cancellation(
ssir_result: &math_rir::SsirResult,
sample_rate: f64,
config: &ReflectionCancellationConfig,
) -> Option<ReflectionCancellationResult> {
if !config.enabled {
return None;
}
// Step 1: Find the direct sound segment.
let direct = ssir_result.direct_sound()?;
// Step 2: Find the first non-direct-sound segment with significant energy.
// "Significant" = peak_energy > 0 (any detectable reflection).
let first_reflection = ssir_result
.reflections()
.find(|s| s.peak_energy > 0.0)?;
// Step 3: Compute delay in samples between direct sound TOA and reflection TOA.
let delay_samples = first_reflection
.toa_sample
.saturating_sub(direct.toa_sample);
if delay_samples == 0 {
return None;
}
let delay_ms = delay_samples as f64 / sample_rate * 1000.0;
// Step 4: Compute gain = sqrt(reflection_energy / direct_energy).
// This is the amplitude ratio of the reflection relative to the direct sound.
if direct.peak_energy <= 0.0 {
return None;
}
let raw_gain = (first_reflection.peak_energy / direct.peak_energy).sqrt();
// Step 5: Clamp gain so attenuation doesn't exceed max_attenuation_db.
// max_attenuation_db limits how much we subtract. Convert dB limit to linear ceiling.
let max_gain = 1.0 - 10f64.powf(-config.max_attenuation_db / 20.0);
let gain = raw_gain.min(max_gain);
// Step 6: Design Butterworth LP cascade at max_freq_hz.
let q_values = math_audio_iir_fir::peq_butterworth_q(config.lp_order);
let lp_biquads: Vec<Biquad> = q_values
.into_iter()
.map(|q| Biquad::new(BiquadFilterType::Lowpass, config.max_freq_hz, sample_rate, q, 0.0))
.collect();
Some(ReflectionCancellationResult {
delay_samples,
delay_ms,
attenuation_linear: gain,
lp_cutoff_hz: config.max_freq_hz,
lp_biquads,
})
}
#[cfg(test)]
mod tests {
use super::*;
use math_rir::{RirSegment, SsirResult};
fn make_ssir(segments: Vec<RirSegment>) -> SsirResult {
let mixing_time = segments.last().map(|s| s.end_sample).unwrap_or(0);
SsirResult {
segments,
mixing_time_samples: mixing_time,
sample_rate: 48000.0,
}
}
fn direct_segment(toa: usize, energy: f64) -> RirSegment {
RirSegment {
onset_sample: 0,
end_sample: toa + 100,
toa_sample: toa,
doa: None,
peak_energy: energy,
is_direct_sound: true,
}
}
fn reflection_segment(onset: usize, toa: usize, end: usize, energy: f64) -> RirSegment {
RirSegment {
onset_sample: onset,
end_sample: end,
toa_sample: toa,
doa: None,
peak_energy: energy,
is_direct_sound: false,
}
}
#[test]
fn test_reflection_cancellation_basic() {
// Direct at sample 0, reflection at sample 480 (10ms @ 48kHz), -6dB energy
// energy ratio = 0.25 (since energy is amplitude squared, -6dB amplitude = 0.5, energy = 0.25)
let ssir = make_ssir(vec![
direct_segment(0, 1.0),
reflection_segment(100, 480, 960, 0.25),
]);
let config = ReflectionCancellationConfig::default();
let result = compute_reflection_cancellation(&ssir, 48000.0, &config).unwrap();
assert_eq!(result.delay_samples, 480);
assert!((result.delay_ms - 10.0).abs() < 0.01);
// gain = sqrt(0.25/1.0) = 0.5, max_gain = 1 - 10^(-6/20) ≈ 0.499
// so gain should be clamped to ~0.499
assert!(result.attenuation_linear > 0.0 && result.attenuation_linear < 1.0);
assert_eq!(result.lp_cutoff_hz, 500.0);
// Order 4 Butterworth = 2 biquad sections
assert_eq!(result.lp_biquads.len(), 2);
}
#[test]
fn test_no_reflection_returns_none() {
// Only direct sound, no reflections.
let ssir = make_ssir(vec![direct_segment(0, 1.0)]);
let config = ReflectionCancellationConfig::default();
assert!(compute_reflection_cancellation(&ssir, 48000.0, &config).is_none());
}
#[test]
fn test_attenuation_clamped() {
// Reflection at -1dB (very strong): energy ratio ~0.794 (amplitude 0.891, energy 0.794)
let ssir = make_ssir(vec![
direct_segment(0, 1.0),
reflection_segment(100, 480, 960, 0.794),
]);
let config = ReflectionCancellationConfig {
max_attenuation_db: 6.0,
..Default::default()
};
let result = compute_reflection_cancellation(&ssir, 48000.0, &config).unwrap();
// raw_gain = sqrt(0.794) ≈ 0.891
// max_gain = 1 - 10^(-6/20) ≈ 0.499
// Should be clamped.
let max_gain = 1.0 - 10f64.powf(-6.0 / 20.0);
assert!((result.attenuation_linear - max_gain).abs() < 0.001);
}
#[test]
fn test_disabled_returns_none() {
let ssir = make_ssir(vec![
direct_segment(0, 1.0),
reflection_segment(100, 480, 960, 0.25),
]);
let config = ReflectionCancellationConfig {
enabled: false,
..Default::default()
};
assert!(compute_reflection_cancellation(&ssir, 48000.0, &config).is_none());
}
}