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:
2026-03-25 10:59:17 +09:00
parent f0646c34eb
commit f52186f732

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