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::{AudioClip, PlayingSound, mix_sounds};
use crate::spatial::{Listener, SpatialParams}; use crate::spatial::{Listener, SpatialParams};
use crate::mix_group::{MixGroup, MixerState};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AudioCommand // AudioCommand
@@ -37,6 +38,10 @@ pub enum AudioCommand {
SetVolume { clip_index: usize, volume: f32 }, SetVolume { clip_index: usize, volume: f32 },
/// Stop all currently playing sounds. /// Stop all currently playing sounds.
StopAll, 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. /// Shut down the audio thread.
Shutdown, Shutdown,
} }
@@ -103,6 +108,16 @@ impl AudioSystem {
pub fn stop_all(&self) { pub fn stop_all(&self) {
let _ = self.sender.send(AudioCommand::StopAll); 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 { 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 playing: Vec<PlayingSound> = Vec::new();
let mut output: Vec<f32> = Vec::new(); let mut output: Vec<f32> = Vec::new();
let mut listener = Listener::default(); let mut listener = Listener::default();
let mut mixer = MixerState::new();
loop { loop {
// Advance mixer fades (~5 ms per iteration)
mixer.tick(0.005);
// Process all pending commands (non-blocking) // Process all pending commands (non-blocking)
loop { loop {
match rx.try_recv() { match rx.try_recv() {
@@ -180,6 +199,12 @@ fn audio_thread_windows(rx: mpsc::Receiver<AudioCommand>, clips: Arc<Vec<AudioCl
AudioCommand::StopAll => { AudioCommand::StopAll => {
playing.clear(); playing.clear();
} }
AudioCommand::SetGroupVolume { group, volume } => {
mixer.set_volume(group, volume);
}
AudioCommand::FadeGroup { group, target, duration } => {
mixer.fade(group, target, duration);
}
AudioCommand::Shutdown => { AudioCommand::Shutdown => {
return; return;
} }
@@ -200,6 +225,7 @@ fn audio_thread_windows(rx: mpsc::Receiver<AudioCommand>, clips: Arc<Vec<AudioCl
device_channels, device_channels,
buffer_frames, buffer_frames,
&listener, &listener,
&mixer,
); );
if let Err(e) = device.write_samples(&output) { if let Err(e) = device.write_samples(&output) {

View File

@@ -1,5 +1,6 @@
use crate::AudioClip; use crate::AudioClip;
use crate::spatial::{Listener, SpatialParams, compute_spatial_gains}; use crate::spatial::{Listener, SpatialParams, compute_spatial_gains};
use crate::mix_group::{MixGroup, MixerState};
/// Represents a sound currently being played back. /// Represents a sound currently being played back.
pub struct PlayingSound { pub struct PlayingSound {
@@ -13,6 +14,8 @@ pub struct PlayingSound {
pub looping: bool, pub looping: bool,
/// Optional 3D spatial parameters. None = 2D (no spatialization). /// Optional 3D spatial parameters. None = 2D (no spatialization).
pub spatial: Option<SpatialParams>, pub spatial: Option<SpatialParams>,
/// Mix group this sound belongs to.
pub group: MixGroup,
} }
impl PlayingSound { impl PlayingSound {
@@ -23,11 +26,12 @@ impl PlayingSound {
volume, volume,
looping, looping,
spatial: None, spatial: None,
group: MixGroup::Sfx,
} }
} }
pub fn new_3d(clip_index: usize, volume: f32, looping: bool, spatial: SpatialParams) -> Self { 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). /// - `device_channels`: output device channel count (1 or 2).
/// - `frames`: number of output frames to fill. /// - `frames`: number of output frames to fill.
/// - `listener`: the listener for 3D spatialization. /// - `listener`: the listener for 3D spatialization.
/// - `mixer`: the global mixer state providing per-group and master volumes.
pub fn mix_sounds( pub fn mix_sounds(
output: &mut Vec<f32>, output: &mut Vec<f32>,
playing: &mut Vec<PlayingSound>, playing: &mut Vec<PlayingSound>,
@@ -49,6 +54,7 @@ pub fn mix_sounds(
device_channels: u16, device_channels: u16,
frames: usize, frames: usize,
listener: &Listener, listener: &Listener,
mixer: &MixerState,
) { ) {
// Ensure output buffer is sized correctly and zeroed. // Ensure output buffer is sized correctly and zeroed.
let output_len = frames * device_channels as usize; 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() { for (sound_idx, sound) in playing.iter_mut().enumerate() {
let clip = &clips[sound.clip_index]; 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. // Compute per-channel effective volumes based on spatial params.
let (vol_left, vol_right) = if let Some(ref spatial) = sound.spatial { let (vol_left, vol_right) = if let Some(ref spatial) = sound.spatial {
let (atten, left_gain, right_gain) = compute_spatial_gains(listener, 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 { } else {
(sound.volume, sound.volume) (base_volume, base_volume)
}; };
// Compute integer rate ratio for naive resampling. // Compute integer rate ratio for naive resampling.
@@ -152,6 +162,7 @@ mod tests {
use super::*; use super::*;
use crate::AudioClip; use crate::AudioClip;
use crate::spatial::{Listener, SpatialParams}; use crate::spatial::{Listener, SpatialParams};
use crate::mix_group::{MixGroup, MixerState};
use voltex_math::Vec3; use voltex_math::Vec3;
fn make_mono_clip(value: f32, frames: usize, sample_rate: u32) -> AudioClip { 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 playing = vec![PlayingSound::new(0, 0.5, false)];
let mut output = Vec::new(); 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) // All output frames should be 0.5 (1.0 * 0.5 volume)
assert_eq!(output.len(), 10); assert_eq!(output.len(), 10);
@@ -195,7 +206,7 @@ mod tests {
]; ];
let mut output = Vec::new(); 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); assert_eq!(output.len(), 10);
for &s in &output { for &s in &output {
@@ -216,7 +227,7 @@ mod tests {
]; ];
let mut output = Vec::new(); 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 { for &s in &output {
assert!(s <= 1.0, "output {} exceeds 1.0 (clamp failed)", s); 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 playing = vec![PlayingSound::new(0, 1.0, false)];
let mut output = Vec::new(); 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 // Sound should have been removed
assert!(playing.is_empty(), "non-looping sound was not 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 playing = vec![PlayingSound::new(0, 1.0, true)];
let mut output = Vec::new(); 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 // Sound should still be in the list
assert_eq!(playing.len(), 1, "looping sound was incorrectly removed"); 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 playing = vec![PlayingSound::new(0, 1.0, false)];
let mut output = Vec::new(); 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 // output length = 10 frames * 2 channels = 20
assert_eq!(output.len(), 20); assert_eq!(output.len(), 20);
@@ -282,7 +293,7 @@ mod tests {
let mut playing = vec![PlayingSound::new(0, 0.5, false)]; let mut playing = vec![PlayingSound::new(0, 0.5, false)];
let mut output = Vec::new(); 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); assert_eq!(output.len(), 10);
for &s in &output { for &s in &output {
@@ -298,7 +309,7 @@ mod tests {
let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)]; let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)];
let mut output = Vec::new(); 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); assert_eq!(output.len(), 20);
for &s in &output { for &s in &output {
@@ -315,7 +326,7 @@ mod tests {
let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)]; let mut playing = vec![PlayingSound::new_3d(0, 1.0, false, spatial)];
let mut output = Vec::new(); 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); assert_eq!(output.len(), 20);
// Check first frame: left=output[0], right=output[1] // Check first frame: left=output[0], right=output[1]
@@ -323,4 +334,40 @@ mod tests {
let right = output[1]; let right = output[1];
assert!(right > left, "expected right > left for +X emitter, got left={}, right={}", left, right); 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);
}
}
} }