1use 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
62const SAMPLE_RATE: u32 = dotzuki_audio::SAMPLE_RATE;
66
67const FULL_VOLUME: u8 = 7;
69
70const FADE_STEP_FRAMES: u8 = 10;
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77enum Fade {
78 None,
79 Out {
81 counter: u8,
82 reload: u8,
83 },
84}
85
86fn step_fade(fade: Fade, master_volume: u8) -> (Fade, u8, bool) {
93 match fade {
94 Fade::None => (Fade::None, master_volume, false),
95 Fade::Out { counter: 0, reload } => {
97 let mv = master_volume.saturating_sub(1);
98 if mv == 0 {
99 (Fade::None, FULL_VOLUME, true) } else {
101 (
102 Fade::Out {
103 counter: reload,
104 reload,
105 },
106 mv,
107 false,
108 )
109 }
110 }
111 Fade::Out { counter, reload } => (
113 Fade::Out {
114 counter: counter - 1,
115 reload,
116 },
117 master_volume,
118 false,
119 ),
120 }
121}
122
123struct Engine {
126 seq: Sequencer,
127 apu: Apu,
128 master_volume: u8,
131 fade: Fade,
132 current_music: Option<String>,
135 #[cfg(feature = "modern-audio")]
137 modern: Option<ModernAudio>,
138 #[cfg(feature = "modern-audio")]
140 modern_music: Option<String>,
141 #[cfg(feature = "modern-audio")]
144 mix_buf: Vec<f32>,
145}
146
147fn 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
164fn update_engine_frame(e: &mut Engine) {
167 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 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 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
203fn new_engine() -> Arc<Mutex<Engine>> {
205 let mut apu = Apu::new();
206 apu.write_register(0xFF26, 0x80); 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
222fn 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#[cfg(feature = "modern-audio")]
236struct FileTrack {
237 bytes: Vec<u8>,
238 ext: String,
239}
240
241pub struct RunnerAudio {
246 library: AudioLibrary,
247 #[cfg(feature = "modern-audio")]
250 file_tracks: BTreeMap<String, FileTrack>,
251 allow_device: bool,
253 pcm_render: bool,
256 engine: Option<Arc<Mutex<Engine>>>,
259 stream: Option<CpalOutput>,
262 init_failed: bool,
264 warned_ids: HashSet<String>,
266}
267
268impl RunnerAudio {
269 pub fn new(data_root: &Path, allow_device: bool) -> Self {
273 Self::from_files(&DiskFiles::new(data_root), "", allow_device)
274 }
275
276 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 pub fn track_count(&self) -> usize {
316 self.library.len()
317 }
318
319 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 pub fn has_output(&self) -> bool {
336 self.stream.is_some()
337 }
338
339 pub fn set_pcm_render(&mut self, on: bool) {
344 self.pcm_render = on;
345 }
346
347 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 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 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 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 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 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 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 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 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 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#[cfg(feature = "modern-audio")]
577const MODERN_FADE_SECS: f32 = 1.2;
578
579fn 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#[cfg(feature = "modern-audio")]
600const AUDIO_FILE_EXTS: [&str; 4] = ["wav", "ogg", "flac", "mp3"];
601
602#[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 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 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 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 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 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 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 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 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 #[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 #[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 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"); {
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 } 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"); for _ in 0..5 {
916 audio.update_frame();
917 }
918 let e = audio.engine.as_ref().unwrap().lock().unwrap();
919 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 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}