Skip to main content

proof_engine/audio/
mixer.rs

1//! Spatial audio mixer — buses, ducking, reverb send, stereo panning, distance attenuation.
2//!
3//! The mixer is structured around named buses:
4//!   - Music bus: looping background audio, cross-fades on vibe change
5//!   - SFX bus: one-shot sound effects with 3D attenuation
6//!   - Ambient bus: looping environmental drones
7//!   - UI bus: non-spatial UI sounds
8//!
9//! The master bus applies limiting and optional reverb send before output.
10
11use glam::Vec3;
12
13// ── Bus types ─────────────────────────────────────────────────────────────────
14
15/// Named audio bus identifiers.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
17pub enum BusId {
18    Music,
19    Sfx,
20    Ambient,
21    Ui,
22    Reverb,
23    Master,
24}
25
26/// Volume and mute state for a single bus.
27#[derive(Debug, Clone)]
28pub struct Bus {
29    pub id:         BusId,
30    pub volume:     f32,   // [0, 1]
31    pub muted:      bool,
32    /// True gain accounting for mute state.
33    pub effective:  f32,
34    /// Target volume for smooth fades.
35    fade_target:    f32,
36    /// Fade duration in seconds.
37    fade_duration:  f32,
38    /// Elapsed fade time.
39    fade_elapsed:   f32,
40    /// Volume before ducking was applied.
41    pre_duck:       f32,
42    pub ducked:     bool,
43}
44
45impl Bus {
46    pub fn new(id: BusId, volume: f32) -> Self {
47        Self {
48            id,
49            volume,
50            muted: false,
51            effective: volume,
52            fade_target: volume,
53            fade_duration: 0.0,
54            fade_elapsed: 0.0,
55            pre_duck: volume,
56            ducked: false,
57        }
58    }
59
60    /// Tick the bus by dt seconds (process fades).
61    pub fn tick(&mut self, dt: f32) {
62        if self.fade_duration > 0.0 {
63            self.fade_elapsed += dt;
64            let t = (self.fade_elapsed / self.fade_duration).min(1.0);
65            self.volume = self.pre_duck + (self.fade_target - self.pre_duck) * smooth_step(t);
66            if t >= 1.0 {
67                self.volume = self.fade_target;
68                self.fade_duration = 0.0;
69                self.fade_elapsed = 0.0;
70            }
71        }
72        self.effective = if self.muted { 0.0 } else { self.volume };
73    }
74
75    /// Start a smooth fade to a target volume over duration seconds.
76    pub fn fade_to(&mut self, target: f32, duration: f32) {
77        self.fade_target = target.clamp(0.0, 1.0);
78        self.fade_duration = duration.max(0.001);
79        self.fade_elapsed = 0.0;
80        self.pre_duck = self.volume;
81    }
82
83    /// Duck this bus to a reduced volume over attack_s seconds.
84    pub fn duck(&mut self, reduced_volume: f32, attack_s: f32) {
85        if !self.ducked {
86            self.pre_duck = self.volume;
87            self.ducked = true;
88        }
89        self.fade_to(reduced_volume, attack_s);
90    }
91
92    /// Un-duck this bus back to its pre-duck volume.
93    pub fn unduck(&mut self, release_s: f32) {
94        if self.ducked {
95            let target = self.pre_duck;
96            self.fade_to(target, release_s);
97            self.ducked = false;
98        }
99    }
100}
101
102// ── Stereo frame ─────────────────────────────────────────────────────────────
103
104/// A stereo audio frame (left, right) in [-1, 1].
105#[derive(Clone, Copy, Debug, Default)]
106pub struct StereoFrame {
107    pub left:  f32,
108    pub right: f32,
109}
110
111impl StereoFrame {
112    pub fn mono(sample: f32) -> Self { Self { left: sample, right: sample } }
113
114    pub fn panned(sample: f32, pan: f32) -> Self {
115        // Equal-power panning law
116        let p = pan.clamp(-1.0, 1.0);
117        let angle = (p + 1.0) * std::f32::consts::FRAC_PI_4;
118        Self {
119            left:  sample * angle.cos(),
120            right: sample * angle.sin(),
121        }
122    }
123
124    pub fn scaled(self, gain: f32) -> Self {
125        Self { left: self.left * gain, right: self.right * gain }
126    }
127}
128
129impl std::ops::Add for StereoFrame {
130    type Output = Self;
131    fn add(self, rhs: Self) -> Self {
132        Self { left: self.left + rhs.left, right: self.right + rhs.right }
133    }
134}
135
136impl std::ops::AddAssign for StereoFrame {
137    fn add_assign(&mut self, rhs: Self) {
138        self.left  += rhs.left;
139        self.right += rhs.right;
140    }
141}
142
143// ── Distance attenuation ──────────────────────────────────────────────────────
144
145/// Model for how volume decreases with distance.
146#[derive(Debug, Clone, Copy)]
147pub enum AttenuationModel {
148    /// Linear falloff from min_dist to max_dist.
149    Linear,
150    /// Inverse distance (natural sound falloff).
151    Inverse,
152    /// Inverse square (physically accurate).
153    InverseSquare,
154    /// Logarithmic (similar to Inverse, smoother).
155    Logarithmic,
156}
157
158/// Compute volume [0, 1] from distance and attenuation model.
159pub fn attenuate(dist: f32, min_dist: f32, max_dist: f32, model: AttenuationModel) -> f32 {
160    if dist <= min_dist { return 1.0; }
161    if dist >= max_dist { return 0.0; }
162    let t = (dist - min_dist) / (max_dist - min_dist).max(0.001);
163    match model {
164        AttenuationModel::Linear       => 1.0 - t,
165        AttenuationModel::Inverse      => min_dist / dist.max(0.001),
166        AttenuationModel::InverseSquare => (min_dist / dist.max(0.001)).powi(2),
167        AttenuationModel::Logarithmic  => 1.0 - t.ln().max(-10.0) / (-10.0),
168    }
169}
170
171/// Mix weight for a source given listener position and source position.
172pub fn spatial_weight(listener: Vec3, source: Vec3, max_distance: f32) -> f32 {
173    let dist = (source - listener).length();
174    if dist >= max_distance { return 0.0; }
175    1.0 - dist / max_distance
176}
177
178/// Compute stereo pan from a 3D position relative to listener.
179/// Returns (left, right) gain in [0, 1].
180pub fn stereo_pan(listener: Vec3, source: Vec3) -> (f32, f32) {
181    let delta = source - listener;
182    let pan = (delta.x / (delta.length().max(0.001))).clamp(-1.0, 1.0);
183    let left  = ((1.0 - pan) * 0.5).sqrt();
184    let right = ((1.0 + pan) * 0.5).sqrt();
185    (left, right)
186}
187
188// ── Channel strip ─────────────────────────────────────────────────────────────
189
190/// A 3D positioned audio source channel.
191#[derive(Debug, Clone)]
192pub struct ChannelStrip {
193    pub id:              u64,
194    pub position:        Vec3,
195    pub volume:          f32,
196    pub bus:             BusId,
197    pub looping:         bool,
198    pub attenuation:     AttenuationModel,
199    pub min_dist:        f32,
200    pub max_dist:        f32,
201    pub reverb_send:     f32,
202    pub pitch_shift:     f32,  // semitone offset
203    /// Whether this channel is actively playing.
204    pub active:          bool,
205    /// Age of this channel (seconds since spawn).
206    pub age:             f32,
207    /// Optional max age (for one-shots, set from sample duration).
208    pub max_age:         Option<f32>,
209}
210
211impl ChannelStrip {
212    pub fn new(id: u64, position: Vec3, bus: BusId) -> Self {
213        Self {
214            id,
215            position,
216            volume: 1.0,
217            bus,
218            looping: false,
219            attenuation: AttenuationModel::Inverse,
220            min_dist: 1.0,
221            max_dist: 50.0,
222            reverb_send: 0.0,
223            pitch_shift: 0.0,
224            active: true,
225            age: 0.0,
226            max_age: None,
227        }
228    }
229
230    pub fn one_shot(mut self, duration_s: f32) -> Self {
231        self.max_age = Some(duration_s);
232        self
233    }
234
235    pub fn looping(mut self) -> Self {
236        self.looping = true;
237        self
238    }
239
240    pub fn tick(&mut self, dt: f32) {
241        self.age += dt;
242        if let Some(max) = self.max_age {
243            if self.age >= max && !self.looping {
244                self.active = false;
245            }
246        }
247    }
248
249    /// Compute the effective stereo gain for this channel given listener position.
250    pub fn stereo_gain(&self, listener: Vec3) -> (f32, f32) {
251        let dist = (self.position - listener).length();
252        let vol = self.volume * attenuate(dist, self.min_dist, self.max_dist, self.attenuation);
253        let (l_pan, r_pan) = stereo_pan(listener, self.position);
254        (vol * l_pan, vol * r_pan)
255    }
256
257    pub fn is_expired(&self) -> bool { !self.active }
258}
259
260// ── Master limiter ────────────────────────────────────────────────────────────
261
262/// Simple lookahead peak limiter to prevent clipping.
263#[derive(Debug, Clone)]
264pub struct Limiter {
265    pub threshold: f32,
266    pub release_coef: f32,
267    gain:  f32,
268}
269
270impl Limiter {
271    pub fn new(threshold_db: f32, release_ms: f32) -> Self {
272        let threshold = 10.0f32.powf(threshold_db / 20.0);
273        let release_coef = 1.0 - 1.0 / (SAMPLE_RATE * release_ms * 0.001);
274        Self { threshold, release_coef, gain: 1.0 }
275    }
276
277    pub fn tick(&mut self, frame: StereoFrame) -> StereoFrame {
278        let peak = frame.left.abs().max(frame.right.abs());
279        if peak * self.gain > self.threshold {
280            self.gain = self.threshold / peak.max(0.0001);
281        } else {
282            self.gain = (self.gain * self.release_coef).min(1.0);
283        }
284        frame.scaled(self.gain)
285    }
286}
287
288const SAMPLE_RATE: f32 = 48_000.0;
289
290// ── Compressor ────────────────────────────────────────────────────────────────
291
292/// RMS compressor for dynamic range control.
293#[derive(Debug, Clone)]
294pub struct Compressor {
295    pub threshold_db: f32,
296    pub ratio:        f32,   // > 1 (e.g. 4 = 4:1)
297    pub attack_coef:  f32,
298    pub release_coef: f32,
299    pub makeup_gain:  f32,   // linear
300    envelope:         f32,
301}
302
303impl Compressor {
304    pub fn new(threshold_db: f32, ratio: f32, attack_ms: f32, release_ms: f32) -> Self {
305        let attack_coef  = (-2.2 / (SAMPLE_RATE * attack_ms  * 0.001)).exp();
306        let release_coef = (-2.2 / (SAMPLE_RATE * release_ms * 0.001)).exp();
307        Self {
308            threshold_db,
309            ratio,
310            attack_coef,
311            release_coef,
312            makeup_gain: 1.0,
313            envelope: 0.0,
314        }
315    }
316
317    pub fn tick(&mut self, frame: StereoFrame) -> StereoFrame {
318        let peak = frame.left.abs().max(frame.right.abs());
319        let peak_db = if peak > 0.0 { 20.0 * peak.log10() } else { -100.0 };
320
321        let coef = if peak_db > self.threshold_db { self.attack_coef } else { self.release_coef };
322        self.envelope = peak_db + coef * (self.envelope - peak_db);
323
324        let gain_db = if self.envelope > self.threshold_db {
325            self.threshold_db + (self.envelope - self.threshold_db) / self.ratio - self.envelope
326        } else {
327            0.0
328        };
329        let gain = 10.0f32.powf(gain_db / 20.0) * self.makeup_gain;
330
331        frame.scaled(gain)
332    }
333}
334
335// ── Mixer ─────────────────────────────────────────────────────────────────────
336
337/// The master audio mixer — manages buses, channels, and effects.
338pub struct Mixer {
339    pub music:   Bus,
340    pub sfx:     Bus,
341    pub ambient: Bus,
342    pub ui:      Bus,
343    pub master:  Bus,
344    pub limiter: Limiter,
345    pub compressor: Compressor,
346    channels:    Vec<ChannelStrip>,
347    next_id:     u64,
348    pub listener_pos: Vec3,
349    /// Ducking: when SFX is playing loudly, music ducks.
350    pub auto_duck: bool,
351    duck_threshold: f32,
352}
353
354impl Mixer {
355    pub fn new() -> Self {
356        Self {
357            music:   Bus::new(BusId::Music,   0.8),
358            sfx:     Bus::new(BusId::Sfx,     1.0),
359            ambient: Bus::new(BusId::Ambient,  0.5),
360            ui:      Bus::new(BusId::Ui,       0.9),
361            master:  Bus::new(BusId::Master,   1.0),
362            limiter: Limiter::new(-1.0, 100.0),
363            compressor: Compressor::new(-12.0, 4.0, 5.0, 100.0),
364            channels: Vec::new(),
365            next_id: 1,
366            listener_pos: Vec3::ZERO,
367            auto_duck: true,
368            duck_threshold: 0.7,
369        }
370    }
371
372    // ── Bus control ───────────────────────────────────────────────────────────
373
374    pub fn bus_mut(&mut self, id: BusId) -> &mut Bus {
375        match id {
376            BusId::Music   => &mut self.music,
377            BusId::Sfx     => &mut self.sfx,
378            BusId::Ambient => &mut self.ambient,
379            BusId::Ui      => &mut self.ui,
380            BusId::Master  => &mut self.master,
381            BusId::Reverb  => &mut self.master, // fallback
382        }
383    }
384
385    pub fn bus(&self, id: BusId) -> &Bus {
386        match id {
387            BusId::Music   => &self.music,
388            BusId::Sfx     => &self.sfx,
389            BusId::Ambient => &self.ambient,
390            BusId::Ui      => &self.ui,
391            _              => &self.master,
392        }
393    }
394
395    pub fn set_music_volume(&mut self, v: f32)   { self.music.volume = v.clamp(0.0, 1.0); }
396    pub fn set_sfx_volume(&mut self, v: f32)     { self.sfx.volume = v.clamp(0.0, 1.0); }
397    pub fn set_ambient_volume(&mut self, v: f32) { self.ambient.volume = v.clamp(0.0, 1.0); }
398    pub fn set_master_volume(&mut self, v: f32)  { self.master.volume = v.clamp(0.0, 1.0); }
399
400    pub fn fade_music(&mut self, target: f32, secs: f32) {
401        self.music.fade_to(target, secs);
402    }
403
404    pub fn mute_all(&mut self) {
405        self.music.muted   = true;
406        self.sfx.muted     = true;
407        self.ambient.muted = true;
408    }
409
410    pub fn unmute_all(&mut self) {
411        self.music.muted   = false;
412        self.sfx.muted     = false;
413        self.ambient.muted = false;
414    }
415
416    // ── Channel management ────────────────────────────────────────────────────
417
418    /// Register a spatial sound channel. Returns its ID.
419    pub fn add_channel(&mut self, position: Vec3, bus: BusId) -> u64 {
420        let id = self.next_id;
421        self.next_id += 1;
422        self.channels.push(ChannelStrip::new(id, position, bus));
423        id
424    }
425
426    /// Add a one-shot SFX at a world position.
427    pub fn add_oneshot_sfx(&mut self, position: Vec3, duration_s: f32) -> u64 {
428        let id = self.next_id;
429        self.next_id += 1;
430        self.channels.push(ChannelStrip::new(id, position, BusId::Sfx).one_shot(duration_s));
431        id
432    }
433
434    /// Remove a channel by ID.
435    pub fn remove_channel(&mut self, id: u64) {
436        self.channels.retain(|c| c.id != id);
437    }
438
439    pub fn get_channel_mut(&mut self, id: u64) -> Option<&mut ChannelStrip> {
440        self.channels.iter_mut().find(|c| c.id == id)
441    }
442
443    // ── Mix a frame ───────────────────────────────────────────────────────────
444
445    /// Tick all buses and channels by dt seconds.
446    pub fn tick(&mut self, dt: f32) {
447        self.music.tick(dt);
448        self.sfx.tick(dt);
449        self.ambient.tick(dt);
450        self.ui.tick(dt);
451        self.master.tick(dt);
452
453        // Expire finished channels
454        for ch in &mut self.channels { ch.tick(dt); }
455        self.channels.retain(|ch| !ch.is_expired());
456    }
457
458    /// Mix one stereo output frame from all active channels.
459    pub fn mix_frame(&mut self, channel_gains: &[(u64, f32)]) -> StereoFrame {
460        let mut mix = StereoFrame::default();
461
462        for ch in &self.channels {
463            if !ch.active { continue; }
464            let bus_gain = self.bus(ch.bus).effective;
465            if bus_gain == 0.0 { continue; }
466
467            // Look up per-sample gain for this channel
468            let ch_gain = channel_gains.iter()
469                .find(|(id, _)| *id == ch.id)
470                .map(|(_, g)| *g)
471                .unwrap_or(0.0);
472
473            let (l, r) = ch.stereo_gain(self.listener_pos);
474            mix += StereoFrame {
475                left:  ch_gain * l * bus_gain,
476                right: ch_gain * r * bus_gain,
477            };
478        }
479
480        // Master gain + limiting
481        mix = mix.scaled(self.master.effective);
482        mix = self.compressor.tick(mix);
483        mix = self.limiter.tick(mix);
484        mix
485    }
486
487    /// Number of active channels.
488    pub fn channel_count(&self) -> usize { self.channels.len() }
489
490    /// Auto-duck music when SFX volume exceeds threshold.
491    pub fn update_auto_duck(&mut self) {
492        if !self.auto_duck { return; }
493        // Simple heuristic: duck music when sfx bus is loud
494        let sfx_vol = self.sfx.volume;
495        if sfx_vol > self.duck_threshold && !self.music.ducked {
496            self.music.duck(sfx_vol * 0.4, 0.2);
497        } else if sfx_vol <= self.duck_threshold && self.music.ducked {
498            self.music.unduck(0.5);
499        }
500    }
501}
502
503impl Default for Mixer {
504    fn default() -> Self { Self::new() }
505}
506
507// ── Smooth step ───────────────────────────────────────────────────────────────
508
509fn smooth_step(t: f32) -> f32 {
510    let t = t.clamp(0.0, 1.0);
511    t * t * (3.0 - 2.0 * t)
512}
513
514// ── Tests ─────────────────────────────────────────────────────────────────────
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519
520    #[test]
521    fn bus_fade_reaches_target() {
522        let mut bus = Bus::new(BusId::Music, 1.0);
523        bus.fade_to(0.0, 1.0);
524        for _ in 0..100 { bus.tick(0.01); }
525        assert!(bus.volume < 0.01, "Expected near-zero volume, got {}", bus.volume);
526    }
527
528    #[test]
529    fn spatial_weight_decreases_with_distance() {
530        let listener = Vec3::ZERO;
531        let near = spatial_weight(listener, Vec3::new(5.0, 0.0, 0.0), 50.0);
532        let far  = spatial_weight(listener, Vec3::new(40.0, 0.0, 0.0), 50.0);
533        assert!(near > far);
534    }
535
536    #[test]
537    fn stereo_pan_right_source_louder_right() {
538        let listener = Vec3::ZERO;
539        let source   = Vec3::new(5.0, 0.0, 0.0);
540        let (l, r)   = stereo_pan(listener, source);
541        assert!(r > l, "Right source should be louder in right channel");
542    }
543
544    #[test]
545    fn attenuation_at_min_dist_is_one() {
546        assert!((attenuate(0.5, 1.0, 50.0, AttenuationModel::Linear) - 1.0).abs() < 0.001);
547    }
548
549    #[test]
550    fn attenuation_at_max_dist_is_zero() {
551        assert!(attenuate(50.0, 1.0, 50.0, AttenuationModel::Inverse) < 0.001);
552    }
553
554    #[test]
555    fn mixer_channel_expires() {
556        let mut mixer = Mixer::new();
557        mixer.add_oneshot_sfx(Vec3::ZERO, 0.1);
558        assert_eq!(mixer.channel_count(), 1);
559        mixer.tick(0.2);
560        assert_eq!(mixer.channel_count(), 0);
561    }
562
563    #[test]
564    fn limiter_clamps_peaks() {
565        let mut lim = Limiter::new(-0.0, 10.0);
566        let loud = StereoFrame { left: 5.0, right: 5.0 };
567        let out = lim.tick(loud);
568        assert!(out.left <= 1.01, "Expected ≤1, got {}", out.left);
569    }
570
571    #[test]
572    fn duck_reduces_music_volume() {
573        let mut mixer = Mixer::new();
574        mixer.music.volume = 1.0;
575        mixer.sfx.volume   = 0.9;
576        mixer.update_auto_duck();
577        // Trigger fade
578        for _ in 0..100 { mixer.tick(0.01); }
579        assert!(mixer.music.volume < 0.9, "Expected ducked, got {}", mixer.music.volume);
580    }
581}