feat(audio): integrate mixer groups into mixing pipeline and AudioSystem
Wires MixerState into mix_sounds (group × master volume applied per sound) and exposes SetGroupVolume/FadeGroup commands through AudioSystem. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ use std::thread;
|
||||
|
||||
use crate::{AudioClip, PlayingSound, mix_sounds};
|
||||
use crate::spatial::{Listener, SpatialParams};
|
||||
use crate::mix_group::{MixGroup, MixerState};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AudioCommand
|
||||
@@ -37,6 +38,10 @@ pub enum AudioCommand {
|
||||
SetVolume { clip_index: usize, volume: f32 },
|
||||
/// Stop all currently playing sounds.
|
||||
StopAll,
|
||||
/// Set volume for a mix group immediately.
|
||||
SetGroupVolume { group: MixGroup, volume: f32 },
|
||||
/// Fade a mix group to a target volume over a duration (seconds).
|
||||
FadeGroup { group: MixGroup, target: f32, duration: f32 },
|
||||
/// Shut down the audio thread.
|
||||
Shutdown,
|
||||
}
|
||||
@@ -103,6 +108,16 @@ impl AudioSystem {
|
||||
pub fn stop_all(&self) {
|
||||
let _ = self.sender.send(AudioCommand::StopAll);
|
||||
}
|
||||
|
||||
/// Set volume for a mix group immediately.
|
||||
pub fn set_group_volume(&self, group: MixGroup, volume: f32) {
|
||||
let _ = self.sender.send(AudioCommand::SetGroupVolume { group, volume });
|
||||
}
|
||||
|
||||
/// Fade a mix group to a target volume over `duration` seconds.
|
||||
pub fn fade_group(&self, group: MixGroup, target: f32, duration: f32) {
|
||||
let _ = self.sender.send(AudioCommand::FadeGroup { group, target, duration });
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AudioSystem {
|
||||
@@ -151,8 +166,12 @@ fn audio_thread_windows(rx: mpsc::Receiver<AudioCommand>, clips: Arc<Vec<AudioCl
|
||||
let mut playing: Vec<PlayingSound> = Vec::new();
|
||||
let mut output: Vec<f32> = Vec::new();
|
||||
let mut listener = Listener::default();
|
||||
let mut mixer = MixerState::new();
|
||||
|
||||
loop {
|
||||
// Advance mixer fades (~5 ms per iteration)
|
||||
mixer.tick(0.005);
|
||||
|
||||
// Process all pending commands (non-blocking)
|
||||
loop {
|
||||
match rx.try_recv() {
|
||||
@@ -180,6 +199,12 @@ fn audio_thread_windows(rx: mpsc::Receiver<AudioCommand>, clips: Arc<Vec<AudioCl
|
||||
AudioCommand::StopAll => {
|
||||
playing.clear();
|
||||
}
|
||||
AudioCommand::SetGroupVolume { group, volume } => {
|
||||
mixer.set_volume(group, volume);
|
||||
}
|
||||
AudioCommand::FadeGroup { group, target, duration } => {
|
||||
mixer.fade(group, target, duration);
|
||||
}
|
||||
AudioCommand::Shutdown => {
|
||||
return;
|
||||
}
|
||||
@@ -200,6 +225,7 @@ fn audio_thread_windows(rx: mpsc::Receiver<AudioCommand>, clips: Arc<Vec<AudioCl
|
||||
device_channels,
|
||||
buffer_frames,
|
||||
&listener,
|
||||
&mixer,
|
||||
);
|
||||
|
||||
if let Err(e) = device.write_samples(&output) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::AudioClip;
|
||||
use crate::spatial::{Listener, SpatialParams, compute_spatial_gains};
|
||||
use crate::mix_group::{MixGroup, MixerState};
|
||||
|
||||
/// Represents a sound currently being played back.
|
||||
pub struct PlayingSound {
|
||||
@@ -13,6 +14,8 @@ pub struct PlayingSound {
|
||||
pub looping: bool,
|
||||
/// Optional 3D spatial parameters. None = 2D (no spatialization).
|
||||
pub spatial: Option<SpatialParams>,
|
||||
/// Mix group this sound belongs to.
|
||||
pub group: MixGroup,
|
||||
}
|
||||
|
||||
impl PlayingSound {
|
||||
@@ -23,11 +26,12 @@ impl PlayingSound {
|
||||
volume,
|
||||
looping,
|
||||
spatial: None,
|
||||
group: MixGroup::Sfx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new_3d(clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) -> Self {
|
||||
Self { clip_index, position: 0, volume, looping, spatial: Some(spatial) }
|
||||
Self { clip_index, position: 0, volume, looping, spatial: Some(spatial), group: MixGroup::Sfx }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +45,7 @@ impl PlayingSound {
|
||||
/// - `device_channels`: output device channel count (1 or 2).
|
||||
/// - `frames`: number of output frames to fill.
|
||||
/// - `listener`: the listener for 3D spatialization.
|
||||
/// - `mixer`: the global mixer state providing per-group and master volumes.
|
||||
pub fn mix_sounds(
|
||||
output: &mut Vec<f32>,
|
||||
playing: &mut Vec<PlayingSound>,
|
||||
@@ -49,6 +54,7 @@ pub fn mix_sounds(
|
||||
device_channels: u16,
|
||||
frames: usize,
|
||||
listener: &Listener,
|
||||
mixer: &MixerState,
|
||||
) {
|
||||
// Ensure output buffer is sized correctly and zeroed.
|
||||
let output_len = frames * device_channels as usize;
|
||||
@@ -60,12 +66,16 @@ pub fn mix_sounds(
|
||||
for (sound_idx, sound) in playing.iter_mut().enumerate() {
|
||||
let clip = &clips[sound.clip_index];
|
||||
|
||||
// Apply group and master volume multiplier.
|
||||
let group_vol = mixer.effective_volume(sound.group);
|
||||
let base_volume = sound.volume * group_vol;
|
||||
|
||||
// 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)
|
||||
(base_volume * atten * left_gain, base_volume * atten * right_gain)
|
||||
} else {
|
||||
(sound.volume, sound.volume)
|
||||
(base_volume, base_volume)
|
||||
};
|
||||
|
||||
// Compute integer rate ratio for naive resampling.
|
||||
@@ -152,6 +162,7 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::AudioClip;
|
||||
use crate::spatial::{Listener, SpatialParams};
|
||||
use crate::mix_group::{MixGroup, MixerState};
|
||||
use voltex_math::Vec3;
|
||||
|
||||
fn make_mono_clip(value: f32, frames: usize, sample_rate: u32) -> AudioClip {
|
||||
@@ -173,7 +184,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, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
// All output frames should be 0.5 (1.0 * 0.5 volume)
|
||||
assert_eq!(output.len(), 10);
|
||||
@@ -195,7 +206,7 @@ mod tests {
|
||||
];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
@@ -216,7 +227,7 @@ mod tests {
|
||||
];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
for &s in &output {
|
||||
assert!(s <= 1.0, "output {} exceeds 1.0 (clamp failed)", s);
|
||||
@@ -231,7 +242,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, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 20, &Listener::default(), &MixerState::new());
|
||||
|
||||
// Sound should have been removed
|
||||
assert!(playing.is_empty(), "non-looping sound was not removed");
|
||||
@@ -244,7 +255,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, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 20, &Listener::default(), &MixerState::new());
|
||||
|
||||
// Sound should still be in the list
|
||||
assert_eq!(playing.len(), 1, "looping sound was incorrectly removed");
|
||||
@@ -259,7 +270,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, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
// output length = 10 frames * 2 channels = 20
|
||||
assert_eq!(output.len(), 20);
|
||||
@@ -282,7 +293,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, &Listener::default());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
@@ -298,7 +309,7 @@ mod tests {
|
||||
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());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
assert_eq!(output.len(), 20);
|
||||
for &s in &output {
|
||||
@@ -315,7 +326,7 @@ mod tests {
|
||||
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());
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 2, 10, &Listener::default(), &MixerState::new());
|
||||
|
||||
assert_eq!(output.len(), 20);
|
||||
// Check first frame: left=output[0], right=output[1]
|
||||
@@ -323,4 +334,40 @@ mod tests {
|
||||
let right = output[1];
|
||||
assert!(right > left, "expected right > left for +X emitter, got left={}, right={}", left, right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_volume_applied() {
|
||||
// Sfx group at 0.5: output should be halved compared to default (1.0)
|
||||
let clips = vec![make_mono_clip(1.0, 100, 44100)];
|
||||
let mut playing = vec![PlayingSound::new(0, 1.0, false)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
let mut mixer = MixerState::new();
|
||||
mixer.set_volume(MixGroup::Sfx, 0.5);
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &mixer);
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
assert!((s - 0.5).abs() < 1e-6, "expected 0.5 (halved by Sfx group vol), got {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn master_zero_mutes_output() {
|
||||
// Master volume at 0: all output must be silence
|
||||
let clips = vec![make_mono_clip(1.0, 100, 44100)];
|
||||
let mut playing = vec![PlayingSound::new(0, 1.0, false)];
|
||||
let mut output = Vec::new();
|
||||
|
||||
let mut mixer = MixerState::new();
|
||||
mixer.set_volume(MixGroup::Master, 0.0);
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10, &Listener::default(), &mixer);
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
assert!(s.abs() < 1e-6, "expected silence with master=0, got {}", s);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user