// Audio engine: music (XM players with crossfades) and synthesised sound effects. // The browser pulls samples through dc_audio_render() from our JS plugin (www/dc_plugin.js), // which starts the AudioContext on the first user interaction. Desktop builds play through // the system's sound output (module `native` below). use crate::sfx; use crate::tracks::TRACKS; use crate::xm::{self, Module, Player}; use std::cell::RefCell; use std::rc::Rc; // Music categories; the tracks themselves are listed in tracks.rs (generated by the composer) #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Category { Title, Build, Battle, Assault, BossPaladin, BossWarden, Torture, Temple, Victory, Defeat, Surface, // above ground: flute, harp, birdsong SurfaceBattle, // fighting above ground } // indices of all tracks of a category, in play order pub fn tracks_in(c: Category) -> Vec { TRACKS.iter().enumerate().filter(|(_, t)| t.1 == c).map(|(i, _)| i).collect() } // the calm tracks the music player offers (no battle, victory or defeat music) pub fn player_tracks() -> Vec { use Category as C; TRACKS.iter().enumerate().filter(|(_, t)| matches!(t.1, C::Title | C::Build | C::Surface | C::Torture | C::Temple)).map(|(i, _)| i).collect() } pub fn track_id(t: usize) -> &'static str { TRACKS.get(t).map_or("", |t| t.0) } pub fn track_by_id(id: &str) -> Option { if id.is_empty() { return None; } TRACKS.iter().position(|t| t.0 == id) } // the song title stored in the XM module (without the "DungeonCraft - " prefix) pub fn track_title(t: usize) -> String { let Some(d) = TRACKS.get(t).map(|t| t.2) else { return String::new() }; let raw = d.get(17..37).unwrap_or(&[]); let name: String = raw.iter().take_while(|&&b| b != 0).map(|&b| b as char).collect(); let name = name.trim().trim_start_matches("DungeonCraft - ").trim().to_string(); if name.is_empty() { track_id(t).to_string() } else { name } } const MUSIC_GAIN: f32 = 0.45; // our replayer is ~4x louder than libopenmpt; leaves headroom for effects const SFX_GAIN: f32 = 0.55; struct MusicVoice { player: Player, gain: f32, target: f32, speed: f32, // gain change per second } struct SfxVoice { data: Rc>, pos: usize, l: f32, r: f32, } struct Engine { rate: f32, modules: Vec>, music: Vec, voices: Vec, sfx: Vec>>, buf: Vec, } // Requests that arrive before the browser allows audio are remembered here struct State { music_vol: f32, sfx_vol: f32, muted: bool, track: Option, engine: Option, } thread_local! { static STATE: RefCell = RefCell::new(State { music_vol: 0.6, sfx_vol: 0.7, muted: false, track: None, engine: None }); } impl Engine { fn new(rate: f32) -> Engine { let modules = TRACKS.iter().map(|(_, _, d)| Rc::new(xm::load(d).expect("built-in music"))).collect(); let sfx = sfx::generate_all(rate).into_iter().map(Rc::new).collect(); Engine { rate, modules, music: Vec::new(), voices: Vec::new(), sfx, buf: Vec::new() } } fn start_track(&mut self, t: usize, fade: f32) { let speed = 1.0 / fade.max(0.01); for v in self.music.iter_mut() { v.target = 0.0; v.speed = speed; } let player = Player::new(self.modules[t.min(self.modules.len() - 1)].clone(), self.rate); let instant = self.music.is_empty() || fade <= 0.0; self.music.push(MusicVoice { player, gain: if instant { 1.0 } else { 0.0 }, target: 1.0, speed }); } fn render(&mut self, frames: usize, music_vol: f32, sfx_vol: f32, muted: bool) { self.buf.clear(); self.buf.resize(frames * 2, 0.0); let dt = frames as f32 / self.rate; for v in self.music.iter_mut() { if v.gain < v.target { v.gain = (v.gain + v.speed * dt).min(v.target); } else if v.gain > v.target { v.gain = (v.gain - v.speed * dt).max(v.target); } if v.gain > 0.0 { v.player.render(&mut self.buf, v.gain * music_vol * MUSIC_GAIN); } } self.music.retain(|v| v.gain > 0.0 || v.target > 0.0); let g = sfx_vol * SFX_GAIN; for v in self.voices.iter_mut() { let n = (v.data.len() - v.pos).min(frames); for k in 0..n { let s = v.data[v.pos + k] * g; self.buf[k * 2] += s * v.l; self.buf[k * 2 + 1] += s * v.r; } v.pos += n; } self.voices.retain(|v| v.pos < v.data.len()); for x in self.buf.iter_mut() { *x = if muted { 0.0 } else { soft_clip(*x) }; } } } fn soft_clip(x: f32) -> f32 { if x.abs() < 0.8 { x } else { x.signum() * (0.8 + 0.2 * ((x.abs() - 0.8) / 0.2).tanh()) } } // ------------------------------------------------------------------ API for the game pub fn set_volumes(music: f32, sfx: f32, muted: bool) { STATE.with(|s| { let mut s = s.borrow_mut(); s.music_vol = music; s.sfx_vol = sfx; s.muted = muted; }); } // Switch music with a crossfade (seconds); does nothing if the track already plays pub fn play_music(t: usize, fade: f32) { STATE.with(|s| { let mut s = s.borrow_mut(); if s.track == Some(t) { return; } s.track = Some(t); if let Some(e) = s.engine.as_mut() { e.start_track(t, fade); } }); } // How often the current track has looped (0 before audio started) pub fn music_loops() -> u32 { STATE.with(|s| { let s = s.borrow(); s.engine.as_ref().and_then(|e| e.music.iter().rev().find(|v| v.target > 0.0)).map_or(0, |v| v.player.loops) }) } pub fn current_track() -> Option { STATE.with(|s| s.borrow().track) } // pan: -1 (left) .. 1 (right) pub fn play_sfx(index: usize, vol: f32, pan: f32) { STATE.with(|s| { let mut s = s.borrow_mut(); if s.muted { return; } if let Some(e) = s.engine.as_mut() { if let Some(d) = e.sfx.get(index) { if e.voices.len() < 24 { let p = (pan.clamp(-1.0, 1.0) + 1.0) * 0.5; e.voices.push(SfxVoice { data: d.clone(), pos: 0, l: vol * (1.0 - p).sqrt(), r: vol * p.sqrt() }); } } } }); } // ------------------------------------------------------------------ called from JS #[no_mangle] pub extern "C" fn dc_audio_init(rate: f32) { STATE.with(|s| { let mut s = s.borrow_mut(); if s.engine.is_some() { return; } let mut e = Engine::new(rate.clamp(8000.0, 192000.0)); if let Some(t) = s.track { e.start_track(t, 0.0); } s.engine = Some(e); }); } #[no_mangle] pub extern "C" fn dc_audio_render(frames: u32) -> *const f32 { STATE.with(|s| { let mut s = s.borrow_mut(); let (mv, sv, muted) = (s.music_vol, s.sfx_vol, s.muted); match s.engine.as_mut() { Some(e) => { e.render(frames as usize, mv, sv, muted); e.buf.as_ptr() } None => std::ptr::null(), } }) } // ------------------------------------------------------------------ desktop sound output // The game renders a little ahead into a queue each frame; the sound card's thread takes from it. // The engine itself stays on the game thread, like in the browser. #[cfg(not(target_arch = "wasm32"))] pub mod native { use super::STATE; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use std::cell::RefCell; use std::collections::VecDeque; use std::sync::{Arc, Mutex}; // seconds rendered ahead: covers a slow frame or two const AHEAD: f32 = 0.07; struct Out { _stream: cpal::Stream, queue: Arc>>, // interleaved stereo rate: f32, } thread_local! { static OUT: RefCell> = const { RefCell::new(None) }; } // opens the default output device and starts the engine; without one the game stays silent pub fn start() { match open() { Ok(out) => { super::dc_audio_init(out.rate); OUT.with(|o| *o.borrow_mut() = Some(out)); } Err(e) => eprintln!("DungeonCraft: no sound ({})", e), } } fn open() -> Result { let dev = cpal::default_host().default_output_device().ok_or("no output device")?; let cfg = dev.default_output_config().map_err(|e| e.to_string())?; let rate = cfg.sample_rate() as f32; let channels = cfg.channels() as usize; let queue = Arc::new(Mutex::new(VecDeque::new())); let sc: cpal::StreamConfig = cfg.config(); use cpal::SampleFormat as F; let stream = match cfg.sample_format() { F::F32 => build::(&dev, &sc, channels, queue.clone()), F::I16 => build::(&dev, &sc, channels, queue.clone()), F::U16 => build::(&dev, &sc, channels, queue.clone()), F::I32 => build::(&dev, &sc, channels, queue.clone()), f => Err(format!("sample format {:?}", f)), }?; stream.play().map_err(|e| e.to_string())?; Ok(Out { _stream: stream, queue, rate }) } fn build>(dev: &cpal::Device, sc: &cpal::StreamConfig, channels: usize, queue: Arc>>) -> Result { let channels = channels.max(1); dev.build_output_stream( sc.clone(), move |out: &mut [T], _: &cpal::OutputCallbackInfo| { let mut q = queue.lock().unwrap_or_else(|e| e.into_inner()); for frame in out.chunks_mut(channels) { // an empty queue (a stalled frame) plays silence let (l, r) = if q.len() >= 2 { (q.pop_front().unwrap_or(0.0), q.pop_front().unwrap_or(0.0)) } else { (0.0, 0.0) }; for (c, s) in frame.iter_mut().enumerate() { let v = match (channels, c) { (1, _) => (l + r) * 0.5, (_, 0) => l, (_, 1) => r, _ => 0.0, }; *s = T::from_sample(v); } } }, |e| eprintln!("DungeonCraft: sound output: {}", e), None, ) .map_err(|e| e.to_string()) } // called once per frame: tops the queue up to AHEAD seconds pub fn pump() { OUT.with(|o| { let o = o.borrow(); let Some(o) = o.as_ref() else { return }; let want = (o.rate * AHEAD) as usize; let have = o.queue.lock().unwrap_or_else(|e| e.into_inner()).len() / 2; if have >= want { return; } STATE.with(|s| { let mut s = s.borrow_mut(); let (mv, sv, muted) = (s.music_vol, s.sfx_vol, s.muted); if let Some(e) = s.engine.as_mut() { e.render(want - have, mv, sv, muted); o.queue.lock().unwrap_or_else(|e| e.into_inner()).extend(e.buf.iter().copied()); } }); }); } } #[cfg(test)] mod tests { use super::*; #[test] fn the_music_player_offers_only_calm_tracks_with_titles() { let l = player_tracks(); assert!(l.len() > 20); for t in l { let id = track_id(t); assert!(!id.starts_with("battle") && !id.starts_with("victory") && id != "defeat" && !id.starts_with("assault"), "{}", id); assert!(!track_title(t).is_empty()); assert_eq!(track_by_id(id), Some(t)); } assert_eq!(track_by_id(""), None); assert_eq!(track_title(track_by_id("build13").unwrap()), "Underground River"); } #[test] fn engine_crossfade_and_sfx() { dc_audio_init(48000.0); let build = tracks_in(Category::Build); let battle = tracks_in(Category::Battle); assert_eq!(build.len(), 19); assert_eq!(tracks_in(Category::Surface).len(), 4); assert_eq!(tracks_in(Category::SurfaceBattle).len(), 2); play_music(build[0], 0.0); let mut peak = 0.0f32; for k in 0..200 { if k == 50 { play_music(battle[0], 2.5); } if k % 10 == 0 { play_sfx(k % crate::game::SFX_ALL.len(), 1.0, 0.3); } let p = dc_audio_render(1024); assert!(!p.is_null()); let buf = unsafe { std::slice::from_raw_parts(p, 2048) }; for &v in buf { assert!(v.is_finite() && v.abs() <= 1.0); peak = peak.max(v.abs()); } } assert!(peak > 0.05); assert_eq!(current_track(), Some(battle[0])); } }