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:
2026-03-26 17:23:48 +09:00
parent bc2880d41c
commit aafebff478
4 changed files with 247 additions and 0 deletions

View 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);
}
}

View 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")));
}
}

View 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);
}
}
}

View File

@@ -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;