Skip to main content

proof_engine/audio/
output.rs

1//! cpal audio output: device enumeration, stream creation, synthesis.
2//!
3//! The audio callback runs on a dedicated real-time thread. It receives
4//! AudioEvents over an mpsc channel and synthesises every active
5//! [`MathAudioSource`] sample by sample.
6//!
7//! What a source is now: an oscillator whose pitch comes from its
8//! MathFunction through a logarithmic range, detuned, with a pitch envelope
9//! that can fall from a multiple of the note down to it (the shape of every
10//! drum and impact there is), an optional second partial, a share of white
11//! noise, up to two biquad filters or a comb, soft drive, and the fade-in and
12//! fade-out the source asked for. Before this the thread ignored all of it:
13//! every sound in the world had the same fixed envelope and no filter, which
14//! is why a sword and a menu blip were the same click at different pitches.
15//!
16//! What the bus does now: sound effects and music are summed separately,
17//! music ducks under effects, a share of everything goes to one reverb, and
18//! a soft limiter keeps the sum from clipping.
19
20use std::sync::mpsc::Receiver;
21
22use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
23use cpal::{SampleFormat, Stream, StreamConfig};
24use glam::Vec3;
25
26use crate::audio::{AudioEvent, MusicVibe};
27use crate::audio::effects::{AudioEffect, Reverb};
28use crate::audio::math_source::{AudioFilter, MathAudioSource, Waveform as MsWaveform};
29use crate::audio::mixer::{spatial_weight, stereo_pan};
30use crate::audio::synth::{oscillator, BiquadFilter, DelayLine, Waveform as SynthWaveform};
31
32fn ms_to_synth_waveform(w: MsWaveform) -> SynthWaveform {
33    match w {
34        MsWaveform::Sine       => SynthWaveform::Sine,
35        MsWaveform::Triangle   => SynthWaveform::Triangle,
36        MsWaveform::Square     => SynthWaveform::Square,
37        MsWaveform::Sawtooth   => SynthWaveform::Sawtooth,
38        MsWaveform::ReverseSaw => SynthWaveform::ReverseSaw,
39        MsWaveform::Pulse(d)   => SynthWaveform::Pulse(d),
40        MsWaveform::Noise      => SynthWaveform::Noise,
41    }
42}
43
44/// A filter stage built from a source's [`AudioFilter`] description.
45enum Stage {
46    Biquad(BiquadFilter),
47    /// A feedback comb: the metallic ring of a struck thing.
48    Comb { delay: DelayLine, feedback: f32, last: f32 },
49}
50
51impl Stage {
52    fn from_filter(f: &AudioFilter) -> Stage {
53        match *f {
54            AudioFilter::LowPass { cutoff_hz, resonance } =>
55                Stage::Biquad(BiquadFilter::low_pass(cutoff_hz.max(20.0), resonance.max(0.5))),
56            AudioFilter::HighPass { cutoff_hz, resonance } =>
57                Stage::Biquad(BiquadFilter::high_pass(cutoff_hz.max(20.0), resonance.max(0.5))),
58            AudioFilter::BandPass { center_hz, bandwidth } =>
59                Stage::Biquad(BiquadFilter::band_pass(center_hz.max(20.0), (center_hz / bandwidth.max(1.0)).clamp(0.3, 20.0))),
60            AudioFilter::Notch { center_hz, bandwidth } =>
61                Stage::Biquad(BiquadFilter::notch(center_hz.max(20.0), (center_hz / bandwidth.max(1.0)).clamp(0.3, 20.0))),
62            AudioFilter::Formant { f1_hz, .. } =>
63                Stage::Biquad(BiquadFilter::band_pass(f1_hz.max(20.0), 4.0)),
64            AudioFilter::Comb { delay_ms, feedback } => {
65                let mut delay = DelayLine::new(delay_ms.max(0.2) + 1.0);
66                delay.set_delay_ms(delay_ms.max(0.2));
67                Stage::Comb { delay, feedback: feedback.clamp(-0.98, 0.98), last: 0.0 }
68            }
69        }
70    }
71
72    fn tick(&mut self, x: f32) -> f32 {
73        match self {
74            Stage::Biquad(b) => b.tick(x),
75            Stage::Comb { delay, feedback, last } => {
76                let y = x + *feedback * *last;
77                *last = delay.tick(y);
78                y
79            }
80        }
81    }
82}
83
84/// A cheap white noise generator with its own state, so two sources never
85/// share a sequence.
86struct Noise(u32);
87
88impl Noise {
89    fn next(&mut self) -> f32 {
90        self.0 ^= self.0 << 13;
91        self.0 ^= self.0 >> 17;
92        self.0 ^= self.0 << 5;
93        (self.0 as f32 / u32::MAX as f32) * 2.0 - 1.0
94    }
95}
96
97/// An active synthesized source on the audio thread.
98struct ActiveSource {
99    src:      MathAudioSource,
100    phase:    f32,
101    phase2:   f32,
102    age:      f32,
103    note_off: Option<f32>,
104    stage1:   Option<Stage>,
105    stage2:   Option<Stage>,
106    noise:    Noise,
107    music:    bool,
108}
109
110impl ActiveSource {
111    fn new(src: MathAudioSource, seed: u32) -> Self {
112        let stage1 = src.filter.as_ref().map(Stage::from_filter);
113        let stage2 = src.filter2.as_ref().map(Stage::from_filter);
114        let music = src.tag.as_deref() == Some("music");
115        Self {
116            src,
117            phase: 0.0,
118            phase2: 0.0,
119            age: 0.0,
120            note_off: None,
121            stage1,
122            stage2,
123            noise: Noise(seed | 1),
124            music,
125        }
126    }
127}
128
129/// How long a stopped source takes to fall silent.
130const RELEASE_SECS: f32 = 0.25;
131/// Fade applied to every start with no fade-in of its own, against clicks.
132const DECLICK_SECS: f32 = 0.003;
133/// How hard effects push the music down, and how fast it comes back.
134const DUCK_DEPTH: f32 = 0.45;
135const DUCK_RELEASE_PER_SEC: f32 = 4.0;
136
137/// State owned by the audio callback closure.
138struct AudioState {
139    sources:       Vec<ActiveSource>,
140    rx:            Receiver<AudioEvent>,
141    master_volume: f32,
142    music_volume:  f32,
143    #[allow(dead_code)]
144    music_vibe:    MusicVibe,
145    sample_rate:   f32,
146    listener:      Vec3,
147    time:          f32,
148    seed:          u32,
149    reverb:        Reverb,
150    /// The sound-effects level the music ducks under.
151    duck:          f32,
152    /// One-sample scratch for the reverb, which processes blocks.
153    scratch:       [f32; 1],
154    /// When something last went to the reverb, so its tail is let out.
155    last_send:     f32,
156}
157
158impl AudioState {
159    fn process_events(&mut self) {
160        while let Ok(event) = self.rx.try_recv() {
161            match event {
162                AudioEvent::SpawnSource { source, position } => {
163                    let mut src = source;
164                    if position != Vec3::ZERO {
165                        src.position = position;
166                    }
167                    self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
168                    self.sources.push(ActiveSource::new(src, self.seed));
169                }
170                AudioEvent::StopTag(tag) => {
171                    for s in &mut self.sources {
172                        if s.src.tag.as_deref() == Some(&tag) && s.note_off.is_none() {
173                            s.note_off = Some(s.age);
174                        }
175                    }
176                }
177                AudioEvent::SetMasterVolume(v) => {
178                    self.master_volume = v.clamp(0.0, 1.0);
179                }
180                AudioEvent::SetMusicVolume(v) => {
181                    self.music_volume = v.clamp(0.0, 1.0);
182                }
183                AudioEvent::PlaySfx { name: _, position, volume } => {
184                    // A named effect with no library behind it: a short
185                    // struck tone, so the call is at least audible.
186                    use crate::math::MathFunction;
187                    let src = MathAudioSource {
188                        function: MathFunction::Constant(0.0),
189                        frequency_range: (520.0, 520.0),
190                        amplitude: volume.clamp(0.0, 1.0) * 0.5,
191                        waveform: MsWaveform::Triangle,
192                        position,
193                        tag: Some("sfx".to_string()),
194                        lifetime: 0.14,
195                        fade_out: 0.12,
196                        pitch_env: (2.5, 0.05),
197                        ..Default::default()
198                    };
199                    self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
200                    self.sources.push(ActiveSource::new(src, self.seed));
201                }
202                AudioEvent::SetMusicVibe(vibe) => {
203                    self.music_vibe = vibe;
204                }
205            }
206        }
207        // Never let the voice count run away: the quietest go first.
208        if self.sources.len() > 96 {
209            self.sources.sort_by(|a, b| b.src.amplitude.total_cmp(&a.src.amplitude));
210            self.sources.truncate(96);
211        }
212    }
213
214    /// Synthesize one stereo sample (left, right).
215    fn next_sample(&mut self) -> (f32, f32) {
216        let dt = 1.0 / self.sample_rate;
217        self.time += dt;
218
219        let mut sfx_l = 0.0f32;
220        let mut sfx_r = 0.0f32;
221        let mut mus_l = 0.0f32;
222        let mut mus_r = 0.0f32;
223        let mut send = 0.0f32;
224        let mut sfx_peak = 0.0f32;
225        let listener = self.listener;
226
227        let mut i = 0;
228        while i < self.sources.len() {
229            let a = &mut self.sources[i];
230            let src = &a.src;
231
232            // Not started yet.
233            let t = a.age - src.start_delay;
234            if t < 0.0 {
235                a.age += dt;
236                i += 1;
237                continue;
238            }
239            // Over.
240            if src.lifetime >= 0.0 && t >= src.lifetime {
241                self.sources.swap_remove(i);
242                continue;
243            }
244            let release = match a.note_off {
245                Some(off) => {
246                    let gone = (a.age - off) / RELEASE_SECS;
247                    if gone >= 1.0 {
248                        self.sources.swap_remove(i);
249                        continue;
250                    }
251                    1.0 - gone
252                }
253                None => 1.0,
254            };
255
256            // Pitch: the function through the log range, detuned, with the
257            // pitch envelope falling onto the note.
258            let fn_out = src.function.evaluate(t, 0.0);
259            let mut freq = src.map_to_frequency(fn_out);
260            if src.detune_cents != 0.0 {
261                freq *= (2.0f32).powf(src.detune_cents / 1200.0);
262            }
263            let (env_mult, env_secs) = src.pitch_env;
264            if env_secs > 0.0 && env_mult != 1.0 {
265                freq *= 1.0 + (env_mult - 1.0) * (-t / (env_secs * 0.25)).exp();
266            }
267            freq = freq.clamp(1.0, self.sample_rate * 0.45);
268
269            a.phase = (a.phase + freq * dt).fract();
270            let mut raw = oscillator(ms_to_synth_waveform(src.waveform), a.phase);
271
272            let (ratio, mix) = src.partial;
273            if mix > 0.0 && ratio > 0.0 {
274                a.phase2 = (a.phase2 + freq * ratio * dt).fract();
275                raw = raw * (1.0 - mix) + oscillator(SynthWaveform::Sine, a.phase2) * mix;
276            }
277            if src.noise_mix > 0.0 {
278                let n = a.noise.next();
279                raw = raw * (1.0 - src.noise_mix) + n * src.noise_mix;
280            }
281            if let Some(s) = a.stage1.as_mut() {
282                raw = s.tick(raw);
283            }
284            if let Some(s) = a.stage2.as_mut() {
285                raw = s.tick(raw);
286            }
287            if src.drive > 0.0 {
288                let g = 1.0 + src.drive * 4.0;
289                raw = (raw * g).tanh() / g.tanh();
290            }
291
292            // Envelope: the source's own fades, the release if stopped, and
293            // a few milliseconds of declick on anything that starts hard.
294            let mut env = src.envelope(t) * release;
295            if src.fade_in <= 0.0 && t < DECLICK_SECS {
296                env *= t / DECLICK_SECS;
297            }
298            let sample = raw * env;
299            if !sample.is_finite() {
300                self.sources.swap_remove(i);
301                continue;
302            }
303
304            let (pan_l, pan_r, weight) = if src.spatial && src.position != Vec3::ZERO {
305                let w = spatial_weight(listener, src.position, src.max_distance.max(1.0));
306                let (l, r) = stereo_pan(listener, src.position);
307                (l, r, w)
308            } else {
309                (0.7071, 0.7071, 1.0)
310            };
311            let l = sample * pan_l * weight;
312            let r = sample * pan_r * weight;
313            if a.music {
314                mus_l += l;
315                mus_r += r;
316            } else {
317                sfx_l += l;
318                sfx_r += r;
319                sfx_peak = sfx_peak.max(sample.abs() * weight);
320            }
321            send += sample * weight * src.reverb_send;
322
323            a.age += dt;
324            i += 1;
325        }
326
327        // Music ducks under effects: fast down, slow back.
328        let target = (sfx_peak * 2.0).clamp(0.0, 1.0) * DUCK_DEPTH;
329        if target > self.duck {
330            self.duck = target;
331        } else {
332            self.duck -= (self.duck - target) * (DUCK_RELEASE_PER_SEC * dt).min(1.0);
333        }
334        let music_gain = self.music_volume * (1.0 - self.duck);
335
336        let mut left = sfx_l + mus_l * music_gain;
337        let mut right = sfx_r + mus_r * music_gain;
338
339        if send.abs() > 1e-6 {
340            self.last_send = self.time;
341        }
342        if self.time - self.last_send < 3.0 {
343            self.scratch[0] = send;
344            self.reverb.process_block(&mut self.scratch, self.sample_rate);
345            let wet = self.scratch[0];
346            left += wet;
347            right += wet;
348        }
349
350        let mv = self.master_volume;
351        (soft_limit(left * mv), soft_limit(right * mv))
352    }
353
354}
355
356/// A soft ceiling. Linear until it starts to matter, then rolls off so a
357/// dozen simultaneous hits get loud rather than harsh.
358#[inline]
359fn soft_limit(x: f32) -> f32 {
360    const CEIL: f32 = 0.98;
361    if x.abs() < 0.6 {
362        x
363    } else {
364        let s = x.signum();
365        let e = (x.abs() - 0.6) / (CEIL - 0.6);
366        s * (0.6 + (CEIL - 0.6) * (1.0 - (-e).exp()))
367    }
368}
369
370// ── Public API ─────────────────────────────────────────────────────────────────
371
372/// Opaque audio output handle. Keeps the cpal stream alive.
373pub struct AudioOutput {
374    pub sample_rate: u32,
375    pub channels:    u16,
376    _stream:         Stream,
377}
378
379impl AudioOutput {
380    /// Open the default output device and start synthesis.
381    /// Returns None if no audio device is available.
382    pub fn try_new(rx: Receiver<AudioEvent>) -> Option<Self> {
383        let host   = cpal::default_host();
384        let device = host.default_output_device()?;
385
386        let supported = device.default_output_config().ok()?;
387        let channels  = supported.channels();
388        let rate      = supported.sample_rate().0;
389
390        let config = StreamConfig {
391            channels,
392            sample_rate: supported.sample_rate(),
393            buffer_size: cpal::BufferSize::Default,
394        };
395
396        let state = AudioState {
397            sources:       Vec::with_capacity(128),
398            rx,
399            master_volume: 1.0,
400            music_volume:  1.0,
401            music_vibe:    MusicVibe::Silence,
402            sample_rate:   rate as f32,
403            listener:      Vec3::ZERO,
404            time:          0.0,
405            seed:          0x9E37_79B9,
406            // A stone room: mid-sized, fairly damped, all wet since the dry
407            // signal is mixed separately.
408            reverb:        Reverb::new(0.62, 0.45, 1.0, 0.0, 12.0, 0.8),
409            duck:          0.0,
410            scratch:       [0.0],
411            last_send:     -10.0,
412        };
413
414        let stream = match supported.sample_format() {
415            SampleFormat::F32 => build_stream_f32(&device, &config, state),
416            fmt => {
417                log::warn!("AudioOutput: unsupported sample format {:?}, defaulting to f32", fmt);
418                build_stream_f32(&device, &config, state)
419            }
420        }?;
421
422        stream.play().ok()?;
423
424        log::info!("AudioOutput: {} Hz, {} ch", rate, channels);
425        Some(Self { sample_rate: rate, channels, _stream: stream })
426    }
427}
428
429fn build_stream_f32(
430    device: &cpal::Device,
431    config: &StreamConfig,
432    mut state: AudioState,
433) -> Option<Stream> {
434    let ch = config.channels as usize;
435    let stream = device
436        .build_output_stream(
437            config,
438            move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
439                state.process_events();
440                for frame in data.chunks_mut(ch) {
441                    let (l, r) = state.next_sample();
442                    frame[0] = l.clamp(-1.0, 1.0);
443                    if ch > 1 {
444                        frame[1] = r.clamp(-1.0, 1.0);
445                    }
446                }
447            },
448            |err| log::error!("AudioOutput stream error: {err}"),
449            None,
450        )
451        .ok()?;
452    Some(stream)
453}
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    #[test]
460    fn the_limiter_is_linear_low_and_never_exceeds_the_ceiling() {
461        assert_eq!(soft_limit(0.3), 0.3);
462        assert_eq!(soft_limit(-0.3), -0.3);
463        for x in [0.7f32, 1.0, 2.0, 10.0, 100.0] {
464            assert!(soft_limit(x) < 0.99, "{x} -> {}", soft_limit(x));
465            assert!(soft_limit(-x) > -0.99);
466            assert!(soft_limit(x) >= soft_limit(x * 0.9), "should never fall");
467        }
468        // Rising through the knee, until it saturates.
469        assert!(soft_limit(1.0) > soft_limit(0.7));
470        assert!(soft_limit(2.0) > soft_limit(1.0));
471    }
472
473    /// An AudioState with no device behind it, fed by hand.
474    fn offline_state() -> (AudioState, std::sync::mpsc::SyncSender<AudioEvent>) {
475        let (tx, rx) = std::sync::mpsc::sync_channel(64);
476        let state = AudioState {
477            sources: Vec::new(),
478            rx,
479            master_volume: 1.0,
480            music_volume: 1.0,
481            music_vibe: MusicVibe::Silence,
482            sample_rate: 44100.0,
483            listener: Vec3::ZERO,
484            time: 0.0,
485            seed: 12345,
486            reverb: Reverb::new(0.62, 0.45, 1.0, 0.0, 12.0, 0.8),
487            duck: 0.0,
488            scratch: [0.0],
489            last_send: -10.0,
490        };
491        (state, tx)
492    }
493
494    fn render(state: &mut AudioState, secs: f32) -> Vec<(f32, f32)> {
495        state.process_events();
496        (0..(secs * 44100.0) as usize).map(|_| state.next_sample()).collect()
497    }
498
499    #[test]
500    fn a_layered_blow_renders_finite_bounded_and_audible() {
501        use crate::math::MathFunction;
502        let (mut state, tx) = offline_state();
503        // Contact, weight, ring: the shape of a sword hit.
504        let crack = MathAudioSource {
505            function: MathFunction::Constant(0.0),
506            frequency_range: (2600.0, 2600.0),
507            amplitude: 0.5,
508            waveform: MsWaveform::Noise,
509            filter: Some(AudioFilter::HighPass { cutoff_hz: 2600.0, resonance: 0.8 }),
510            lifetime: 0.035,
511            fade_out: 0.02,
512            spatial: false,
513            ..Default::default()
514        };
515        let thud = MathAudioSource {
516            function: MathFunction::Constant(0.0),
517            frequency_range: (170.0, 170.0),
518            amplitude: 0.5,
519            waveform: MsWaveform::Sine,
520            pitch_env: (3.4, 0.07),
521            drive: 0.45,
522            noise_mix: 0.05,
523            filter: Some(AudioFilter::LowPass { cutoff_hz: 700.0, resonance: 0.8 }),
524            lifetime: 0.15,
525            fade_out: 0.12,
526            reverb_send: 0.15,
527            spatial: true,
528            position: Vec3::new(-0.3, 0.0, 0.85),
529            ..Default::default()
530        };
531        let ring = MathAudioSource {
532            function: MathFunction::Constant(0.0),
533            frequency_range: (1900.0, 1900.0),
534            amplitude: 0.2,
535            waveform: MsWaveform::Triangle,
536            partial: (2.76, 0.45),
537            filter: Some(AudioFilter::Comb { delay_ms: 1000.0 / 1900.0, feedback: 0.55 }),
538            lifetime: 0.24,
539            fade_in: 0.002,
540            fade_out: 0.2,
541            start_delay: 0.012,
542            reverb_send: 0.3,
543            spatial: false,
544            ..Default::default()
545        };
546        for s in [crack, thud, ring] {
547            tx.send(AudioEvent::SpawnSource { source: s, position: Vec3::ZERO }).unwrap();
548        }
549        let out = render(&mut state, 0.6);
550        let mut peak = 0.0f32;
551        let mut energy = 0.0f32;
552        for (l, r) in &out {
553            assert!(l.is_finite() && r.is_finite(), "NaN in the output");
554            assert!(l.abs() <= 1.0 && r.abs() <= 1.0, "clipped: {l} {r}");
555            peak = peak.max(l.abs()).max(r.abs());
556            energy += l * l + r * r;
557        }
558        assert!(peak > 0.05, "the blow is inaudible: peak {peak}");
559        assert!(energy > 1.0, "the blow has no body: energy {energy}");
560        // Panned left: more energy on the left.
561        let left: f32 = out.iter().map(|(l, _)| l * l).sum();
562        let right: f32 = out.iter().map(|(_, r)| r * r).sum();
563        assert!(left > right, "a left-panned blow should favour the left: {left} vs {right}");
564        // And it ends: the last tenth of a second is quiet apart from the
565        // reverb tail.
566        let tail: f32 = out[out.len() - 4410..].iter().map(|(l, r)| l.abs().max(r.abs())).fold(0.0, f32::max);
567        assert!(tail < 0.2, "the blow never ends: tail peak {tail}");
568        assert!(state.sources.is_empty(), "sources were not retired");
569    }
570
571    #[test]
572    fn music_ducks_under_effects_and_comes_back() {
573        use crate::math::MathFunction;
574        let (mut state, tx) = offline_state();
575        let music = MathAudioSource {
576            function: MathFunction::Constant(0.0),
577            frequency_range: (220.0, 220.0),
578            amplitude: 0.3,
579            waveform: MsWaveform::Sine,
580            tag: Some("music".to_string()),
581            lifetime: 2.0,
582            spatial: false,
583            ..Default::default()
584        };
585        tx.send(AudioEvent::SpawnSource { source: music, position: Vec3::ZERO }).unwrap();
586        let before = render(&mut state, 0.3);
587        let hit = MathAudioSource {
588            function: MathFunction::Constant(0.0),
589            frequency_range: (100.0, 100.0),
590            amplitude: 0.8,
591            waveform: MsWaveform::Sine,
592            lifetime: 0.1,
593            spatial: false,
594            ..Default::default()
595        };
596        tx.send(AudioEvent::SpawnSource { source: hit, position: Vec3::ZERO }).unwrap();
597        let _during = render(&mut state, 0.12);
598        assert!(state.duck > 0.1, "the hit did not duck the music: {}", state.duck);
599        let _after = render(&mut state, 1.0);
600        assert!(state.duck < 0.05, "the duck never released: {}", state.duck);
601        let rms = |v: &[(f32, f32)]| (v.iter().map(|(l, _)| l * l).sum::<f32>() / v.len() as f32).sqrt();
602        assert!(rms(&before) > 0.1, "music is inaudible");
603    }
604
605    #[test]
606    fn noise_is_not_constant() {
607        let mut n = Noise(7);
608        let a = n.next();
609        let b = n.next();
610        assert_ne!(a, b);
611        assert!(a.abs() <= 1.0 && b.abs() <= 1.0);
612    }
613}