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