forge-audio 0.1.0

Zero-allocation, lock-free audio architecture for real-time DSP, game engines, and WebAssembly
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
//! Real-time audio output via cpal. Lock-free SPSC ring buffer path only.

use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;

// ════════════════════════════════════════════════════════════════════════════
// HARD GATE — TYPED PLAYBACK GUARD
// ════════════════════════════════════════════════════════════════════════════
//
// `PlaybackHandle` is the ONLY way to obtain a real-time audio producer.
// The `cpal::Stream` is held in a private field and cannot be extracted.
// Rust's borrow checker enforces audio device lifetime: the only way to
// stop playback is to drop the entire handle.
//
// Burned origin (2026-04-09): forge-app-winamp/src/player.rs:45 used
// `let (_stream, prod, ...) = ...` inside a match arm. The `_stream`
// binding went out of scope at the end of the arm, dropping cpal::Stream
// and silently closing the audio device. The whole player ran perfectly
// — mixer math, FFT, IPC, snapshots — and zero sound reached the speakers.
// No compile error, no runtime warning.
//
// This guard makes that bug class STRUCTURALLY IMPOSSIBLE. The same
// pattern protects forge-app-daw, forge-app-studio, and any future audio
// app from typo'd destructures, agent-generated code, or copy-paste errors.
//
// See: feedback_verify_mute_before_audio_test.md
//      feedback_hard_gate_new_systems.md

/// Owns a real-time audio playback resource.
///
/// `cpal::Stream` is held privately — there is no public accessor and no
/// way to destructure it out. Drop the entire `PlaybackHandle` to stop
/// playback. The borrow checker prevents the bug class where a caller
/// drops the stream while holding the producer.
///
/// `PlaybackHandle` is `!Send` because `cpal::Stream` is `!Send`. For the
/// stream-on-init-thread, producer-on-feeder-thread pattern. Call [`PlaybackHandle::split`] to obtain a `!Send` `StreamKeeper`
/// that stays on the init thread and a `Send` `FeederBundle` that moves
/// into the audio feeder thread.
///
/// For the co-located pattern (where stream and producer live in the same
/// thread, e.g. forge-app-winamp), hold the whole handle in scope and use
/// [`PlaybackHandle::producer_mut`] to push samples.
pub struct PlaybackHandle {
    // PRIVATE — never expose. Held alive for the lifetime of the handle.
    // `None` when DAW_NO_AUDIO=1 (no real device opened).
    stream_guard: Option<cpal::Stream>,
    producer: rtrb::Producer<f32>,
    flush: Arc<AtomicBool>,
    device_error: Arc<AtomicBool>,
    channels: usize,
    sample_rate: u32,
    underruns: Arc<AtomicU64>,
}

impl PlaybackHandle {
    /// Push samples to the audio device. Use only inside the thread that
    /// owns this handle (typically the audio feeder thread or, for the
    /// co-located pattern, the player thread).
    pub fn producer_mut(&mut self) -> &mut rtrb::Producer<f32> {
        &mut self.producer
    }

    pub fn channels(&self) -> usize { self.channels }
    pub fn sample_rate(&self) -> u32 { self.sample_rate }
    pub fn flush_flag(&self) -> Arc<AtomicBool> { self.flush.clone() }
    pub fn device_error_flag(&self) -> Arc<AtomicBool> { self.device_error.clone() }
    pub fn underrun_counter(&self) -> Arc<AtomicU64> { self.underruns.clone() }

    /// Returns true when a real cpal output device is open. Returns false
    /// under `DAW_NO_AUDIO=1` (the hard-gate test mode).
    pub fn is_live(&self) -> bool { self.stream_guard.is_some() }

    /// Split the handle into a `!Send` `StreamKeeper` (must stay on the
    /// init thread or be intentionally leaked) and a `Send` `FeederBundle`
    /// (move into the audio feeder thread).
    ///
    /// Use this only when stream and producer cannot be co-located. The
    /// co-located pattern is preferred — it keeps audio device lifetime
    /// trivially correct without any !Send dance.
    pub fn split(self) -> (StreamKeeper, FeederBundle) {
        // Suppress the Drop log on the source handle — the resources are
        // moving into two new guards, not actually being released.
        let mut me = std::mem::ManuallyDrop::new(self);
        let stream = me.stream_guard.take();
        // SAFETY: ManuallyDrop disables the source's Drop. We move every
        // owned field out exactly once before `me` goes out of scope.
        let producer = unsafe { std::ptr::read(&me.producer) };
        let flush = unsafe { std::ptr::read(&me.flush) };
        let device_error = unsafe { std::ptr::read(&me.device_error) };
        let underruns = unsafe { std::ptr::read(&me.underruns) };
        let channels = me.channels;
        let sample_rate = me.sample_rate;

        let keeper = StreamKeeper { stream_guard: stream };
        let bundle = FeederBundle {
            producer,
            flush,
            device_error,
            channels,
            sample_rate,
            underruns,
        };
        (keeper, bundle)
    }
}

impl Drop for PlaybackHandle {
    fn drop(&mut self) {
        if self.stream_guard.is_some() {
            eprintln!("[forge-audio] PlaybackHandle dropped — audio device closed");
        }
    }
}

/// `!Send` half of a split [`PlaybackHandle`]. Owns the `cpal::Stream`.
/// Must stay on the thread that called `start_playback_lockfree`, OR be
/// `Box::leak`'d if you need cross-thread reconnect.
///
/// Drop this to stop the audio device. The stream cannot be extracted.
pub struct StreamKeeper {
    stream_guard: Option<cpal::Stream>,
}

impl Drop for StreamKeeper {
    fn drop(&mut self) {
        if self.stream_guard.is_some() {
            eprintln!("[forge-audio] StreamKeeper dropped — audio device closed");
        }
    }
}

/// `Send` half of a split [`PlaybackHandle`]. Owns the producer plus all
/// the metadata flags. Move this into the audio feeder thread.
pub struct FeederBundle {
    pub producer: rtrb::Producer<f32>,
    pub flush: Arc<AtomicBool>,
    pub device_error: Arc<AtomicBool>,
    pub channels: usize,
    pub sample_rate: u32,
    pub underruns: Arc<AtomicU64>,
}

/// Information about an available audio output device.
#[derive(Debug, Clone, serde::Serialize)]
pub struct AudioDeviceInfo {
    pub name: String,
    pub api: String,
    pub sample_rates: Vec<u32>,
    pub channels: u16,
}

/// Enumerate all available audio output devices with their supported configs.
pub fn list_output_devices() -> Vec<AudioDeviceInfo> {
    let host = cpal::default_host();
    let host_name = format!("{:?}", host.id());
    let devices = match host.output_devices() {
        Ok(d) => d,
        Err(e) => {
            eprintln!("[audio] Cannot enumerate output devices: {}", e);
            return vec![];
        }
    };
    devices
        .filter_map(|dev| {
            let name = dev.name().unwrap_or_else(|_| "Unknown".into());
            let mut sample_rates = Vec::new();
            let mut channels = 2u16;
            if let Ok(configs) = dev.supported_output_configs() {
                for cfg in configs {
                    channels = channels.max(cfg.channels());
                    let min = cfg.min_sample_rate().0;
                    let max = cfg.max_sample_rate().0;
                    for &sr in &[44100u32, 48000, 88200, 96000, 192000] {
                        if sr >= min && sr <= max && !sample_rates.contains(&sr) {
                            sample_rates.push(sr);
                        }
                    }
                }
            }
            sample_rates.sort();
            if sample_rates.is_empty() {
                sample_rates.push(48000);
            }
            Some(AudioDeviceInfo {
                name,
                api: host_name.clone(),
                sample_rates,
                channels,
            })
        })
        .collect()
}

/// Start real-time audio playback using a lock-free SPSC ring buffer.
///
/// Returns a [`PlaybackHandle`] guard. The cpal::Stream is held privately
/// inside the guard — drop the handle to stop playback. The handle prevents
/// the bug class where callers drop the stream while keeping the producer
/// alive (silent audio death, no compile error).
///
/// GUARDRAIL: This is the ONLY audio output path. The feeder thread owns
/// the Mixer and pushes samples here. Never lock a Mutex on the audio thread.
pub fn start_playback_lockfree(
    buffer_size: usize,
) -> Result<PlaybackHandle, String> {
    start_playback_lockfree_with_error_flag(buffer_size, None, None, None)
}

/// Silent fallback handle for when no audio device is available.
/// Mixer math and IPC still run; nothing reaches speakers. App stays alive.
pub fn null_playback(buffer_size: usize, sample_rate: u32) -> PlaybackHandle {
    let (producer, consumer) = rtrb::RingBuffer::<f32>::new(buffer_size);
    std::mem::forget(consumer);
    PlaybackHandle {
        stream_guard: None,
        producer,
        flush: Arc::new(AtomicBool::new(false)),
        device_error: Arc::new(AtomicBool::new(true)),
        channels: 2,
        sample_rate,
        underruns: Arc::new(AtomicU64::new(0)),
    }
}

/// Inner implementation that optionally accepts an existing error flag for reconnect scenarios.
///
/// HARD GATE: if `DAW_NO_AUDIO=1` is set in the environment, NO cpal device is opened.
/// The function returns a [`PlaybackHandle`] whose `stream_guard` is `None` and
/// whose producer's consumer is leaked into the void. Mixer math + IPC +
/// ForgeWright still run; nothing reaches phones/S2/speakers. Required for
/// any ForgeWright-driven audio test.
/// See feedback_verify_mute_before_audio_test.md.
pub fn start_playback_lockfree_with_error_flag(
    buffer_size: usize,
    error_flag: Option<Arc<AtomicBool>>,
    device_name: Option<&str>,
    cpal_buffer: Option<u32>,
) -> Result<PlaybackHandle, String> {
    if std::env::var("DAW_NO_AUDIO").is_ok() {
        eprintln!("[DAW_NO_AUDIO] *** AUDIO OUTPUT DISABLED *** No cpal device opened.");
        eprintln!("[DAW_NO_AUDIO] Mixer math + IPC still run; nothing reaches phones/S2/speakers.");
        let (producer, consumer) = rtrb::RingBuffer::<f32>::new(buffer_size);
        std::mem::forget(consumer); // keep producer push valid; samples drop into the void
        let flush = Arc::new(AtomicBool::new(false));
        let device_error = error_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
        let underruns = Arc::new(AtomicU64::new(0));
        return Ok(PlaybackHandle {
            stream_guard: None,
            producer,
            flush,
            device_error,
            channels: 2,
            sample_rate: 48000,
            underruns,
        });
    }
    let host = cpal::default_host();
    let device = if let Some(name) = device_name {
        use cpal::traits::HostTrait;
        host.output_devices()
            .map_err(|e| format!("enumerate devices: {}", e))?
            .find(|d| {
                use cpal::traits::DeviceTrait;
                d.name().map_or(false, |n| n == name)
            })
            .unwrap_or_else(|| host.default_output_device().expect("No audio output device"))
    } else {
        host.default_output_device().ok_or("No audio output device")?
    };
    let supported = device.default_output_config().map_err(|e| format!("{}", e))?;
    let channels = supported.channels() as usize;
    let device_sample_rate = supported.sample_rate().0;
    let mut config: cpal::StreamConfig = supported.into();
    if let Some(bs) = cpal_buffer {
        config.buffer_size = cpal::BufferSize::Fixed(bs);
        eprintln!("[audio] Output device: {} channels, {} Hz, buffer {}", channels, device_sample_rate, bs);
    } else {
        eprintln!("[audio] Output device: {} channels, {} Hz", channels, device_sample_rate);
    }

    let (producer, mut consumer) = rtrb::RingBuffer::new(buffer_size);
    let flush = Arc::new(AtomicBool::new(false));
    let flush_cb = flush.clone();
    let device_error = error_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
    let device_error_cb = device_error.clone();
    let underruns = Arc::new(AtomicU64::new(0));
    let underruns_cb = underruns.clone();

    let stream = device.build_output_stream(
        &config,
        move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
            // If flush requested, drain the ring buffer and output silence
            if flush_cb.swap(false, Ordering::Relaxed) {
                while consumer.pop().is_ok() {}
                for sample in data.iter_mut() {
                    *sample = 0.0;
                }
                return;
            }
            let mut had_underrun = false;
            for sample in data.iter_mut() {
                match consumer.pop() {
                    Ok(s) => *sample = s,
                    Err(_) => { *sample = 0.0; had_underrun = true; }
                }
            }
            if had_underrun {
                underruns_cb.fetch_add(1, Ordering::Relaxed);
            }
        },
        move |err| {
            eprintln!("[audio] Output error (device disconnect?): {}", err);
            device_error_cb.store(true, Ordering::Relaxed);
        },
        None,
    ).map_err(|e| format!("Stream: {}", e))?;

    stream.play().map_err(|e| format!("Play: {}", e))?;
    Ok(PlaybackHandle {
        stream_guard: Some(stream),
        producer,
        flush,
        device_error,
        channels,
        sample_rate: device_sample_rate,
        underruns,
    })
}

/// Start headphone/booth output on a second audio device.
/// Returns None if no second device is available (graceful fallback).
/// The feeder thread pushes headphone_mix samples to the returned producer.
pub fn start_headphone_output(
    buffer_size: usize,
) -> Option<(cpal::Stream, rtrb::Producer<f32>, usize)> {
    if std::env::var("DAW_NO_AUDIO").is_ok() {
        eprintln!("[DAW_NO_AUDIO] Headphone output skipped");
        return None;
    }
    let host = cpal::default_host();
    let default = host.default_output_device();
    let default_name = default.as_ref().and_then(|d| d.name().ok()).unwrap_or_default();

    // Find first non-default output device
    let devices = match host.output_devices() {
        Ok(d) => d,
        Err(_) => { eprintln!("[audio] Cannot enumerate output devices"); return None; }
    };

    let alt_device = devices
        .filter(|d| {
            let name = d.name().unwrap_or_default();
            name != default_name
                && !name.to_lowercase().contains("nvidia")
                && !name.to_lowercase().contains("hdmi")
                && !name.to_lowercase().contains("displayport")
                && !name.to_lowercase().contains("ultragear")
        })
        .next();

    let device = match alt_device {
        Some(d) => {
            eprintln!("[audio] Headphone output: {}", d.name().unwrap_or_default());
            d
        }
        None => {
            eprintln!("[audio] No second output device — headphone mix disabled");
            return None;
        }
    };

    let config = match device.default_output_config() {
        Ok(c) => c,
        Err(e) => { eprintln!("[audio] Headphone device config error: {}", e); return None; }
    };
    let channels = config.channels() as usize;
    let (producer, mut consumer) = rtrb::RingBuffer::new(buffer_size);

    let stream = match device.build_output_stream(
        &config.into(),
        move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
            for sample in data.iter_mut() {
                *sample = consumer.pop().unwrap_or(0.0);
            }
        },
        |err| eprintln!("[audio] Headphone output error: {}", err),
        None,
    ) {
        Ok(s) => s,
        Err(e) => { eprintln!("[audio] Headphone stream error: {}", e); return None; }
    };

    if let Err(e) = stream.play() {
        eprintln!("[audio] Headphone play error: {}", e);
        return None;
    }

    Some((stream, producer, channels))
}

// ════════════════════════════════════════════════════════════════════════════
// Hard-gate guard tests — verify the typed PlaybackHandle bug class is closed.
// ════════════════════════════════════════════════════════════════════════════
//
// All tests run under DAW_NO_AUDIO=1 — no real cpal device is opened. The
// guard's `is_live()` returns false, but every other field is populated and
// the producer is push-able. Tests assert the guard's structural invariants
// (private stream, Drop fires, split yields a Send bundle).

#[cfg(test)]
mod hard_gate_tests {
    use super::*;
    use std::sync::Once;

    static INIT: Once = Once::new();

    fn enter_hard_gate() {
        // Set DAW_NO_AUDIO once per test process. Safe across parallel tests
        // because every assertion in this module assumes the gate is on.
        INIT.call_once(|| {
            std::env::set_var("DAW_NO_AUDIO", "1");
        });
    }

    #[test]
    fn handle_under_hard_gate_is_not_live() {
        enter_hard_gate();
        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
        assert!(!h.is_live(), "DAW_NO_AUDIO must produce non-live handle");
        assert_eq!(h.sample_rate(), 48000);
        assert_eq!(h.channels(), 2);
    }

    #[test]
    fn producer_is_pushable_under_hard_gate() {
        enter_hard_gate();
        let mut h = start_playback_lockfree(1024).expect("guard mode must succeed");
        // The consumer is leaked into mem::forget — push must succeed until
        // ring is full, then return Err. Either is fine for this assertion;
        // we only verify the producer is reachable via the guard's accessor.
        let _ = h.producer_mut().push(0.5);
    }

    #[test]
    fn flags_are_clonable_arcs() {
        enter_hard_gate();
        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
        let flush_a = h.flush_flag();
        let flush_b = h.flush_flag();
        flush_a.store(true, Ordering::Relaxed);
        assert!(flush_b.load(Ordering::Relaxed), "flags must be Arc-clones, not copies");
    }

    #[test]
    fn split_produces_send_bundle() {
        enter_hard_gate();
        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
        let (keeper, mut bundle) = h.split();
        // The bundle MUST be Send — if it isn't, this thread::spawn won't compile.
        let jh = std::thread::spawn(move || {
            assert_eq!(bundle.sample_rate, 48000);
            assert_eq!(bundle.channels, 2);
            let _ = bundle.producer.push(0.25);
            bundle.sample_rate
        });
        let sr = jh.join().expect("feeder thread joined");
        assert_eq!(sr, 48000);
        // Keeper stays on this thread (it is !Send because cpal::Stream is !Send,
        // even when None, because the Option<cpal::Stream> field type is !Send).
        // Drop happens at end of scope.
        drop(keeper);
    }

    #[test]
    fn drop_under_hard_gate_does_not_panic() {
        enter_hard_gate();
        let h = start_playback_lockfree(1024).expect("guard mode must succeed");
        // The Drop log fires only when stream_guard is Some — under DAW_NO_AUDIO
        // it is None and Drop is silent. Just verify no panic on scope exit.
        drop(h);
    }

    #[test]
    fn with_error_flag_passthrough_preserves_arc_identity() {
        enter_hard_gate();
        let shared = Arc::new(AtomicBool::new(false));
        let h = start_playback_lockfree_with_error_flag(1024, Some(shared.clone()), None, None)
            .expect("guard mode must succeed");
        // The handle's device_error flag must be the same Arc we passed in,
        // so reconnect callers can mutate one source.
        h.device_error_flag().store(true, Ordering::Relaxed);
        assert!(shared.load(Ordering::Relaxed),
            "with_error_flag must reuse the caller's Arc, not clone its contents");
    }
}