From be290bd6e048763f829148a952b635ba0a95df65 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 17:28:31 +0900 Subject: [PATCH] feat(audio): add async loader, effect chain Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_audio/src/async_loader.rs | 91 +++++++++++++++++++++++++ crates/voltex_audio/src/effect_chain.rs | 62 +++++++++++++++++ crates/voltex_audio/src/lib.rs | 4 ++ 3 files changed, 157 insertions(+) create mode 100644 crates/voltex_audio/src/async_loader.rs create mode 100644 crates/voltex_audio/src/effect_chain.rs diff --git a/crates/voltex_audio/src/async_loader.rs b/crates/voltex_audio/src/async_loader.rs new file mode 100644 index 0000000..b45ef08 --- /dev/null +++ b/crates/voltex_audio/src/async_loader.rs @@ -0,0 +1,91 @@ +use std::sync::{Arc, Mutex}; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq)] +pub enum LoadState { + Pending, + Loading, + Loaded, + Error(String), +} + +pub struct AsyncAudioLoader { + pending: Arc>>, +} + +impl AsyncAudioLoader { + pub fn new() -> Self { + AsyncAudioLoader { pending: Arc::new(Mutex::new(Vec::new())) } + } + + /// Queue a clip for async loading. Returns immediately. + pub fn load(&self, clip_id: u32, path: PathBuf) { + let mut pending = self.pending.lock().unwrap(); + pending.push((clip_id, path.clone(), LoadState::Pending)); + let pending_clone = Arc::clone(&self.pending); + std::thread::spawn(move || { + // Mark loading + { let mut p = pending_clone.lock().unwrap(); + if let Some(entry) = p.iter_mut().find(|(id, _, _)| *id == clip_id) { + entry.2 = LoadState::Loading; + } + } + // Simulate load (read file) + match std::fs::read(&path) { + Ok(_data) => { + let mut p = pending_clone.lock().unwrap(); + if let Some(entry) = p.iter_mut().find(|(id, _, _)| *id == clip_id) { + entry.2 = LoadState::Loaded; + } + } + Err(e) => { + let mut p = pending_clone.lock().unwrap(); + if let Some(entry) = p.iter_mut().find(|(id, _, _)| *id == clip_id) { + entry.2 = LoadState::Error(e.to_string()); + } + } + } + }); + } + + pub fn state(&self, clip_id: u32) -> LoadState { + let pending = self.pending.lock().unwrap(); + pending.iter().find(|(id, _, _)| *id == clip_id) + .map(|(_, _, s)| s.clone()) + .unwrap_or(LoadState::Error("not found".to_string())) + } + + pub fn poll_completed(&self) -> Vec { + let pending = self.pending.lock().unwrap(); + pending.iter().filter(|(_, _, s)| *s == LoadState::Loaded).map(|(id, _, _)| *id).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_new() { + let loader = AsyncAudioLoader::new(); + assert!(loader.poll_completed().is_empty()); + } + #[test] + fn test_load_nonexistent() { + let loader = AsyncAudioLoader::new(); + loader.load(1, PathBuf::from("/nonexistent/path.wav")); + std::thread::sleep(std::time::Duration::from_millis(100)); + let state = loader.state(1); + assert!(matches!(state, LoadState::Error(_))); + } + #[test] + fn test_load_existing() { + let dir = std::env::temp_dir().join("voltex_async_test"); + let _ = std::fs::create_dir_all(&dir); + std::fs::write(dir.join("test.wav"), b"RIFF").unwrap(); + let loader = AsyncAudioLoader::new(); + loader.load(42, dir.join("test.wav")); + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!(loader.state(42), LoadState::Loaded); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/voltex_audio/src/effect_chain.rs b/crates/voltex_audio/src/effect_chain.rs new file mode 100644 index 0000000..e907bca --- /dev/null +++ b/crates/voltex_audio/src/effect_chain.rs @@ -0,0 +1,62 @@ +/// Trait for audio effects. +pub trait AudioEffect { + fn process(&mut self, sample: f32) -> f32; + fn name(&self) -> &str; +} + +/// Chain of audio effects processed in order. +pub struct EffectChain { + effects: Vec>, + pub bypass: bool, +} + +impl EffectChain { + pub fn new() -> Self { EffectChain { effects: Vec::new(), bypass: false } } + pub fn add(&mut self, effect: Box) { self.effects.push(effect); } + pub fn remove(&mut self, index: usize) { if index < self.effects.len() { self.effects.remove(index); } } + pub fn len(&self) -> usize { self.effects.len() } + pub fn is_empty(&self) -> bool { self.effects.is_empty() } + + pub fn process(&mut self, sample: f32) -> f32 { + if self.bypass { return sample; } + let mut s = sample; + for effect in &mut self.effects { + s = effect.process(s); + } + s + } +} + +// Simple gain effect for testing +pub struct GainEffect { pub gain: f32 } +impl AudioEffect for GainEffect { + fn process(&mut self, sample: f32) -> f32 { sample * self.gain } + fn name(&self) -> &str { "Gain" } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn test_empty_chain() { let mut c = EffectChain::new(); assert!((c.process(1.0) - 1.0).abs() < 1e-6); } + #[test] + fn test_single_effect() { + let mut c = EffectChain::new(); + c.add(Box::new(GainEffect { gain: 0.5 })); + assert!((c.process(1.0) - 0.5).abs() < 1e-6); + } + #[test] + fn test_chain_order() { + let mut c = EffectChain::new(); + c.add(Box::new(GainEffect { gain: 0.5 })); + c.add(Box::new(GainEffect { gain: 0.5 })); + assert!((c.process(1.0) - 0.25).abs() < 1e-6); + } + #[test] + fn test_bypass() { + let mut c = EffectChain::new(); + c.add(Box::new(GainEffect { gain: 0.0 })); + c.bypass = true; + assert!((c.process(1.0) - 1.0).abs() < 1e-6); + } +} diff --git a/crates/voltex_audio/src/lib.rs b/crates/voltex_audio/src/lib.rs index 1ab6095..9b03f74 100644 --- a/crates/voltex_audio/src/lib.rs +++ b/crates/voltex_audio/src/lib.rs @@ -26,3 +26,7 @@ pub mod dynamic_groups; pub use dynamic_groups::MixGroupManager; pub mod audio_bus; pub use audio_bus::AudioBus; +pub mod async_loader; +pub use async_loader::AsyncAudioLoader; +pub mod effect_chain; +pub use effect_chain::{AudioEffect, EffectChain};