feat(audio): add fade curves, dynamic mix groups, audio bus routing
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
82
crates/voltex_audio/src/audio_bus.rs
Normal file
82
crates/voltex_audio/src/audio_bus.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
/// Audio bus: mixes multiple input signals.
|
||||
pub struct AudioBus {
|
||||
pub inputs: Vec<BusInput>,
|
||||
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);
|
||||
}
|
||||
}
|
||||
101
crates/voltex_audio/src/dynamic_groups.rs
Normal file
101
crates/voltex_audio/src/dynamic_groups.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Dynamic audio mix group system.
|
||||
pub struct MixGroupManager {
|
||||
groups: HashMap<String, MixGroupConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MixGroupConfig {
|
||||
pub name: String,
|
||||
pub volume: f32,
|
||||
pub muted: bool,
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
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")));
|
||||
}
|
||||
}
|
||||
58
crates/voltex_audio/src/fade_curves.rs
Normal file
58
crates/voltex_audio/src/fade_curves.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user