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