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}