feat(audio): add mixing functions with volume, looping, and channel conversion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
252
crates/voltex_audio/src/mixing.rs
Normal file
252
crates/voltex_audio/src/mixing.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use crate::AudioClip;
|
||||
|
||||
/// Represents a sound currently being played back.
|
||||
pub struct PlayingSound {
|
||||
/// Index into the clips slice passed to `mix_sounds`.
|
||||
pub clip_index: usize,
|
||||
/// Current playback position in frames (not samples).
|
||||
pub position: usize,
|
||||
/// Linear volume multiplier [0.0 = silent, 1.0 = full].
|
||||
pub volume: f32,
|
||||
/// Whether the sound loops when it reaches the end.
|
||||
pub looping: bool,
|
||||
}
|
||||
|
||||
impl PlayingSound {
|
||||
pub fn new(clip_index: usize, volume: f32, looping: bool) -> Self {
|
||||
Self {
|
||||
clip_index,
|
||||
position: 0,
|
||||
volume,
|
||||
looping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mix all active sounds in `playing` into `output`.
|
||||
///
|
||||
/// # Parameters
|
||||
/// - `output`: interleaved output buffer (len = frames * device_channels).
|
||||
/// - `playing`: mutable list of active sounds; finished non-looping sounds are removed.
|
||||
/// - `clips`: the audio clip assets.
|
||||
/// - `device_sample_rate`: output device sample rate.
|
||||
/// - `device_channels`: output device channel count (1 or 2).
|
||||
/// - `frames`: number of output frames to fill.
|
||||
pub fn mix_sounds(
|
||||
output: &mut Vec<f32>,
|
||||
playing: &mut Vec<PlayingSound>,
|
||||
clips: &[AudioClip],
|
||||
device_sample_rate: u32,
|
||||
device_channels: u16,
|
||||
frames: usize,
|
||||
) {
|
||||
// Ensure output buffer is sized correctly and zeroed.
|
||||
let output_len = frames * device_channels as usize;
|
||||
output.clear();
|
||||
output.resize(output_len, 0.0);
|
||||
|
||||
let mut finished = Vec::new();
|
||||
|
||||
for (sound_idx, sound) in playing.iter_mut().enumerate() {
|
||||
let clip = &clips[sound.clip_index];
|
||||
|
||||
// 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 {
|
||||
clip.sample_rate as f64 / device_sample_rate as f64
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
|
||||
for frame_out in 0..frames {
|
||||
// Map the output frame index to a clip frame index.
|
||||
let clip_frame = sound.position + (frame_out as f64 * rate_ratio) as usize;
|
||||
|
||||
if clip_frame >= clip.frame_count() {
|
||||
// Sound exhausted within this render block.
|
||||
if sound.looping {
|
||||
// We'll reset after the loop; for now just stop filling.
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Fetch sample(s) from clip frame.
|
||||
let out_base = frame_out * device_channels as usize;
|
||||
|
||||
if device_channels == 2 {
|
||||
let (left, right) = if clip.channels == 1 {
|
||||
// Mono -> stereo: duplicate
|
||||
let s = clip.samples[clip_frame] * sound.volume;
|
||||
(s, s)
|
||||
} else {
|
||||
// Stereo clip
|
||||
let l = clip.samples[clip_frame * 2] * sound.volume;
|
||||
let r = clip.samples[clip_frame * 2 + 1] * sound.volume;
|
||||
(l, r)
|
||||
};
|
||||
output[out_base] += left;
|
||||
output[out_base + 1] += right;
|
||||
} else {
|
||||
// Mono output
|
||||
let s = if clip.channels == 1 {
|
||||
clip.samples[clip_frame] * sound.volume
|
||||
} else {
|
||||
// Mix stereo clip to mono
|
||||
(clip.samples[clip_frame * 2] + clip.samples[clip_frame * 2 + 1])
|
||||
* 0.5
|
||||
* sound.volume
|
||||
};
|
||||
output[out_base] += s;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance position by the number of clip frames consumed this block.
|
||||
let frames_consumed = (frames as f64 * rate_ratio) as usize;
|
||||
sound.position += frames_consumed;
|
||||
|
||||
if sound.position >= clip.frame_count() {
|
||||
if sound.looping {
|
||||
sound.position = 0;
|
||||
} else {
|
||||
finished.push(sound_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove finished sounds in reverse order to preserve indices.
|
||||
for &idx in finished.iter().rev() {
|
||||
playing.remove(idx);
|
||||
}
|
||||
|
||||
// Clamp output to [-1.0, 1.0].
|
||||
for sample in output.iter_mut() {
|
||||
*sample = sample.clamp(-1.0, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::AudioClip;
|
||||
|
||||
fn make_mono_clip(value: f32, frames: usize, sample_rate: u32) -> AudioClip {
|
||||
AudioClip::new(vec![value; frames], sample_rate, 1)
|
||||
}
|
||||
|
||||
fn make_stereo_clip(left: f32, right: f32, frames: usize, sample_rate: u32) -> AudioClip {
|
||||
let mut samples = Vec::with_capacity(frames * 2);
|
||||
for _ in 0..frames {
|
||||
samples.push(left);
|
||||
samples.push(right);
|
||||
}
|
||||
AudioClip::new(samples, sample_rate, 2)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sound_volume() {
|
||||
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);
|
||||
|
||||
// All output frames should be 0.5 (1.0 * 0.5 volume)
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
assert!((s - 0.5).abs() < 1e-6, "expected 0.5, got {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_sounds_sum() {
|
||||
// Two clips each at 0.3; sum is 0.6 (below clamp threshold)
|
||||
let clips = vec![
|
||||
make_mono_clip(0.3, 100, 44100),
|
||||
make_mono_clip(0.3, 100, 44100),
|
||||
];
|
||||
let mut playing = vec![
|
||||
PlayingSound::new(0, 1.0, false),
|
||||
PlayingSound::new(1, 1.0, false),
|
||||
];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10);
|
||||
|
||||
assert_eq!(output.len(), 10);
|
||||
for &s in &output {
|
||||
assert!((s - 0.6).abs() < 1e-5, "expected 0.6, got {}", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipping() {
|
||||
// Two clips at 1.0 each; sum would be 2.0 but must be clamped to 1.0
|
||||
let clips = vec![
|
||||
make_mono_clip(1.0, 100, 44100),
|
||||
make_mono_clip(1.0, 100, 44100),
|
||||
];
|
||||
let mut playing = vec![
|
||||
PlayingSound::new(0, 1.0, false),
|
||||
PlayingSound::new(1, 1.0, false),
|
||||
];
|
||||
let mut output = Vec::new();
|
||||
|
||||
mix_sounds(&mut output, &mut playing, &clips, 44100, 1, 10);
|
||||
|
||||
for &s in &output {
|
||||
assert!(s <= 1.0, "output {} exceeds 1.0 (clamp failed)", s);
|
||||
assert!(s >= -1.0, "output {} below -1.0 (clamp failed)", s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_looping_removal() {
|
||||
// Clip is only 5 frames; request 20 frames; sound should be removed after
|
||||
let clips = vec![make_mono_clip(0.5, 5, 44100)];
|
||||
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);
|
||||
|
||||
// Sound should have been removed
|
||||
assert!(playing.is_empty(), "non-looping sound was not removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn looping_continues() {
|
||||
// Clip is 5 frames; request 20 frames; looping sound should remain
|
||||
let clips = vec![make_mono_clip(0.5, 5, 44100)];
|
||||
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);
|
||||
|
||||
// Sound should still be in the list
|
||||
assert_eq!(playing.len(), 1, "looping sound was incorrectly removed");
|
||||
// Position should have been reset to 0
|
||||
assert_eq!(playing[0].position, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mono_to_stereo() {
|
||||
// Mono clip mixed to stereo output: both channels should have same value
|
||||
let clips = vec![make_mono_clip(0.7, 100, 44100)];
|
||||
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);
|
||||
|
||||
// output length = 10 frames * 2 channels = 20
|
||||
assert_eq!(output.len(), 20);
|
||||
for i in 0..10 {
|
||||
let l = output[i * 2];
|
||||
let r = output[i * 2 + 1];
|
||||
assert!((l - 0.7).abs() < 1e-6, "left={}", l);
|
||||
assert!((r - 0.7).abs() < 1e-6, "right={}", r);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user