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:
126
crates/voltex_audio/src/hrtf.rs
Normal file
126
crates/voltex_audio/src/hrtf.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ pub mod mixing;
|
|||||||
pub mod wasapi;
|
pub mod wasapi;
|
||||||
pub mod audio_system;
|
pub mod audio_system;
|
||||||
pub mod spatial;
|
pub mod spatial;
|
||||||
|
pub mod hrtf;
|
||||||
pub mod mix_group;
|
pub mod mix_group;
|
||||||
pub mod audio_source;
|
pub mod audio_source;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user