From 6de5681707a971054de346ddff071b97131e189a Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Wed, 25 Mar 2026 11:10:28 +0900 Subject: [PATCH] feat(audio): add AudioSystem with WASAPI audio thread Introduces AudioCommand enum (Play, Stop, SetVolume, StopAll, Shutdown) and AudioSystem that spawns a dedicated audio thread. On Windows the thread drives WasapiDevice with a 5ms mix-and-write loop; on other platforms it runs in silent null mode. lib.rs exports wasapi (windows) and audio_system modules with AudioSystem re-exported at crate root. Co-Authored-By: Claude Sonnet 4.6 --- crates/voltex_audio/src/audio_system.rs | 245 ++++++++++++++++++++++++ crates/voltex_audio/src/lib.rs | 4 + 2 files changed, 249 insertions(+) create mode 100644 crates/voltex_audio/src/audio_system.rs diff --git a/crates/voltex_audio/src/audio_system.rs b/crates/voltex_audio/src/audio_system.rs new file mode 100644 index 0000000..bae7c57 --- /dev/null +++ b/crates/voltex_audio/src/audio_system.rs @@ -0,0 +1,245 @@ +//! AudioSystem: high-level audio playback management with a dedicated audio thread. + +use std::sync::{Arc, mpsc}; +use std::thread; + +use crate::{AudioClip, PlayingSound, mix_sounds}; + +// --------------------------------------------------------------------------- +// AudioCommand +// --------------------------------------------------------------------------- + +/// Commands sent to the audio thread. +pub enum AudioCommand { + /// Start playing a clip. + Play { + clip_index: usize, + volume: f32, + looping: bool, + }, + /// Stop all instances of a clip. + Stop { clip_index: usize }, + /// Change volume of all playing instances of a clip. + SetVolume { clip_index: usize, volume: f32 }, + /// Stop all currently playing sounds. + StopAll, + /// Shut down the audio thread. + Shutdown, +} + +// --------------------------------------------------------------------------- +// AudioSystem +// --------------------------------------------------------------------------- + +/// Manages audio playback via a dedicated OS-level thread. +pub struct AudioSystem { + sender: mpsc::Sender, +} + +impl AudioSystem { + /// Create an AudioSystem with the given pre-loaded clips. + /// + /// Spawns a background audio thread that owns the clips and drives the + /// WASAPI device (on Windows) or runs in silent mode (other platforms). + pub fn new(clips: Vec) -> Result { + let clips = Arc::new(clips); + let (tx, rx) = mpsc::channel::(); + + let clips_arc = Arc::clone(&clips); + thread::Builder::new() + .name("voltex_audio_thread".to_string()) + .spawn(move || { + audio_thread(rx, clips_arc); + }) + .map_err(|e| format!("Failed to spawn audio thread: {}", e))?; + + Ok(AudioSystem { sender: tx }) + } + + /// Start playing a clip at the given volume and looping setting. + pub fn play(&self, clip_index: usize, volume: f32, looping: bool) { + let _ = self.sender.send(AudioCommand::Play { + clip_index, + volume, + looping, + }); + } + + /// Stop all instances of the specified clip. + pub fn stop(&self, clip_index: usize) { + let _ = self.sender.send(AudioCommand::Stop { clip_index }); + } + + /// Set the volume for all playing instances of a clip. + pub fn set_volume(&self, clip_index: usize, volume: f32) { + let _ = self.sender.send(AudioCommand::SetVolume { clip_index, volume }); + } + + /// Stop all currently playing sounds. + pub fn stop_all(&self) { + let _ = self.sender.send(AudioCommand::StopAll); + } +} + +impl Drop for AudioSystem { + fn drop(&mut self) { + let _ = self.sender.send(AudioCommand::Shutdown); + } +} + +// --------------------------------------------------------------------------- +// Audio thread implementation +// --------------------------------------------------------------------------- + +/// Main audio thread function. +/// +/// On Windows, initializes WasapiDevice and drives playback. +/// On other platforms, runs in a silent "null" mode (processes commands only). +fn audio_thread(rx: mpsc::Receiver, clips: Arc>) { + #[cfg(target_os = "windows")] + audio_thread_windows(rx, clips); + + #[cfg(not(target_os = "windows"))] + audio_thread_null(rx, clips); +} + +// --------------------------------------------------------------------------- +// Windows implementation +// --------------------------------------------------------------------------- + +#[cfg(target_os = "windows")] +fn audio_thread_windows(rx: mpsc::Receiver, clips: Arc>) { + use crate::wasapi::WasapiDevice; + + let device = match WasapiDevice::new() { + Ok(d) => d, + Err(e) => { + eprintln!("[voltex_audio] WASAPI init failed: {}. Running in silent mode.", e); + audio_thread_null(rx, clips); + return; + } + }; + + let device_sample_rate = device.sample_rate; + let device_channels = device.channels; + let buffer_frames = device.buffer_frames() as usize; + + let mut playing: Vec = Vec::new(); + let mut output: Vec = Vec::new(); + + loop { + // Process all pending commands (non-blocking) + loop { + match rx.try_recv() { + Ok(cmd) => { + match cmd { + AudioCommand::Play { clip_index, volume, looping } => { + playing.push(PlayingSound::new(clip_index, volume, looping)); + } + AudioCommand::Stop { clip_index } => { + playing.retain(|s| s.clip_index != clip_index); + } + AudioCommand::SetVolume { clip_index, volume } => { + for s in playing.iter_mut() { + if s.clip_index == clip_index { + s.volume = volume; + } + } + } + AudioCommand::StopAll => { + playing.clear(); + } + AudioCommand::Shutdown => { + return; + } + } + } + Err(mpsc::TryRecvError::Empty) => break, + Err(mpsc::TryRecvError::Disconnected) => return, + } + } + + // Mix and write audio + if !playing.is_empty() || !output.is_empty() { + mix_sounds( + &mut output, + &mut playing, + &clips, + device_sample_rate, + device_channels, + buffer_frames, + ); + + if let Err(e) = device.write_samples(&output) { + eprintln!("[voltex_audio] write_samples error: {}", e); + } + } else { + // Write silence to keep the device happy + let silence_len = buffer_frames * device_channels as usize; + let silence = vec![0.0f32; silence_len]; + let _ = device.write_samples(&silence); + } + + // Sleep ~5 ms between iterations + thread::sleep(std::time::Duration::from_millis(5)); + } +} + +// --------------------------------------------------------------------------- +// Null (non-Windows) implementation +// --------------------------------------------------------------------------- + +#[cfg(not(target_os = "windows"))] +fn audio_thread_null(rx: mpsc::Receiver, clips: Arc>) { + audio_thread_null_impl(rx, clips); +} + +#[cfg(target_os = "windows")] +fn audio_thread_null(rx: mpsc::Receiver, _clips: Arc>) { + audio_thread_null_impl(rx, _clips); +} + +fn audio_thread_null_impl(rx: mpsc::Receiver, _clips: Arc>) { + loop { + match rx.recv() { + Ok(AudioCommand::Shutdown) | Err(_) => return, + Ok(_) => {} + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::AudioClip; + + fn silence_clip() -> AudioClip { + AudioClip::new(vec![0.0f32; 44100], 44100, 1) + } + + #[test] + fn create_and_drop() { + // AudioSystem should be created and dropped without panicking. + // The audio thread is spawned but will run in null mode in CI. + let _sys = AudioSystem::new(vec![silence_clip()]) + .expect("AudioSystem::new failed"); + // Drop happens here, Shutdown command is sent automatically. + } + + #[test] + fn send_commands() { + let sys = AudioSystem::new(vec![silence_clip(), silence_clip()]) + .expect("AudioSystem::new failed"); + + sys.play(0, 1.0, false); + sys.play(1, 0.5, true); + sys.set_volume(0, 0.8); + sys.stop(0); + sys.stop_all(); + // All sends must succeed without panic. + } +} diff --git a/crates/voltex_audio/src/lib.rs b/crates/voltex_audio/src/lib.rs index 91abd33..bec1344 100644 --- a/crates/voltex_audio/src/lib.rs +++ b/crates/voltex_audio/src/lib.rs @@ -1,7 +1,11 @@ pub mod audio_clip; pub mod wav; pub mod mixing; +#[cfg(target_os = "windows")] +pub mod wasapi; +pub mod audio_system; pub use audio_clip::AudioClip; pub use wav::{parse_wav, generate_wav_bytes}; pub use mixing::{PlayingSound, mix_sounds}; +pub use audio_system::AudioSystem;