feat(audio): integrate spatial 3D audio into mixing pipeline
Add SpatialParams field to PlayingSound, new_3d constructor, and listener parameter to mix_sounds. Compute per-channel attenuation and stereo panning when spatial params are present; 2D sounds are unchanged. Add three new tests: spatial_2d_unchanged, spatial_far_away_silent, and spatial_right_panning. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use crate::AudioClip;
|
||||
use crate::spatial::{Listener, SpatialParams, compute_spatial_gains};
|
||||
|
||||
/// Represents a sound currently being played back.
|
||||
pub struct PlayingSound {
|
||||
@@ -10,6 +11,8 @@ pub struct PlayingSound {
|
||||
pub volume: f32,
|
||||
/// Whether the sound loops when it reaches the end.
|
||||
pub looping: bool,
|
||||
/// Optional 3D spatial parameters. None = 2D (no spatialization).
|
||||
pub spatial: Option<SpatialParams>,
|
||||
}
|
||||
|
||||
impl PlayingSound {
|
||||
@@ -19,8 +22,13 @@ impl PlayingSound {
|
||||
position: 0,
|
||||
volume,
|
||||
looping,
|
||||
spatial: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_3d(clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) -> Self {
|
||||
Self { clip_index, position: 0, volume, looping, spatial: Some(spatial) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix all active sounds in `playing` into `output`.
|
||||
@@ -32,6 +40,7 @@ impl PlayingSound {
|
||||
/// - `device_sample_rate`: output device sample rate.
|
||||
/// - `device_channels`: output device channel count (1 or 2).
|
||||
/// - `frames`: number of output frames to fill.
|
||||
/// - `listener`: the listener for 3D spatialization.
|
||||
pub fn mix_sounds(
|
||||
output: &mut Vec<f32>,
|
||||
playing: &mut Vec<PlayingSound>,
|
||||
@@ -39,6 +48,7 @@ pub fn mix_sounds(
|
||||
device_sample_rate: u32,
|
||||
device_channels: u16,
|
||||
frames: usize,
|
||||
listener: &Listener,
|
||||
) {
|
||||
// Ensure output buffer is sized correctly and zeroed.
|
||||
let output_len = frames * device_channels as usize;
|
||||
@@ -50,6 +60,14 @@ pub fn mix_sounds(
|
||||
for (sound_idx, sound) in playing.iter_mut().enumerate() {
|
||||
let clip = &clips[sound.clip_index];
|
||||
|
||||
// Compute per-channel effective volumes based on spatial params.
|
||||
let (vol_left, vol_right) = if let Some(ref spatial) = sound.spatial {
|
||||
let (atten, left_gain, right_gain) = compute_spatial_gains(listener, spatial);
|
||||
(sound.volume * atten * left_gain, sound.volume * atten * right_gain)
|
||||
} else {
|
||||
(sound.volume, sound.volume)
|
||||
};
|
||||
|
||||
// Compute integer rate ratio for naive resampling.
|
||||
// For same-rate clips ratio == 1, so we read every frame.
|
||||
let rate_ratio = if device_sample_rate > 0 {
|
||||
@@ -75,26 +93,27 @@ pub fn mix_sounds(
|
||||
|
||||
if device_channels == 2 {
|
||||
let (left, right) = if clip.channels == 1 {
|
||||
// Mono -> stereo: duplicate
|
||||
let s = clip.samples[clip_frame] * sound.volume;
|
||||
(s, s)
|
||||
// Mono -> stereo: apply per-channel volumes
|
||||
let raw = clip.samples[clip_frame];
|
||||
(raw * vol_left, raw * vol_right)
|
||||
} else {
|
||||
// Stereo clip
|
||||
let l = clip.samples[clip_frame * 2] * sound.volume;
|
||||
let r = clip.samples[clip_frame * 2 + 1] * sound.volume;
|
||||
let l = clip.samples[clip_frame * 2] * vol_left;
|
||||
let r = clip.samples[clip_frame * 2 + 1] * vol_right;
|
||||
(l, r)
|
||||
};
|
||||
output[out_base] += left;
|
||||
output[out_base + 1] += right;
|
||||
} else {
|
||||
// Mono output
|
||||
// Mono output: average left and right volumes
|
||||
let vol_mono = (vol_left + vol_right) * 0.5;
|
||||
let s = if clip.channels == 1 {
|
||||
clip.samples[clip_frame] * sound.volume
|
||||
clip.samples[clip_frame] * vol_mono
|
||||
} else {
|
||||
// Mix stereo clip to mono
|
||||
(clip.samples[clip_frame * 2] + clip.samples[clip_frame * 2 + 1])
|
||||
* 0.5
|
||||
* sound.volume
|
||||
* vol_mono
|
||||
};
|
||||
output[out_base] += s;
|
||||
}
|
||||
@@ -132,6 +151,8 @@ pub fn mix_sounds(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::AudioClip;
|
||||
use crate::spatial::{Listener, SpatialParams};
|
||||
use voltex_math::Vec3;
|
||||
|
||||
fn make_mono_clip(value: f32, frames: usize, sample_rate: u32) -> AudioClip {
|
||||
AudioClip::new(vec![value; frames], sample_rate, 1)
|
||||
@@ -152,7 +173,7 @@ mod tests {
|
||||
let mut playing = vec![PlayingSound::new(0, 0.5, false)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10);
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default());
|
||||
|
||||
// All output frames should be 0.5 (1.0 * 0.5 volume)
|
||||
assert_eq!(output.len(), 10);
|
||||
@@ -174,7 +195,7 @@ mod tests {
|
||||
];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10);
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default());
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
@@ -195,7 +216,7 @@ mod tests {
|
||||
];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10);
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default());
|
||||
|
||||
for &s in &output {
|
||||
assert!(s <= 1.0, "output {} exceeds 1.0 (clamp failed)", s);
|
||||
@@ -210,7 +231,7 @@ mod tests {
|
||||
let mut playing = vec![PlayingSound::new(0, 1.0, false)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 20);
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 20, &Listener::default());
|
||||
|
||||
// Sound should have been removed
|
||||
assert!(playing.is_empty(), "non-looping sound was not removed");
|
||||
@@ -223,7 +244,7 @@ mod tests {
|
||||
let mut playing = vec![PlayingSound::new(0, 1.0, true)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 20);
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 20, &Listener::default());
|
||||
|
||||
// Sound should still be in the list
|
||||
assert_eq!(playing.len(), 1, "looping sound was incorrectly removed");
|
||||
@@ -238,7 +259,7 @@ mod tests {
|
||||
let mut playing = vec![PlayingSound::new(0, 1.0, false)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10);
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default());
|
||||
|
||||
// output length = 10 frames * 2 channels = 20
|
||||
assert_eq!(output.len(), 20);
|
||||
@@ -249,4 +270,57 @@ mod tests {
|
||||
assert!((r - 0.7).abs() < 1e-6, "right={}", r);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Spatial audio tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn spatial_2d_unchanged() {
|
||||
// spatial=None should produce the same output as before (no spatialization)
|
||||
let clips = vec![make_mono_clip(1.0, 100, 44100)];
|
||||
let mut playing = vec![PlayingSound::new(0, 0.5, false)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default());
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
assert!((s - 0.5).abs() < 1e-6, "expected 0.5, got {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_far_away_silent() {
|
||||
// Emitter beyond max_distance → attenuation = 0.0 → ~0 output
|
||||
let clips = vec![make_mono_clip(1.0, 100, 44100)];
|
||||
let spatial = SpatialParams::new(Vec3::new(200.0, 0.0, 0.0), 1.0, 100.0);
|
||||
let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default());
|
||||
|
||||
assert_eq!(output.len(), 20);
|
||||
for &s in &output {
|
||||
assert!(s.abs() < 1e-6, "expected ~0 for far-away emitter, got {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_right_panning() {
|
||||
// Emitter on +X → right channel should be louder than left channel (stereo output)
|
||||
let clips = vec![make_mono_clip(1.0, 100, 44100)];
|
||||
// Emitter close enough to be audible (within min_distance → atten=1)
|
||||
let spatial = SpatialParams::new(Vec3::new(0.5, 0.0, 0.0), 1.0, 100.0);
|
||||
let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default());
|
||||
|
||||
assert_eq!(output.len(), 20);
|
||||
// Check first frame: left=output[0], right=output[1]
|
||||
let left = output[0];
|
||||
let right = output[1];
|
||||
assert!(right > left, "expected right > left for +X emitter, got left={}, right={}", left, right);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user