398 lines
13 KiB
Rust
398 lines
13 KiB
Rust
// 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<usize> {
|
|
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<usize> {
|
|
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<usize> {
|
|
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<Vec<f32>>,
|
|
pos: usize,
|
|
l: f32,
|
|
r: f32,
|
|
}
|
|
|
|
struct Engine {
|
|
rate: f32,
|
|
modules: Vec<Rc<Module>>,
|
|
music: Vec<MusicVoice>,
|
|
voices: Vec<SfxVoice>,
|
|
sfx: Vec<Rc<Vec<f32>>>,
|
|
buf: Vec<f32>,
|
|
}
|
|
|
|
// Requests that arrive before the browser allows audio are remembered here
|
|
struct State {
|
|
music_vol: f32,
|
|
sfx_vol: f32,
|
|
muted: bool,
|
|
track: Option<usize>,
|
|
engine: Option<Engine>,
|
|
}
|
|
|
|
thread_local! {
|
|
static STATE: RefCell<State> = 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<usize> {
|
|
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<Mutex<VecDeque<f32>>>, // interleaved stereo
|
|
rate: f32,
|
|
}
|
|
|
|
thread_local! {
|
|
static OUT: RefCell<Option<Out>> = 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<Out, String> {
|
|
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::<f32>(&dev, &sc, channels, queue.clone()),
|
|
F::I16 => build::<i16>(&dev, &sc, channels, queue.clone()),
|
|
F::U16 => build::<u16>(&dev, &sc, channels, queue.clone()),
|
|
F::I32 => build::<i32>(&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<T: cpal::SizedSample + cpal::FromSample<f32>>(dev: &cpal::Device, sc: &cpal::StreamConfig, channels: usize, queue: Arc<Mutex<VecDeque<f32>>>) -> Result<cpal::Stream, String> {
|
|
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]));
|
|
}
|
|
}
|