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 mix_group::{MixGroup, MixerState};
|
||||||
pub use reverb::{Reverb, Echo, DelayLine};
|
pub use reverb::{Reverb, Echo, DelayLine};
|
||||||
pub use occlusion::{OcclusionResult, LowPassFilter, calculate_occlusion};
|
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