From aafebff4782223b3082f42eebb26c8d684bd8289 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Thu, 26 Mar 2026 17:23:48 +0900 Subject: [PATCH] feat(audio): add fade curves, dynamic mix groups, audio bus routing Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/voltex_audio/src/audio_bus.rs | 82 ++++++++++++++++++ crates/voltex_audio/src/dynamic_groups.rs | 101 ++++++++++++++++++++++ crates/voltex_audio/src/fade_curves.rs | 58 +++++++++++++ crates/voltex_audio/src/lib.rs | 6 ++ 4 files changed, 247 insertions(+) create mode 100644 crates/voltex_audio/src/audio_bus.rs create mode 100644 crates/voltex_audio/src/dynamic_groups.rs create mode 100644 crates/voltex_audio/src/fade_curves.rs diff --git a/crates/voltex_audio/src/audio_bus.rs b/crates/voltex_audio/src/audio_bus.rs new file mode 100644 index 0000000..9e1910d --- /dev/null +++ b/crates/voltex_audio/src/audio_bus.rs @@ -0,0 +1,82 @@ +/// Audio bus: mixes multiple input signals. +pub struct AudioBus { + pub inputs: Vec, + pub output_gain: f32, +} + +#[derive(Debug, Clone)] +pub struct BusInput { + pub source_id: u32, + pub gain: f32, +} + +impl AudioBus { + pub fn new() -> Self { + AudioBus { inputs: Vec::new(), output_gain: 1.0 } + } + + pub fn add_input(&mut self, source_id: u32, gain: f32) { + self.inputs.push(BusInput { source_id, gain }); + } + + pub fn remove_input(&mut self, source_id: u32) { + self.inputs.retain(|i| i.source_id != source_id); + } + + /// Mix samples from all inputs. Each input provides a sample value. + pub fn mix(&self, samples: &[(u32, f32)]) -> f32 { + let mut sum = 0.0; + for input in &self.inputs { + if let Some((_, sample)) = samples.iter().find(|(id, _)| *id == input.source_id) { + sum += sample * input.gain; + } + } + sum * self.output_gain + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_bus() { + let bus = AudioBus::new(); + assert!((bus.mix(&[]) - 0.0).abs() < 1e-6); + } + + #[test] + fn test_single_input() { + let mut bus = AudioBus::new(); + bus.add_input(1, 0.5); + let out = bus.mix(&[(1, 1.0)]); + assert!((out - 0.5).abs() < 1e-6); + } + + #[test] + fn test_multiple_inputs() { + let mut bus = AudioBus::new(); + bus.add_input(1, 1.0); + bus.add_input(2, 1.0); + let out = bus.mix(&[(1, 0.3), (2, 0.5)]); + assert!((out - 0.8).abs() < 1e-6); + } + + #[test] + fn test_remove_input() { + let mut bus = AudioBus::new(); + bus.add_input(1, 1.0); + bus.add_input(2, 1.0); + bus.remove_input(1); + assert_eq!(bus.inputs.len(), 1); + } + + #[test] + fn test_output_gain() { + let mut bus = AudioBus::new(); + bus.output_gain = 0.5; + bus.add_input(1, 1.0); + let out = bus.mix(&[(1, 1.0)]); + assert!((out - 0.5).abs() < 1e-6); + } +} diff --git a/crates/voltex_audio/src/dynamic_groups.rs b/crates/voltex_audio/src/dynamic_groups.rs new file mode 100644 index 0000000..02f52fe --- /dev/null +++ b/crates/voltex_audio/src/dynamic_groups.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; + +/// Dynamic audio mix group system. +pub struct MixGroupManager { + groups: HashMap, +} + +#[derive(Debug, Clone)] +pub struct MixGroupConfig { + pub name: String, + pub volume: f32, + pub muted: bool, + pub parent: Option, +} + +impl MixGroupManager { + pub fn new() -> Self { + let mut mgr = MixGroupManager { groups: HashMap::new() }; + mgr.add_group("Master", None); + mgr + } + + pub fn add_group(&mut self, name: &str, parent: Option<&str>) -> bool { + if self.groups.contains_key(name) { return false; } + if let Some(p) = parent { + if !self.groups.contains_key(p) { return false; } + } + self.groups.insert(name.to_string(), MixGroupConfig { + name: name.to_string(), + volume: 1.0, + muted: false, + parent: parent.map(|s| s.to_string()), + }); + true + } + + pub fn remove_group(&mut self, name: &str) -> bool { + if name == "Master" { return false; } // can't remove master + self.groups.remove(name).is_some() + } + + pub fn set_volume(&mut self, name: &str, volume: f32) { + if let Some(g) = self.groups.get_mut(name) { g.volume = volume.clamp(0.0, 1.0); } + } + + pub fn effective_volume(&self, name: &str) -> f32 { + let mut vol = 1.0; + let mut current = name; + for _ in 0..10 { // max depth to prevent infinite loops + if let Some(g) = self.groups.get(current) { + if g.muted { return 0.0; } + vol *= g.volume; + if let Some(ref p) = g.parent { current = p; } else { break; } + } else { break; } + } + vol + } + + pub fn group_count(&self) -> usize { self.groups.len() } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_has_master() { + let mgr = MixGroupManager::new(); + assert_eq!(mgr.group_count(), 1); + assert!((mgr.effective_volume("Master") - 1.0).abs() < 1e-6); + } + + #[test] + fn test_add_group() { + let mut mgr = MixGroupManager::new(); + assert!(mgr.add_group("SFX", Some("Master"))); + assert_eq!(mgr.group_count(), 2); + } + + #[test] + fn test_effective_volume_chain() { + let mut mgr = MixGroupManager::new(); + mgr.set_volume("Master", 0.8); + mgr.add_group("SFX", Some("Master")); + mgr.set_volume("SFX", 0.5); + assert!((mgr.effective_volume("SFX") - 0.4).abs() < 1e-6); // 0.5 * 0.8 + } + + #[test] + fn test_cant_remove_master() { + let mut mgr = MixGroupManager::new(); + assert!(!mgr.remove_group("Master")); + } + + #[test] + fn test_add_duplicate_fails() { + let mut mgr = MixGroupManager::new(); + mgr.add_group("SFX", Some("Master")); + assert!(!mgr.add_group("SFX", Some("Master"))); + } +} diff --git a/crates/voltex_audio/src/fade_curves.rs b/crates/voltex_audio/src/fade_curves.rs new file mode 100644 index 0000000..4cedbd2 --- /dev/null +++ b/crates/voltex_audio/src/fade_curves.rs @@ -0,0 +1,58 @@ +/// Fade curve types. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum FadeCurve { + Linear, + Exponential, + Logarithmic, + SCurve, +} + +/// Apply fade curve to a normalized parameter t (0.0 to 1.0). +pub fn apply_fade(t: f32, curve: FadeCurve) -> f32 { + let t = t.clamp(0.0, 1.0); + match curve { + FadeCurve::Linear => t, + FadeCurve::Exponential => t * t, + FadeCurve::Logarithmic => t.sqrt(), + FadeCurve::SCurve => { + // Smoothstep: 3t^2 - 2t^3 + t * t * (3.0 - 2.0 * t) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_linear() { + assert!((apply_fade(0.5, FadeCurve::Linear) - 0.5).abs() < 1e-6); + } + + #[test] + fn test_exponential() { + assert!((apply_fade(0.5, FadeCurve::Exponential) - 0.25).abs() < 1e-6); + assert!(apply_fade(0.5, FadeCurve::Exponential) < 0.5); // slower start + } + + #[test] + fn test_logarithmic() { + assert!(apply_fade(0.5, FadeCurve::Logarithmic) > 0.5); // faster start + } + + #[test] + fn test_scurve() { + assert!((apply_fade(0.0, FadeCurve::SCurve) - 0.0).abs() < 1e-6); + assert!((apply_fade(1.0, FadeCurve::SCurve) - 1.0).abs() < 1e-6); + assert!((apply_fade(0.5, FadeCurve::SCurve) - 0.5).abs() < 1e-6); // midpoint same + } + + #[test] + fn test_endpoints() { + for curve in [FadeCurve::Linear, FadeCurve::Exponential, FadeCurve::Logarithmic, FadeCurve::SCurve] { + assert!((apply_fade(0.0, curve) - 0.0).abs() < 1e-6); + assert!((apply_fade(1.0, curve) - 1.0).abs() < 1e-6); + } + } +} diff --git a/crates/voltex_audio/src/lib.rs b/crates/voltex_audio/src/lib.rs index c6122f5..1ab6095 100644 --- a/crates/voltex_audio/src/lib.rs +++ b/crates/voltex_audio/src/lib.rs @@ -20,3 +20,9 @@ pub use spatial::{Listener, SpatialParams, distance_attenuation, stereo_pan, com pub use mix_group::{MixGroup, MixerState}; pub use reverb::{Reverb, Echo, DelayLine}; pub use occlusion::{OcclusionResult, LowPassFilter, calculate_occlusion}; +pub mod fade_curves; +pub use fade_curves::{FadeCurve, apply_fade}; +pub mod dynamic_groups; +pub use dynamic_groups::MixGroupManager; +pub mod audio_bus; +pub use audio_bus::AudioBus;