Skip to main content

forge_audio/
realtime.rs

1//! Real-time audio output via cpal. Lock-free SPSC ring buffer path only.
2
3use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
4use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
5use std::sync::Arc;
6
7// ════════════════════════════════════════════════════════════════════════════
8// HARD GATE — TYPED PLAYBACK GUARD
9// ════════════════════════════════════════════════════════════════════════════
10//
11// `PlaybackHandle` is the ONLY way to obtain a real-time audio producer.
12// The `cpal::Stream` is held in a private field and cannot be extracted.
13// Rust's borrow checker enforces audio device lifetime: the only way to
14// stop playback is to drop the entire handle.
15//
16// Burned origin (2026-04-09): forge-app-winamp/src/player.rs:45 used
17// `let (_stream, prod, ...) = ...` inside a match arm. The `_stream`
18// binding went out of scope at the end of the arm, dropping cpal::Stream
19// and silently closing the audio device. The whole player ran perfectly
20// — mixer math, FFT, IPC, snapshots — and zero sound reached the speakers.
21// No compile error, no runtime warning.
22//
23// This guard makes that bug class STRUCTURALLY IMPOSSIBLE. The same
24// pattern protects forge-app-daw, forge-app-studio, and any future audio
25// app from typo'd destructures, agent-generated code, or copy-paste errors.
26//
27// See: feedback_verify_mute_before_audio_test.md
28//      feedback_hard_gate_new_systems.md
29
30/// Owns a real-time audio playback resource.
31///
32/// `cpal::Stream` is held privately — there is no public accessor and no
33/// way to destructure it out. Drop the entire `PlaybackHandle` to stop
34/// playback. The borrow checker prevents the bug class where a caller
35/// drops the stream while holding the producer.
36///
37/// `PlaybackHandle` is `!Send` because `cpal::Stream` is `!Send`. For the
38/// stream-on-init-thread, producer-on-feeder-thread pattern. Call [`PlaybackHandle::split`] to obtain a `!Send` `StreamKeeper`
39/// that stays on the init thread and a `Send` `FeederBundle` that moves
40/// into the audio feeder thread.
41///
42/// For the co-located pattern (where stream and producer live in the same
43/// thread, e.g. forge-app-winamp), hold the whole handle in scope and use
44/// [`PlaybackHandle::producer_mut`] to push samples.
45pub struct PlaybackHandle {
46    // PRIVATE — never expose. Held alive for the lifetime of the handle.
47    // `None` when DAW_NO_AUDIO=1 (no real device opened).
48    stream_guard: Option<cpal::Stream>,
49    producer: rtrb::Producer<f32>,
50    flush: Arc<AtomicBool>,
51    device_error: Arc<AtomicBool>,
52    channels: usize,
53    sample_rate: u32,
54    underruns: Arc<AtomicU64>,
55}
56
57impl PlaybackHandle {
58    /// Push samples to the audio device. Use only inside the thread that
59    /// owns this handle (typically the audio feeder thread or, for the
60    /// co-located pattern, the player thread).
61    pub fn producer_mut(&mut self) -> &mut rtrb::Producer<f32> {
62        &mut self.producer
63    }
64
65    pub fn channels(&self) -> usize { self.channels }
66    pub fn sample_rate(&self) -> u32 { self.sample_rate }
67    pub fn flush_flag(&self) -> Arc<AtomicBool> { self.flush.clone() }
68    pub fn device_error_flag(&self) -> Arc<AtomicBool> { self.device_error.clone() }
69    pub fn underrun_counter(&self) -> Arc<AtomicU64> { self.underruns.clone() }
70
71    /// Returns true when a real cpal output device is open. Returns false
72    /// under `DAW_NO_AUDIO=1` (the hard-gate test mode).
73    pub fn is_live(&self) -> bool { self.stream_guard.is_some() }
74
75    /// Split the handle into a `!Send` `StreamKeeper` (must stay on the
76    /// init thread or be intentionally leaked) and a `Send` `FeederBundle`
77    /// (move into the audio feeder thread).
78    ///
79    /// Use this only when stream and producer cannot be co-located. The
80    /// co-located pattern is preferred — it keeps audio device lifetime
81    /// trivially correct without any !Send dance.
82    pub fn split(self) -> (StreamKeeper, FeederBundle) {
83        // Suppress the Drop log on the source handle — the resources are
84        // moving into two new guards, not actually being released.
85        let mut me = std::mem::ManuallyDrop::new(self);
86        let stream = me.stream_guard.take();
87        // SAFETY: ManuallyDrop disables the source's Drop. We move every
88        // owned field out exactly once before `me` goes out of scope.
89        let producer = unsafe { std::ptr::read(&me.producer) };
90        let flush = unsafe { std::ptr::read(&me.flush) };
91        let device_error = unsafe { std::ptr::read(&me.device_error) };
92        let underruns = unsafe { std::ptr::read(&me.underruns) };
93        let channels = me.channels;
94        let sample_rate = me.sample_rate;
95
96        let keeper = StreamKeeper { stream_guard: stream };
97        let bundle = FeederBundle {
98            producer,
99            flush,
100            device_error,
101            channels,
102            sample_rate,
103            underruns,
104        };
105        (keeper, bundle)
106    }
107}
108
109impl Drop for PlaybackHandle {
110    fn drop(&mut self) {
111        if self.stream_guard.is_some() {
112            eprintln!("[forge-audio] PlaybackHandle dropped — audio device closed");
113        }
114    }
115}
116
117/// `!Send` half of a split [`PlaybackHandle`]. Owns the `cpal::Stream`.
118/// Must stay on the thread that called `start_playback_lockfree`, OR be
119/// `Box::leak`'d if you need cross-thread reconnect.
120///
121/// Drop this to stop the audio device. The stream cannot be extracted.
122pub struct StreamKeeper {
123    stream_guard: Option<cpal::Stream>,
124}
125
126impl Drop for StreamKeeper {
127    fn drop(&mut self) {
128        if self.stream_guard.is_some() {
129            eprintln!("[forge-audio] StreamKeeper dropped — audio device closed");
130        }
131    }
132}
133
134/// `Send` half of a split [`PlaybackHandle`]. Owns the producer plus all
135/// the metadata flags. Move this into the audio feeder thread.
136pub struct FeederBundle {
137    pub producer: rtrb::Producer<f32>,
138    pub flush: Arc<AtomicBool>,
139    pub device_error: Arc<AtomicBool>,
140    pub channels: usize,
141    pub sample_rate: u32,
142    pub underruns: Arc<AtomicU64>,
143}
144
145/// Information about an available audio output device.
146#[derive(Debug, Clone, serde::Serialize)]
147pub struct AudioDeviceInfo {
148    pub name: String,
149    pub api: String,
150    pub sample_rates: Vec<u32>,
151    pub channels: u16,
152}
153
154/// Enumerate all available audio output devices with their supported configs.
155pub fn list_output_devices() -> Vec<AudioDeviceInfo> {
156    let host = cpal::default_host();
157    let host_name = format!("{:?}", host.id());
158    let devices = match host.output_devices() {
159        Ok(d) => d,
160        Err(e) => {
161            eprintln!("[audio] Cannot enumerate output devices: {}", e);
162            return vec![];
163        }
164    };
165    devices
166        .filter_map(|dev| {
167            let name = dev.name().unwrap_or_else(|_| "Unknown".into());
168            let mut sample_rates = Vec::new();
169            let mut channels = 2u16;
170            if let Ok(configs) = dev.supported_output_configs() {
171                for cfg in configs {
172                    channels = channels.max(cfg.channels());
173                    let min = cfg.min_sample_rate().0;
174                    let max = cfg.max_sample_rate().0;
175                    for &sr in &[44100u32, 48000, 88200, 96000, 192000] {
176                        if sr >= min && sr <= max && !sample_rates.contains(&sr) {
177                            sample_rates.push(sr);
178                        }
179                    }
180                }
181            }
182            sample_rates.sort();
183            if sample_rates.is_empty() {
184                sample_rates.push(48000);
185            }
186            Some(AudioDeviceInfo {
187                name,
188                api: host_name.clone(),
189                sample_rates,
190                channels,
191            })
192        })
193        .collect()
194}
195
196/// Start real-time audio playback using a lock-free SPSC ring buffer.
197///
198/// Returns a [`PlaybackHandle`] guard. The cpal::Stream is held privately
199/// inside the guard — drop the handle to stop playback. The handle prevents
200/// the bug class where callers drop the stream while keeping the producer
201/// alive (silent audio death, no compile error).
202///
203/// GUARDRAIL: This is the ONLY audio output path. The feeder thread owns
204/// the Mixer and pushes samples here. Never lock a Mutex on the audio thread.
205pub fn start_playback_lockfree(
206    buffer_size: usize,
207) -> Result<PlaybackHandle, String> {
208    start_playback_lockfree_with_error_flag(buffer_size, None, None, None)
209}
210
211/// Silent fallback handle for when no audio device is available.
212/// Mixer math and IPC still run; nothing reaches speakers. App stays alive.
213pub fn null_playback(buffer_size: usize, sample_rate: u32) -> PlaybackHandle {
214    let (producer, consumer) = rtrb::RingBuffer::<f32>::new(buffer_size);
215    std::mem::forget(consumer);
216    PlaybackHandle {
217        stream_guard: None,
218        producer,
219        flush: Arc::new(AtomicBool::new(false)),
220        device_error: Arc::new(AtomicBool::new(true)),
221        channels: 2,
222        sample_rate,
223        underruns: Arc::new(AtomicU64::new(0)),
224    }
225}
226
227/// Inner implementation that optionally accepts an existing error flag for reconnect scenarios.
228///
229/// HARD GATE: if `DAW_NO_AUDIO=1` is set in the environment, NO cpal device is opened.
230/// The function returns a [`PlaybackHandle`] whose `stream_guard` is `None` and
231/// whose producer's consumer is leaked into the void. Mixer math + IPC +
232/// ForgeWright still run; nothing reaches phones/S2/speakers. Required for
233/// any ForgeWright-driven audio test.
234/// See feedback_verify_mute_before_audio_test.md.
235pub fn start_playback_lockfree_with_error_flag(
236    buffer_size: usize,
237    error_flag: Option<Arc<AtomicBool>>,
238    device_name: Option<&str>,
239    cpal_buffer: Option<u32>,
240) -> Result<PlaybackHandle, String> {
241    if std::env::var("DAW_NO_AUDIO").is_ok() {
242        eprintln!("[DAW_NO_AUDIO] *** AUDIO OUTPUT DISABLED *** No cpal device opened.");
243        eprintln!("[DAW_NO_AUDIO] Mixer math + IPC still run; nothing reaches phones/S2/speakers.");
244        let (producer, consumer) = rtrb::RingBuffer::<f32>::new(buffer_size);
245        std::mem::forget(consumer); // keep producer push valid; samples drop into the void
246        let flush = Arc::new(AtomicBool::new(false));
247        let device_error = error_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
248        let underruns = Arc::new(AtomicU64::new(0));
249        return Ok(PlaybackHandle {
250            stream_guard: None,
251            producer,
252            flush,
253            device_error,
254            channels: 2,
255            sample_rate: 48000,
256            underruns,
257        });
258    }
259    let host = cpal::default_host();
260    let device = if let Some(name) = device_name {
261        use cpal::traits::HostTrait;
262        host.output_devices()
263            .map_err(|e| format!("enumerate devices: {}", e))?
264            .find(|d| {
265                use cpal::traits::DeviceTrait;
266                d.name().map_or(false, |n| n == name)
267            })
268            .unwrap_or_else(|| host.default_output_device().expect("No audio output device"))
269    } else {
270        host.default_output_device().ok_or("No audio output device")?
271    };
272    let supported = device.default_output_config().map_err(|e| format!("{}", e))?;
273    let channels = supported.channels() as usize;
274    let device_sample_rate = supported.sample_rate().0;
275    let mut config: cpal::StreamConfig = supported.into();
276    if let Some(bs) = cpal_buffer {
277        config.buffer_size = cpal::BufferSize::Fixed(bs);
278        eprintln!("[audio] Output device: {} channels, {} Hz, buffer {}", channels, device_sample_rate, bs);
279    } else {
280        eprintln!("[audio] Output device: {} channels, {} Hz", channels, device_sample_rate);
281    }
282
283    let (producer, mut consumer) = rtrb::RingBuffer::new(buffer_size);
284    let flush = Arc::new(AtomicBool::new(false));
285    let flush_cb = flush.clone();
286    let device_error = error_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
287    let device_error_cb = device_error.clone();
288    let underruns = Arc::new(AtomicU64::new(0));
289    let underruns_cb = underruns.clone();
290
291    let stream = device.build_output_stream(
292        &config,
293        move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
294            // If flush requested, drain the ring buffer and output silence
295            if flush_cb.swap(false, Ordering::Relaxed) {
296                while consumer.pop().is_ok() {}
297                for sample in data.iter_mut() {
298                    *sample = 0.0;
299                }
300                return;
301            }
302            let mut had_underrun = false;
303            for sample in data.iter_mut() {
304                match consumer.pop() {
305                    Ok(s) => *sample = s,
306                    Err(_) => { *sample = 0.0; had_underrun = true; }
307                }
308            }
309            if had_underrun {
310                underruns_cb.fetch_add(1, Ordering::Relaxed);
311            }
312        },
313        move |err| {
314            eprintln!("[audio] Output error (device disconnect?): {}", err);
315            device_error_cb.store(true, Ordering::Relaxed);
316        },
317        None,
318    ).map_err(|e| format!("Stream: {}", e))?;
319
320    stream.play().map_err(|e| format!("Play: {}", e))?;
321    Ok(PlaybackHandle {
322        stream_guard: Some(stream),
323        producer,
324        flush,
325        device_error,
326        channels,
327        sample_rate: device_sample_rate,
328        underruns,
329    })
330}
331
332/// Start headphone/booth output on a second audio device.
333/// Returns None if no second device is available (graceful fallback).
334/// The feeder thread pushes headphone_mix samples to the returned producer.
335pub fn start_headphone_output(
336    buffer_size: usize,
337) -> Option<(cpal::Stream, rtrb::Producer<f32>, usize)> {
338    if std::env::var("DAW_NO_AUDIO").is_ok() {
339        eprintln!("[DAW_NO_AUDIO] Headphone output skipped");
340        return None;
341    }
342    let host = cpal::default_host();
343    let default = host.default_output_device();
344    let default_name = default.as_ref().and_then(|d| d.name().ok()).unwrap_or_default();
345
346    // Find first non-default output device
347    let devices = match host.output_devices() {
348        Ok(d) => d,
349        Err(_) => { eprintln!("[audio] Cannot enumerate output devices"); return None; }
350    };
351
352    let alt_device = devices
353        .filter(|d| {
354            let name = d.name().unwrap_or_default();
355            name != default_name
356                && !name.to_lowercase().contains("nvidia")
357                && !name.to_lowercase().contains("hdmi")
358                && !name.to_lowercase().contains("displayport")
359                && !name.to_lowercase().contains("ultragear")
360        })
361        .next();
362
363    let device = match alt_device {
364        Some(d) => {
365            eprintln!("[audio] Headphone output: {}", d.name().unwrap_or_default());
366            d
367        }
368        None => {
369            eprintln!("[audio] No second output device — headphone mix disabled");
370            return None;
371        }
372    };
373
374    let config = match device.default_output_config() {
375        Ok(c) => c,
376        Err(e) => { eprintln!("[audio] Headphone device config error: {}", e); return None; }
377    };
378    let channels = config.channels() as usize;
379    let (producer, mut consumer) = rtrb::RingBuffer::new(buffer_size);
380
381    let stream = match device.build_output_stream(
382        &config.into(),
383        move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
384            for sample in data.iter_mut() {
385                *sample = consumer.pop().unwrap_or(0.0);
386            }
387        },
388        |err| eprintln!("[audio] Headphone output error: {}", err),
389        None,
390    ) {
391        Ok(s) => s,
392        Err(e) => { eprintln!("[audio] Headphone stream error: {}", e); return None; }
393    };
394
395    if let Err(e) = stream.play() {
396        eprintln!("[audio] Headphone play error: {}", e);
397        return None;
398    }
399
400    Some((stream, producer, channels))
401}
402
403// ════════════════════════════════════════════════════════════════════════════
404// Hard-gate guard tests — verify the typed PlaybackHandle bug class is closed.
405// ════════════════════════════════════════════════════════════════════════════
406//
407// All tests run under DAW_NO_AUDIO=1 — no real cpal device is opened. The
408// guard's `is_live()` returns false, but every other field is populated and
409// the producer is push-able. Tests assert the guard's structural invariants
410// (private stream, Drop fires, split yields a Send bundle).
411
412#[cfg(test)]
413mod hard_gate_tests {
414    use super::*;
415    use std::sync::Once;
416
417    static INIT: Once = Once::new();
418
419    fn enter_hard_gate() {
420        // Set DAW_NO_AUDIO once per test process. Safe across parallel tests
421        // because every assertion in this module assumes the gate is on.
422        INIT.call_once(|| {
423            std::env::set_var("DAW_NO_AUDIO", "1");
424        });
425    }
426
427    #[test]
428    fn handle_under_hard_gate_is_not_live() {
429        enter_hard_gate();
430        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
431        assert!(!h.is_live(), "DAW_NO_AUDIO must produce non-live handle");
432        assert_eq!(h.sample_rate(), 48000);
433        assert_eq!(h.channels(), 2);
434    }
435
436    #[test]
437    fn producer_is_pushable_under_hard_gate() {
438        enter_hard_gate();
439        let mut h = start_playback_lockfree(1024).expect("guard mode must succeed");
440        // The consumer is leaked into mem::forget — push must succeed until
441        // ring is full, then return Err. Either is fine for this assertion;
442        // we only verify the producer is reachable via the guard's accessor.
443        let _ = h.producer_mut().push(0.5);
444    }
445
446    #[test]
447    fn flags_are_clonable_arcs() {
448        enter_hard_gate();
449        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
450        let flush_a = h.flush_flag();
451        let flush_b = h.flush_flag();
452        flush_a.store(true, Ordering::Relaxed);
453        assert!(flush_b.load(Ordering::Relaxed), "flags must be Arc-clones, not copies");
454    }
455
456    #[test]
457    fn split_produces_send_bundle() {
458        enter_hard_gate();
459        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
460        let (keeper, mut bundle) = h.split();
461        // The bundle MUST be Send — if it isn't, this thread::spawn won't compile.
462        let jh = std::thread::spawn(move || {
463            assert_eq!(bundle.sample_rate, 48000);
464            assert_eq!(bundle.channels, 2);
465            let _ = bundle.producer.push(0.25);
466            bundle.sample_rate
467        });
468        let sr = jh.join().expect("feeder thread joined");
469        assert_eq!(sr, 48000);
470        // Keeper stays on this thread (it is !Send because cpal::Stream is !Send,
471        // even when None, because the Option<cpal::Stream> field type is !Send).
472        // Drop happens at end of scope.
473        drop(keeper);
474    }
475
476    #[test]
477    fn drop_under_hard_gate_does_not_panic() {
478        enter_hard_gate();
479        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
480        // The Drop log fires only when stream_guard is Some — under DAW_NO_AUDIO
481        // it is None and Drop is silent. Just verify no panic on scope exit.
482        drop(h);
483    }
484
485    #[test]
486    fn with_error_flag_passthrough_preserves_arc_identity() {
487        enter_hard_gate();
488        let shared = Arc::new(AtomicBool::new(false));
489        let h = start_playback_lockfree_with_error_flag(1024, Some(shared.clone()), None, None)
490            .expect("guard mode must succeed");
491        // The handle's device_error flag must be the same Arc we passed in,
492        // so reconnect callers can mutate one source.
493        h.device_error_flag().store(true, Ordering::Relaxed);
494        assert!(shared.load(Ordering::Relaxed),
495            "with_error_flag must reuse the caller's Arc, not clone its contents");
496    }
497}