Skip to main content

dotzuki_runner/
audio.rs

1//! Audio playback for `dotzuki run`: [`RunnerAudio`].
2//!
3//! Backs the scene commands `PlayMusic` / `PlaySound` / `StopMusic` /
4//! `FadeOutMusic`. Tracks are dotzuki-audio [`TrackDef`](dotzuki_audio::format::TrackDef)
5//! JSON files under `<dataRoot>/audio/` (loaded recursively, so the
6//! `music/` + `sfx/` split is a convention, not a rule); the id a scene
7//! passes to `playMusic`/`playSound` is the track's `id` field.
8//!
9//! Audio is **fully optional**:
10//!
11//! - no `data/audio/` dir (scaffolded projects) → empty library, every
12//!   command is a silent no-op (debug-logged), cpal is never touched;
13//! - the cpal output stream is initialised **lazily** — only when the
14//!   library is non-empty *and* a play command actually arrives;
15//! - no output device (CI / headless) or a stream failure → one warning,
16//!   permanent silent mode, the game keeps running;
17//! - headless runs pass `allow_device: false` and never init a device.
18//!
19//! Threading follows `pokered-app`/`wuxia-app`: the emulated APU is shared
20//! with cpal's callback thread via a mutex; the callback advances it
21//! (`tick_n`) and reads one stereo sample (`mix_sample`) per output frame.
22//! The game thread only advances the *sequencer* once per video frame
23//! ([`update_frame`](Self::update_frame)) and mutates playback via
24//! `play_music`/`play_sound`/`stop_music`/`fade_out_music`.
25//!
26//! ## PCM render mode (WASM / callback-less hosts)
27//!
28//! On hosts where cpal has no real output (the browser's Null host), the
29//! push model above never runs: there is no callback thread to drive the
30//! APU. [`set_pcm_render`](Self::set_pcm_render) switches to a pull model:
31//! play commands still create the shared engine (without touching cpal),
32//! and the host pulls samples with [`render_samples`](Self::render_samples),
33//! feeding them to e.g. a WebAudio `AudioBuffer`. Sample generation is the
34//! same `tick_n` + `mix_sample` path the cpal callback uses, and
35//! `update_frame` still advances the sequencer/fade once per video frame,
36//! so music, dedup, and fades behave exactly as on native.
37
38use std::collections::HashSet;
39use std::path::Path;
40use std::sync::{Arc, Mutex};
41
42use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
43
44use dotzuki_audio::apu::Apu;
45use dotzuki_audio::format::TrackDef;
46use dotzuki_audio::library::AudioLibrary;
47use dotzuki_audio::sequencer::Sequencer;
48use dotzuki_audio::CPU_CLOCK_HZ;
49
50use crate::vfs::{join_path, DiskFiles, ProjectFiles};
51
52/// Output rate of the cpal stream (Hz). The GB APU is resampled to this by
53/// ticking `CPU_CLOCK_HZ / SAMPLE_RATE` cycles per output sample.
54const SAMPLE_RATE: u32 = 44_100;
55
56/// APU peak used to normalise `mix_sample`'s `i16` output to `[-1.0, 1.0]`.
57const MAX_AMPLITUDE: f32 = 480.0;
58
59/// Full master volume (NR50 per-side range is 0-7).
60const FULL_VOLUME: u8 = 7;
61
62/// Video frames between master-volume steps during a fade-out. A fade walks
63/// volume 7→0 (8 audible levels), so total ≈ `FADE_STEP_FRAMES * 7` frames
64/// (~1.2 s at 60 fps) before the music is cut.
65const FADE_STEP_FRAMES: u8 = 10;
66
67/// APU clock cycles advanced per output sample (the GB APU is resampled to
68/// `SAMPLE_RATE` by ticking this many cycles per sample).
69const CYCLES_PER_SAMPLE: u32 = CPU_CLOCK_HZ / SAMPLE_RATE;
70
71/// Music fade-out state.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum Fade {
74    None,
75    /// Fading out: `counter` frames until the next volume step down.
76    Out { counter: u8, reload: u8 },
77}
78
79/// Advance a fade by one frame.
80///
81/// Returns `(next_fade, next_master_volume, completed)`. When `completed` is
82/// true the caller should stop the music; the returned volume is reset to
83/// full so the next track starts at normal level. Pure, so the state machine
84/// is unit-tested without a device.
85fn step_fade(fade: Fade, master_volume: u8) -> (Fade, u8, bool) {
86    match fade {
87        Fade::None => (Fade::None, master_volume, false),
88        // Time to step the volume down.
89        Fade::Out { counter: 0, reload } => {
90            let mv = master_volume.saturating_sub(1);
91            if mv == 0 {
92                (Fade::None, FULL_VOLUME, true) // faded out — stop, restore volume
93            } else {
94                (Fade::Out { counter: reload, reload }, mv, false)
95            }
96        }
97        // Still counting down to the next step.
98        Fade::Out { counter, reload } => {
99            (Fade::Out { counter: counter - 1, reload }, master_volume, false)
100        }
101    }
102}
103
104/// The APU + sequencer + playback state, shared between the game thread and
105/// the audio callback thread behind a single mutex.
106struct Engine {
107    seq: Sequencer,
108    apu: Apu,
109    /// Current master volume (0-7), stamped onto NR50 each frame so a fade
110    /// takes audible effect and in-stream volume writes don't fight it.
111    master_volume: u8,
112    fade: Fade,
113    /// The music track currently requested, for dedup (don't restart BGM
114    /// every frame / every map re-entry).
115    current_music: Option<String>,
116}
117
118/// Generate stereo samples from the engine into `data` (interleaved L/R,
119/// `SAMPLE_RATE` Hz, normalised to `[-1.0, 1.0]`). Shared by the cpal output
120/// callback and [`RunnerAudio::render_samples`] so both paths produce
121/// byte-identical audio.
122fn render_into(e: &mut Engine, data: &mut [f32]) {
123    for frame in data.chunks_mut(2) {
124        e.apu.tick_n(CYCLES_PER_SAMPLE);
125        let (left, right) = e.apu.mix_sample();
126        frame[0] = left as f32 / MAX_AMPLITUDE;
127        frame[1] = right as f32 / MAX_AMPLITUDE;
128    }
129}
130
131/// Advance the music/SFX sequencer one video frame and step any active fade.
132/// Sample *generation* happens separately (cpal callback or `render_samples`).
133fn update_engine_frame(e: &mut Engine) {
134    // Advance the fade (if any) before the sequencer runs.
135    let (fade, mv, completed) = step_fade(e.fade, e.master_volume);
136    e.fade = fade;
137    e.master_volume = mv;
138    if completed {
139        e.seq.stop_music();
140        e.current_music = None;
141    }
142
143    // Advance the sequencer, then stamp master volume onto NR50 (bits
144    // 6-4 = left, 2-0 = right) so the fade is audible and outlives
145    // in-stream writes.
146    let Engine {
147        seq,
148        apu,
149        master_volume,
150        ..
151    } = e;
152    seq.update_frame(apu);
153    let v = *master_volume & 0x07;
154    apu.write_register(0xFF24, (v << 4) | v);
155}
156
157/// Create the shared engine: a powered-on APU plus a fresh sequencer.
158fn new_engine() -> Arc<Mutex<Engine>> {
159    let mut apu = Apu::new();
160    apu.write_register(0xFF26, 0x80); // NR52: power on (else register writes are ignored)
161    Arc::new(Mutex::new(Engine {
162        seq: Sequencer::new(),
163        apu,
164        master_volume: FULL_VOLUME,
165        fade: Fade::None,
166        current_music: None,
167    }))
168}
169
170/// Open the default output device and start streaming from `engine`. Returns
171/// the live stream (kept alive for the lifetime of playback; dropping it
172/// stops the stream), or `None` when no device is available or the stream
173/// cannot be built.
174fn open_stream(engine: Arc<Mutex<Engine>>) -> Option<cpal::Stream> {
175    let host = cpal::default_host();
176    let device = host.default_output_device()?;
177    let config = cpal::StreamConfig {
178        channels: 2,
179        sample_rate: cpal::SampleRate(SAMPLE_RATE),
180        buffer_size: cpal::BufferSize::Default,
181    };
182
183    let cb = engine;
184    let stream = device
185        .build_output_stream(
186            &config,
187            move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
188                let mut e = cb.lock().unwrap();
189                render_into(&mut e, data);
190            },
191            |err| log::error!("audio stream error: {err}"),
192            None,
193        )
194        .ok()?;
195    stream.play().ok()?;
196
197    Some(stream)
198}
199
200/// Audio for a [`crate::game::RunnerGame`]: an [`AudioLibrary`] plus a lazily
201/// initialised engine, driven either by a cpal output stream (native) or by
202/// PCM pull-rendering (WASM). Silent by default; every method is a safe
203/// no-op in silent mode.
204pub struct RunnerAudio {
205    library: AudioLibrary,
206    /// `false` on headless runs — never open a device.
207    allow_device: bool,
208    /// PCM pull-render mode: play commands create the engine without a
209    /// device, and the host pulls samples via [`render_samples`](Self::render_samples).
210    pcm_render: bool,
211    /// The shared engine; `None` until the first play command. Created
212    /// together with the cpal stream on native, stand-alone in PCM mode.
213    engine: Option<Arc<Mutex<Engine>>>,
214    /// The live cpal output, kept alive for the lifetime of playback;
215    /// dropping it stops the stream. Always `None` in PCM/headless mode.
216    stream: Option<cpal::Stream>,
217    /// Set once an init attempt failed: don't retry, stay silent.
218    init_failed: bool,
219    /// Track ids already warned about (unknown ids warn once each).
220    warned_ids: HashSet<String>,
221}
222
223impl RunnerAudio {
224    /// Load every track under `<data_root>/audio/` (recursively) from disk.
225    /// Convenience for [`from_files`](Self::from_files) over a [`DiskFiles`]
226    /// rooted at `data_root`.
227    pub fn new(data_root: &Path, allow_device: bool) -> Self {
228        Self::from_files(&DiskFiles::new(data_root), "", allow_device)
229    }
230
231    /// VFS form of [`new`](Self::new): load every track under
232    /// `<data_root_rel>/audio/` (recursively). A missing directory or an
233    /// unloadable library yields an empty library — silent mode, not an
234    /// error.
235    pub fn from_files(files: &dyn ProjectFiles, data_root_rel: &str, allow_device: bool) -> Self {
236        let prefix = join_path(data_root_rel, "audio");
237        let library = load_library(files, &prefix).unwrap_or_else(|e| {
238            log::warn!("audio: failed to load {prefix}: {e:#}");
239            AudioLibrary::new()
240        });
241        if !library.is_empty() {
242            log::info!("audio: loaded {} track(s) from {prefix}", library.len());
243        }
244        Self {
245            library,
246            allow_device,
247            pcm_render: false,
248            engine: None,
249            stream: None,
250            init_failed: false,
251            warned_ids: HashSet::new(),
252        }
253    }
254
255    /// Number of loaded tracks (0 ⇒ every command is a silent no-op).
256    pub fn track_count(&self) -> usize {
257        self.library.len()
258    }
259
260    /// Whether a track with this id exists (test/debug introspection).
261    pub fn has_track(&self, id: &str) -> bool {
262        self.library.get(id).is_some()
263    }
264
265    /// Whether the cpal output stream is live (test/debug introspection).
266    pub fn has_output(&self) -> bool {
267        self.stream.is_some()
268    }
269
270    /// Switch PCM pull-render mode on/off (see the module docs). In PCM mode
271    /// play commands create the engine even with no usable output device —
272    /// cpal is never touched — and the host pulls samples via
273    /// [`render_samples`](Self::render_samples). Off by default (native).
274    pub fn set_pcm_render(&mut self, on: bool) {
275        self.pcm_render = on;
276    }
277
278    /// Render `frames` stereo PCM frames from the engine: interleaved L/R
279    /// `f32` samples (length `2 * frames`) at 44100 Hz, normalised exactly
280    /// like the cpal callback. Returns an empty `Vec` when no engine exists
281    /// yet (silent mode, or no play command has arrived).
282    ///
283    /// Each call advances the APU — call this instead of (never in addition
284    /// to) a live output stream, and advance the sequencer with
285    /// [`update_frame`](Self::update_frame) once per video frame as usual.
286    pub fn render_samples(&mut self, frames: usize) -> Vec<f32> {
287        let Some(engine) = &self.engine else {
288            return Vec::new();
289        };
290        let mut e = engine.lock().unwrap();
291        let mut data = vec![0.0; frames * 2];
292        render_into(&mut e, &mut data);
293        data
294    }
295
296    /// The shared engine, lazily initialised on first use. Returns `None`
297    /// (staying silent) when the library is empty or — outside PCM mode —
298    /// the device is disallowed or init has failed before; an init failure
299    /// is warned about once. On native the engine and the cpal stream are
300    /// created together; in PCM mode the engine stands alone.
301    fn engine(&mut self) -> Option<Arc<Mutex<Engine>>> {
302        if let Some(engine) = &self.engine {
303            return Some(Arc::clone(engine));
304        }
305        if self.library.is_empty() {
306            return None;
307        }
308        if self.pcm_render {
309            log::info!("audio: pcm render mode — engine started without an output device");
310            let engine = new_engine();
311            self.engine = Some(Arc::clone(&engine));
312            return Some(engine);
313        }
314        if !self.allow_device || self.init_failed {
315            return None;
316        }
317        let engine = new_engine();
318        match open_stream(Arc::clone(&engine)) {
319            Some(stream) => {
320                log::info!("audio: output stream started");
321                self.stream = Some(stream);
322                self.engine = Some(Arc::clone(&engine));
323                Some(engine)
324            }
325            None => {
326                log::warn!("audio: no output device — sound disabled, continuing silent");
327                self.init_failed = true;
328                None
329            }
330        }
331    }
332
333    /// Warn about an unknown track id, once per id.
334    fn warn_unknown(&mut self, kind: &str, id: &str) {
335        if self.warned_ids.insert(id.to_string()) {
336            log::warn!("audio: no {kind} track '{id}'");
337        }
338    }
339
340    /// Advance the sequencer one video frame (called from
341    /// [`RunnerGame::update`](crate::game::RunnerGame::update)); a no-op in
342    /// silent mode. Runs against the engine, so the sequencer and fades
343    /// advance in PCM render mode too.
344    pub fn update_frame(&mut self) {
345        if let Some(engine) = &self.engine {
346            update_engine_frame(&mut engine.lock().unwrap());
347        }
348    }
349
350    /// Start a background-music track by id. No-op if that track is already
351    /// the active BGM (re-entering a map doesn't restart its theme). Cancels
352    /// any in-progress fade and restores full volume.
353    pub fn play_music(&mut self, id: &str) {
354        if !self.has_track(id) {
355            self.warn_unknown("music", id);
356            return;
357        }
358        let Some(engine) = self.engine() else {
359            return;
360        };
361        let mut e = engine.lock().unwrap();
362        if e.current_music.as_deref() == Some(id) {
363            return;
364        }
365        e.current_music = Some(id.to_string());
366        e.fade = Fade::None;
367        e.master_volume = FULL_VOLUME;
368        // Existence was checked above; play() only fails on an unknown id.
369        self.library.play(&mut e.seq, id);
370    }
371
372    /// Play a one-shot sound effect by id (always retriggers).
373    pub fn play_sound(&mut self, id: &str) {
374        if !self.has_track(id) {
375            self.warn_unknown("sfx", id);
376            return;
377        }
378        let Some(engine) = self.engine() else {
379            return;
380        };
381        let mut e = engine.lock().unwrap();
382        self.library.play(&mut e.seq, id);
383    }
384
385    /// Stop all background music immediately.
386    pub fn stop_music(&mut self) {
387        let Some(engine) = &self.engine else {
388            return;
389        };
390        let mut e = engine.lock().unwrap();
391        e.seq.stop_music();
392        e.fade = Fade::None;
393        e.master_volume = FULL_VOLUME;
394        e.current_music = None;
395    }
396
397    /// Begin fading the current music out to silence, then stop it. No-op if
398    /// no music is playing or a fade is already under way.
399    pub fn fade_out_music(&mut self) {
400        let Some(engine) = &self.engine else {
401            return;
402        };
403        let mut e = engine.lock().unwrap();
404        if e.current_music.is_none() || matches!(e.fade, Fade::Out { .. }) {
405            return;
406        }
407        e.fade = Fade::Out {
408            counter: FADE_STEP_FRAMES,
409            reload: FADE_STEP_FRAMES,
410        };
411    }
412}
413
414/// Load every `*.json` track under `prefix` (recursively) through the VFS
415/// into an [`AudioLibrary`] — the [`ProjectFiles`] counterpart of
416/// `AudioLibrary::load_dir`. A missing prefix yields an empty library; a
417/// track that fails to parse aborts the whole load with an error naming the
418/// file (same bar as the disk loader).
419fn load_library(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<AudioLibrary> {
420    let mut lib = AudioLibrary::new();
421    for path in files.list(prefix) {
422        if path.rsplit('.').next() != Some("json") {
423            continue;
424        }
425        let bytes = files.read(&path)?;
426        let track: TrackDef = serde_json::from_slice(&bytes)
427            .map_err(|e| anyhow::anyhow!("{path}: {e}"))?;
428        lib.insert(track);
429    }
430    Ok(lib)
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    /// A minimal valid music track (dotzuki-audio `TrackDef` JSON).
438    const THEME_JSON: &str = r#"{
439      "id": "theme",
440      "kind": "music",
441      "tempo": 256,
442      "channels": [
443        {
444          "hw": "pulse1",
445          "commands": [
446            { "type": "note_type", "speed": 12, "param": 197 },
447            { "type": "octave", "value": 5 },
448            { "type": "note", "pitch": 0, "length": 4 },
449            { "type": "rest", "length": 4 },
450            { "type": "sound_ret" }
451          ]
452        }
453      ]
454    }"#;
455
456    /// A `RunnerAudio` in PCM render mode with a one-track library and no
457    /// device access — the WASM shell setup.
458    fn pcm_audio() -> RunnerAudio {
459        let track: TrackDef = serde_json::from_str(THEME_JSON).unwrap();
460        let mut library = AudioLibrary::new();
461        library.insert(track);
462        RunnerAudio {
463            library,
464            allow_device: false,
465            pcm_render: true,
466            engine: None,
467            stream: None,
468            init_failed: false,
469            warned_ids: HashSet::new(),
470        }
471    }
472
473    #[test]
474    fn fade_none_is_inert() {
475        assert_eq!(step_fade(Fade::None, 5), (Fade::None, 5, false));
476    }
477
478    #[test]
479    fn fade_counts_down_then_steps_volume() {
480        // reload 2: counter 2 → 1 → 0 (holds volume), then the 0 tick steps it.
481        let (f, v, done) = step_fade(Fade::Out { counter: 2, reload: 2 }, 7);
482        assert_eq!((f, v, done), (Fade::Out { counter: 1, reload: 2 }, 7, false));
483        let (f, v, done) = step_fade(f, v);
484        assert_eq!((f, v, done), (Fade::Out { counter: 0, reload: 2 }, 7, false));
485        let (f, v, done) = step_fade(f, v);
486        assert_eq!((f, v, done), (Fade::Out { counter: 2, reload: 2 }, 6, false));
487    }
488
489    #[test]
490    fn fade_completes_and_restores_full_volume() {
491        // Drive a whole fade from full volume with the fastest reload.
492        let mut fade = Fade::Out { counter: 0, reload: 0 };
493        let mut vol = FULL_VOLUME;
494        let mut completed = false;
495        for _ in 0..64 {
496            let (f, v, done) = step_fade(fade, vol);
497            fade = f;
498            vol = v;
499            if done {
500                completed = true;
501                break;
502            }
503        }
504        assert!(completed, "fade never completed");
505        assert_eq!(fade, Fade::None);
506        assert_eq!(vol, FULL_VOLUME, "volume should reset to full after a fade");
507    }
508
509    #[test]
510    fn render_samples_is_empty_before_any_play() {
511        let mut audio = pcm_audio();
512        assert!(audio.render_samples(4410).is_empty());
513        // Silent (non-PCM) mode never creates an engine either.
514        let mut silent = RunnerAudio {
515            pcm_render: false,
516            ..pcm_audio()
517        };
518        silent.play_music("theme");
519        assert!(silent.render_samples(4410).is_empty());
520        assert!(!silent.has_output());
521    }
522
523    #[test]
524    fn pcm_play_renders_nonzero_samples_without_a_device() {
525        let mut audio = pcm_audio();
526        audio.play_music("theme");
527        assert!(!audio.has_output(), "pcm mode must not open a device");
528        assert!(audio.engine.is_some(), "pcm mode creates the engine on play");
529
530        // A few video frames so the sequencer triggers the first note.
531        for _ in 0..5 {
532            audio.update_frame();
533        }
534        let pcm = audio.render_samples(4410);
535        assert_eq!(pcm.len(), 8820, "stereo frames: 2 * frames");
536        assert!(
537            pcm.iter().any(|s| *s != 0.0),
538            "playing music must render non-silent samples"
539        );
540    }
541
542    #[test]
543    fn pcm_play_music_dedups_like_native() {
544        let mut audio = pcm_audio();
545        audio.play_music("theme");
546        audio.update_frame();
547        {
548            let e = audio.engine.as_ref().unwrap().lock().unwrap();
549            assert_eq!(e.current_music.as_deref(), Some("theme"));
550        }
551        // Re-requesting the same track must not restart it…
552        audio.play_music("theme");
553        let e = audio.engine.as_ref().unwrap().lock().unwrap();
554        assert_eq!(e.current_music.as_deref(), Some("theme"));
555        assert_eq!(e.master_volume, FULL_VOLUME);
556        assert_eq!(e.fade, Fade::None);
557    }
558
559    #[test]
560    fn pcm_fade_out_completes_and_stops_music() {
561        let mut audio = pcm_audio();
562        audio.play_music("theme");
563        for _ in 0..5 {
564            audio.update_frame();
565        }
566        assert!(audio.render_samples(4410).iter().any(|s| *s != 0.0));
567
568        audio.fade_out_music();
569        // FADE_STEP_FRAMES * 7 volume steps plus slack to run the fade out.
570        for _ in 0..200 {
571            audio.update_frame();
572        }
573        let e = audio.engine.as_ref().unwrap().lock().unwrap();
574        assert_eq!(e.fade, Fade::None, "fade state machine completed");
575        assert!(e.current_music.is_none(), "music stopped after fade-out");
576        assert_eq!(e.master_volume, FULL_VOLUME, "volume restored for next track");
577    }
578}