diff --git a/crates/voltex_audio/src/mixing.rs b/crates/voltex_audio/src/mixing.rs index eb7ebc2..1da4faf 100644 --- a/crates/voltex_audio/src/mixing.rs +++ b/crates/voltex_audio/src/mixing.rs @@ -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, } 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, playing: &mut Vec, @@ -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); + } }