1#[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
66const SAMPLE_RATE: u32 = dotzuki_audio::SAMPLE_RATE;
70
71const FULL_VOLUME: u8 = 7;
73
74const FADE_STEP_FRAMES: u8 = 10;
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Fade {
82 None,
83 Out {
85 counter: u8,
86 reload: u8,
87 },
88}
89
90fn step_fade(fade: Fade, master_volume: u8) -> (Fade, u8, bool) {
97 match fade {
98 Fade::None => (Fade::None, master_volume, false),
99 Fade::Out { counter: 0, reload } => {
101 let mv = master_volume.saturating_sub(1);
102 if mv == 0 {
103 (Fade::None, FULL_VOLUME, true) } else {
105 (
106 Fade::Out {
107 counter: reload,
108 reload,
109 },
110 mv,
111 false,
112 )
113 }
114 }
115 Fade::Out { counter, reload } => (
117 Fade::Out {
118 counter: counter - 1,
119 reload,
120 },
121 master_volume,
122 false,
123 ),
124 }
125}
126
127struct Engine {
130 seq: Sequencer,
131 apu: Apu,
132 master_volume: u8,
135 fade: Fade,
136 current_music: Option<String>,
139 #[cfg(feature = "modern-audio")]
141 modern: Option<ModernAudio>,
142 #[cfg(feature = "modern-audio")]
144 modern_music: Option<String>,
145 #[cfg(feature = "modern-audio")]
148 mix_buf: Vec<f32>,
149}
150
151fn 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
168fn update_engine_frame(e: &mut Engine) {
171 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 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 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
207fn new_engine() -> Arc<Mutex<Engine>> {
209 let mut apu = Apu::new();
210 apu.write_register(0xFF26, 0x80); 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#[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#[cfg(feature = "modern-audio")]
241struct FileTrack {
242 bytes: Vec<u8>,
243 ext: String,
244}
245
246pub struct RunnerAudio {
251 library: AudioLibrary,
252 #[cfg(feature = "modern-audio")]
255 file_tracks: BTreeMap<String, FileTrack>,
256 allow_device: bool,
258 pcm_render: bool,
261 engine: Option<Arc<Mutex<Engine>>>,
264 #[cfg(feature = "device-audio")]
267 stream: Option<CpalOutput>,
268 init_failed: bool,
270 warned_ids: HashSet<String>,
272}
273
274impl RunnerAudio {
275 pub fn new(data_root: &Path, allow_device: bool) -> Self {
279 Self::from_files(&DiskFiles::new(data_root), "", allow_device)
280 }
281
282 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 pub fn track_count(&self) -> usize {
323 self.library.len()
324 }
325
326 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 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 pub fn set_pcm_render(&mut self, on: bool) {
358 self.pcm_render = on;
359 }
360
361 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 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 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 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 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 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 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 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 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 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#[cfg(feature = "modern-audio")]
600const MODERN_FADE_SECS: f32 = 1.2;
601
602fn 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#[cfg(feature = "modern-audio")]
623const AUDIO_FILE_EXTS: [&str; 4] = ["wav", "ogg", "flac", "mp3"];
624
625#[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 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 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 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 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 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 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 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 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 #[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 #[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 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"); {
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 } 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"); for _ in 0..5 {
941 audio.update_frame();
942 }
943 let e = audio.engine.as_ref().unwrap().lock().unwrap();
944 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 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}