diff --git a/crates/voltex_audio/Cargo.toml b/crates/voltex_audio/Cargo.toml index e1ac68f..f325f47 100644 --- a/crates/voltex_audio/Cargo.toml +++ b/crates/voltex_audio/Cargo.toml @@ -4,3 +4,4 @@ version = "0.1.0" edition = "2021" [dependencies] +voltex_math.workspace = true diff --git a/crates/voltex_audio/src/lib.rs b/crates/voltex_audio/src/lib.rs index bec1344..119034f 100644 --- a/crates/voltex_audio/src/lib.rs +++ b/crates/voltex_audio/src/lib.rs @@ -4,8 +4,10 @@ pub mod mixing; #[cfg(target_os = "windows")] pub mod wasapi; pub mod audio_system; +pub mod spatial; pub use audio_clip::AudioClip; pub use wav::{parse_wav, generate_wav_bytes}; pub use mixing::{PlayingSound, mix_sounds}; pub use audio_system::AudioSystem; +pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains}; diff --git a/crates/voltex_audio/src/spatial.rs b/crates/voltex_audio/src/spatial.rs new file mode 100644 index 0000000..38a9d1a --- /dev/null +++ b/crates/voltex_audio/src/spatial.rs @@ -0,0 +1,162 @@ +use voltex_math::Vec3; +use std::f32::consts::PI; + +/// Represents the listener in 3D space. +#[derive(Debug, Clone)] +pub struct Listener { + pub position: Vec3, + pub forward: Vec3, + pub right: Vec3, +} + +impl Default for Listener { + fn default() -> Self { + Self { + position: Vec3::ZERO, + forward: Vec3::new(0.0, 0.0, -1.0), + right: Vec3::X, + } + } +} + +/// Parameters for a 3D audio emitter. +#[derive(Debug, Clone)] +pub struct SpatialParams { + pub position: Vec3, + pub min_distance: f32, + pub max_distance: f32, +} + +impl SpatialParams { + /// Create with explicit parameters. + pub fn new(position: Vec3, min_distance: f32, max_distance: f32) -> Self { + Self { position, min_distance, max_distance } + } + + /// Create at a position with default distances (1.0 min, 100.0 max). + pub fn at(position: Vec3) -> Self { + Self { position, min_distance: 1.0, max_distance: 100.0 } + } +} + +/// Inverse-distance attenuation model. +/// Returns 1.0 when distance <= min_dist, 0.0 when distance >= max_dist, +/// otherwise min_dist / distance clamped to [0, 1]. +pub fn distance_attenuation(distance: f32, min_dist: f32, max_dist: f32) -> f32 { + if distance <= min_dist { + 1.0 + } else if distance >= max_dist { + 0.0 + } else { + (min_dist / distance).clamp(0.0, 1.0) + } +} + +/// Equal-power stereo panning based on listener orientation and emitter position. +/// Returns (left_gain, right_gain). +/// If the emitter is at the same position as the listener, returns (1.0, 1.0). +pub fn stereo_pan(listener: &Listener, emitter_pos: Vec3) -> (f32, f32) { + let diff = emitter_pos - listener.position; + let distance = diff.length(); + const EPSILON: f32 = 1e-6; + if distance < EPSILON { + return (1.0, 1.0); + } + let direction = diff * (1.0 / distance); + let pan = direction.dot(listener.right).clamp(-1.0, 1.0); + // Map pan [-1, 1] to angle [0, PI/2] via angle = pan * PI/4 + PI/4 + let angle = pan * (PI / 4.0) + (PI / 4.0); + let left = angle.cos(); + let right = angle.sin(); + (left, right) +} + +/// Convenience function combining distance attenuation and stereo panning. +/// Returns (attenuation, left_gain, right_gain). +pub fn compute_spatial_gains(listener: &Listener, spatial: &SpatialParams) -> (f32, f32, f32) { + let diff = spatial.position - listener.position; + let distance = diff.length(); + let attenuation = distance_attenuation(distance, spatial.min_distance, spatial.max_distance); + let (left, right) = stereo_pan(listener, spatial.position); + (attenuation, left, right) +} + +#[cfg(test)] +mod tests { + use super::*; + use voltex_math::Vec3; + + #[test] + fn attenuation_at_min() { + // distance <= min_distance should return 1.0 + assert_eq!(distance_attenuation(0.5, 1.0, 10.0), 1.0); + assert_eq!(distance_attenuation(1.0, 1.0, 10.0), 1.0); + } + + #[test] + fn attenuation_at_max() { + // distance >= max_distance should return 0.0 + assert_eq!(distance_attenuation(10.0, 1.0, 10.0), 0.0); + assert_eq!(distance_attenuation(50.0, 1.0, 10.0), 0.0); + } + + #[test] + fn attenuation_between() { + // inverse: min_dist / distance + let result = distance_attenuation(5.0, 1.0, 10.0); + let expected = 1.0_f32 / 5.0_f32; + assert!((result - expected).abs() < 1e-6, "expected {expected}, got {result}"); + } + + #[test] + fn pan_right() { + // Emitter to the right (+X) should give right > left + let listener = Listener::default(); + let emitter = Vec3::new(10.0, 0.0, 0.0); + let (left, right) = stereo_pan(&listener, emitter); + assert!(right > left, "expected right > left for +X emitter, got left={left}, right={right}"); + } + + #[test] + fn pan_left() { + // Emitter to the left (-X) should give left > right + let listener = Listener::default(); + let emitter = Vec3::new(-10.0, 0.0, 0.0); + let (left, right) = stereo_pan(&listener, emitter); + assert!(left > right, "expected left > right for -X emitter, got left={left}, right={right}"); + } + + #[test] + fn pan_front() { + // Emitter directly in front (-Z) should give roughly equal gains + let listener = Listener::default(); + let emitter = Vec3::new(0.0, 0.0, -10.0); + let (left, right) = stereo_pan(&listener, emitter); + assert!((left - right).abs() < 0.01, "expected roughly equal for front emitter, got left={left}, right={right}"); + } + + #[test] + fn pan_same_position() { + // Same position as listener should return (1.0, 1.0) + let listener = Listener::default(); + let emitter = Vec3::ZERO; + let (left, right) = stereo_pan(&listener, emitter); + assert_eq!((left, right), (1.0, 1.0)); + } + + #[test] + fn compute_spatial_gains_combines_both() { + let listener = Listener::default(); + // Emitter at (5, 0, 0) with min=1, max=10 + let spatial = SpatialParams::new(Vec3::new(5.0, 0.0, 0.0), 1.0, 10.0); + let (attenuation, left, right) = compute_spatial_gains(&listener, &spatial); + + // Check attenuation: distance=5, min=1, max=10 → 1/5 = 0.2 + let expected_atten = 1.0_f32 / 5.0_f32; + assert!((attenuation - expected_atten).abs() < 1e-6, + "attenuation mismatch: expected {expected_atten}, got {attenuation}"); + + // Emitter is to the right, so right > left + assert!(right > left, "expected right > left, got left={left}, right={right}"); + } +}