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:
2026-03-25 11:35:05 +09:00
parent 261e52a752
commit 26e4d7fa4f
2 changed files with 85 additions and 12 deletions

View File

@@ -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) {

View File

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