From f52186f732f5a283199b887fdcf96d178b53ceb7 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 10:59:17 +0900 Subject: [PATCH] feat(audio): add mixing functions with volume, looping, and channel conversion Co-Authored-By: Claude Sonnet 4.6 --- crates/voltex_audio/src/mixing.rs | 252 ++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 crates/voltex_audio/src/mixing.rs diff --git a/crates/voltex_audio/src/mixing.rs b/crates/voltex_audio/src/mixing.rs new file mode 100644 index 0000000..eb7ebc2 --- /dev/null +++ b/crates/voltex_audio/src/mixing.rs @@ -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, + playing: &mut Vec, + 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); + } + } +}