1use std::collections::{BTreeMap, HashSet};
48use std::path::Path;
49use std::sync::{Arc, Mutex};
50
51use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
52
53use dotzuki_audio::apu::Apu;
54use dotzuki_audio::format::TrackDef;
55use dotzuki_audio::library::AudioLibrary;
56use dotzuki_audio::sequencer::Sequencer;
57use dotzuki_audio::CPU_CLOCK_HZ;
58
59#[cfg(feature = "modern-audio")]
60use dotzuki_audio::modern::{Bus as ModernBus, ModernAudio, PlayOptions as ModernPlayOptions};
61
62use crate::vfs::{join_path, DiskFiles, ProjectFiles};
63
64const SAMPLE_RATE: u32 = 44_100;
67
68const MAX_AMPLITUDE: f32 = 480.0;
70
71const FULL_VOLUME: u8 = 7;
73
74const FADE_STEP_FRAMES: u8 = 10;
78
79const CYCLES_PER_SAMPLE: u32 = CPU_CLOCK_HZ / SAMPLE_RATE;
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
85enum Fade {
86 None,
87 Out { counter: u8, reload: u8 },
89}
90
91fn step_fade(fade: Fade, master_volume: u8) -> (Fade, u8, bool) {
98 match fade {
99 Fade::None => (Fade::None, master_volume, false),
100 Fade::Out { counter: 0, reload } => {
102 let mv = master_volume.saturating_sub(1);
103 if mv == 0 {
104 (Fade::None, FULL_VOLUME, true) } else {
106 (Fade::Out { counter: reload, reload }, mv, false)
107 }
108 }
109 Fade::Out { counter, reload } => {
111 (Fade::Out { counter: counter - 1, reload }, master_volume, false)
112 }
113 }
114}
115
116struct Engine {
119 seq: Sequencer,
120 apu: Apu,
121 master_volume: u8,
124 fade: Fade,
125 current_music: Option<String>,
128 #[cfg(feature = "modern-audio")]
130 modern: Option<ModernAudio>,
131 #[cfg(feature = "modern-audio")]
133 modern_music: Option<String>,
134 #[cfg(feature = "modern-audio")]
137 mix_buf: Vec<f32>,
138}
139
140fn render_into(e: &mut Engine, data: &mut [f32]) {
145 for frame in data.chunks_mut(2) {
146 e.apu.tick_n(CYCLES_PER_SAMPLE);
147 let (left, right) = e.apu.mix_sample();
148 frame[0] = left as f32 / MAX_AMPLITUDE;
149 frame[1] = right as f32 / MAX_AMPLITUDE;
150 }
151 #[cfg(feature = "modern-audio")]
152 if let Some(modern) = &mut e.modern {
153 e.mix_buf.resize(data.len(), 0.0);
154 e.mix_buf.fill(0.0);
155 modern.render_into(&mut e.mix_buf);
156 for (out, modern_s) in data.iter_mut().zip(e.mix_buf.iter()) {
157 *out += *modern_s;
158 }
159 }
160}
161
162fn update_engine_frame(e: &mut Engine) {
165 let (fade, mv, completed) = step_fade(e.fade, e.master_volume);
167 e.fade = fade;
168 e.master_volume = mv;
169 if completed {
170 e.seq.stop_music();
171 e.current_music = None;
172 }
173
174 #[cfg(feature = "modern-audio")]
175 {
176 if completed {
180 if let Some(modern) = &mut e.modern {
181 modern.stop_music(None);
182 }
183 e.modern_music = None;
184 }
185 }
186
187 let Engine {
191 seq,
192 apu,
193 master_volume,
194 ..
195 } = e;
196 seq.update_frame(apu);
197 let v = *master_volume & 0x07;
198 apu.write_register(0xFF24, (v << 4) | v);
199}
200
201fn new_engine() -> Arc<Mutex<Engine>> {
203 let mut apu = Apu::new();
204 apu.write_register(0xFF26, 0x80); Arc::new(Mutex::new(Engine {
206 seq: Sequencer::new(),
207 apu,
208 master_volume: FULL_VOLUME,
209 fade: Fade::None,
210 current_music: None,
211 #[cfg(feature = "modern-audio")]
212 modern: None,
213 #[cfg(feature = "modern-audio")]
214 modern_music: None,
215 #[cfg(feature = "modern-audio")]
216 mix_buf: Vec::new(),
217 }))
218}
219
220fn open_stream(engine: Arc<Mutex<Engine>>) -> Option<cpal::Stream> {
225 let host = cpal::default_host();
226 let device = host.default_output_device()?;
227 let config = cpal::StreamConfig {
228 channels: 2,
229 sample_rate: cpal::SampleRate(SAMPLE_RATE),
230 buffer_size: cpal::BufferSize::Default,
231 };
232
233 let cb = engine;
234 let stream = device
235 .build_output_stream(
236 &config,
237 move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
238 let mut e = cb.lock().unwrap();
239 render_into(&mut e, data);
240 },
241 |err| log::error!("audio stream error: {err}"),
242 None,
243 )
244 .ok()?;
245 stream.play().ok()?;
246
247 Some(stream)
248}
249
250#[cfg(feature = "modern-audio")]
253struct FileTrack {
254 bytes: Vec<u8>,
255 ext: String,
256}
257
258pub struct RunnerAudio {
263 library: AudioLibrary,
264 #[cfg(feature = "modern-audio")]
267 file_tracks: BTreeMap<String, FileTrack>,
268 allow_device: bool,
270 pcm_render: bool,
273 engine: Option<Arc<Mutex<Engine>>>,
276 stream: Option<cpal::Stream>,
279 init_failed: bool,
281 warned_ids: HashSet<String>,
283}
284
285impl RunnerAudio {
286 pub fn new(data_root: &Path, allow_device: bool) -> Self {
290 Self::from_files(&DiskFiles::new(data_root), "", allow_device)
291 }
292
293 pub fn from_files(files: &dyn ProjectFiles, data_root_rel: &str, allow_device: bool) -> Self {
298 let prefix = join_path(data_root_rel, "audio");
299 let library = load_library(files, &prefix).unwrap_or_else(|e| {
300 log::warn!("audio: failed to load {prefix}: {e:#}");
301 AudioLibrary::new()
302 });
303 if !library.is_empty() {
304 log::info!("audio: loaded {} track(s) from {prefix}", library.len());
305 }
306 #[cfg(feature = "modern-audio")]
307 let file_tracks = load_file_tracks(files, &prefix).unwrap_or_else(|e| {
308 log::warn!("audio: failed to load file tracks from {prefix}: {e:#}");
309 BTreeMap::new()
310 });
311 #[cfg(feature = "modern-audio")]
312 if !file_tracks.is_empty() {
313 log::info!(
314 "audio: loaded {} file track(s) from {prefix}",
315 file_tracks.len()
316 );
317 }
318 Self {
319 library,
320 #[cfg(feature = "modern-audio")]
321 file_tracks,
322 allow_device,
323 pcm_render: false,
324 engine: None,
325 stream: None,
326 init_failed: false,
327 warned_ids: HashSet::new(),
328 }
329 }
330
331 pub fn track_count(&self) -> usize {
333 self.library.len()
334 }
335
336 pub fn has_track(&self, id: &str) -> bool {
339 self.library.get(id).is_some()
340 || {
341 #[cfg(feature = "modern-audio")]
342 {
343 self.file_tracks.contains_key(id)
344 }
345 #[cfg(not(feature = "modern-audio"))]
346 {
347 false
348 }
349 }
350 }
351
352 pub fn has_output(&self) -> bool {
354 self.stream.is_some()
355 }
356
357 pub fn set_pcm_render(&mut self, on: bool) {
362 self.pcm_render = on;
363 }
364
365 pub fn render_samples(&mut self, frames: usize) -> Vec<f32> {
374 let Some(engine) = &self.engine else {
375 return Vec::new();
376 };
377 let mut e = engine.lock().unwrap();
378 let mut data = vec![0.0; frames * 2];
379 render_into(&mut e, &mut data);
380 data
381 }
382
383 fn engine(&mut self) -> Option<Arc<Mutex<Engine>>> {
389 if let Some(engine) = &self.engine {
390 return Some(Arc::clone(engine));
391 }
392 if self.library.is_empty()
393 && {
394 #[cfg(feature = "modern-audio")]
395 {
396 self.file_tracks.is_empty()
397 }
398 #[cfg(not(feature = "modern-audio"))]
399 {
400 true
401 }
402 }
403 {
404 return None;
405 }
406 if self.pcm_render {
407 log::info!("audio: pcm render mode — engine started without an output device");
408 let engine = new_engine();
409 self.engine = Some(Arc::clone(&engine));
410 return Some(engine);
411 }
412 if !self.allow_device || self.init_failed {
413 return None;
414 }
415 let engine = new_engine();
416 match open_stream(Arc::clone(&engine)) {
417 Some(stream) => {
418 log::info!("audio: output stream started");
419 self.stream = Some(stream);
420 self.engine = Some(Arc::clone(&engine));
421 Some(engine)
422 }
423 None => {
424 log::warn!("audio: no output device — sound disabled, continuing silent");
425 self.init_failed = true;
426 None
427 }
428 }
429 }
430
431 fn warn_unknown(&mut self, kind: &str, id: &str) {
433 if self.warned_ids.insert(id.to_string()) {
434 log::warn!("audio: no {kind} track '{id}'");
435 }
436 }
437
438 pub fn update_frame(&mut self) {
443 if let Some(engine) = &self.engine {
444 update_engine_frame(&mut engine.lock().unwrap());
445 }
446 }
447
448 pub fn play_music(&mut self, id: &str) {
456 if !self.has_track(id) {
457 self.warn_unknown("music", id);
458 return;
459 }
460 let Some(engine) = self.engine() else {
461 return;
462 };
463 let mut e = engine.lock().unwrap();
464 if self.library.get(id).is_some() {
465 if e.current_music.as_deref() == Some(id) {
466 return;
467 }
468 e.current_music = Some(id.to_string());
469 e.fade = Fade::None;
470 e.master_volume = FULL_VOLUME;
471 self.library.play(&mut e.seq, id);
473 return;
474 }
475 #[cfg(feature = "modern-audio")]
476 {
477 if e.modern_music.as_deref() == Some(id) {
478 return;
479 }
480 let Some(track) = self.file_tracks.get(id) else {
481 return;
482 };
483 e.seq.stop_music();
486 e.current_music = None;
487 let modern = e.modern.get_or_insert_with(|| ModernAudio::new(SAMPLE_RATE));
488 if modern
489 .play_music_bytes(track.bytes.clone(), Some(&track.ext), ModernPlayOptions::default())
490 .is_ok()
491 {
492 e.modern_music = Some(id.to_string());
493 } else {
494 log::warn!("audio: failed to decode music track '{id}'");
495 }
496 }
497 #[cfg(not(feature = "modern-audio"))]
498 {
499 log::warn!("audio: music track '{id}' exists but modern-audio is not enabled");
500 }
501 }
502
503 pub fn play_sound(&mut self, id: &str) {
506 if !self.has_track(id) {
507 self.warn_unknown("sfx", id);
508 return;
509 }
510 let Some(engine) = self.engine() else {
511 return;
512 };
513 let mut e = engine.lock().unwrap();
514 if self.library.get(id).is_some() {
515 self.library.play(&mut e.seq, id);
516 return;
517 }
518 #[cfg(feature = "modern-audio")]
519 {
520 let Some(track) = self.file_tracks.get(id) else {
521 return;
522 };
523 let modern = e.modern.get_or_insert_with(|| ModernAudio::new(SAMPLE_RATE));
524 if modern
525 .play_sfx_bytes(track.bytes.clone(), Some(&track.ext), ModernPlayOptions::default())
526 .is_err()
527 {
528 log::warn!("audio: failed to decode sfx track '{id}'");
529 }
530 }
531 #[cfg(not(feature = "modern-audio"))]
532 {
533 log::warn!("audio: sfx track '{id}' exists but modern-audio is not enabled");
534 }
535 }
536
537 pub fn stop_music(&mut self) {
539 let Some(engine) = &self.engine else {
540 return;
541 };
542 let mut e = engine.lock().unwrap();
543 e.seq.stop_music();
544 e.fade = Fade::None;
545 e.master_volume = FULL_VOLUME;
546 e.current_music = None;
547 #[cfg(feature = "modern-audio")]
548 {
549 if let Some(modern) = &mut e.modern {
550 modern.stop_music(None);
551 }
552 e.modern_music = None;
553 }
554 }
555
556 pub fn fade_out_music(&mut self) {
561 let Some(engine) = &self.engine else {
562 return;
563 };
564 let mut e = engine.lock().unwrap();
565 #[cfg(feature = "modern-audio")]
566 if e.modern_music.is_some() {
567 if let Some(modern) = &mut e.modern {
568 modern.stop_music(Some(MODERN_FADE_SECS));
569 }
570 e.modern_music = None;
571 return;
572 }
573 if e.current_music.is_none() || matches!(e.fade, Fade::Out { .. }) {
574 return;
575 }
576 e.fade = Fade::Out {
577 counter: FADE_STEP_FRAMES,
578 reload: FADE_STEP_FRAMES,
579 };
580 }
581}
582
583#[cfg(feature = "modern-audio")]
585const MODERN_FADE_SECS: f32 = 1.2;
586
587fn load_library(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<AudioLibrary> {
593 let mut lib = AudioLibrary::new();
594 for path in files.list(prefix) {
595 if path.rsplit('.').next() != Some("json") {
596 continue;
597 }
598 let bytes = files.read(&path)?;
599 let track: TrackDef = serde_json::from_slice(&bytes)
600 .map_err(|e| anyhow::anyhow!("{path}: {e}"))?;
601 lib.insert(track);
602 }
603 Ok(lib)
604}
605
606#[cfg(feature = "modern-audio")]
608const AUDIO_FILE_EXTS: [&str; 4] = ["wav", "ogg", "flac", "mp3"];
609
610#[cfg(feature = "modern-audio")]
614fn load_file_tracks(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<BTreeMap<String, FileTrack>> {
615 let mut out = BTreeMap::new();
616 for path in files.list(prefix) {
617 let ext = match path.rsplit('.').next() {
618 Some(e) if AUDIO_FILE_EXTS.contains(&e) => e.to_string(),
619 _ => continue,
620 };
621 let bytes = files.read(&path)?;
622 let id = path
623 .strip_prefix(&format!("{prefix}/"))
624 .unwrap_or(&path)
625 .trim_end_matches(&format!(".{ext}"))
626 .to_string();
627 out.insert(id, FileTrack { bytes, ext });
628 }
629 Ok(out)
630}
631
632#[cfg(test)]
633mod tests {
634 use super::*;
635 use crate::vfs::MemoryFiles;
636
637 const THEME_JSON: &str = r#"{
639 "id": "theme",
640 "kind": "music",
641 "tempo": 256,
642 "channels": [
643 {
644 "hw": "pulse1",
645 "commands": [
646 { "type": "note_type", "speed": 12, "param": 197 },
647 { "type": "octave", "value": 5 },
648 { "type": "note", "pitch": 0, "length": 4 },
649 { "type": "rest", "length": 4 },
650 { "type": "sound_ret" }
651 ]
652 }
653 ]
654 }"#;
655
656 fn pcm_audio() -> RunnerAudio {
659 let track: TrackDef = serde_json::from_str(THEME_JSON).unwrap();
660 let mut library = AudioLibrary::new();
661 library.insert(track);
662 RunnerAudio {
663 library,
664 #[cfg(feature = "modern-audio")]
665 file_tracks: BTreeMap::new(),
666 allow_device: false,
667 pcm_render: true,
668 engine: None,
669 stream: None,
670 init_failed: false,
671 warned_ids: HashSet::new(),
672 }
673 }
674
675 #[test]
676 fn fade_none_is_inert() {
677 assert_eq!(step_fade(Fade::None, 5), (Fade::None, 5, false));
678 }
679
680 #[test]
681 fn fade_counts_down_then_steps_volume() {
682 let (f, v, done) = step_fade(Fade::Out { counter: 2, reload: 2 }, 7);
684 assert_eq!((f, v, done), (Fade::Out { counter: 1, reload: 2 }, 7, false));
685 let (f, v, done) = step_fade(f, v);
686 assert_eq!((f, v, done), (Fade::Out { counter: 0, reload: 2 }, 7, false));
687 let (f, v, done) = step_fade(f, v);
688 assert_eq!((f, v, done), (Fade::Out { counter: 2, reload: 2 }, 6, false));
689 }
690
691 #[test]
692 fn fade_completes_and_restores_full_volume() {
693 let mut fade = Fade::Out { counter: 0, reload: 0 };
695 let mut vol = FULL_VOLUME;
696 let mut completed = false;
697 for _ in 0..64 {
698 let (f, v, done) = step_fade(fade, vol);
699 fade = f;
700 vol = v;
701 if done {
702 completed = true;
703 break;
704 }
705 }
706 assert!(completed, "fade never completed");
707 assert_eq!(fade, Fade::None);
708 assert_eq!(vol, FULL_VOLUME, "volume should reset to full after a fade");
709 }
710
711 #[test]
712 fn render_samples_is_empty_before_any_play() {
713 let mut audio = pcm_audio();
714 assert!(audio.render_samples(4410).is_empty());
715 let mut silent = RunnerAudio {
717 pcm_render: false,
718 ..pcm_audio()
719 };
720 silent.play_music("theme");
721 assert!(silent.render_samples(4410).is_empty());
722 assert!(!silent.has_output());
723 }
724
725 #[test]
726 fn pcm_play_renders_nonzero_samples_without_a_device() {
727 let mut audio = pcm_audio();
728 audio.play_music("theme");
729 assert!(!audio.has_output(), "pcm mode must not open a device");
730 assert!(audio.engine.is_some(), "pcm mode creates the engine on play");
731
732 for _ in 0..5 {
734 audio.update_frame();
735 }
736 let pcm = audio.render_samples(4410);
737 assert_eq!(pcm.len(), 8820, "stereo frames: 2 * frames");
738 assert!(
739 pcm.iter().any(|s| *s != 0.0),
740 "playing music must render non-silent samples"
741 );
742 }
743
744 #[test]
745 fn pcm_play_music_dedups_like_native() {
746 let mut audio = pcm_audio();
747 audio.play_music("theme");
748 audio.update_frame();
749 {
750 let e = audio.engine.as_ref().unwrap().lock().unwrap();
751 assert_eq!(e.current_music.as_deref(), Some("theme"));
752 }
753 audio.play_music("theme");
755 let e = audio.engine.as_ref().unwrap().lock().unwrap();
756 assert_eq!(e.current_music.as_deref(), Some("theme"));
757 assert_eq!(e.master_volume, FULL_VOLUME);
758 assert_eq!(e.fade, Fade::None);
759 }
760
761 #[test]
762 fn pcm_fade_out_completes_and_stops_music() {
763 let mut audio = pcm_audio();
764 audio.play_music("theme");
765 for _ in 0..5 {
766 audio.update_frame();
767 }
768 assert!(audio.render_samples(4410).iter().any(|s| *s != 0.0));
769
770 audio.fade_out_music();
771 for _ in 0..200 {
773 audio.update_frame();
774 }
775 let e = audio.engine.as_ref().unwrap().lock().unwrap();
776 assert_eq!(e.fade, Fade::None, "fade state machine completed");
777 assert!(e.current_music.is_none(), "music stopped after fade-out");
778 assert_eq!(e.master_volume, FULL_VOLUME, "volume restored for next track");
779 }
780
781 #[cfg(feature = "modern-audio")]
786 fn test_wav_bytes() -> Vec<u8> {
787 let rate = 8000u32;
788 let seconds = 1u32;
789 let samples = rate * seconds;
790 let data_len = samples * 2;
791 let mut wav = Vec::with_capacity(44 + data_len as usize);
792 wav.extend_from_slice(b"RIFF");
793 wav.extend_from_slice(&(36 + data_len).to_le_bytes());
794 wav.extend_from_slice(b"WAVE");
795 wav.extend_from_slice(b"fmt ");
796 wav.extend_from_slice(&16u32.to_le_bytes());
797 wav.extend_from_slice(&1u16.to_le_bytes());
798 wav.extend_from_slice(&1u16.to_le_bytes());
799 wav.extend_from_slice(&rate.to_le_bytes());
800 wav.extend_from_slice(&(rate * 2).to_le_bytes());
801 wav.extend_from_slice(&2u16.to_le_bytes());
802 wav.extend_from_slice(&16u16.to_le_bytes());
803 wav.extend_from_slice(b"data");
804 wav.extend_from_slice(&data_len.to_le_bytes());
805 for i in 0..samples {
806 let v = (i as f32 * 2.0 * std::f32::consts::PI * 440.0 / rate as f32).sin();
807 wav.extend_from_slice(&((v * 32000.0) as i16).to_le_bytes());
808 }
809 wav
810 }
811
812 #[cfg(feature = "modern-audio")]
815 fn file_audio() -> RunnerAudio {
816 use std::collections::HashMap;
817 let mut map = HashMap::new();
818 map.insert(
819 "data/audio/music/town.wav".to_string(),
820 test_wav_bytes(),
821 );
822 let files = MemoryFiles::from(map);
823 let mut audio = RunnerAudio::from_files(&files, "data", false);
824 audio.set_pcm_render(true);
825 audio
826 }
827
828 #[cfg(feature = "modern-audio")]
829 #[test]
830 fn file_tracks_load_with_path_ids() {
831 let audio = file_audio();
832 assert!(audio.has_track("music/town"), "extension-stripped path id");
833 assert!(!audio.has_track("town"), "no bare filename ids");
834 }
835
836 #[cfg(feature = "modern-audio")]
837 #[test]
838 fn file_music_plays_and_renders_audio() {
839 let mut audio = file_audio();
840 audio.play_music("music/town");
841 assert!(audio.engine.is_some(), "engine created on play");
842 for _ in 0..3 {
843 audio.update_frame();
844 }
845 let pcm = audio.render_samples(8000);
846 assert_eq!(pcm.len(), 16_000, "stereo: 2 * frames");
847 assert!(
848 pcm.iter().any(|s| *s != 0.0),
849 "file BGM must render non-silent samples"
850 );
851 let e = audio.engine.as_ref().unwrap().lock().unwrap();
853 assert_eq!(e.modern_music.as_deref(), Some("music/town"));
854 }
855
856 #[cfg(feature = "modern-audio")]
857 #[test]
858 fn file_music_dedups_and_stops() {
859 let mut audio = file_audio();
860 audio.play_music("music/town");
861 audio.play_music("music/town"); {
863 let e = audio.engine.as_ref().unwrap().lock().unwrap();
864 assert_eq!(e.modern_music.as_deref(), Some("music/town"));
865 assert_eq!(e.modern.as_ref().unwrap().active_voices(), 1);
866 } audio.stop_music();
869 let e = audio.engine.as_ref().unwrap().lock().unwrap();
870 assert!(e.modern_music.is_none(), "file BGM slot freed");
871 }
872
873 #[cfg(feature = "modern-audio")]
874 #[test]
875 fn file_sfx_one_shot_finishes() {
876 let mut audio = file_audio();
877 audio.play_sound("music/town"); for _ in 0..5 {
879 audio.update_frame();
880 }
881 let e = audio.engine.as_ref().unwrap().lock().unwrap();
882 assert!(e.modern_music.is_none());
884 assert_eq!(e.modern.as_ref().unwrap().active_voices(), 1);
885 }
886
887 #[cfg(feature = "modern-audio")]
888 #[test]
889 fn json_track_wins_over_file_track() {
890 use std::collections::HashMap;
891 let mut map = HashMap::new();
892 map.insert("data/audio/theme.json".to_string(), THEME_JSON.as_bytes().to_vec());
893 map.insert("data/audio/theme.wav".to_string(), test_wav_bytes());
894 let files = MemoryFiles::from(map);
895 let mut audio = RunnerAudio::from_files(&files, "data", false);
896 audio.set_pcm_render(true);
897 audio.play_music("theme");
899 let e = audio.engine.as_ref().unwrap().lock().unwrap();
900 assert_eq!(e.current_music.as_deref(), Some("theme"), "JSON track played");
901 assert!(e.modern_music.is_none(), "file track not used");
902 }
903}