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::{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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user