Own XM/MOD replayer, audio mixer with crossfades, synthesised chip sound effects, music director
- src/xm.rs: FastTracker 2 XM and ProTracker MOD replayer (verified against libopenmpt) - src/audio.rs: music players with crossfade + effect voices, rendered on demand - src/sfx.rs: 29 synthesised chip sound effects - www/dc_plugin.js: AudioContext started on first input, streams samples from WASM - music follows the game: title, rotating build tracks, battle, gate assault, jingles - macroquad audio removed; all tests run natively with cargo test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2dba65be8b
commit
925be17e82
13 files changed
+1711
-205
No files matched your search
+248
@@ -0,0 +1,248 @@
|
||||
// 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.
|
||||
|
||||
use crate::sfx;
|
||||
use crate::xm::{self, Module, Player};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum Track {
|
||||
Title,
|
||||
Build1,
|
||||
Build2,
|
||||
Build3,
|
||||
Battle1,
|
||||
Battle2,
|
||||
Victory,
|
||||
Defeat,
|
||||
}
|
||||
|
||||
const TRACKS: [(Track, &[u8]); 8] = [
|
||||
(Track::Title, include_bytes!("../assets/music/title.xm")),
|
||||
(Track::Build1, include_bytes!("../assets/music/build1.xm")),
|
||||
(Track::Build2, include_bytes!("../assets/music/build2.xm")),
|
||||
(Track::Build3, include_bytes!("../assets/music/build3.xm")),
|
||||
(Track::Battle1, include_bytes!("../assets/music/battle1.xm")),
|
||||
(Track::Battle2, include_bytes!("../assets/music/battle2.xm")),
|
||||
(Track::Victory, include_bytes!("../assets/music/victory.xm")),
|
||||
(Track::Defeat, include_bytes!("../assets/music/defeat.xm")),
|
||||
];
|
||||
|
||||
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<Track>,
|
||||
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 });
|
||||
}
|
||||
|
||||
fn track_index(t: Track) -> usize {
|
||||
TRACKS.iter().position(|(k, _)| *k == t).unwrap_or(0)
|
||||
}
|
||||
|
||||
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: Track, 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[track_index(t)].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: Track, 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<Track> {
|
||||
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(),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn engine_crossfade_and_sfx() {
|
||||
dc_audio_init(48000.0);
|
||||
play_music(Track::Build1, 0.0);
|
||||
let mut peak = 0.0f32;
|
||||
for k in 0..200 {
|
||||
if k == 50 {
|
||||
play_music(Track::Battle1, 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(Track::Battle1));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user