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`. Track ids are looked up in two libraries, in order:
5//!
6//! 1. dotzuki-audio [`TrackDef`](dotzuki_audio::format::TrackDef) JSON files
7//!    under `<dataRoot>/audio/` (loaded recursively, so the `music/` +
8//!    `sfx/` split is a convention, not a rule); the id is the track's
9//!    `id` field;
10//! 2. — with the `modern-audio` feature — real audio files (`*.wav`,
11//!    `*.ogg`, `*.flac`, `*.mp3`) under the same tree; the id is the path
12//!    relative to `audio/` without its extension, e.g. `playMusic("music/town")`
13//!    plays `data/audio/music/town.ogg`. Such tracks stream through the
14//!    dotzuki-audio `modern` mixer (BGM loops, SFX is one-shot), mixed with
15//!    the chiptune APU output.
16//!
17//! Audio is **fully optional**:
18//!
19//! - no `data/audio/` dir (scaffolded projects) → empty library, every
20//!   command is a silent no-op (debug-logged), cpal is never touched;
21//! - the cpal output stream is initialised **lazily** — only when the
22//!   library is non-empty *and* a play command actually arrives;
23//! - no output device (CI / headless) or a stream failure → one warning,
24//!   permanent silent mode, the game keeps running;
25//! - headless runs pass `allow_device: false` and never init a device.
26//!
27//! Threading follows `pokered-app`/`wuxia-app`: the emulated APU is shared
28//! with cpal's callback thread via a mutex; the callback advances it
29//! (`tick_n`) and reads one stereo sample (`mix_sample`) per output frame.
30//! The game thread only advances the *sequencer* once per video frame
31//! ([`update_frame`](Self::update_frame)) and mutates playback via
32//! `play_music`/`play_sound`/`stop_music`/`fade_out_music`.
33//!
34//! ## PCM render mode (WASM / callback-less hosts)
35//!
36//! On hosts where cpal has no real output (the browser's Null host), the
37//! push model above never runs: there is no callback thread to drive the
38//! APU. [`set_pcm_render`](Self::set_pcm_render) switches to a pull model:
39//! play commands still create the shared engine (without touching cpal),
40//! and the host pulls samples with [`render_samples`](Self::render_samples),
41//! feeding them to e.g. a WebAudio `AudioBuffer`. Sample generation is the
42//! same `tick_n` + `mix_sample` path the cpal callback uses (plus the
43//! modern mixer when enabled), and `update_frame` still advances the
44//! sequencer/fade once per video frame, so music, dedup, and fades behave
45//! exactly as on native.
46
47use std::collections::{BTreeMap, HashSet};
48use std::path::Path;
49use std::sync::{Arc, Mutex};
50
51use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
52
53use dotzuki_audio::apu::Apu;
54use dotzuki_audio::format::TrackDef;
55use dotzuki_audio::library::AudioLibrary;
56use dotzuki_audio::sequencer::Sequencer;
57use dotzuki_audio::CPU_CLOCK_HZ;
58
59#[cfg(feature = "modern-audio")]
60use dotzuki_audio::modern::{Bus as ModernBus, ModernAudio, PlayOptions as ModernPlayOptions};
61
62use crate::vfs::{join_path, DiskFiles, ProjectFiles};
63
64/// Output rate of the cpal stream (Hz). The GB APU is resampled to this by
65/// ticking `CPU_CLOCK_HZ / SAMPLE_RATE` cycles per output sample.
66const SAMPLE_RATE: u32 = 44_100;
67
68/// APU peak used to normalise `mix_sample`'s `i16` output to `[-1.0, 1.0]`.
69const MAX_AMPLITUDE: f32 = 480.0;
70
71/// Full master volume (NR50 per-side range is 0-7).
72const FULL_VOLUME: u8 = 7;
73
74/// Video frames between master-volume steps during a fade-out. A fade walks
75/// volume 7→0 (8 audible levels), so total ≈ `FADE_STEP_FRAMES * 7` frames
76/// (~1.2 s at 60 fps) before the music is cut.
77const FADE_STEP_FRAMES: u8 = 10;
78
79/// APU clock cycles advanced per output sample (the GB APU is resampled to
80/// `SAMPLE_RATE` by ticking this many cycles per sample).
81const CYCLES_PER_SAMPLE: u32 = CPU_CLOCK_HZ / SAMPLE_RATE;
82
83/// Music fade-out state.
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Fade {
86    None,
87    /// Fading out: `counter` frames until the next volume step down.
88    Out { counter: u8, reload: u8 },
89}
90
91/// Advance a fade by one frame.
92///
93/// Returns `(next_fade, next_master_volume, completed)`. When `completed` is
94/// true the caller should stop the music; the returned volume is reset to
95/// full so the next track starts at normal level. Pure, so the state machine
96/// is unit-tested without a device.
97fn step_fade(fade: Fade, master_volume: u8) -> (Fade, u8, bool) {
98    match fade {
99        Fade::None => (Fade::None, master_volume, false),
100        // Time to step the volume down.
101        Fade::Out { counter: 0, reload } => {
102            let mv = master_volume.saturating_sub(1);
103            if mv == 0 {
104                (Fade::None, FULL_VOLUME, true) // faded out — stop, restore volume
105            } else {
106                (Fade::Out { counter: reload, reload }, mv, false)
107            }
108        }
109        // Still counting down to the next step.
110        Fade::Out { counter, reload } => {
111            (Fade::Out { counter: counter - 1, reload }, master_volume, false)
112        }
113    }
114}
115
116/// The APU + sequencer + playback state, shared between the game thread and
117/// the audio callback thread behind a single mutex.
118struct Engine {
119    seq: Sequencer,
120    apu: Apu,
121    /// Current master volume (0-7), stamped onto NR50 each frame so a fade
122    /// takes audible effect and in-stream volume writes don't fight it.
123    master_volume: u8,
124    fade: Fade,
125    /// The music track currently requested, for dedup (don't restart BGM
126    /// every frame / every map re-entry).
127    current_music: Option<String>,
128    /// Modern file-audio mixer (lazy; `None` until a file track plays).
129    #[cfg(feature = "modern-audio")]
130    modern: Option<ModernAudio>,
131    /// The file-audio track currently requested (dedup like `current_music`).
132    #[cfg(feature = "modern-audio")]
133    modern_music: Option<String>,
134    /// Reusable overlay buffer for the modern mixer (avoids per-callback
135    /// allocation in `render_into`).
136    #[cfg(feature = "modern-audio")]
137    mix_buf: Vec<f32>,
138}
139
140/// Generate stereo samples from the engine into `data` (interleaved L/R,
141/// `SAMPLE_RATE` Hz, normalised to `[-1.0, 1.0]`). Shared by the cpal output
142/// callback and [`RunnerAudio::render_samples`] so both paths produce
143/// byte-identical audio.
144fn render_into(e: &mut Engine, data: &mut [f32]) {
145    for frame in data.chunks_mut(2) {
146        e.apu.tick_n(CYCLES_PER_SAMPLE);
147        let (left, right) = e.apu.mix_sample();
148        frame[0] = left as f32 / MAX_AMPLITUDE;
149        frame[1] = right as f32 / MAX_AMPLITUDE;
150    }
151    #[cfg(feature = "modern-audio")]
152    if let Some(modern) = &mut e.modern {
153        e.mix_buf.resize(data.len(), 0.0);
154        e.mix_buf.fill(0.0);
155        modern.render_into(&mut e.mix_buf);
156        for (out, modern_s) in data.iter_mut().zip(e.mix_buf.iter()) {
157            *out += *modern_s;
158        }
159    }
160}
161
162/// Advance the music/SFX sequencer one video frame and step any active fade.
163/// Sample *generation* happens separately (cpal callback or `render_samples`).
164fn update_engine_frame(e: &mut Engine) {
165    // Advance the fade (if any) before the sequencer runs.
166    let (fade, mv, completed) = step_fade(e.fade, e.master_volume);
167    e.fade = fade;
168    e.master_volume = mv;
169    if completed {
170        e.seq.stop_music();
171        e.current_music = None;
172    }
173
174    #[cfg(feature = "modern-audio")]
175    {
176        // A completed chiptune fade also stops modern file music (fades are
177        // issued per-kind by the runner, but a fade that completed here
178        // means the caller wanted silence).
179        if completed {
180            if let Some(modern) = &mut e.modern {
181                modern.stop_music(None);
182            }
183            e.modern_music = None;
184        }
185    }
186
187    // Advance the sequencer, then stamp master volume onto NR50 (bits
188    // 6-4 = left, 2-0 = right) so the fade is audible and outlives
189    // in-stream writes.
190    let Engine {
191        seq,
192        apu,
193        master_volume,
194        ..
195    } = e;
196    seq.update_frame(apu);
197    let v = *master_volume & 0x07;
198    apu.write_register(0xFF24, (v << 4) | v);
199}
200
201/// Create the shared engine: a powered-on APU plus a fresh sequencer.
202fn new_engine() -> Arc<Mutex<Engine>> {
203    let mut apu = Apu::new();
204    apu.write_register(0xFF26, 0x80); // NR52: power on (else register writes are ignored)
205    Arc::new(Mutex::new(Engine {
206        seq: Sequencer::new(),
207        apu,
208        master_volume: FULL_VOLUME,
209        fade: Fade::None,
210        current_music: None,
211        #[cfg(feature = "modern-audio")]
212        modern: None,
213        #[cfg(feature = "modern-audio")]
214        modern_music: None,
215        #[cfg(feature = "modern-audio")]
216        mix_buf: Vec::new(),
217    }))
218}
219
220/// Open the default output device and start streaming from `engine`. Returns
221/// the live stream (kept alive for the lifetime of playback; dropping it
222/// stops the stream), or `None` when no device is available or the stream
223/// cannot be built.
224fn open_stream(engine: Arc<Mutex<Engine>>) -> Option<cpal::Stream> {
225    let host = cpal::default_host();
226    let device = host.default_output_device()?;
227    let config = cpal::StreamConfig {
228        channels: 2,
229        sample_rate: cpal::SampleRate(SAMPLE_RATE),
230        buffer_size: cpal::BufferSize::Default,
231    };
232
233    let cb = engine;
234    let stream = device
235        .build_output_stream(
236            &config,
237            move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
238                let mut e = cb.lock().unwrap();
239                render_into(&mut e, data);
240            },
241            |err| log::error!("audio stream error: {err}"),
242            None,
243        )
244        .ok()?;
245    stream.play().ok()?;
246
247    Some(stream)
248}
249
250/// A file-backed audio track (loaded into memory as compressed bytes; the
251/// decoder streams them, so PCM is never fully materialised).
252#[cfg(feature = "modern-audio")]
253struct FileTrack {
254    bytes: Vec<u8>,
255    ext: String,
256}
257
258/// Audio for a [`crate::game::RunnerGame`]: an [`AudioLibrary`] plus a lazily
259/// initialised engine, driven either by a cpal output stream (native) or by
260/// PCM pull-rendering (WASM). Silent by default; every method is a safe
261/// no-op in silent mode.
262pub struct RunnerAudio {
263    library: AudioLibrary,
264    /// File-audio tracks keyed by their extension-stripped `audio/`-relative
265    /// path (e.g. `"music/town"`), only with the `modern-audio` feature.
266    #[cfg(feature = "modern-audio")]
267    file_tracks: BTreeMap<String, FileTrack>,
268    /// `false` on headless runs — never open a device.
269    allow_device: bool,
270    /// PCM pull-render mode: play commands create the engine without a
271    /// device, and the host pulls samples via [`render_samples`](Self::render_samples).
272    pcm_render: bool,
273    /// The shared engine; `None` until the first play command. Created
274    /// together with the cpal stream on native, stand-alone in PCM mode.
275    engine: Option<Arc<Mutex<Engine>>>,
276    /// The live cpal output, kept alive for the lifetime of playback;
277    /// dropping it stops the stream. Always `None` in PCM/headless mode.
278    stream: Option<cpal::Stream>,
279    /// Set once an init attempt failed: don't retry, stay silent.
280    init_failed: bool,
281    /// Track ids already warned about (unknown ids warn once each).
282    warned_ids: HashSet<String>,
283}
284
285impl RunnerAudio {
286    /// Load every track under `<data_root>/audio/` (recursively) from disk.
287    /// Convenience for [`from_files`](Self::from_files) over a [`DiskFiles`]
288    /// rooted at `data_root`.
289    pub fn new(data_root: &Path, allow_device: bool) -> Self {
290        Self::from_files(&DiskFiles::new(data_root), "", allow_device)
291    }
292
293    /// VFS form of [`new`](Self::new): load every track under
294    /// `<data_root_rel>/audio/` (recursively). A missing directory or an
295    /// unloadable library yields an empty library — silent mode, not an
296    /// error.
297    pub fn from_files(files: &dyn ProjectFiles, data_root_rel: &str, allow_device: bool) -> Self {
298        let prefix = join_path(data_root_rel, "audio");
299        let library = load_library(files, &prefix).unwrap_or_else(|e| {
300            log::warn!("audio: failed to load {prefix}: {e:#}");
301            AudioLibrary::new()
302        });
303        if !library.is_empty() {
304            log::info!("audio: loaded {} track(s) from {prefix}", library.len());
305        }
306        #[cfg(feature = "modern-audio")]
307        let file_tracks = load_file_tracks(files, &prefix).unwrap_or_else(|e| {
308            log::warn!("audio: failed to load file tracks from {prefix}: {e:#}");
309            BTreeMap::new()
310        });
311        #[cfg(feature = "modern-audio")]
312        if !file_tracks.is_empty() {
313            log::info!(
314                "audio: loaded {} file track(s) from {prefix}",
315                file_tracks.len()
316            );
317        }
318        Self {
319            library,
320            #[cfg(feature = "modern-audio")]
321            file_tracks,
322            allow_device,
323            pcm_render: false,
324            engine: None,
325            stream: None,
326            init_failed: false,
327            warned_ids: HashSet::new(),
328        }
329    }
330
331    /// Number of loaded JSON tracks (0 ⇒ every command is a silent no-op).
332    pub fn track_count(&self) -> usize {
333        self.library.len()
334    }
335
336    /// Whether a track with this id exists in either library (JSON first,
337    /// then files, when the `modern-audio` feature is on).
338    pub fn has_track(&self, id: &str) -> bool {
339        self.library.get(id).is_some()
340            || {
341                #[cfg(feature = "modern-audio")]
342                {
343                    self.file_tracks.contains_key(id)
344                }
345                #[cfg(not(feature = "modern-audio"))]
346                {
347                    false
348                }
349            }
350    }
351
352    /// Whether the cpal output stream is live (test/debug introspection).
353    pub fn has_output(&self) -> bool {
354        self.stream.is_some()
355    }
356
357    /// Switch PCM pull-render mode on/off (see the module docs). In PCM mode
358    /// play commands create the engine even with no usable output device —
359    /// cpal is never touched — and the host pulls samples via
360    /// [`render_samples`](Self::render_samples). Off by default (native).
361    pub fn set_pcm_render(&mut self, on: bool) {
362        self.pcm_render = on;
363    }
364
365    /// Render `frames` stereo PCM frames from the engine: interleaved L/R
366    /// `f32` samples (length `2 * frames`) at 44100 Hz, normalised exactly
367    /// like the cpal callback. Returns an empty `Vec` when no engine exists
368    /// yet (silent mode, or no play command has arrived).
369    ///
370    /// Each call advances the APU — call this instead of (never in addition
371    /// to) a live output stream, and advance the sequencer with
372    /// [`update_frame`](Self::update_frame) once per video frame as usual.
373    pub fn render_samples(&mut self, frames: usize) -> Vec<f32> {
374        let Some(engine) = &self.engine else {
375            return Vec::new();
376        };
377        let mut e = engine.lock().unwrap();
378        let mut data = vec![0.0; frames * 2];
379        render_into(&mut e, &mut data);
380        data
381    }
382
383    /// The shared engine, lazily initialised on first use. Returns `None`
384    /// (staying silent) when every library is empty or — outside PCM mode —
385    /// the device is disallowed or init has failed before; an init failure
386    /// is warned about once. On native the engine and the cpal stream are
387    /// created together; in PCM mode the engine stands alone.
388    fn engine(&mut self) -> Option<Arc<Mutex<Engine>>> {
389        if let Some(engine) = &self.engine {
390            return Some(Arc::clone(engine));
391        }
392        if self.library.is_empty()
393            && {
394                #[cfg(feature = "modern-audio")]
395                {
396                    self.file_tracks.is_empty()
397                }
398                #[cfg(not(feature = "modern-audio"))]
399                {
400                    true
401                }
402            }
403        {
404            return None;
405        }
406        if self.pcm_render {
407            log::info!("audio: pcm render mode — engine started without an output device");
408            let engine = new_engine();
409            self.engine = Some(Arc::clone(&engine));
410            return Some(engine);
411        }
412        if !self.allow_device || self.init_failed {
413            return None;
414        }
415        let engine = new_engine();
416        match open_stream(Arc::clone(&engine)) {
417            Some(stream) => {
418                log::info!("audio: output stream started");
419                self.stream = Some(stream);
420                self.engine = Some(Arc::clone(&engine));
421                Some(engine)
422            }
423            None => {
424                log::warn!("audio: no output device — sound disabled, continuing silent");
425                self.init_failed = true;
426                None
427            }
428        }
429    }
430
431    /// Warn about an unknown track id, once per id.
432    fn warn_unknown(&mut self, kind: &str, id: &str) {
433        if self.warned_ids.insert(id.to_string()) {
434            log::warn!("audio: no {kind} track '{id}'");
435        }
436    }
437
438    /// Advance the sequencer one video frame (called from
439    /// [`RunnerGame::update`](crate::game::RunnerGame::update)); a no-op in
440    /// silent mode. Runs against the engine, so the sequencer and fades
441    /// advance in PCM render mode too.
442    pub fn update_frame(&mut self) {
443        if let Some(engine) = &self.engine {
444            update_engine_frame(&mut engine.lock().unwrap());
445        }
446    }
447
448    /// Start a background-music track by id. No-op if that track is already
449    /// the active BGM (re-entering a map doesn't restart its theme). Cancels
450    /// any in-progress fade and restores full volume.
451    ///
452    /// JSON [`TrackDef`] tracks take precedence; with the `modern-audio`
453    /// feature, ids that match a file track (extension-stripped
454    /// `audio/`-relative path) play as looping streamed audio instead.
455    pub fn play_music(&mut self, id: &str) {
456        if !self.has_track(id) {
457            self.warn_unknown("music", id);
458            return;
459        }
460        let Some(engine) = self.engine() else {
461            return;
462        };
463        let mut e = engine.lock().unwrap();
464        if self.library.get(id).is_some() {
465            if e.current_music.as_deref() == Some(id) {
466                return;
467            }
468            e.current_music = Some(id.to_string());
469            e.fade = Fade::None;
470            e.master_volume = FULL_VOLUME;
471            // Existence was checked above; play() only fails on an unknown id.
472            self.library.play(&mut e.seq, id);
473            return;
474        }
475        #[cfg(feature = "modern-audio")]
476        {
477            if e.modern_music.as_deref() == Some(id) {
478                return;
479            }
480            let Some(track) = self.file_tracks.get(id) else {
481                return;
482            };
483            // Replacing a JSON BGM? Stop the chiptune side too, so a single
484            // music command owns the music slot.
485            e.seq.stop_music();
486            e.current_music = None;
487            let modern = e.modern.get_or_insert_with(|| ModernAudio::new(SAMPLE_RATE));
488            if modern
489                .play_music_bytes(track.bytes.clone(), Some(&track.ext), ModernPlayOptions::default())
490                .is_ok()
491            {
492                e.modern_music = Some(id.to_string());
493            } else {
494                log::warn!("audio: failed to decode music track '{id}'");
495            }
496        }
497        #[cfg(not(feature = "modern-audio"))]
498        {
499            log::warn!("audio: music track '{id}' exists but modern-audio is not enabled");
500        }
501    }
502
503    /// Play a one-shot sound effect by id (always retriggers). JSON tracks
504    /// take precedence; file tracks play as one-shots on the SFX bus.
505    pub fn play_sound(&mut self, id: &str) {
506        if !self.has_track(id) {
507            self.warn_unknown("sfx", id);
508            return;
509        }
510        let Some(engine) = self.engine() else {
511            return;
512        };
513        let mut e = engine.lock().unwrap();
514        if self.library.get(id).is_some() {
515            self.library.play(&mut e.seq, id);
516            return;
517        }
518        #[cfg(feature = "modern-audio")]
519        {
520            let Some(track) = self.file_tracks.get(id) else {
521                return;
522            };
523            let modern = e.modern.get_or_insert_with(|| ModernAudio::new(SAMPLE_RATE));
524            if modern
525                .play_sfx_bytes(track.bytes.clone(), Some(&track.ext), ModernPlayOptions::default())
526                .is_err()
527            {
528                log::warn!("audio: failed to decode sfx track '{id}'");
529            }
530        }
531        #[cfg(not(feature = "modern-audio"))]
532        {
533            log::warn!("audio: sfx track '{id}' exists but modern-audio is not enabled");
534        }
535    }
536
537    /// Stop all background music immediately (chiptune and file audio).
538    pub fn stop_music(&mut self) {
539        let Some(engine) = &self.engine else {
540            return;
541        };
542        let mut e = engine.lock().unwrap();
543        e.seq.stop_music();
544        e.fade = Fade::None;
545        e.master_volume = FULL_VOLUME;
546        e.current_music = None;
547        #[cfg(feature = "modern-audio")]
548        {
549            if let Some(modern) = &mut e.modern {
550                modern.stop_music(None);
551            }
552            e.modern_music = None;
553        }
554    }
555
556    /// Begin fading the current music out to silence, then stop it. No-op if
557    /// no music is playing or a fade is already under way. Fades whichever
558    /// kind of music is actually playing (chiptune fades through the APU
559    /// master volume; file music fades through the modern mixer).
560    pub fn fade_out_music(&mut self) {
561        let Some(engine) = &self.engine else {
562            return;
563        };
564        let mut e = engine.lock().unwrap();
565        #[cfg(feature = "modern-audio")]
566        if e.modern_music.is_some() {
567            if let Some(modern) = &mut e.modern {
568                modern.stop_music(Some(MODERN_FADE_SECS));
569            }
570            e.modern_music = None;
571            return;
572        }
573        if e.current_music.is_none() || matches!(e.fade, Fade::Out { .. }) {
574            return;
575        }
576        e.fade = Fade::Out {
577            counter: FADE_STEP_FRAMES,
578            reload: FADE_STEP_FRAMES,
579        };
580    }
581}
582
583/// Fade-out duration for modern file music (≈ the chiptune fade's ~1.2 s).
584#[cfg(feature = "modern-audio")]
585const MODERN_FADE_SECS: f32 = 1.2;
586
587/// Load every `*.json` track under `prefix` (recursively) through the VFS
588/// into an [`AudioLibrary`] — the [`ProjectFiles`] counterpart of
589/// `AudioLibrary::load_dir`. A missing prefix yields an empty library; a
590/// track that fails to parse aborts the whole load with an error naming the
591/// file (same bar as the disk loader).
592fn load_library(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<AudioLibrary> {
593    let mut lib = AudioLibrary::new();
594    for path in files.list(prefix) {
595        if path.rsplit('.').next() != Some("json") {
596            continue;
597        }
598        let bytes = files.read(&path)?;
599        let track: TrackDef = serde_json::from_slice(&bytes)
600            .map_err(|e| anyhow::anyhow!("{path}: {e}"))?;
601        lib.insert(track);
602    }
603    Ok(lib)
604}
605
606/// File extensions the modern decoder can play.
607#[cfg(feature = "modern-audio")]
608const AUDIO_FILE_EXTS: [&str; 4] = ["wav", "ogg", "flac", "mp3"];
609
610/// Load every playable audio file under `prefix` (recursively) through the
611/// VFS. Track ids are the extension-stripped `prefix`-relative path, e.g.
612/// `"data/audio/music/town.ogg"` → `"music/town"`.
613#[cfg(feature = "modern-audio")]
614fn load_file_tracks(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<BTreeMap<String, FileTrack>> {
615    let mut out = BTreeMap::new();
616    for path in files.list(prefix) {
617        let ext = match path.rsplit('.').next() {
618            Some(e) if AUDIO_FILE_EXTS.contains(&e) => e.to_string(),
619            _ => continue,
620        };
621        let bytes = files.read(&path)?;
622        let id = path
623            .strip_prefix(&format!("{prefix}/"))
624            .unwrap_or(&path)
625            .trim_end_matches(&format!(".{ext}"))
626            .to_string();
627        out.insert(id, FileTrack { bytes, ext });
628    }
629    Ok(out)
630}
631
632#[cfg(test)]
633mod tests {
634    use super::*;
635    use crate::vfs::MemoryFiles;
636
637    /// A minimal valid music track (dotzuki-audio `TrackDef` JSON).
638    const THEME_JSON: &str = r#"{
639      "id": "theme",
640      "kind": "music",
641      "tempo": 256,
642      "channels": [
643        {
644          "hw": "pulse1",
645          "commands": [
646            { "type": "note_type", "speed": 12, "param": 197 },
647            { "type": "octave", "value": 5 },
648            { "type": "note", "pitch": 0, "length": 4 },
649            { "type": "rest", "length": 4 },
650            { "type": "sound_ret" }
651          ]
652        }
653      ]
654    }"#;
655
656    /// A `RunnerAudio` in PCM render mode with a one-track library and no
657    /// device access — the WASM shell setup.
658    fn pcm_audio() -> RunnerAudio {
659        let track: TrackDef = serde_json::from_str(THEME_JSON).unwrap();
660        let mut library = AudioLibrary::new();
661        library.insert(track);
662        RunnerAudio {
663            library,
664            #[cfg(feature = "modern-audio")]
665            file_tracks: BTreeMap::new(),
666            allow_device: false,
667            pcm_render: true,
668            engine: None,
669            stream: None,
670            init_failed: false,
671            warned_ids: HashSet::new(),
672        }
673    }
674
675    #[test]
676    fn fade_none_is_inert() {
677        assert_eq!(step_fade(Fade::None, 5), (Fade::None, 5, false));
678    }
679
680    #[test]
681    fn fade_counts_down_then_steps_volume() {
682        // reload 2: counter 2 → 1 → 0 (holds volume), then the 0 tick steps it.
683        let (f, v, done) = step_fade(Fade::Out { counter: 2, reload: 2 }, 7);
684        assert_eq!((f, v, done), (Fade::Out { counter: 1, reload: 2 }, 7, false));
685        let (f, v, done) = step_fade(f, v);
686        assert_eq!((f, v, done), (Fade::Out { counter: 0, reload: 2 }, 7, false));
687        let (f, v, done) = step_fade(f, v);
688        assert_eq!((f, v, done), (Fade::Out { counter: 2, reload: 2 }, 6, false));
689    }
690
691    #[test]
692    fn fade_completes_and_restores_full_volume() {
693        // Drive a whole fade from full volume with the fastest reload.
694        let mut fade = Fade::Out { counter: 0, reload: 0 };
695        let mut vol = FULL_VOLUME;
696        let mut completed = false;
697        for _ in 0..64 {
698            let (f, v, done) = step_fade(fade, vol);
699            fade = f;
700            vol = v;
701            if done {
702                completed = true;
703                break;
704            }
705        }
706        assert!(completed, "fade never completed");
707        assert_eq!(fade, Fade::None);
708        assert_eq!(vol, FULL_VOLUME, "volume should reset to full after a fade");
709    }
710
711    #[test]
712    fn render_samples_is_empty_before_any_play() {
713        let mut audio = pcm_audio();
714        assert!(audio.render_samples(4410).is_empty());
715        // Silent (non-PCM) mode never creates an engine either.
716        let mut silent = RunnerAudio {
717            pcm_render: false,
718            ..pcm_audio()
719        };
720        silent.play_music("theme");
721        assert!(silent.render_samples(4410).is_empty());
722        assert!(!silent.has_output());
723    }
724
725    #[test]
726    fn pcm_play_renders_nonzero_samples_without_a_device() {
727        let mut audio = pcm_audio();
728        audio.play_music("theme");
729        assert!(!audio.has_output(), "pcm mode must not open a device");
730        assert!(audio.engine.is_some(), "pcm mode creates the engine on play");
731
732        // A few video frames so the sequencer triggers the first note.
733        for _ in 0..5 {
734            audio.update_frame();
735        }
736        let pcm = audio.render_samples(4410);
737        assert_eq!(pcm.len(), 8820, "stereo frames: 2 * frames");
738        assert!(
739            pcm.iter().any(|s| *s != 0.0),
740            "playing music must render non-silent samples"
741        );
742    }
743
744    #[test]
745    fn pcm_play_music_dedups_like_native() {
746        let mut audio = pcm_audio();
747        audio.play_music("theme");
748        audio.update_frame();
749        {
750            let e = audio.engine.as_ref().unwrap().lock().unwrap();
751            assert_eq!(e.current_music.as_deref(), Some("theme"));
752        }
753        // Re-requesting the same track must not restart it…
754        audio.play_music("theme");
755        let e = audio.engine.as_ref().unwrap().lock().unwrap();
756        assert_eq!(e.current_music.as_deref(), Some("theme"));
757        assert_eq!(e.master_volume, FULL_VOLUME);
758        assert_eq!(e.fade, Fade::None);
759    }
760
761    #[test]
762    fn pcm_fade_out_completes_and_stops_music() {
763        let mut audio = pcm_audio();
764        audio.play_music("theme");
765        for _ in 0..5 {
766            audio.update_frame();
767        }
768        assert!(audio.render_samples(4410).iter().any(|s| *s != 0.0));
769
770        audio.fade_out_music();
771        // FADE_STEP_FRAMES * 7 volume steps plus slack to run the fade out.
772        for _ in 0..200 {
773            audio.update_frame();
774        }
775        let e = audio.engine.as_ref().unwrap().lock().unwrap();
776        assert_eq!(e.fade, Fade::None, "fade state machine completed");
777        assert!(e.current_music.is_none(), "music stopped after fade-out");
778        assert_eq!(e.master_volume, FULL_VOLUME, "volume restored for next track");
779    }
780
781    // ── Modern file audio (feature `modern-audio`) ───────────────────────
782
783    /// A tiny 16-bit PCM mono WAV (the modern file-audio counterpart of
784    /// `THEME_JSON`), so file-track tests never touch the real device.
785    #[cfg(feature = "modern-audio")]
786    fn test_wav_bytes() -> Vec<u8> {
787        let rate = 8000u32;
788        let seconds = 1u32;
789        let samples = rate * seconds;
790        let data_len = samples * 2;
791        let mut wav = Vec::with_capacity(44 + data_len as usize);
792        wav.extend_from_slice(b"RIFF");
793        wav.extend_from_slice(&(36 + data_len).to_le_bytes());
794        wav.extend_from_slice(b"WAVE");
795        wav.extend_from_slice(b"fmt ");
796        wav.extend_from_slice(&16u32.to_le_bytes());
797        wav.extend_from_slice(&1u16.to_le_bytes());
798        wav.extend_from_slice(&1u16.to_le_bytes());
799        wav.extend_from_slice(&rate.to_le_bytes());
800        wav.extend_from_slice(&(rate * 2).to_le_bytes());
801        wav.extend_from_slice(&2u16.to_le_bytes());
802        wav.extend_from_slice(&16u16.to_le_bytes());
803        wav.extend_from_slice(b"data");
804        wav.extend_from_slice(&data_len.to_le_bytes());
805        for i in 0..samples {
806            let v = (i as f32 * 2.0 * std::f32::consts::PI * 440.0 / rate as f32).sin();
807            wav.extend_from_slice(&((v * 32000.0) as i16).to_le_bytes());
808        }
809        wav
810    }
811
812    /// A `RunnerAudio` loaded from an in-memory project containing one file
813    /// track at `data/audio/music/town.wav` (PCM render, no device).
814    #[cfg(feature = "modern-audio")]
815    fn file_audio() -> RunnerAudio {
816        use std::collections::HashMap;
817        let mut map = HashMap::new();
818        map.insert(
819            "data/audio/music/town.wav".to_string(),
820            test_wav_bytes(),
821        );
822        let files = MemoryFiles::from(map);
823        let mut audio = RunnerAudio::from_files(&files, "data", false);
824        audio.set_pcm_render(true);
825        audio
826    }
827
828    #[cfg(feature = "modern-audio")]
829    #[test]
830    fn file_tracks_load_with_path_ids() {
831        let audio = file_audio();
832        assert!(audio.has_track("music/town"), "extension-stripped path id");
833        assert!(!audio.has_track("town"), "no bare filename ids");
834    }
835
836    #[cfg(feature = "modern-audio")]
837    #[test]
838    fn file_music_plays_and_renders_audio() {
839        let mut audio = file_audio();
840        audio.play_music("music/town");
841        assert!(audio.engine.is_some(), "engine created on play");
842        for _ in 0..3 {
843            audio.update_frame();
844        }
845        let pcm = audio.render_samples(8000);
846        assert_eq!(pcm.len(), 16_000, "stereo: 2 * frames");
847        assert!(
848            pcm.iter().any(|s| *s != 0.0),
849            "file BGM must render non-silent samples"
850        );
851        // Still playing after one full second (loops).
852        let e = audio.engine.as_ref().unwrap().lock().unwrap();
853        assert_eq!(e.modern_music.as_deref(), Some("music/town"));
854    }
855
856    #[cfg(feature = "modern-audio")]
857    #[test]
858    fn file_music_dedups_and_stops() {
859        let mut audio = file_audio();
860        audio.play_music("music/town");
861        audio.play_music("music/town"); // must not restart
862        {
863            let e = audio.engine.as_ref().unwrap().lock().unwrap();
864            assert_eq!(e.modern_music.as_deref(), Some("music/town"));
865            assert_eq!(e.modern.as_ref().unwrap().active_voices(), 1);
866        } // drop the guard before mutating audio again
867
868        audio.stop_music();
869        let e = audio.engine.as_ref().unwrap().lock().unwrap();
870        assert!(e.modern_music.is_none(), "file BGM slot freed");
871    }
872
873    #[cfg(feature = "modern-audio")]
874    #[test]
875    fn file_sfx_one_shot_finishes() {
876        let mut audio = file_audio();
877        audio.play_sound("music/town"); // reuse the wav as a one-shot sfx
878        for _ in 0..5 {
879            audio.update_frame();
880        }
881        let e = audio.engine.as_ref().unwrap().lock().unwrap();
882        // SFX plays on its own bus; BGM slot stays empty.
883        assert!(e.modern_music.is_none());
884        assert_eq!(e.modern.as_ref().unwrap().active_voices(), 1);
885    }
886
887    #[cfg(feature = "modern-audio")]
888    #[test]
889    fn json_track_wins_over_file_track() {
890        use std::collections::HashMap;
891        let mut map = HashMap::new();
892        map.insert("data/audio/theme.json".to_string(), THEME_JSON.as_bytes().to_vec());
893        map.insert("data/audio/theme.wav".to_string(), test_wav_bytes());
894        let files = MemoryFiles::from(map);
895        let mut audio = RunnerAudio::from_files(&files, "data", false);
896        audio.set_pcm_render(true);
897        // Same id "theme" in both libraries → JSON wins.
898        audio.play_music("theme");
899        let e = audio.engine.as_ref().unwrap().lock().unwrap();
900        assert_eq!(e.current_music.as_deref(), Some("theme"), "JSON track played");
901        assert!(e.modern_music.is_none(), "file track not used");
902    }
903}