feat(audio): add async loader, effect chain
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
91
crates/voltex_audio/src/async_loader.rs
Normal file
91
crates/voltex_audio/src/async_loader.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
62
crates/voltex_audio/src/effect_chain.rs
Normal file
62
crates/voltex_audio/src/effect_chain.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
Reference in New Issue
Block a user