Skip to main content

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}