Skip to main content

qualia_core_db/audio/
hrtf.rs

1//! HRTF — analytic + KemarLite embedded tables (zero-heap hot path).
2//!
3//! Full measured KEMAR datasets are optional cold assets; default hot path uses KemarLite
4//! (8-azimuth ITD/ILD samples) with analytic fallback.
5
6use core::f32::consts::PI;
7use std::sync::atomic::{AtomicU8, Ordering};
8
9/// HRTF synthesis profile (hot path).
10#[repr(u8)]
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum HrtfProfile {
13    Analytic = 0,
14    KemarLite = 1,
15}
16
17static HRTF_PROFILE: AtomicU8 = AtomicU8::new(HrtfProfile::KemarLite as u8);
18
19#[inline]
20pub fn set_hrtf_profile(profile: HrtfProfile) {
21    HRTF_PROFILE.store(profile as u8, Ordering::Relaxed);
22}
23
24#[inline]
25pub fn hrtf_profile() -> HrtfProfile {
26    match HRTF_PROFILE.load(Ordering::Relaxed) {
27        0 => HrtfProfile::Analytic,
28        _ => HrtfProfile::KemarLite,
29    }
30}
31
32/// KemarLite: 8 azimuth samples (pan -1..1) — ITD µs and ILD dB-ish scalars (embedded cold asset).
33const KEMAR_LITE_ITD_US: [f32; 8] = [-650.0, -480.0, -280.0, -80.0, 0.0, 80.0, 280.0, 480.0];
34const KEMAR_LITE_ILD_DB: [f32; 8] = [6.0, 4.5, 2.5, 0.8, 0.0, -0.8, -2.5, -4.5];
35
36/// Binaural staging gains + ITD for stereo worklet.
37#[derive(Debug, Clone, Copy, PartialEq)]
38pub struct BinauralGains {
39    pub gain_l: f32,
40    pub gain_r: f32,
41    pub itd_seconds: f32,
42    pub azimuth_rad: f32,
43    pub elevation_rad: f32,
44    pub distance: f32,
45}
46
47impl Default for BinauralGains {
48    fn default() -> Self {
49        Self {
50            gain_l: 0.707,
51            gain_r: 0.707,
52            itd_seconds: 0.0,
53            azimuth_rad: 0.0,
54            elevation_rad: 0.0,
55            distance: 1.0,
56        }
57    }
58}
59
60/// Rotate source position into head frame (listener at origin, yaw around Y).
61#[inline]
62pub fn head_relative_position(source: [f32; 3], listener_yaw: f32) -> [f32; 3] {
63    let c = listener_yaw.cos();
64    let s = listener_yaw.sin();
65    let x = source[0];
66    let z = source[2];
67    [x * c - z * s, source[1], x * s + z * c]
68}
69
70#[inline]
71fn lerp_table(pan: f32, table: &[f32; 8]) -> f32 {
72    let t = ((pan + 1.0) * 0.5 * 7.0).clamp(0.0, 7.0);
73    let i = t.floor() as usize;
74    let f = t - i as f32;
75    let a = table[i.min(7)];
76    let b = table[(i + 1).min(7)];
77    a + (b - a) * f
78}
79
80/// KemarLite binaural — interpolated embedded ITD/ILD tables.
81#[inline]
82pub fn binaural_kemar_lite(source: [f32; 3], listener_yaw: f32) -> BinauralGains {
83    let mut g = binaural_analytic(source, listener_yaw);
84    let pan = (g.azimuth_rad / (PI * 0.5)).clamp(-1.0, 1.0);
85    let itd_us = lerp_table(pan, &KEMAR_LITE_ITD_US);
86    let ild_db = lerp_table(pan, &KEMAR_LITE_ILD_DB);
87    let ild = (ild_db / 20.0).clamp(-0.45, 0.45);
88    g.itd_seconds = itd_us * 1e-6;
89    g.gain_l = (g.gain_l * (1.0 - ild)).clamp(0.05, 1.0);
90    g.gain_r = (g.gain_r * (1.0 + ild)).clamp(0.05, 1.0);
91    g
92}
93
94/// Analytic binaural gains — azimuth ∈ [-π, π], elevation ∈ [-π/2, π/2].
95#[inline]
96pub fn binaural_analytic(source: [f32; 3], listener_yaw: f32) -> BinauralGains {
97    let rel = head_relative_position(source, listener_yaw);
98    let x = rel[0];
99    let y = rel[1];
100    let z = (-rel[2]).max(0.05);
101    let horiz = (x * x + z * z).sqrt();
102    let dist = (x * x + y * y + rel[2] * rel[2]).sqrt().max(0.15);
103    let azimuth = x.atan2(z);
104    let elevation = y.atan2(horiz.max(1e-4));
105
106    let pan = (azimuth / (PI * 0.5)).clamp(-1.0, 1.0);
107    let ild = pan * 0.42;
108    let gain_l = (0.707 * (1.0 - ild)).clamp(0.05, 1.0);
109    let gain_r = (0.707 * (1.0 + ild)).clamp(0.05, 1.0);
110    let itd_seconds = pan * 0.0006;
111    let atten = (1.0 / dist).min(1.0);
112
113    BinauralGains {
114        gain_l: gain_l * atten,
115        gain_r: gain_r * atten,
116        itd_seconds,
117        azimuth_rad: azimuth,
118        elevation_rad: elevation,
119        distance: dist,
120    }
121}
122
123/// Dispatch binaural model from active `HrtfProfile`.
124#[inline]
125pub fn binaural_from_position(source: [f32; 3], listener_yaw: f32) -> BinauralGains {
126    match hrtf_profile() {
127        HrtfProfile::Analytic => binaural_analytic(source, listener_yaw),
128        HrtfProfile::KemarLite => binaural_kemar_lite(source, listener_yaw),
129    }
130}
131
132/// Manifold `w` biases room absorption (higher w → softer highs).
133#[inline]
134pub fn room_damp_from_manifold(manifold_w: f32) -> f32 {
135    (1.0 - manifold_w * 0.08).clamp(0.55, 1.0)
136}
137
138// ---------------------------------------------------------------------------
139// Real HRTF convolution (cold path; heap OK).
140//
141// Full measured KEMAR HRIRs remain an optional cold asset. The functions below
142// SYNTHESIZE a physically-plausible HRIR directly from the ITD/ILD model in
143// `BinauralGains` (interaural time difference as a fractional sample delay,
144// interaural level difference as per-ear gain, and a one-pole head-shadow
145// low-pass on the farther/contralateral ear). `binaural_render` then convolves a
146// mono source with the left/right impulse responses to produce a binaural pair.
147// ---------------------------------------------------------------------------
148
149/// Direct linear (FIR) convolution: `y[i] = Σ_j signal[j]·h[i-j]`.
150///
151/// Output length is `signal.len() + h.len() - 1`. Either operand empty → empty.
152pub fn convolve_fir(signal: &[f32], h: &[f32]) -> Vec<f32> {
153    if signal.is_empty() || h.is_empty() {
154        return Vec::new();
155    }
156    let out_len = signal.len() + h.len() - 1;
157    let mut out = vec![0.0_f32; out_len];
158    for (j, &s) in signal.iter().enumerate() {
159        if s == 0.0 {
160            continue;
161        }
162        for (m, &hm) in h.iter().enumerate() {
163            out[j + m] += s * hm;
164        }
165    }
166    out
167}
168
169/// Write a unit impulse scaled by `gain` at fractional `delay_samples` into `ir`,
170/// splitting energy linearly across the two straddling integer taps.
171#[inline]
172fn place_fractional_impulse(ir: &mut [f32], delay_samples: f32, gain: f32) {
173    if ir.is_empty() {
174        return;
175    }
176    let d = delay_samples.max(0.0);
177    let i0 = d.floor() as usize;
178    let frac = d - i0 as f32;
179    if i0 < ir.len() {
180        ir[i0] += gain * (1.0 - frac);
181    }
182    if i0 + 1 < ir.len() {
183        ir[i0 + 1] += gain * frac;
184    }
185}
186
187/// One-pole low-pass `y[n] = y[n-1] + a·(x[n] - y[n-1])` applied in place
188/// (head-shadow model; `a ∈ (0,1]`, smaller `a` = more high-frequency rolloff).
189#[inline]
190fn one_pole_lowpass_in_place(buf: &mut [f32], a: f32) {
191    let a = a.clamp(0.0, 1.0);
192    let mut y = 0.0_f32;
193    for x in buf.iter_mut() {
194        y += a * (*x - y);
195        *x = y;
196    }
197}
198
199/// Synthesize left/right HRIRs from `gains`.
200///
201/// Each ear is a unit impulse scaled by `gain_l`/`gain_r`, delayed by its ear
202/// delay: the nearer ear sits at ~0, the farther ear is delayed by
203/// `|itd_seconds|·sample_rate` samples (placed with linear-interpolated
204/// fractional delay). A gentle one-pole low-pass (head shadow) is applied ONLY
205/// to the contralateral (farther, quieter) ear so its highs are attenuated.
206///
207/// `itd_seconds < 0` ⇒ source to the left ⇒ right ear is the farther/contralateral
208/// one; `itd_seconds > 0` ⇒ left ear is farther. Returns `(left_ir, right_ir)`,
209/// both length `taps` (min 1).
210pub fn synthesize_hrir(
211    gains: &BinauralGains,
212    sample_rate: f32,
213    taps: usize,
214) -> (Vec<f32>, Vec<f32>) {
215    let n = taps.max(1);
216    let mut left = vec![0.0_f32; n];
217    let mut right = vec![0.0_f32; n];
218
219    let delay = (gains.itd_seconds.abs() * sample_rate.max(1.0)).max(0.0);
220    // Head-shadow low-pass coefficient for the contralateral ear (gentle).
221    const SHADOW_A: f32 = 0.35;
222
223    if gains.itd_seconds <= 0.0 {
224        // Source to the LEFT: left ear nearer (no delay), right ear farther.
225        place_fractional_impulse(&mut left, 0.0, gains.gain_l);
226        place_fractional_impulse(&mut right, delay, gains.gain_r);
227        one_pole_lowpass_in_place(&mut right, SHADOW_A);
228    } else {
229        // Source to the RIGHT: right ear nearer, left ear farther.
230        place_fractional_impulse(&mut right, 0.0, gains.gain_r);
231        place_fractional_impulse(&mut left, delay, gains.gain_l);
232        one_pole_lowpass_in_place(&mut left, SHADOW_A);
233    }
234
235    (left, right)
236}
237
238/// Render a `mono` source at `source` (world space) into a binaural pair.
239///
240/// Pipeline: `binaural_from_position` (active `HrtfProfile`) →
241/// `synthesize_hrir` → `convolve_fir` the mono signal with each ear's HRIR.
242/// Returns `(left, right)`, each of length `mono.len() + taps - 1` (taps = 64).
243pub fn binaural_render(
244    mono: &[f32],
245    source: [f32; 3],
246    listener_yaw: f32,
247    sample_rate: f32,
248) -> (Vec<f32>, Vec<f32>) {
249    const TAPS: usize = 64;
250    let gains = binaural_from_position(source, listener_yaw);
251    let (hl, hr) = synthesize_hrir(&gains, sample_rate, TAPS);
252    let left = convolve_fir(mono, &hl);
253    let right = convolve_fir(mono, &hr);
254    (left, right)
255}
256
257/// First index where the running energy of `x` crosses `frac` of its total
258/// energy (onset proxy for ITD comparison). Returns `x.len()` if all-zero.
259#[cfg(test)]
260fn energy_onset(x: &[f32], frac: f32) -> usize {
261    let total: f32 = x.iter().map(|&v| v * v).sum();
262    if total <= 0.0 {
263        return x.len();
264    }
265    let threshold = total * frac;
266    let mut acc = 0.0_f32;
267    for (i, &v) in x.iter().enumerate() {
268        acc += v * v;
269        if acc >= threshold {
270            return i;
271        }
272    }
273    x.len()
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn center_source_balanced() {
282        let g = binaural_from_position([0.0, 0.0, 1.0], 0.0);
283        assert!((g.gain_l - g.gain_r).abs() < 0.15);
284        assert!(g.itd_seconds.abs() < 1e-4);
285    }
286
287    #[test]
288    fn left_source_favors_left_ear() {
289        let g = binaural_from_position([-1.0, 0.0, -1.0], 0.0);
290        assert!(g.gain_l > g.gain_r);
291        assert!(g.itd_seconds < 0.0);
292    }
293
294    #[test]
295    fn right_source_favors_right_ear() {
296        let g = binaural_from_position([1.0, 0.0, -1.0], 0.0);
297        assert!(g.gain_r > g.gain_l);
298        assert!(g.itd_seconds > 0.0);
299    }
300
301    #[test]
302    fn kemar_lite_stronger_itd_than_analytic_at_side() {
303        set_hrtf_profile(HrtfProfile::KemarLite);
304        let k = binaural_from_position([-1.0, 0.0, 0.5], 0.0);
305        set_hrtf_profile(HrtfProfile::Analytic);
306        let a = binaural_from_position([-1.0, 0.0, 0.5], 0.0);
307        assert!(k.itd_seconds.abs() >= a.itd_seconds.abs());
308        set_hrtf_profile(HrtfProfile::KemarLite);
309    }
310
311    #[test]
312    fn yaw_rotates_pan() {
313        let g0 = binaural_from_position([1.0, 0.0, 1.0], 0.0);
314        let g1 = binaural_from_position([1.0, 0.0, 1.0], PI * 0.5);
315        assert!(g0.gain_r > g0.gain_l, "right-front source favors right ear");
316        assert!(g1.gain_l > g1.gain_r, "90° yaw inverts pan to left ear");
317        assert!(
318            (g0.gain_r - g1.gain_l).abs() < 0.12,
319            "yaw swap: g0.gain_r≈g1.gain_l"
320        );
321        assert!(
322            (g0.gain_l - g1.gain_r).abs() < 0.12,
323            "yaw swap: g0.gain_l≈g1.gain_r"
324        );
325    }
326
327    #[test]
328    fn convolve_identity_kernel_returns_input() {
329        let x = [0.1_f32, -0.4, 0.7, 0.2, -0.9];
330        let y = convolve_fir(&x, &[1.0]);
331        assert_eq!(y.len(), x.len());
332        for (a, b) in y.iter().zip(x.iter()) {
333            assert!((a - b).abs() < 1e-6, "identity convolution");
334        }
335    }
336
337    #[test]
338    fn convolve_output_length() {
339        let x = vec![0.5_f32; 17];
340        let h = vec![0.25_f32; 9];
341        let y = convolve_fir(&x, &h);
342        assert_eq!(y.len(), x.len() + h.len() - 1);
343    }
344
345    #[test]
346    fn convolve_known_result() {
347        // [1,2,3] * [1,1] = [1, 3, 5, 3]
348        let y = convolve_fir(&[1.0, 2.0, 3.0], &[1.0, 1.0]);
349        assert_eq!(y.len(), 4);
350        let expect = [1.0, 3.0, 5.0, 3.0];
351        for (a, b) in y.iter().zip(expect.iter()) {
352            assert!((a - b).abs() < 1e-6, "got {y:?}");
353        }
354    }
355
356    #[test]
357    fn hard_left_source_earlier_and_louder_on_left() {
358        set_hrtf_profile(HrtfProfile::KemarLite);
359        let sample_rate = 48_000.0_f32;
360        // A unit click followed by silence.
361        let mut click = vec![0.0_f32; 128];
362        click[0] = 1.0;
363        // Hard-left source (−X, in front).
364        let (left, right) = binaural_render(&click, [-1.0, 0.0, -1.0], 0.0, sample_rate);
365
366        // ITD: the left (nearer) ear's energy onset is EARLIER than the right.
367        let onset_l = energy_onset(&left, 0.5);
368        let onset_r = energy_onset(&right, 0.5);
369        assert!(
370            onset_l < onset_r,
371            "left onset {onset_l} should precede right onset {onset_r} (ITD)"
372        );
373
374        // ILD: left energy >= right energy (nearer/louder ear).
375        let energy_l: f32 = left.iter().map(|&v| v * v).sum();
376        let energy_r: f32 = right.iter().map(|&v| v * v).sum();
377        assert!(
378            energy_l >= energy_r,
379            "left energy {energy_l} should be >= right energy {energy_r} (ILD)"
380        );
381    }
382
383    #[test]
384    fn synthesize_hrir_delays_contralateral_ear() {
385        let sample_rate = 48_000.0_f32;
386        // itd < 0 ⇒ source left ⇒ right ear delayed.
387        let g = BinauralGains {
388            gain_l: 0.8,
389            gain_r: 0.6,
390            itd_seconds: -0.0006,
391            ..Default::default()
392        };
393        let (left, right) = synthesize_hrir(&g, sample_rate, 64);
394        assert_eq!(left.len(), 64);
395        assert_eq!(right.len(), 64);
396        // Left ear (near) has its first tap energetic; right ear's peak is later.
397        let left_peak = left
398            .iter()
399            .enumerate()
400            .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
401            .unwrap()
402            .0;
403        let right_peak = right
404            .iter()
405            .enumerate()
406            .max_by(|a, b| a.1.abs().partial_cmp(&b.1.abs()).unwrap())
407            .unwrap()
408            .0;
409        assert_eq!(left_peak, 0, "near (left) ear impulse at tap 0");
410        assert!(
411            right_peak > 0,
412            "contralateral (right) ear delayed, peak at {right_peak}"
413        );
414    }
415
416    #[test]
417    fn binaural_render_output_length() {
418        let mono = vec![0.3_f32; 100];
419        let (l, r) = binaural_render(&mono, [1.0, 0.0, -1.0], 0.0, 48_000.0);
420        assert_eq!(l.len(), 100 + 64 - 1);
421        assert_eq!(r.len(), 100 + 64 - 1);
422    }
423}