use std::collections::{BTreeMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use dotzuki_audio::apu::Apu;
use dotzuki_audio::format::TrackDef;
use dotzuki_audio::library::AudioLibrary;
use dotzuki_audio::sequencer::Sequencer;
use dotzuki_audio::CPU_CLOCK_HZ;
#[cfg(feature = "modern-audio")]
use dotzuki_audio::modern::{Bus as ModernBus, ModernAudio, PlayOptions as ModernPlayOptions};
use crate::vfs::{join_path, DiskFiles, ProjectFiles};
const SAMPLE_RATE: u32 = 44_100;
const MAX_AMPLITUDE: f32 = 480.0;
const FULL_VOLUME: u8 = 7;
const FADE_STEP_FRAMES: u8 = 10;
const CYCLES_PER_SAMPLE: u32 = CPU_CLOCK_HZ / SAMPLE_RATE;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Fade {
None,
Out { counter: u8, reload: u8 },
}
fn step_fade(fade: Fade, master_volume: u8) -> (Fade, u8, bool) {
match fade {
Fade::None => (Fade::None, master_volume, false),
Fade::Out { counter: 0, reload } => {
let mv = master_volume.saturating_sub(1);
if mv == 0 {
(Fade::None, FULL_VOLUME, true) } else {
(Fade::Out { counter: reload, reload }, mv, false)
}
}
Fade::Out { counter, reload } => {
(Fade::Out { counter: counter - 1, reload }, master_volume, false)
}
}
}
struct Engine {
seq: Sequencer,
apu: Apu,
master_volume: u8,
fade: Fade,
current_music: Option<String>,
#[cfg(feature = "modern-audio")]
modern: Option<ModernAudio>,
#[cfg(feature = "modern-audio")]
modern_music: Option<String>,
#[cfg(feature = "modern-audio")]
mix_buf: Vec<f32>,
}
fn render_into(e: &mut Engine, data: &mut [f32]) {
for frame in data.chunks_mut(2) {
e.apu.tick_n(CYCLES_PER_SAMPLE);
let (left, right) = e.apu.mix_sample();
frame[0] = left as f32 / MAX_AMPLITUDE;
frame[1] = right as f32 / MAX_AMPLITUDE;
}
#[cfg(feature = "modern-audio")]
if let Some(modern) = &mut e.modern {
e.mix_buf.resize(data.len(), 0.0);
e.mix_buf.fill(0.0);
modern.render_into(&mut e.mix_buf);
for (out, modern_s) in data.iter_mut().zip(e.mix_buf.iter()) {
*out += *modern_s;
}
}
}
fn update_engine_frame(e: &mut Engine) {
let (fade, mv, completed) = step_fade(e.fade, e.master_volume);
e.fade = fade;
e.master_volume = mv;
if completed {
e.seq.stop_music();
e.current_music = None;
}
#[cfg(feature = "modern-audio")]
{
if completed {
if let Some(modern) = &mut e.modern {
modern.stop_music(None);
}
e.modern_music = None;
}
}
let Engine {
seq,
apu,
master_volume,
..
} = e;
seq.update_frame(apu);
let v = *master_volume & 0x07;
apu.write_register(0xFF24, (v << 4) | v);
}
fn new_engine() -> Arc<Mutex<Engine>> {
let mut apu = Apu::new();
apu.write_register(0xFF26, 0x80); Arc::new(Mutex::new(Engine {
seq: Sequencer::new(),
apu,
master_volume: FULL_VOLUME,
fade: Fade::None,
current_music: None,
#[cfg(feature = "modern-audio")]
modern: None,
#[cfg(feature = "modern-audio")]
modern_music: None,
#[cfg(feature = "modern-audio")]
mix_buf: Vec::new(),
}))
}
fn open_stream(engine: Arc<Mutex<Engine>>) -> Option<cpal::Stream> {
let host = cpal::default_host();
let device = host.default_output_device()?;
let config = cpal::StreamConfig {
channels: 2,
sample_rate: cpal::SampleRate(SAMPLE_RATE),
buffer_size: cpal::BufferSize::Default,
};
let cb = engine;
let stream = device
.build_output_stream(
&config,
move |data: &mut [f32], _: &cpal::OutputCallbackInfo| {
let mut e = cb.lock().unwrap();
render_into(&mut e, data);
},
|err| log::error!("audio stream error: {err}"),
None,
)
.ok()?;
stream.play().ok()?;
Some(stream)
}
#[cfg(feature = "modern-audio")]
struct FileTrack {
bytes: Vec<u8>,
ext: String,
}
pub struct RunnerAudio {
library: AudioLibrary,
#[cfg(feature = "modern-audio")]
file_tracks: BTreeMap<String, FileTrack>,
allow_device: bool,
pcm_render: bool,
engine: Option<Arc<Mutex<Engine>>>,
stream: Option<cpal::Stream>,
init_failed: bool,
warned_ids: HashSet<String>,
}
impl RunnerAudio {
pub fn new(data_root: &Path, allow_device: bool) -> Self {
Self::from_files(&DiskFiles::new(data_root), "", allow_device)
}
pub fn from_files(files: &dyn ProjectFiles, data_root_rel: &str, allow_device: bool) -> Self {
let prefix = join_path(data_root_rel, "audio");
let library = load_library(files, &prefix).unwrap_or_else(|e| {
log::warn!("audio: failed to load {prefix}: {e:#}");
AudioLibrary::new()
});
if !library.is_empty() {
log::info!("audio: loaded {} track(s) from {prefix}", library.len());
}
#[cfg(feature = "modern-audio")]
let file_tracks = load_file_tracks(files, &prefix).unwrap_or_else(|e| {
log::warn!("audio: failed to load file tracks from {prefix}: {e:#}");
BTreeMap::new()
});
#[cfg(feature = "modern-audio")]
if !file_tracks.is_empty() {
log::info!(
"audio: loaded {} file track(s) from {prefix}",
file_tracks.len()
);
}
Self {
library,
#[cfg(feature = "modern-audio")]
file_tracks,
allow_device,
pcm_render: false,
engine: None,
stream: None,
init_failed: false,
warned_ids: HashSet::new(),
}
}
pub fn track_count(&self) -> usize {
self.library.len()
}
pub fn has_track(&self, id: &str) -> bool {
self.library.get(id).is_some()
|| {
#[cfg(feature = "modern-audio")]
{
self.file_tracks.contains_key(id)
}
#[cfg(not(feature = "modern-audio"))]
{
false
}
}
}
pub fn has_output(&self) -> bool {
self.stream.is_some()
}
pub fn set_pcm_render(&mut self, on: bool) {
self.pcm_render = on;
}
pub fn render_samples(&mut self, frames: usize) -> Vec<f32> {
let Some(engine) = &self.engine else {
return Vec::new();
};
let mut e = engine.lock().unwrap();
let mut data = vec![0.0; frames * 2];
render_into(&mut e, &mut data);
data
}
fn engine(&mut self) -> Option<Arc<Mutex<Engine>>> {
if let Some(engine) = &self.engine {
return Some(Arc::clone(engine));
}
if self.library.is_empty()
&& {
#[cfg(feature = "modern-audio")]
{
self.file_tracks.is_empty()
}
#[cfg(not(feature = "modern-audio"))]
{
true
}
}
{
return None;
}
if self.pcm_render {
log::info!("audio: pcm render mode — engine started without an output device");
let engine = new_engine();
self.engine = Some(Arc::clone(&engine));
return Some(engine);
}
if !self.allow_device || self.init_failed {
return None;
}
let engine = new_engine();
match open_stream(Arc::clone(&engine)) {
Some(stream) => {
log::info!("audio: output stream started");
self.stream = Some(stream);
self.engine = Some(Arc::clone(&engine));
Some(engine)
}
None => {
log::warn!("audio: no output device — sound disabled, continuing silent");
self.init_failed = true;
None
}
}
}
fn warn_unknown(&mut self, kind: &str, id: &str) {
if self.warned_ids.insert(id.to_string()) {
log::warn!("audio: no {kind} track '{id}'");
}
}
pub fn update_frame(&mut self) {
if let Some(engine) = &self.engine {
update_engine_frame(&mut engine.lock().unwrap());
}
}
pub fn play_music(&mut self, id: &str) {
if !self.has_track(id) {
self.warn_unknown("music", id);
return;
}
let Some(engine) = self.engine() else {
return;
};
let mut e = engine.lock().unwrap();
if self.library.get(id).is_some() {
if e.current_music.as_deref() == Some(id) {
return;
}
e.current_music = Some(id.to_string());
e.fade = Fade::None;
e.master_volume = FULL_VOLUME;
self.library.play(&mut e.seq, id);
return;
}
#[cfg(feature = "modern-audio")]
{
if e.modern_music.as_deref() == Some(id) {
return;
}
let Some(track) = self.file_tracks.get(id) else {
return;
};
e.seq.stop_music();
e.current_music = None;
let modern = e.modern.get_or_insert_with(|| ModernAudio::new(SAMPLE_RATE));
if modern
.play_music_bytes(track.bytes.clone(), Some(&track.ext), ModernPlayOptions::default())
.is_ok()
{
e.modern_music = Some(id.to_string());
} else {
log::warn!("audio: failed to decode music track '{id}'");
}
}
#[cfg(not(feature = "modern-audio"))]
{
log::warn!("audio: music track '{id}' exists but modern-audio is not enabled");
}
}
pub fn play_sound(&mut self, id: &str) {
if !self.has_track(id) {
self.warn_unknown("sfx", id);
return;
}
let Some(engine) = self.engine() else {
return;
};
let mut e = engine.lock().unwrap();
if self.library.get(id).is_some() {
self.library.play(&mut e.seq, id);
return;
}
#[cfg(feature = "modern-audio")]
{
let Some(track) = self.file_tracks.get(id) else {
return;
};
let modern = e.modern.get_or_insert_with(|| ModernAudio::new(SAMPLE_RATE));
if modern
.play_sfx_bytes(track.bytes.clone(), Some(&track.ext), ModernPlayOptions::default())
.is_err()
{
log::warn!("audio: failed to decode sfx track '{id}'");
}
}
#[cfg(not(feature = "modern-audio"))]
{
log::warn!("audio: sfx track '{id}' exists but modern-audio is not enabled");
}
}
pub fn stop_music(&mut self) {
let Some(engine) = &self.engine else {
return;
};
let mut e = engine.lock().unwrap();
e.seq.stop_music();
e.fade = Fade::None;
e.master_volume = FULL_VOLUME;
e.current_music = None;
#[cfg(feature = "modern-audio")]
{
if let Some(modern) = &mut e.modern {
modern.stop_music(None);
}
e.modern_music = None;
}
}
pub fn fade_out_music(&mut self) {
let Some(engine) = &self.engine else {
return;
};
let mut e = engine.lock().unwrap();
#[cfg(feature = "modern-audio")]
if e.modern_music.is_some() {
if let Some(modern) = &mut e.modern {
modern.stop_music(Some(MODERN_FADE_SECS));
}
e.modern_music = None;
return;
}
if e.current_music.is_none() || matches!(e.fade, Fade::Out { .. }) {
return;
}
e.fade = Fade::Out {
counter: FADE_STEP_FRAMES,
reload: FADE_STEP_FRAMES,
};
}
}
#[cfg(feature = "modern-audio")]
const MODERN_FADE_SECS: f32 = 1.2;
fn load_library(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<AudioLibrary> {
let mut lib = AudioLibrary::new();
for path in files.list(prefix) {
if path.rsplit('.').next() != Some("json") {
continue;
}
let bytes = files.read(&path)?;
let track: TrackDef = serde_json::from_slice(&bytes)
.map_err(|e| anyhow::anyhow!("{path}: {e}"))?;
lib.insert(track);
}
Ok(lib)
}
#[cfg(feature = "modern-audio")]
const AUDIO_FILE_EXTS: [&str; 4] = ["wav", "ogg", "flac", "mp3"];
#[cfg(feature = "modern-audio")]
fn load_file_tracks(files: &dyn ProjectFiles, prefix: &str) -> anyhow::Result<BTreeMap<String, FileTrack>> {
let mut out = BTreeMap::new();
for path in files.list(prefix) {
let ext = match path.rsplit('.').next() {
Some(e) if AUDIO_FILE_EXTS.contains(&e) => e.to_string(),
_ => continue,
};
let bytes = files.read(&path)?;
let id = path
.strip_prefix(&format!("{prefix}/"))
.unwrap_or(&path)
.trim_end_matches(&format!(".{ext}"))
.to_string();
out.insert(id, FileTrack { bytes, ext });
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::MemoryFiles;
const THEME_JSON: &str = r#"{
"id": "theme",
"kind": "music",
"tempo": 256,
"channels": [
{
"hw": "pulse1",
"commands": [
{ "type": "note_type", "speed": 12, "param": 197 },
{ "type": "octave", "value": 5 },
{ "type": "note", "pitch": 0, "length": 4 },
{ "type": "rest", "length": 4 },
{ "type": "sound_ret" }
]
}
]
}"#;
fn pcm_audio() -> RunnerAudio {
let track: TrackDef = serde_json::from_str(THEME_JSON).unwrap();
let mut library = AudioLibrary::new();
library.insert(track);
RunnerAudio {
library,
#[cfg(feature = "modern-audio")]
file_tracks: BTreeMap::new(),
allow_device: false,
pcm_render: true,
engine: None,
stream: None,
init_failed: false,
warned_ids: HashSet::new(),
}
}
#[test]
fn fade_none_is_inert() {
assert_eq!(step_fade(Fade::None, 5), (Fade::None, 5, false));
}
#[test]
fn fade_counts_down_then_steps_volume() {
let (f, v, done) = step_fade(Fade::Out { counter: 2, reload: 2 }, 7);
assert_eq!((f, v, done), (Fade::Out { counter: 1, reload: 2 }, 7, false));
let (f, v, done) = step_fade(f, v);
assert_eq!((f, v, done), (Fade::Out { counter: 0, reload: 2 }, 7, false));
let (f, v, done) = step_fade(f, v);
assert_eq!((f, v, done), (Fade::Out { counter: 2, reload: 2 }, 6, false));
}
#[test]
fn fade_completes_and_restores_full_volume() {
let mut fade = Fade::Out { counter: 0, reload: 0 };
let mut vol = FULL_VOLUME;
let mut completed = false;
for _ in 0..64 {
let (f, v, done) = step_fade(fade, vol);
fade = f;
vol = v;
if done {
completed = true;
break;
}
}
assert!(completed, "fade never completed");
assert_eq!(fade, Fade::None);
assert_eq!(vol, FULL_VOLUME, "volume should reset to full after a fade");
}
#[test]
fn render_samples_is_empty_before_any_play() {
let mut audio = pcm_audio();
assert!(audio.render_samples(4410).is_empty());
let mut silent = RunnerAudio {
pcm_render: false,
..pcm_audio()
};
silent.play_music("theme");
assert!(silent.render_samples(4410).is_empty());
assert!(!silent.has_output());
}
#[test]
fn pcm_play_renders_nonzero_samples_without_a_device() {
let mut audio = pcm_audio();
audio.play_music("theme");
assert!(!audio.has_output(), "pcm mode must not open a device");
assert!(audio.engine.is_some(), "pcm mode creates the engine on play");
for _ in 0..5 {
audio.update_frame();
}
let pcm = audio.render_samples(4410);
assert_eq!(pcm.len(), 8820, "stereo frames: 2 * frames");
assert!(
pcm.iter().any(|s| *s != 0.0),
"playing music must render non-silent samples"
);
}
#[test]
fn pcm_play_music_dedups_like_native() {
let mut audio = pcm_audio();
audio.play_music("theme");
audio.update_frame();
{
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert_eq!(e.current_music.as_deref(), Some("theme"));
}
audio.play_music("theme");
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert_eq!(e.current_music.as_deref(), Some("theme"));
assert_eq!(e.master_volume, FULL_VOLUME);
assert_eq!(e.fade, Fade::None);
}
#[test]
fn pcm_fade_out_completes_and_stops_music() {
let mut audio = pcm_audio();
audio.play_music("theme");
for _ in 0..5 {
audio.update_frame();
}
assert!(audio.render_samples(4410).iter().any(|s| *s != 0.0));
audio.fade_out_music();
for _ in 0..200 {
audio.update_frame();
}
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert_eq!(e.fade, Fade::None, "fade state machine completed");
assert!(e.current_music.is_none(), "music stopped after fade-out");
assert_eq!(e.master_volume, FULL_VOLUME, "volume restored for next track");
}
#[cfg(feature = "modern-audio")]
fn test_wav_bytes() -> Vec<u8> {
let rate = 8000u32;
let seconds = 1u32;
let samples = rate * seconds;
let data_len = samples * 2;
let mut wav = Vec::with_capacity(44 + data_len as usize);
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36 + data_len).to_le_bytes());
wav.extend_from_slice(b"WAVE");
wav.extend_from_slice(b"fmt ");
wav.extend_from_slice(&16u32.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&1u16.to_le_bytes());
wav.extend_from_slice(&rate.to_le_bytes());
wav.extend_from_slice(&(rate * 2).to_le_bytes());
wav.extend_from_slice(&2u16.to_le_bytes());
wav.extend_from_slice(&16u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&data_len.to_le_bytes());
for i in 0..samples {
let v = (i as f32 * 2.0 * std::f32::consts::PI * 440.0 / rate as f32).sin();
wav.extend_from_slice(&((v * 32000.0) as i16).to_le_bytes());
}
wav
}
#[cfg(feature = "modern-audio")]
fn file_audio() -> RunnerAudio {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert(
"data/audio/music/town.wav".to_string(),
test_wav_bytes(),
);
let files = MemoryFiles::from(map);
let mut audio = RunnerAudio::from_files(&files, "data", false);
audio.set_pcm_render(true);
audio
}
#[cfg(feature = "modern-audio")]
#[test]
fn file_tracks_load_with_path_ids() {
let audio = file_audio();
assert!(audio.has_track("music/town"), "extension-stripped path id");
assert!(!audio.has_track("town"), "no bare filename ids");
}
#[cfg(feature = "modern-audio")]
#[test]
fn file_music_plays_and_renders_audio() {
let mut audio = file_audio();
audio.play_music("music/town");
assert!(audio.engine.is_some(), "engine created on play");
for _ in 0..3 {
audio.update_frame();
}
let pcm = audio.render_samples(8000);
assert_eq!(pcm.len(), 16_000, "stereo: 2 * frames");
assert!(
pcm.iter().any(|s| *s != 0.0),
"file BGM must render non-silent samples"
);
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert_eq!(e.modern_music.as_deref(), Some("music/town"));
}
#[cfg(feature = "modern-audio")]
#[test]
fn file_music_dedups_and_stops() {
let mut audio = file_audio();
audio.play_music("music/town");
audio.play_music("music/town"); {
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert_eq!(e.modern_music.as_deref(), Some("music/town"));
assert_eq!(e.modern.as_ref().unwrap().active_voices(), 1);
}
audio.stop_music();
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert!(e.modern_music.is_none(), "file BGM slot freed");
}
#[cfg(feature = "modern-audio")]
#[test]
fn file_sfx_one_shot_finishes() {
let mut audio = file_audio();
audio.play_sound("music/town"); for _ in 0..5 {
audio.update_frame();
}
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert!(e.modern_music.is_none());
assert_eq!(e.modern.as_ref().unwrap().active_voices(), 1);
}
#[cfg(feature = "modern-audio")]
#[test]
fn json_track_wins_over_file_track() {
use std::collections::HashMap;
let mut map = HashMap::new();
map.insert("data/audio/theme.json".to_string(), THEME_JSON.as_bytes().to_vec());
map.insert("data/audio/theme.wav".to_string(), test_wav_bytes());
let files = MemoryFiles::from(map);
let mut audio = RunnerAudio::from_files(&files, "data", false);
audio.set_pcm_render(true);
audio.play_music("theme");
let e = audio.engine.as_ref().unwrap().lock().unwrap();
assert_eq!(e.current_music.as_deref(), Some("theme"), "JSON track played");
assert!(e.modern_music.is_none(), "file track not used");
}
}