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:
2026-03-25 11:27:29 +09:00
parent 4436382baf
commit bd044c6653

View File

@@ -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);
}
}