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 new(rx: Receiver<AudioEvent>, sample_rate: f32, seed: u32) -> Self {
160        AudioState {
161            sources:       Vec::with_capacity(128),
162            rx,
163            master_volume: 1.0,
164            music_volume:  1.0,
165            music_vibe:    MusicVibe::Silence,
166            sample_rate,
167            listener:      Vec3::ZERO,
168            time:          0.0,
169            seed,
170            // A stone room: mid-sized, fairly damped, all wet since the dry
171            // signal is mixed separately.
172            reverb:        Reverb::new(0.62, 0.45, 1.0, 0.0, 12.0, 0.8),
173            duck:          0.0,
174            scratch:       [0.0],
175            last_send:     -10.0,
176        }
177    }
178
179    fn process_events(&mut self) {
180        while let Ok(event) = self.rx.try_recv() {
181            match event {
182                AudioEvent::SpawnSource { source, position } => {
183                    let mut src = source;
184                    if position != Vec3::ZERO {
185                        src.position = position;
186                    }
187                    self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
188                    self.sources.push(ActiveSource::new(src, self.seed));
189                }
190                AudioEvent::StopTag(tag) => {
191                    for s in &mut self.sources {
192                        if s.src.tag.as_deref() == Some(&tag) && s.note_off.is_none() {
193                            s.note_off = Some(s.age);
194                        }
195                    }
196                }
197                AudioEvent::SetMasterVolume(v) => {
198                    self.master_volume = v.clamp(0.0, 1.0);
199                }
200                AudioEvent::SetMusicVolume(v) => {
201                    self.music_volume = v.clamp(0.0, 1.0);
202                }
203                AudioEvent::PlaySfx { name: _, position, volume } => {
204                    // A named effect with no library behind it: a short
205                    // struck tone, so the call is at least audible.
206                    use crate::math::MathFunction;
207                    let src = MathAudioSource {
208                        function: MathFunction::Constant(0.0),
209                        frequency_range: (520.0, 520.0),
210                        amplitude: volume.clamp(0.0, 1.0) * 0.5,
211                        waveform: MsWaveform::Triangle,
212                        position,
213                        tag: Some("sfx".to_string()),
214                        lifetime: 0.14,
215                        fade_out: 0.12,
216                        pitch_env: (2.5, 0.05),
217                        ..Default::default()
218                    };
219                    self.seed = self.seed.wrapping_mul(1664525).wrapping_add(1013904223);
220                    self.sources.push(ActiveSource::new(src, self.seed));
221                }
222                AudioEvent::SetMusicVibe(vibe) => {
223                    self.music_vibe = vibe;
224                }
225            }
226        }
227        // Never let the voice count run away: the quietest go first.
228        if self.sources.len() > 96 {
229            self.sources.sort_by(|a, b| b.src.amplitude.total_cmp(&a.src.amplitude));
230            self.sources.truncate(96);
231        }
232    }
233
234    /// Synthesize one stereo sample (left, right).
235    fn next_sample(&mut self) -> (f32, f32) {
236        let dt = 1.0 / self.sample_rate;
237        self.time += dt;
238
239        let mut sfx_l = 0.0f32;
240        let mut sfx_r = 0.0f32;
241        let mut mus_l = 0.0f32;
242        let mut mus_r = 0.0f32;
243        let mut send = 0.0f32;
244        let mut sfx_peak = 0.0f32;
245        let listener = self.listener;
246
247        let mut i = 0;
248        while i < self.sources.len() {
249            let a = &mut self.sources[i];
250            let src = &a.src;
251
252            // Not started yet.
253            let t = a.age - src.start_delay;
254            if t < 0.0 {
255                a.age += dt;
256                i += 1;
257                continue;
258            }
259            // Over.
260            if src.lifetime >= 0.0 && t >= src.lifetime {
261                self.sources.swap_remove(i);
262                continue;
263            }
264            let release = match a.note_off {
265                Some(off) => {
266                    let gone = (a.age - off) / RELEASE_SECS;
267                    if gone >= 1.0 {
268                        self.sources.swap_remove(i);
269                        continue;
270                    }
271                    1.0 - gone
272                }
273                None => 1.0,
274            };
275
276            // Pitch: the function through the log range, detuned, with the
277            // pitch envelope falling onto the note.
278            let fn_out = src.function.evaluate(t, 0.0);
279            let mut freq = src.map_to_frequency(fn_out);
280            if src.detune_cents != 0.0 {
281                freq *= (2.0f32).powf(src.detune_cents / 1200.0);
282            }
283            let (env_mult, env_secs) = src.pitch_env;
284            if env_secs > 0.0 && env_mult != 1.0 {
285                freq *= 1.0 + (env_mult - 1.0) * (-t / (env_secs * 0.25)).exp();
286            }
287            freq = freq.clamp(1.0, self.sample_rate * 0.45);
288
289            a.phase = (a.phase + freq * dt).fract();
290            let mut raw = oscillator(ms_to_synth_waveform(src.waveform), a.phase);
291
292            let (ratio, mix) = src.partial;
293            if mix > 0.0 && ratio > 0.0 {
294                a.phase2 = (a.phase2 + freq * ratio * dt).fract();
295                raw = raw * (1.0 - mix) + oscillator(SynthWaveform::Sine, a.phase2) * mix;
296            }
297            if src.noise_mix > 0.0 {
298                let n = a.noise.next();
299                raw = raw * (1.0 - src.noise_mix) + n * src.noise_mix;
300            }
301            if let Some(s) = a.stage1.as_mut() {
302                raw = s.tick(raw);
303            }
304            if let Some(s) = a.stage2.as_mut() {
305                raw = s.tick(raw);
306            }
307            if src.drive > 0.0 {
308                let g = 1.0 + src.drive * 4.0;
309                raw = (raw * g).tanh() / g.tanh();
310            }
311
312            // Envelope: the source's own fades, the release if stopped, and
313            // a few milliseconds of declick on anything that starts hard.
314            let mut env = src.envelope(t) * release;
315            if src.fade_in <= 0.0 && t < DECLICK_SECS {
316                env *= t / DECLICK_SECS;
317            }
318            let sample = raw * env;
319            if !sample.is_finite() {
320                self.sources.swap_remove(i);
321                continue;
322            }
323
324            let (pan_l, pan_r, weight) = if src.spatial && src.position != Vec3::ZERO {
325                let w = spatial_weight(listener, src.position, src.max_distance.max(1.0));
326                let (l, r) = stereo_pan(listener, src.position);
327                (l, r, w)
328            } else {
329                (0.7071, 0.7071, 1.0)
330            };
331            let l = sample * pan_l * weight;
332            let r = sample * pan_r * weight;
333            if a.music {
334                mus_l += l;
335                mus_r += r;
336            } else {
337                sfx_l += l;
338                sfx_r += r;
339                sfx_peak = sfx_peak.max(sample.abs() * weight);
340            }
341            send += sample * weight * src.reverb_send;
342
343            a.age += dt;
344            i += 1;
345        }
346
347        // Music ducks under effects: fast down, slow back.
348        let target = (sfx_peak * 2.0).clamp(0.0, 1.0) * DUCK_DEPTH;
349        if target > self.duck {
350            self.duck = target;
351        } else {
352            self.duck -= (self.duck - target) * (DUCK_RELEASE_PER_SEC * dt).min(1.0);
353        }
354        let music_gain = self.music_volume * (1.0 - self.duck);
355
356        let mut left = sfx_l + mus_l * music_gain;
357        let mut right = sfx_r + mus_r * music_gain;
358
359        if send.abs() > 1e-6 {
360            self.last_send = self.time;
361        }
362        if self.time - self.last_send < 3.0 {
363            self.scratch[0] = send;
364            self.reverb.process_block(&mut self.scratch, self.sample_rate);
365            let wet = self.scratch[0];
366            left += wet;
367            right += wet;
368        }
369
370        let mv = self.master_volume;
371        (soft_limit(left * mv), soft_limit(right * mv))
372    }
373
374}
375
376/// A soft ceiling. Linear until it starts to matter, then rolls off so a
377/// dozen simultaneous hits get loud rather than harsh.
378#[inline]
379fn soft_limit(x: f32) -> f32 {
380    const CEIL: f32 = 0.98;
381    if x.abs() < 0.6 {
382        x
383    } else {
384        let s = x.signum();
385        let e = (x.abs() - 0.6) / (CEIL - 0.6);
386        s * (0.6 + (CEIL - 0.6) * (1.0 - (-e).exp()))
387    }
388}
389
390// ── Public API ─────────────────────────────────────────────────────────────────
391
392/// Opaque audio output handle. Keeps the cpal stream alive.
393pub struct AudioOutput {
394    pub sample_rate: u32,
395    pub channels:    u16,
396    _stream:         Stream,
397}
398
399impl AudioOutput {
400    /// Open the default output device and start synthesis.
401    /// Returns None if no audio device is available.
402    pub fn try_new(rx: Receiver<AudioEvent>) -> Option<Self> {
403        let host   = cpal::default_host();
404        let device = host.default_output_device()?;
405
406        let supported = device.default_output_config().ok()?;
407        let channels  = supported.channels();
408        let rate      = supported.sample_rate().0;
409
410        let config = StreamConfig {
411            channels,
412            sample_rate: supported.sample_rate(),
413            buffer_size: cpal::BufferSize::Default,
414        };
415
416        let state = AudioState::new(rx, rate as f32, 0x9E37_79B9);
417
418        let stream = match supported.sample_format() {
419            SampleFormat::F32 => build_stream_f32(&device, &config, state),
420            fmt => {
421                log::warn!("AudioOutput: unsupported sample format {:?}, defaulting to f32", fmt);
422                build_stream_f32(&device, &config, state)
423            }
424        }?;
425
426        stream.play().ok()?;
427
428        log::info!("AudioOutput: {} Hz, {} ch", rate, channels);
429        Some(Self { sample_rate: rate, channels, _stream: stream })
430    }
431}
432
433/// The engine's synthesiser with no audio device behind it.
434///
435/// It runs exactly the code the real-time thread runs (the same sources,
436/// filters, ducking, reverb and limiter), but you pull the samples out
437/// yourself. Use it to bounce sounds to a WAV file with
438/// [`crate::audio::wav::write_wav`], to render audio for a captured video,
439/// or to test sound design on a machine with no sound card.
440///
441/// ```rust
442/// use proof_engine::audio::{AudioEvent, OfflineRenderer};
443/// use proof_engine::audio::math_source::MathAudioSource;
444/// use proof_engine::prelude::Vec3;
445///
446/// let mut synth = OfflineRenderer::new(48_000);
447/// synth.emit(AudioEvent::SpawnSource {
448///     source: MathAudioSource::death_knell(Vec3::ZERO),
449///     position: Vec3::ZERO,
450/// });
451/// let stereo = synth.render(0.5);
452/// assert_eq!(stereo.len(), 2 * 24_000);
453/// assert!(stereo.iter().any(|s| s.abs() > 0.01));
454/// ```
455pub struct OfflineRenderer {
456    state: AudioState,
457    tx: std::sync::mpsc::Sender<AudioEvent>,
458    sample_rate: u32,
459}
460
461impl OfflineRenderer {
462    /// A silent synthesiser running at `sample_rate` Hz.
463    pub fn new(sample_rate: u32) -> Self {
464        Self::with_seed(sample_rate, 0x9E37_79B9)
465    }
466
467    /// As [`new`](Self::new), with the seed of the noise generators, for
468    /// renders that must differ from each other.
469    pub fn with_seed(sample_rate: u32, seed: u32) -> Self {
470        let (tx, rx) = std::sync::mpsc::channel();
471        let sample_rate = sample_rate.max(1);
472        Self { state: AudioState::new(rx, sample_rate as f32, seed), tx, sample_rate }
473    }
474
475    /// Queue an event. It takes effect at the start of the next
476    /// [`render`](Self::render), as events do at the start of each buffer
477    /// on the real-time thread.
478    pub fn emit(&mut self, event: AudioEvent) {
479        let _ = self.tx.send(event);
480    }
481
482    /// Render `secs` seconds as interleaved stereo (left, right, left, ...),
483    /// each sample in `[-1, 1]`.
484    pub fn render(&mut self, secs: f32) -> Vec<f32> {
485        let frames = (secs.max(0.0) * self.sample_rate as f32).round() as usize;
486        self.render_frames(frames)
487    }
488
489    /// Render exactly `frames` stereo frames, interleaved.
490    pub fn render_frames(&mut self, frames: usize) -> Vec<f32> {
491        self.state.process_events();
492        let mut out = Vec::with_capacity(frames * 2);
493        for _ in 0..frames {
494            let (l, r) = self.state.next_sample();
495            out.push(l.clamp(-1.0, 1.0));
496            out.push(r.clamp(-1.0, 1.0));
497        }
498        out
499    }
500
501    /// The sample rate this renderer was made with.
502    pub fn sample_rate(&self) -> u32 {
503        self.sample_rate
504    }
505
506    /// Seconds of audio rendered so far.
507    pub fn time(&self) -> f32 {
508        self.state.time
509    }
510
511    /// Sources still sounding (effects that have not ended, plus music).
512    pub fn active_sources(&self) -> usize {
513        self.state.sources.len()
514    }
515}
516
517fn build_stream_f32(
518    device: &cpal::Device,
519    config: &StreamConfig,
520    mut state: AudioState,
521) -> Option<Stream> {
522    let ch = config.channels as usize;
523    let stream = device
524        .build_output_stream(
525            config,
526            move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
527                state.process_events();
528                for frame in data.chunks_mut(ch) {
529                    let (l, r) = state.next_sample();
530                    frame[0] = l.clamp(-1.0, 1.0);
531                    if ch > 1 {
532                        frame[1] = r.clamp(-1.0, 1.0);
533                    }
534                }
535            },
536            |err| log::error!("AudioOutput stream error: {err}"),
537            None,
538        )
539        .ok()?;
540    Some(stream)
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    #[test]
548    fn the_limiter_is_linear_low_and_never_exceeds_the_ceiling() {
549        assert_eq!(soft_limit(0.3), 0.3);
550        assert_eq!(soft_limit(-0.3), -0.3);
551        for x in [0.7f32, 1.0, 2.0, 10.0, 100.0] {
552            assert!(soft_limit(x) < 0.99, "{x} -> {}", soft_limit(x));
553            assert!(soft_limit(-x) > -0.99);
554            assert!(soft_limit(x) >= soft_limit(x * 0.9), "should never fall");
555        }
556        // Rising through the knee, until it saturates.
557        assert!(soft_limit(1.0) > soft_limit(0.7));
558        assert!(soft_limit(2.0) > soft_limit(1.0));
559    }
560
561    /// An AudioState with no device behind it, fed by hand.
562    fn offline_state() -> (AudioState, std::sync::mpsc::SyncSender<AudioEvent>) {
563        let (tx, rx) = std::sync::mpsc::sync_channel(64);
564        let state = AudioState::new(rx, 44100.0, 12345);
565        (state, tx)
566    }
567
568    fn render(state: &mut AudioState, secs: f32) -> Vec<(f32, f32)> {
569        state.process_events();
570        (0..(secs * 44100.0) as usize).map(|_| state.next_sample()).collect()
571    }
572
573    #[test]
574    fn a_layered_blow_renders_finite_bounded_and_audible() {
575        use crate::math::MathFunction;
576        let (mut state, tx) = offline_state();
577        // Contact, weight, ring: the shape of a sword hit.
578        let crack = MathAudioSource {
579            function: MathFunction::Constant(0.0),
580            frequency_range: (2600.0, 2600.0),
581            amplitude: 0.5,
582            waveform: MsWaveform::Noise,
583            filter: Some(AudioFilter::HighPass { cutoff_hz: 2600.0, resonance: 0.8 }),
584            lifetime: 0.035,
585            fade_out: 0.02,
586            spatial: false,
587            ..Default::default()
588        };
589        let thud = MathAudioSource {
590            function: MathFunction::Constant(0.0),
591            frequency_range: (170.0, 170.0),
592            amplitude: 0.5,
593            waveform: MsWaveform::Sine,
594            pitch_env: (3.4, 0.07),
595            drive: 0.45,
596            noise_mix: 0.05,
597            filter: Some(AudioFilter::LowPass { cutoff_hz: 700.0, resonance: 0.8 }),
598            lifetime: 0.15,
599            fade_out: 0.12,
600            reverb_send: 0.15,
601            spatial: true,
602            position: Vec3::new(-0.3, 0.0, 0.85),
603            ..Default::default()
604        };
605        let ring = MathAudioSource {
606            function: MathFunction::Constant(0.0),
607            frequency_range: (1900.0, 1900.0),
608            amplitude: 0.2,
609            waveform: MsWaveform::Triangle,
610            partial: (2.76, 0.45),
611            filter: Some(AudioFilter::Comb { delay_ms: 1000.0 / 1900.0, feedback: 0.55 }),
612            lifetime: 0.24,
613            fade_in: 0.002,
614            fade_out: 0.2,
615            start_delay: 0.012,
616            reverb_send: 0.3,
617            spatial: false,
618            ..Default::default()
619        };
620        for s in [crack, thud, ring] {
621            tx.send(AudioEvent::SpawnSource { source: s, position: Vec3::ZERO }).unwrap();
622        }
623        let out = render(&mut state, 0.6);
624        let mut peak = 0.0f32;
625        let mut energy = 0.0f32;
626        for (l, r) in &out {
627            assert!(l.is_finite() && r.is_finite(), "NaN in the output");
628            assert!(l.abs() <= 1.0 && r.abs() <= 1.0, "clipped: {l} {r}");
629            peak = peak.max(l.abs()).max(r.abs());
630            energy += l * l + r * r;
631        }
632        assert!(peak > 0.05, "the blow is inaudible: peak {peak}");
633        assert!(energy > 1.0, "the blow has no body: energy {energy}");
634        // Panned left: more energy on the left.
635        let left: f32 = out.iter().map(|(l, _)| l * l).sum();
636        let right: f32 = out.iter().map(|(_, r)| r * r).sum();
637        assert!(left > right, "a left-panned blow should favour the left: {left} vs {right}");
638        // And it ends: the last tenth of a second is quiet apart from the
639        // reverb tail.
640        let tail: f32 = out[out.len() - 4410..].iter().map(|(l, r)| l.abs().max(r.abs())).fold(0.0, f32::max);
641        assert!(tail < 0.2, "the blow never ends: tail peak {tail}");
642        assert!(state.sources.is_empty(), "sources were not retired");
643    }
644
645    #[test]
646    fn music_ducks_under_effects_and_comes_back() {
647        use crate::math::MathFunction;
648        let (mut state, tx) = offline_state();
649        let music = MathAudioSource {
650            function: MathFunction::Constant(0.0),
651            frequency_range: (220.0, 220.0),
652            amplitude: 0.3,
653            waveform: MsWaveform::Sine,
654            tag: Some("music".to_string()),
655            lifetime: 2.0,
656            spatial: false,
657            ..Default::default()
658        };
659        tx.send(AudioEvent::SpawnSource { source: music, position: Vec3::ZERO }).unwrap();
660        let before = render(&mut state, 0.3);
661        let hit = MathAudioSource {
662            function: MathFunction::Constant(0.0),
663            frequency_range: (100.0, 100.0),
664            amplitude: 0.8,
665            waveform: MsWaveform::Sine,
666            lifetime: 0.1,
667            spatial: false,
668            ..Default::default()
669        };
670        tx.send(AudioEvent::SpawnSource { source: hit, position: Vec3::ZERO }).unwrap();
671        let _during = render(&mut state, 0.12);
672        assert!(state.duck > 0.1, "the hit did not duck the music: {}", state.duck);
673        let _after = render(&mut state, 1.0);
674        assert!(state.duck < 0.05, "the duck never released: {}", state.duck);
675        let rms = |v: &[(f32, f32)]| (v.iter().map(|(l, _)| l * l).sum::<f32>() / v.len() as f32).sqrt();
676        assert!(rms(&before) > 0.1, "music is inaudible");
677    }
678
679    #[test]
680    fn the_offline_renderer_matches_the_real_time_path() {
681        use crate::math::MathFunction;
682        let tone = MathAudioSource {
683            function: MathFunction::Constant(0.0),
684            frequency_range: (440.0, 440.0),
685            amplitude: 0.4,
686            waveform: MsWaveform::Sine,
687            lifetime: 0.2,
688            spatial: false,
689            ..Default::default()
690        };
691        // The same event through the test harness and the public renderer
692        // gives the same samples: it is one synthesiser, not a copy.
693        let (mut state, tx) = offline_state();
694        tx.send(AudioEvent::SpawnSource { source: tone.clone(), position: Vec3::ZERO }).unwrap();
695        let reference = render(&mut state, 0.3);
696        let mut off = OfflineRenderer::with_seed(44100, 12345);
697        off.emit(AudioEvent::SpawnSource { source: tone, position: Vec3::ZERO });
698        let out = off.render(0.3);
699        assert_eq!(out.len(), reference.len() * 2);
700        for (i, (l, r)) in reference.iter().enumerate() {
701            assert_eq!((out[2 * i], out[2 * i + 1]), (l.clamp(-1.0, 1.0), r.clamp(-1.0, 1.0)));
702        }
703        assert!((off.time() - 0.3).abs() < 1e-3);
704        assert_eq!(off.active_sources(), 0, "the tone ended");
705        // A 440 Hz sine crosses zero about 2 * 440 times a second.
706        let left: Vec<f32> = out.iter().step_by(2).copied().take(4410).collect();
707        let crossings = left.windows(2).filter(|w| w[0] <= 0.0 && w[1] > 0.0).count();
708        assert!((40..=48).contains(&crossings), "{crossings} upward crossings in 0.1 s");
709    }
710
711    #[test]
712    fn noise_is_not_constant() {
713        let mut n = Noise(7);
714        let a = n.next();
715        let b = n.next();
716        assert_ne!(a, b);
717        assert!(a.abs() <= 1.0 && b.abs() <= 1.0);
718    }
719}