feat(audio): add simplified HRTF with ITD and ILD

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 16:27:05 +09:00
parent 8685d7c4aa
commit 28b24226e7
2 changed files with 127 additions and 0 deletions

View File

@@ -0,0 +1,126 @@
use std::f32::consts::PI;
/// Head radius in meters (average human).
const HEAD_RADIUS: f32 = 0.0875;
/// Speed of sound in m/s.
const SPEED_OF_SOUND: f32 = 343.0;
/// HRTF filter result for a sound at a given azimuth angle.
#[derive(Debug, Clone, Copy)]
pub struct HrtfResult {
pub left_delay_samples: f32, // ITD: delay for left ear in samples
pub right_delay_samples: f32, // ITD: delay for right ear in samples
pub left_gain: f32, // ILD: gain for left ear (0.0-1.0)
pub right_gain: f32, // ILD: gain for right ear (0.0-1.0)
}
/// Calculate HRTF parameters from azimuth angle.
/// azimuth: angle in radians, 0 = front, PI/2 = right, -PI/2 = left, PI = behind.
pub fn calculate_hrtf(azimuth: f32, sample_rate: u32) -> HrtfResult {
// ITD: Woodworth formula
// time_diff = (HEAD_RADIUS / SPEED_OF_SOUND) * (azimuth + sin(azimuth))
let itd = (HEAD_RADIUS / SPEED_OF_SOUND) * (azimuth.abs() + azimuth.abs().sin());
let delay_samples = itd * sample_rate as f32;
// ILD: simplified frequency-independent model
// Sound is louder on the side facing the source
let shadow = 0.5 * (1.0 + azimuth.cos()); // 1.0 at front, 0.5 at side, 0.0 at back
let (left_delay, right_delay, left_gain, right_gain);
if azimuth >= 0.0 {
// Sound from right side
left_delay = delay_samples;
right_delay = 0.0;
left_gain = (0.3 + 0.7 * shadow).min(1.0); // shadowed side
right_gain = 1.0;
} else {
// Sound from left side
left_delay = 0.0;
right_delay = delay_samples;
left_gain = 1.0;
right_gain = (0.3 + 0.7 * shadow).min(1.0);
}
HrtfResult {
left_delay_samples: left_delay,
right_delay_samples: right_delay,
left_gain,
right_gain,
}
}
/// Calculate azimuth angle from listener position/forward to sound position.
pub fn azimuth_from_positions(
listener_pos: [f32; 3],
listener_forward: [f32; 3],
listener_right: [f32; 3],
sound_pos: [f32; 3],
) -> f32 {
let dx = sound_pos[0] - listener_pos[0];
let dy = sound_pos[1] - listener_pos[1];
let dz = sound_pos[2] - listener_pos[2];
let len = (dx * dx + dy * dy + dz * dz).sqrt();
if len < 1e-6 {
return 0.0;
}
let dir = [dx / len, dy / len, dz / len];
// Dot with right vector gives sin(azimuth)
let right_dot =
dir[0] * listener_right[0] + dir[1] * listener_right[1] + dir[2] * listener_right[2];
// Dot with forward vector gives cos(azimuth)
let fwd_dot = dir[0] * listener_forward[0]
+ dir[1] * listener_forward[1]
+ dir[2] * listener_forward[2];
right_dot.atan2(fwd_dot)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hrtf_front() {
let r = calculate_hrtf(0.0, 44100);
assert!((r.left_delay_samples - 0.0).abs() < 1.0);
assert!((r.right_delay_samples - 0.0).abs() < 1.0);
assert!((r.left_gain - r.right_gain).abs() < 0.01); // symmetric
}
#[test]
fn test_hrtf_right() {
let r = calculate_hrtf(PI / 2.0, 44100);
assert!(r.left_delay_samples > r.right_delay_samples);
assert!(r.left_gain < r.right_gain);
}
#[test]
fn test_hrtf_left() {
let r = calculate_hrtf(-PI / 2.0, 44100);
assert!(r.right_delay_samples > r.left_delay_samples);
assert!(r.right_gain < r.left_gain);
}
#[test]
fn test_azimuth_front() {
let az = azimuth_from_positions(
[0.0; 3],
[0.0, 0.0, -1.0],
[1.0, 0.0, 0.0],
[0.0, 0.0, -5.0],
);
assert!(az.abs() < 0.1); // roughly front
}
#[test]
fn test_azimuth_right() {
let az = azimuth_from_positions(
[0.0; 3],
[0.0, 0.0, -1.0],
[1.0, 0.0, 0.0],
[5.0, 0.0, 0.0],
);
assert!((az - PI / 2.0).abs() < 0.1);
}
}

View File

@@ -5,6 +5,7 @@ pub mod mixing;
pub mod wasapi;
pub mod audio_system;
pub mod spatial;
pub mod hrtf;
pub mod mix_group;
pub mod audio_source;