1use core::f32::consts::PI;
7use std::sync::atomic::{AtomicU8, Ordering};
8
9#[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
32const 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#[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#[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#[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#[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#[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#[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
138pub 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#[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#[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
199pub 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 const SHADOW_A: f32 = 0.35;
222
223 if gains.itd_seconds <= 0.0 {
224 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 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
238pub 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#[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 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 let mut click = vec![0.0_f32; 128];
362 click[0] = 1.0;
363 let (left, right) = binaural_render(&click, [-1.0, 0.0, -1.0], 0.0, sample_rate);
365
366 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 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 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 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}