audio_engine_core/processor/crossfeed.rs
1//! Bauer Binaural Crossfeed for Headphone Listening
2//!
3//! Simulates speaker crosstalk to reduce "inside-head" localization.
4//! Based on the Bauer stereophonic-to-binaural filter.
5//!
6//! # Algorithm
7//!
8//! ```text
9//! L_out = L + α × HPF(R)
10//! R_out = R + α × HPF(L)
11//! ```
12//!
13//! Where HPF is a second-order high-pass filter (~700Hz cutoff),
14//! and α is the crossfeed amount (0.3-0.45 typical).
15//!
16//! # Use Cases
17//!
18//! - Headphone listening with speaker-like imaging
19//! - Reduces listener fatigue from extreme stereo separation
20//! - Particularly beneficial for older recordings with hard-panned instruments
21
22/// Bauer crossfeed processor
23///
24/// Uses a 2nd-order Butterworth high-pass filter for the crossfeed path.
25/// Implementation uses Direct Form II Transposed for numerical stability.
26pub struct Crossfeed {
27 // HPF state for L→R path (filtering L to mix into R)
28 // Direct Form II Transposed only needs 2 state variables
29 w_lr: [f64; 2],
30 // HPF state for R→L path (filtering R to mix into L)
31 w_rl: [f64; 2],
32
33 // Biquad coefficients (2nd-order Butterworth HPF)
34 // H(z) = (b0 + b1*z^-1 + b2*z^-2) / (1 + a1*z^-1 + a2*z^-2)
35 b0: f64,
36 b1: f64,
37 b2: f64,
38 a1: f64,
39 a2: f64,
40
41 // Crossfeed amount (0.0 - 1.0)
42 mix: f64,
43 // Enable flag
44 enabled: bool,
45}
46
47impl Crossfeed {
48 /// Create a new crossfeed processor with default settings
49 ///
50 /// Default: 700Hz cutoff, 0.35 crossfeed amount
51 pub fn new(sample_rate: f64) -> Self {
52 Self::with_params(sample_rate, 700.0, 0.35)
53 }
54
55 /// Create with custom parameters
56 ///
57 /// # Arguments
58 /// * `sample_rate` - Audio sample rate in Hz
59 /// * `cutoff_hz` - HPF cutoff frequency (600-900 Hz typical)
60 /// * `mix` - Crossfeed amount (0.0 - 1.0, 0.3-0.45 typical)
61 pub fn with_params(sample_rate: f64, cutoff_hz: f64, mix: f64) -> Self {
62 let (b0, b1, b2, a1, a2) = Self::calc_hpf_coeffs(cutoff_hz, sample_rate);
63
64 Self {
65 w_lr: [0.0; 2],
66 w_rl: [0.0; 2],
67 b0,
68 b1,
69 b2,
70 a1,
71 a2,
72 mix: mix.clamp(0.0, 1.0),
73 enabled: true,
74 }
75 }
76
77 /// Calculate 2nd-order Butterworth HPF coefficients using bilinear transform
78 ///
79 /// High-pass transform: substitute s → 1/s in low-pass prototype
80 /// LPF: H(s) = 1 / (1 + √2·s + s²)
81 /// HPF: H(s) = s² / (s² + √2·s + 1)
82 fn calc_hpf_coeffs(cutoff: f64, sr: f64) -> (f64, f64, f64, f64, f64) {
83 // Bilinear transform: s = (2/T) * (z-1)/(z+1) = (1/k) * (z-1)/(z+1)
84 // where k = tan(ωc·T/2) = tan(π·fc/fs)
85 let wc = std::f64::consts::PI * cutoff / sr;
86 let k = wc.tan();
87 let k2 = k * k;
88
89 // Butterworth 2nd-order HPF via low-pass to high-pass transform
90 // HPF numerator: (1 - 2z^-1 + z^-2) for the z² factor
91 // After bilinear transform on HPF prototype:
92 // b0 = 1/(1 + √2k + k²), b1 = -2/(...), b2 = 1/(...)
93 let sqrt2_k = std::f64::consts::SQRT_2 * k;
94 let norm = 1.0 / (1.0 + sqrt2_k + k2);
95
96 // HPF coefficients (numerator has 1, -2, 1 pattern, NOT k² pattern)
97 let b0 = norm;
98 let b1 = -2.0 * norm;
99 let b2 = norm;
100
101 // Denominator (same for LPF and HPF after transform)
102 let a1 = 2.0 * (k2 - 1.0) * norm;
103 let a2 = (1.0 - sqrt2_k + k2) * norm;
104
105 (b0, b1, b2, a1, a2)
106 }
107
108 /// Set crossfeed amount (0.0 - 1.0)
109 pub fn set_mix(&mut self, mix: f64) {
110 self.mix = mix.clamp(0.0, 1.0);
111 }
112
113 /// Update sample rate and recalculate HPF coefficients
114 /// This is critical when playing files with different sample rates
115 /// (e.g., 44.1kHz vs 192kHz) to maintain correct cutoff frequency.
116 pub fn set_sample_rate(&mut self, sample_rate: f64, cutoff_hz: f64) {
117 let (b0, b1, b2, a1, a2) = Self::calc_hpf_coeffs(cutoff_hz, sample_rate);
118 self.b0 = b0;
119 self.b1 = b1;
120 self.b2 = b2;
121 self.a1 = a1;
122 self.a2 = a2;
123 // Reset filter state to avoid artifacts from previous sample rate
124 self.reset();
125 }
126
127 /// Set enabled state
128 pub fn set_enabled(&mut self, enabled: bool) {
129 self.enabled = enabled;
130 }
131
132 /// Reset filter state
133 pub fn reset(&mut self) {
134 self.w_lr = [0.0; 2];
135 self.w_rl = [0.0; 2];
136 }
137
138 /// Process interleaved stereo samples in-place
139 ///
140 /// Only processes if channels == 2 (stereo). Mono and multi-channel pass through.
141 pub fn process(&mut self, samples: &mut [f64], channels: usize) {
142 if !self.enabled || channels != 2 {
143 return;
144 }
145
146 // Cache coefficients to avoid borrowing issues
147 let b0 = self.b0;
148 let b1 = self.b1;
149 let b2 = self.b2;
150 let a1 = self.a1;
151 let a2 = self.a2;
152 let mix = self.mix;
153
154 for chunk in samples.chunks_exact_mut(2) {
155 let l_in = chunk[0];
156 let r_in = chunk[1];
157
158 // Apply HPF to L for R output (L→R crossfeed)
159 let hpf_l = Self::process_hpf_df2t_static(&mut self.w_lr, b0, b1, b2, a1, a2, l_in);
160
161 // Apply HPF to R for L output (R→L crossfeed)
162 let hpf_r = Self::process_hpf_df2t_static(&mut self.w_rl, b0, b1, b2, a1, a2, r_in);
163
164 // Mix crossfeed
165 chunk[0] = l_in + hpf_r * mix; // L + α×HPF(R)
166 chunk[1] = r_in + hpf_l * mix; // R + α×HPF(L)
167 }
168 }
169
170 /// Direct Form II Transposed biquad processing (static version)
171 ///
172 /// Numerically stable, only requires 2 state variables.
173 /// y[n] = b0*x[n] + w0
174 /// w0' = b1*x[n] - a1*y[n] + w1
175 /// w1' = b2*x[n] - a2*y[n]
176 #[inline(always)]
177 fn process_hpf_df2t_static(
178 w: &mut [f64; 2],
179 b0: f64,
180 b1: f64,
181 b2: f64,
182 a1: f64,
183 a2: f64,
184 input: f64,
185 ) -> f64 {
186 let output = b0 * input + w[0];
187 w[0] = b1 * input - a1 * output + w[1];
188 w[1] = b2 * input - a2 * output;
189 #[cfg(not(any(target_arch = "x86", target_arch = "x86_64", target_arch = "aarch64")))]
190 {
191 w[0] = crate::runtime::flush_subnormal_sample(w[0]);
192 w[1] = crate::runtime::flush_subnormal_sample(w[1]);
193 }
194 output
195 }
196
197 /// Get current settings
198 pub fn get_settings(&self) -> CrossfeedSettings {
199 CrossfeedSettings {
200 mix: self.mix,
201 enabled: self.enabled,
202 }
203 }
204}
205
206impl Default for Crossfeed {
207 fn default() -> Self {
208 Self::new(44100.0)
209 }
210}
211
212/// Settings struct for API responses
213#[derive(Debug, Clone, serde::Serialize)]
214pub struct CrossfeedSettings {
215 pub mix: f64,
216 pub enabled: bool,
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 #[test]
224 fn test_crossfeed_stereo() {
225 let mut cf = Crossfeed::new(44100.0);
226 cf.set_mix(0.5);
227
228 // Hard-panned left signal
229 let mut samples = vec![1.0, 0.0, 0.5, 0.0, 0.0, 0.0];
230 cf.process(&mut samples, 2);
231
232 // Right channel should now have some signal (crossfeed from L)
233 // Left channel should be slightly modified
234 assert!(samples[1].abs() > 0.0); // R got crossfeed from L
235 }
236
237 #[test]
238 fn test_crossfeed_mono_passthrough() {
239 let mut cf = Crossfeed::new(44100.0);
240 cf.set_enabled(true);
241
242 let mut samples = vec![1.0, 0.5, 0.25];
243 let original = samples.clone();
244 cf.process(&mut samples, 1);
245
246 // Mono should pass through unchanged
247 assert_eq!(samples, original);
248 }
249
250 #[test]
251 fn test_crossfeed_disabled() {
252 let mut cf = Crossfeed::new(44100.0);
253 cf.set_enabled(false);
254
255 let mut samples = vec![1.0, 0.0, 0.5, 0.0];
256 let original = samples.clone();
257 cf.process(&mut samples, 2);
258
259 // Should pass through unchanged when disabled
260 assert_eq!(samples, original);
261 }
262
263 #[test]
264 fn test_hpf_coefficients_highpass() {
265 let (b0, b1, b2, _a1, _a2) = Crossfeed::calc_hpf_coeffs(700.0, 44100.0);
266
267 // HPF numerator should have 1, -2, 1 pattern (normalized)
268 // This is the key difference from LPF which has k², -2k², k² pattern
269 assert!((b0 - b2).abs() < 1e-10); // b0 == b2
270 assert!((b1 + 2.0 * b0).abs() < 1e-10); // b1 == -2*b0
271
272 // DC gain of HPF should be near 0 (high-pass blocks DC)
273 // DC gain = (b0 + b1 + b2) / (1 + a1 + a2)
274 // For HPF with 1,-2,1 numerator, b0+b1+b2 ≈ 0
275 assert!((b0 + b1 + b2).abs() < 1e-10);
276 }
277
278 #[test]
279 fn test_hpf_attenuates_low_freq() {
280 let mut cf = Crossfeed::with_params(44100.0, 700.0, 1.0);
281
282 // DC signal (0 Hz) should be strongly attenuated by HPF
283 let mut samples: Vec<f64> = vec![0.0; 200]; // 100 stereo samples
284 for i in 0..100 {
285 samples[i * 2] = 1.0; // L = 1.0 (DC)
286 samples[i * 2 + 1] = 0.0; // R = 0.0
287 }
288 cf.process(&mut samples, 2);
289
290 // N-1 fix: Clarify intent — skip initial transient (first 50 stereo frames = 100 samples),
291 // then take R channel samples (odd indices) by starting at index 101 (R of frame 50).
292 let sum_r: f64 = samples.iter().skip(100).skip(1).step_by(2).take(50).sum();
293 assert!(sum_r.abs() < 1.0); // Much less than 50 samples of DC
294 }
295
296 #[test]
297 fn test_crossfeed_flushes_denormals_with_audio_thread_init() {
298 crate::runtime::audio_thread_init();
299 if !crate::runtime::audio_thread_float_mode_is_enabled() {
300 return;
301 }
302
303 let subnormal = f64::from_bits(1);
304 let mut state = [subnormal, -subnormal];
305 let _ = Crossfeed::process_hpf_df2t_static(&mut state, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0);
306 assert_eq!(state, [0.0, 0.0]);
307 }
308
309 #[test]
310 fn test_crossfeed_sustained_subnormal_input_flushes_to_zero() {
311 crate::runtime::audio_thread_init();
312 if !crate::runtime::audio_thread_float_mode_is_enabled() {
313 return;
314 }
315
316 let mut cf = Crossfeed::new(44_100.0);
317 let subnormal = f64::from_bits(1);
318 let mut samples = (0..1024)
319 .flat_map(|_| [subnormal, -subnormal])
320 .collect::<Vec<_>>();
321
322 cf.process(&mut samples, 2);
323
324 assert!(samples.iter().all(|sample| *sample == 0.0));
325 assert_eq!(cf.w_lr, [0.0, 0.0]);
326 assert_eq!(cf.w_rl, [0.0, 0.0]);
327 }
328}