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 use dynamic_groups::MixGroupManager;
|
||||||
pub mod audio_bus;
|
pub mod audio_bus;
|
||||||
pub use audio_bus::AudioBus;
|
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