feat(audio): add async loader, effect chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 17:28:31 +09:00
parent a50f79e4fc
commit be290bd6e0
3 changed files with 157 additions and 0 deletions

View File

@@ -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<Mutex<Vec<(u32, PathBuf, LoadState)>>>,
}
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<u32> {
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);
}
}

View File

@@ -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<Box<dyn AudioEffect>>,
pub bypass: bool,
}
impl EffectChain {
pub fn new() -> Self { EffectChain { effects: Vec::new(), bypass: false } }
pub fn add(&mut self, effect: Box<dyn AudioEffect>) { 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);
}
}

View File

@@ -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};