feat(audio): add 3D audio spatial functions (distance attenuation, stereo panning)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,3 +4,4 @@ version = "0.1.0"
|
|||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
voltex_math.workspace = true
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ pub mod mixing;
|
|||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
pub mod wasapi;
|
pub mod wasapi;
|
||||||
pub mod audio_system;
|
pub mod audio_system;
|
||||||
|
pub mod spatial;
|
||||||
|
|
||||||
pub use audio_clip::AudioClip;
|
pub use audio_clip::AudioClip;
|
||||||
pub use wav::{parse_wav, generate_wav_bytes};
|
pub use wav::{parse_wav, generate_wav_bytes};
|
||||||
pub use mixing::{PlayingSound, mix_sounds};
|
pub use mixing::{PlayingSound, mix_sounds};
|
||||||
pub use audio_system::AudioSystem;
|
pub use audio_system::AudioSystem;
|
||||||
|
pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, compute_spatial_gains};
|
||||||
|
|||||||
162
crates/voltex_audio/src/spatial.rs
Normal file
162
crates/voltex_audio/src/spatial.rs
Normal file
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user