Skip to main content

proof_engine/audio/
output.rs

1//! cpal audio output — device enumeration, stream creation, math-driven synthesis.
2//!
3//! The audio callback runs on a dedicated real-time thread. It receives
4//! AudioEvents via an mpsc channel and synthesizes samples by evaluating
5//! each active MathAudioSource's function every sample.
6
7use std::sync::mpsc::Receiver;
8
9use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
10use cpal::{SampleFormat, Stream, StreamConfig};
11use glam::Vec3;
12
13use crate::audio::{AudioEvent, MusicVibe};
14use crate::audio::math_source::{MathAudioSource, Waveform as MsWaveform};
15use crate::audio::mixer::{spatial_weight, stereo_pan};
16use crate::audio::synth::{oscillator, Adsr, Waveform as SynthWaveform};
17
18fn ms_to_synth_waveform(w: MsWaveform) -> SynthWaveform {
19    match w {
20        MsWaveform::Sine       => SynthWaveform::Sine,
21        MsWaveform::Triangle   => SynthWaveform::Triangle,
22        MsWaveform::Square     => SynthWaveform::Square,
23        MsWaveform::Sawtooth   => SynthWaveform::Sawtooth,
24        MsWaveform::ReverseSaw => SynthWaveform::ReverseSaw,
25        MsWaveform::Pulse(d)   => SynthWaveform::Pulse(d),
26        MsWaveform::Noise      => SynthWaveform::Noise,
27    }
28}
29
30/// An active synthesized source on the audio thread.
31struct ActiveSource {
32    src:      MathAudioSource,
33    phase:    f32,   // oscillator phase [0, 1)
34    age:      f32,   // seconds since spawn
35    note_off: Option<f32>,
36    adsr:     Adsr,
37}
38
39/// State owned by the audio callback closure.
40#[allow(dead_code)]
41struct AudioState {
42    sources:       Vec<ActiveSource>,
43    rx:            Receiver<AudioEvent>,
44    master_volume: f32,
45    music_volume:  f32,
46    music_vibe:    MusicVibe,
47    sample_rate:   f32,
48    channels:      usize,
49    /// Listener position for spatial audio (updated via AudioEvent).
50    listener:      Vec3,
51    /// Seconds counter (for sustained sources driven by time).
52    time:          f32,
53}
54
55impl AudioState {
56    fn process_events(&mut self) {
57        while let Ok(event) = self.rx.try_recv() {
58            match event {
59                AudioEvent::SpawnSource { source, position } => {
60                    let mut src = source;
61                    src.position = position;
62                    self.sources.push(ActiveSource {
63                        src,
64                        phase: 0.0,
65                        age: 0.0,
66                        note_off: None,
67                        adsr: Adsr {
68                            attack:  0.02,
69                            decay:   0.1,
70                            sustain: 0.8,
71                            release: 0.3,
72                        },
73                    });
74                }
75                AudioEvent::StopTag(tag) => {
76                    let now = self.time;
77                    for s in &mut self.sources {
78                        if s.src.tag.as_deref() == Some(&tag) {
79                            s.note_off = Some(now);
80                        }
81                    }
82                }
83                AudioEvent::SetMasterVolume(v) => {
84                    self.master_volume = v.clamp(0.0, 1.0);
85                }
86                AudioEvent::SetMusicVolume(v) => {
87                    self.music_volume = v.clamp(0.0, 1.0);
88                }
89                AudioEvent::PlaySfx { name: _, position, volume } => {
90                    // Spawn a short sine click as placeholder for named SFX
91                    use crate::math::MathFunction;
92                    self.sources.push(ActiveSource {
93                        src: MathAudioSource {
94                            function: MathFunction::Sine { amplitude: 1.0, frequency: 1.0, phase: 0.0 },
95                            frequency_range: (440.0, 880.0),
96                            amplitude: volume,
97                            waveform: MsWaveform::Sine,
98                            filter: None,
99                            position,
100                            tag: Some("sfx".to_string()),
101                            lifetime: 0.15,
102                            ..Default::default()
103                        },
104                        phase: 0.0,
105                        age: 0.0,
106                        note_off: None,
107                        adsr: Adsr { attack: 0.01, decay: 0.05, sustain: 0.0, release: 0.05 },
108                    });
109                }
110                AudioEvent::SetMusicVibe(vibe) => {
111                    self.music_vibe = vibe;
112                }
113            }
114        }
115    }
116
117    /// Synthesize one stereo sample (left, right).
118    fn next_sample(&mut self) -> (f32, f32) {
119        let dt = 1.0 / self.sample_rate;
120        self.time += dt;
121
122        let mut left  = 0.0f32;
123        let mut right = 0.0f32;
124        let mut to_remove = Vec::new();
125
126        for (i, active) in self.sources.iter_mut().enumerate() {
127            // Expire check
128            if active.src.lifetime >= 0.0 && active.age >= active.src.lifetime {
129                to_remove.push(i);
130                continue;
131            }
132            // Release envelope expired
133            if let Some(off) = active.note_off {
134                if active.age - off > active.adsr.release + 0.05 {
135                    to_remove.push(i);
136                    continue;
137                }
138            }
139
140            // Evaluate MathFunction to get normalized output in approx [-1, 1]
141            let fn_out = active.src.function.evaluate(active.age, 0.0);
142
143            // Map to frequency range
144            let (f_min, f_max) = active.src.frequency_range;
145            let t_freq = (fn_out * 0.5 + 0.5).clamp(0.0, 1.0); // [0, 1]
146            let freq = f_min + t_freq * (f_max - f_min);
147
148            // Advance oscillator phase
149            active.phase = (active.phase + freq * dt).fract();
150
151            // Synthesize sample
152            let raw = oscillator(ms_to_synth_waveform(active.src.waveform), active.phase);
153            let env = active.adsr.level(active.age, active.note_off);
154            let vol = active.src.amplitude * env;
155
156            // Spatial weight
157            let weight = spatial_weight(self.listener, active.src.position, 30.0);
158            let (pan_l, pan_r) = stereo_pan(self.listener, active.src.position);
159            let sample = raw * vol * weight;
160
161            left  += sample * pan_l;
162            right += sample * pan_r;
163
164            active.age += dt;
165        }
166
167        // Remove expired sources in reverse order to preserve indices
168        for &i in to_remove.iter().rev() {
169            self.sources.swap_remove(i);
170        }
171
172        let mv = self.master_volume;
173        (left * mv, right * mv)
174    }
175}
176
177// ── Public API ─────────────────────────────────────────────────────────────────
178
179/// Opaque audio output handle. Keeps the cpal stream alive.
180pub struct AudioOutput {
181    pub sample_rate: u32,
182    pub channels:    u16,
183    _stream:         Stream,
184}
185
186impl AudioOutput {
187    /// Open the default output device and start synthesis.
188    /// Returns None if no audio device is available.
189    pub fn try_new(rx: Receiver<AudioEvent>) -> Option<Self> {
190        let host   = cpal::default_host();
191        let device = host.default_output_device()?;
192
193        let supported = device.default_output_config().ok()?;
194        let channels  = supported.channels();
195        let rate      = supported.sample_rate().0;
196
197        let config = StreamConfig {
198            channels,
199            sample_rate: supported.sample_rate(),
200            buffer_size: cpal::BufferSize::Default,
201        };
202
203        let state = AudioState {
204            sources:       Vec::new(),
205            rx,
206            master_volume: 1.0,
207            music_volume:  0.6,
208            music_vibe:    MusicVibe::Silence,
209            sample_rate:   rate as f32,
210            channels:      channels as usize,
211            listener:      Vec3::ZERO,
212            time:          0.0,
213        };
214
215        let stream = match supported.sample_format() {
216            SampleFormat::F32 => build_stream_f32(&device, &config, state),
217            fmt => {
218                log::warn!("AudioOutput: unsupported sample format {:?}, defaulting to f32", fmt);
219                // Try f32 anyway
220                build_stream_f32(&device, &config, state)
221            }
222        }?;
223
224        stream.play().ok()?;
225
226        log::info!("AudioOutput: {} Hz, {} ch", rate, channels);
227        Some(Self { sample_rate: rate, channels, _stream: stream })
228    }
229}
230
231fn build_stream_f32(
232    device: &cpal::Device,
233    config: &StreamConfig,
234    mut state: AudioState,
235) -> Option<Stream> {
236    let ch = config.channels as usize;
237    let stream = device
238        .build_output_stream(
239            config,
240            move |data: &mut [f32], _info: &cpal::OutputCallbackInfo| {
241                state.process_events();
242                for frame in data.chunks_mut(ch) {
243                    let (l, r) = state.next_sample();
244                    frame[0] = l.clamp(-1.0, 1.0);
245                    if ch > 1 {
246                        frame[1] = r.clamp(-1.0, 1.0);
247                    }
248                }
249            },
250            |err| log::error!("AudioOutput stream error: {err}"),
251            None,
252        )
253        .ok()?;
254    Some(stream)
255}