From 925be17e820be12f0e31e1517bbeb70cfeac3b80 Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Tue, 3 Nov 2026 06:10:28 +0100 Subject: [PATCH] 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 --- .cargo/config.toml | 4 +- Cargo.lock | 97 -- Cargo.toml | 5 - DESIGN.md | 9 +- README.md | 8 +- src/audio.rs | 248 ++++ src/main.rs | 68 +- src/sfx.rs | 155 +++ src/sound.rs | 90 +- src/ui.rs | 5 +- src/xm.rs | 1186 +++++++++++++++++ .../__pycache__/compose_music.cpython-311.pyc | Bin 0 -> 49868 bytes www/dc_plugin.js | 41 +- 13 files changed, 1711 insertions(+), 205 deletions(-) create mode 100644 src/audio.rs create mode 100644 src/sfx.rs create mode 100644 src/xm.rs create mode 100644 tools/__pycache__/compose_music.cpython-311.pyc diff --git a/.cargo/config.toml b/.cargo/config.toml index 8b9e8e6..67d01aa 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,4 +1,4 @@ -# quad-snd (macroquad audio) imports JS functions from mq_js_bundle.js, and our own -# plugin (www/dc_plugin.js) provides more. Newer Rust versions need --allow-undefined for that. +# Our JS plugin (www/dc_plugin.js) provides functions that the WASM module imports. +# Newer Rust versions need --allow-undefined for imports that are not known at link time. [target.wasm32-unknown-unknown] rustflags = ["-C", "link-arg=--allow-undefined"] diff --git a/Cargo.lock b/Cargo.lock index 3506807..a422ae2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,24 +14,6 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" -[[package]] -name = "audir-sles" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea47348666a8edb7ad80cbee3940eb2bccf70df0e6ce09009abe1a836cb779f5" - -[[package]] -name = "audrey" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58b92a84e89497e3cd25d3672cd5d1c288abaac02c18ff21283f17d118b889b8" -dependencies = [ - "dasp_frame", - "dasp_sample", - "hound", - "lewton", -] - [[package]] name = "autocfg" version = "1.5.1" @@ -86,21 +68,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "dasp_frame" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a3937f5fe2135702897535c8d4a5553f8b116f76c1529088797f2eee7c5cd6" -dependencies = [ - "dasp_sample", -] - -[[package]] -name = "dasp_sample" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" - [[package]] name = "dungeoncraft" version = "0.1.0" @@ -169,12 +136,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hound" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" - [[package]] name = "image" version = "0.24.9" @@ -194,17 +155,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "lewton" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d542c1a317036c45c2aa1cf10cc9d403ca91eb2d333ef1a4917e5cb10628bd0" -dependencies = [ - "byteorder", - "ogg", - "smallvec", -] - [[package]] name = "libc" version = "0.2.191" @@ -229,7 +179,6 @@ dependencies = [ "macroquad_macro", "miniquad", "quad-rand", - "quad-snd", ] [[package]] @@ -247,12 +196,6 @@ dependencies = [ "libc", ] -[[package]] -name = "maybe-uninit" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60302e4db3a61da70c0cb7991976248362f30319e88850c487b9b95bbf059e00" - [[package]] name = "memchr" version = "2.8.3" @@ -315,15 +258,6 @@ dependencies = [ "malloc_buf", ] -[[package]] -name = "ogg" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13e571c3517af9e1729d4c63571a27edd660ade0667973bfc74a67c660c2b651" -dependencies = [ - "byteorder", -] - [[package]] name = "png" version = "0.17.16" @@ -346,34 +280,12 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quad-alsa-sys" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c66c2f04a6946293477973d85adc251d502da51c57b08cd9c997f0cfd8dcd4b5" -dependencies = [ - "libc", -] - [[package]] name = "quad-rand" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a651516ddc9168ebd67b24afd085a718be02f8858fe406591b013d101ce2f40" -[[package]] -name = "quad-snd" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba0c4943fc67147fbe9d1eb731fb9e678bfc9d926507eebbbfe0103e154e5b0" -dependencies = [ - "audir-sles", - "audrey", - "libc", - "quad-alsa-sys", - "winapi", -] - [[package]] name = "quote" version = "1.0.47" @@ -432,15 +344,6 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" -[[package]] -name = "smallvec" -version = "0.6.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97fcaeba89edba30f044a10c6a3cc39df9c3f17d7cd829dd1446cab35f890e0" -dependencies = [ - "maybe-uninit", -] - [[package]] name = "syn" version = "3.0.9" diff --git a/Cargo.toml b/Cargo.toml index ae32529..5b28681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,11 +8,6 @@ macroquad = { version = "0.4" } serde = { version = "1", features = ["derive"] } serde_json = "1" -[features] -default = ["audio"] -# macroquad audio (needs libasound natively); disable for native tests: cargo test --no-default-features -audio = ["macroquad/audio"] - [profile.release] opt-level = 3 lto = true diff --git a/DESIGN.md b/DESIGN.md index da9797f..0133978 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -36,9 +36,12 @@ An original dungeon management game (genre: dungeon building & defence) that run - Settings: language + sound, camera (rotation/zoom speed, edge scrolling, invert mouse), graphics (tile outlines, wall detail, light sources, interface size), game (speed 1×/2×, hints), **key bindings** (conflicts swap keys; Esc and arrow keys are fixed) - Settings are stored in the browser **and** in every savegame; loading a savegame **always applies** its settings -## Planned -- Music: own **XM/MOD replayer in Rust**, shared mixer for music + sound effects (streamed via a JS audio plugin, replaces macroquad audio) -- Soundtrack (composed as `.xm`, editable in MilkyTracker): title/menu, several calm build tracks, battle, victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece** +## Music & sound +- Own **XM/MOD replayer in Rust** (`src/xm.rs`): instruments, envelopes, fadeout, auto-vibrato, linear/Amiga frequencies, common FT2 effects; verified against libopenmpt +- **Shared mixer** for music + sound effects (`src/audio.rs`), streamed to the browser by the JS plugin (replaces macroquad audio) +- Soundtrack in classic **Amiga chiptune** style, composed as `.xm` by `tools/compose_music.py` (editable in MilkyTracker): title/menu, **3 build tracks** (rotating), **2 battle tracks** (wave defence, gate assault), victory/defeat jingles; **the title melody is a recognisable leitmotif in every piece** +- Music changes with **soft crossfades** (2.5 s): title → build → battle when heroes invade → storm music while attacking the gate → jingles +- Sound effects are **synthesised chip sounds** (`src/sfx.rs`), positioned by distance and stereo panning ## Game content - **Digging**: mark earth/gold → Pit Imps dig, bring gold home and claim ground diff --git a/README.md b/README.md index 2554b1d..8cefab8 100644 --- a/README.md +++ b/README.md @@ -34,12 +34,16 @@ python3 -m http.server 8080 -d www # open http://localhost:8080 ### Testing the game logic -The game logic (`src/game.rs`, `src/map.rs`) does not depend on macroquad. The tests (a headless simulation of whole games and a savegame round trip) run natively without the audio feature: +The tests run natively: a headless simulation of whole games, a savegame round trip, rendering of all music modules and the audio engine: ```sh -cargo test --release --no-default-features +cargo test --release ``` +## Music + +The soundtrack consists of FastTracker 2 modules in `assets/music/`, generated by `tools/compose_music.py` (`python3 tools/compose_music.py`). They can be opened and edited in MilkyTracker. The game plays them with its own XM/MOD replayer (`src/xm.rs`); sound effects are synthesised chip sounds (`src/sfx.rs`). Both are mixed in `src/audio.rs` and streamed to the browser by `www/dc_plugin.js`. + ## Controls | Input | Action | diff --git a/src/audio.rs b/src/audio.rs new file mode 100644 index 0000000..5a0b729 --- /dev/null +++ b/src/audio.rs @@ -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>, + 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 }); +} + +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 { + 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)); + } +} diff --git a/src/main.rs b/src/main.rs index af8fb2d..3309009 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ // DungeonCraft – dungeon building & defence in the browser (Rust + macroquad, WASM). +mod audio; mod game; mod gfx; mod i18n; @@ -9,8 +10,10 @@ mod platform; mod render3d; mod save; mod settings; +mod sfx; mod sound; mod ui; +mod xm; use game::{Game, Msg}; use gfx::Gfx; @@ -61,14 +64,45 @@ fn next_seed(seed: u32) -> u32 { seed.wrapping_mul(1103515245).wrapping_add(12345) | 1 } -fn apply_settings(s: &Settings, r3d: &mut R3d, sounds: &mut SoundBank) { +fn apply_settings(s: &Settings, r3d: &mut R3d) { i18n::set_language(&s.language); ui::set_ui_scale(s.ui_scale as f32); r3d.outlines = s.outlines; r3d.set_wall_detail(s.wall_subdivisions()); r3d.max_lights = s.max_lights as usize; - sounds.volume = s.volume; - sounds.muted = s.muted; + audio::set_volumes(s.music_volume, s.volume, s.muted); +} + +// Chooses the music for the current situation; build tracks rotate after two loops +struct MusicDirector { + build: usize, +} + +impl MusicDirector { + fn update(&mut self, g: &Game, title: bool) { + use audio::Track; + const BUILD: [Track; 3] = [Track::Build1, Track::Build2, Track::Build3]; + let gate_fight = g.time - g.gate_hit_t < 12.0 || g.ents.iter().any(|e| e.alive && e.guard); + let invasion = g.ents.iter().any(|e| e.alive && e.hero && !e.guard); + let want = if title { + Track::Title + } else if g.state == 1 { + Track::Victory + } else if g.state == 2 { + Track::Defeat + } else if gate_fight { + Track::Battle2 + } else if invasion { + Track::Battle1 + } else { + if BUILD.contains(&audio::current_track().unwrap_or(Track::Title)) && audio::music_loops() >= 2 { + self.build = (self.build + 1) % BUILD.len(); + } + BUILD[self.build] + }; + let fade = if matches!(want, Track::Victory | Track::Defeat) { 1.0 } else { 2.5 }; + audio::play_music(want, fade); + } } fn store_settings(s: &Settings) { @@ -107,7 +141,7 @@ impl App { } // Load a savegame (settings in the savegame are always applied) - fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, sounds: &mut SoundBank, now: f64) { + fn load_bytes(&mut self, bytes: &[u8], ui: &mut Ui, menu: &mut Menu, r3d: &mut R3d, now: f64) { match save::from_bytes(bytes) { Ok(l) => { self.game = l.game; @@ -116,7 +150,7 @@ impl App { } if let Some(s) = l.settings { self.settings = s; - apply_settings(&self.settings, r3d, sounds); + apply_settings(&self.settings, r3d); store_settings(&self.settings); } self.game.hints = self.settings.hints; @@ -141,7 +175,7 @@ async fn main() { let gfx = Gfx::new(); let mut r3d = R3d::new(); let mut sounds = SoundBank::new(); - sounds.generate_all().await; + let mut music = MusicDirector { build: 0 }; let mut ui = Ui::new(); let mut menu = Menu::new(); @@ -154,7 +188,7 @@ async fn main() { None => ui.show_langsel = true, } } - apply_settings(&settings, &mut r3d, &mut sounds); + apply_settings(&settings, &mut r3d); ui.show_help = false; menu.has_autosave = platform::storage_get(save::AUTOSAVE_KEY).is_some(); @@ -180,7 +214,7 @@ async fn main() { } } if let Some(bytes) = incoming { - app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, &mut sounds, now); + app.load_bytes(&bytes, &mut ui, &mut menu, &mut r3d, now); } // ---------------- key capture for the key binding dialog @@ -190,12 +224,12 @@ async fn main() { } else if let Some(k) = get_last_key_pressed() { if bindable_keys().any(|b| b == k) { app.settings.bind(action, k); - apply_settings(&app.settings, &mut r3d, &mut sounds); + apply_settings(&app.settings, &mut r3d); store_settings(&app.settings); menu.finish_capture(); } } - draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &inp, now); + draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &inp, now); next_frame().await; continue; } @@ -391,7 +425,7 @@ async fn main() { } MenuAction::Continue => { if let Some(s) = platform::storage_get(save::AUTOSAVE_KEY) { - app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, &mut sounds, now); + app.load_bytes(s.as_bytes(), &mut ui, &mut menu, &mut r3d, now); } } MenuAction::Load => { @@ -477,7 +511,7 @@ async fn main() { app.cam.tz = app.cam.tz.clamp(0.0, MH as f32); if settings_changed { - apply_settings(&app.settings, &mut r3d, &mut sounds); + apply_settings(&app.settings, &mut r3d); store_settings(&app.settings); app.game.hints = app.settings.hints; } @@ -503,20 +537,22 @@ async fn main() { } } let hear = app.cam.zoom * 1.2; + let (right, _) = app.cam.ground_axes(); for ev in app.game.events.drain(..) { - sounds.play(ev, vec2(app.cam.tx, app.cam.tz), hear, now); + sounds.play(ev, vec2(app.cam.tx, app.cam.tz), right, hear, now); } + music.update(&app.game, menu.page == Some(Page::Title) || matches!(menu.page, Some(Page::Settings { from_title: true, .. }) | Some(Page::KeyCapture { from_title: true, .. }))); for m in app.game.msgs.drain(..) { ui.log.push((m, now)); } - draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &sounds, &inp, now); + draw_frame(&mut app, &gfx, &mut r3d, &mut ui, &mut menu, &inp, now); next_frame().await } } #[allow(clippy::too_many_arguments)] -fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, sounds: &SoundBank, inp: &Input, now: f64) { +fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut Menu, inp: &Input, now: f64) { let (mx, my) = mouse_position(); let blocked = menu.open() || ui.show_help || ui.show_langsel; let over_ui = blocked || ui.over_ui(mx, my); @@ -552,7 +588,7 @@ fn draw_frame(app: &mut App, gfx: &Gfx, r3d: &mut R3d, ui: &mut Ui, menu: &mut M let ent = r3d.pick_ent(&app.game, &app.cam, mx, my, false); hover_info(&app.game, ent, hover_tile) }; - ui.draw(&app.game, gfx, &app.cam, sounds, &app.settings, &info, now); + ui.draw(&app.game, gfx, &app.cam, &app.settings, &info, now); } else { ui.rects.clear(); } diff --git a/src/sfx.rs b/src/sfx.rs new file mode 100644 index 0000000..289c6ee --- /dev/null +++ b/src/sfx.rs @@ -0,0 +1,155 @@ +// Chip-style sound effects, synthesised once when audio starts. +// Each effect is a list of layers; a layer is a sequence of segments +// (waveform, start/end frequency, duration, volume, decay). + +use crate::game::{Sfx, SFX_ALL}; + +#[derive(Clone, Copy)] +enum W { + Square(f32), // duty cycle + Tri, + Saw, + Noise, + Sine, +} + +#[derive(Clone, Copy)] +struct Seg { + w: W, + f0: f32, + f1: f32, + dur: f32, + vol: f32, + decay: f32, // exponential decay rate per second (0 = flat) + vib: f32, // vibrato depth (fraction of frequency) +} + +fn seg(w: W, f0: f32, f1: f32, dur: f32, vol: f32, decay: f32) -> Seg { + Seg { w, f0, f1, dur, vol, decay, vib: 0.0 } +} + +fn vib(mut s: Seg, v: f32) -> Seg { + s.vib = v; + s +} + +fn render_layer(rate: f32, segs: &[Seg], seed: u32) -> Vec { + let mut out = Vec::new(); + let mut ph = 0.0f32; + let mut rng = seed | 1; + let mut noise = 0.0f32; + let mut noise_ph = 0.0f32; + for s in segs { + let n = (s.dur * rate) as usize; + let attack = (rate * 0.003) as usize; + let release = (rate * 0.008) as usize; + for i in 0..n { + let t = i as f32 / rate; + let k = i as f32 / n.max(1) as f32; + let mut f = s.f0 * (s.f1 / s.f0).powf(k); // exponential sweep + if s.vib > 0.0 { + f *= 1.0 + s.vib * (t * 2.0 * std::f32::consts::PI * 7.0).sin(); + } + ph = (ph + f / rate).fract(); + let v = match s.w { + W::Square(d) => { + if ph < d { + 1.0 + } else { + -1.0 + } + } + W::Tri => 4.0 * (ph - 0.5).abs() - 1.0, + W::Saw => 2.0 * ph - 1.0, + W::Sine => (ph * 2.0 * std::f32::consts::PI).sin(), + W::Noise => { + // sample-and-hold noise; frequency controls the "pitch" of the noise + noise_ph += f / rate; + if noise_ph >= 1.0 { + noise_ph -= 1.0; + rng ^= rng << 13; + rng ^= rng >> 17; + rng ^= rng << 5; + noise = (rng as f32 / u32::MAX as f32) * 2.0 - 1.0; + } + noise + } + }; + let mut env = (-s.decay * t).exp(); + if i < attack { + env *= i as f32 / attack as f32; + } + if n - i < release { + env *= (n - i) as f32 / release as f32; + } + out.push(v * s.vol * env); + } + } + out +} + +fn mix(layers: Vec>) -> Vec { + let n = layers.iter().map(|l| l.len()).max().unwrap_or(0); + let mut out = vec![0.0; n]; + for l in layers { + for (o, v) in out.iter_mut().zip(l) { + *o += v; + } + } + out +} + +fn arp(w: W, notes: &[f32], each: f32, vol: f32) -> Vec { + notes.iter().map(|&f| seg(w, f, f, each, vol, 6.0)).collect() +} + +fn def(s: Sfx, r: f32) -> Vec { + use W::*; + let l = |segs: &[Seg], seed: u32| render_layer(r, segs, seed); + match s { + Sfx::Dig => mix(vec![l(&[seg(Noise, 3000.0, 1500.0, 0.06, 0.35, 40.0)], 1), l(&[seg(Tri, 140.0, 70.0, 0.08, 0.5, 30.0)], 2)]), + Sfx::GoldDig => mix(vec![l(&[seg(Square(0.25), 1568.0, 2093.0, 0.07, 0.22, 25.0)], 3), l(&[seg(Noise, 6000.0, 6000.0, 0.02, 0.2, 80.0)], 4)]), + Sfx::DigDone => mix(vec![l(&[seg(Noise, 2500.0, 400.0, 0.3, 0.4, 9.0)], 5), l(&[seg(Tri, 110.0, 45.0, 0.25, 0.6, 12.0)], 6)]), + Sfx::Claim => l(&[seg(Square(0.5), 440.0, 660.0, 0.07, 0.12, 20.0)], 7), + Sfx::Deposit => l(&[seg(Square(0.25), 988.0, 988.0, 0.05, 0.22, 8.0), seg(Square(0.25), 1319.0, 1319.0, 0.14, 0.22, 14.0)], 8), + Sfx::Build => mix(vec![l(&[seg(Tri, 220.0, 80.0, 0.14, 0.6, 15.0)], 9), l(&[seg(Noise, 1800.0, 900.0, 0.08, 0.25, 30.0)], 10)]), + Sfx::Sell => l(&[seg(Square(0.25), 1319.0, 1319.0, 0.05, 0.2, 8.0), seg(Square(0.25), 988.0, 988.0, 0.12, 0.2, 14.0)], 11), + Sfx::MeleeHit => mix(vec![l(&[seg(Noise, 4000.0, 2000.0, 0.07, 0.4, 35.0)], 12), l(&[seg(Tri, 200.0, 90.0, 0.08, 0.5, 30.0)], 13)]), + Sfx::Shoot => l(&[seg(Square(0.125), 900.0, 300.0, 0.13, 0.25, 12.0)], 14), + Sfx::Death => l(&[vib(seg(Square(0.5), 420.0, 70.0, 0.4, 0.3, 5.0), 0.04)], 15), + Sfx::HeroDeath => mix(vec![l(&[seg(Noise, 2000.0, 300.0, 0.3, 0.3, 8.0)], 16), l(&[seg(Tri, 330.0, 60.0, 0.35, 0.5, 6.0)], 17)]), + Sfx::Lightning => mix(vec![l(&[seg(Noise, 9000.0, 2000.0, 0.55, 0.6, 5.0)], 18), l(&[seg(Tri, 80.0, 40.0, 0.6, 0.6, 4.0)], 19)]), + Sfx::Heal => l(&arp(Sine, &[523.0, 659.0, 784.0, 1047.0], 0.07, 0.45), 20), + Sfx::Summon => l(&[vib(seg(Square(0.25), 300.0, 900.0, 0.45, 0.22, 2.0), 0.06)], 21), + Sfx::PortalArrive => l(&[vib(seg(Saw, 900.0, 260.0, 0.45, 0.2, 3.0), 0.08)], 22), + Sfx::WaveAlarm => l(&[ + seg(Square(0.5), 660.0, 660.0, 0.13, 0.2, 0.0), + seg(Square(0.5), 880.0, 880.0, 0.13, 0.2, 0.0), + seg(Square(0.5), 660.0, 660.0, 0.13, 0.2, 0.0), + seg(Square(0.5), 880.0, 880.0, 0.13, 0.2, 0.0), + ], 23), + Sfx::WaveStart => mix(vec![l(&[seg(Saw, 220.0, 220.0, 0.7, 0.22, 2.0)], 24), l(&[seg(Saw, 330.0, 330.0, 0.7, 0.16, 2.0)], 25)]), + Sfx::CoreHit => mix(vec![l(&[seg(Tri, 95.0, 40.0, 0.22, 0.7, 10.0)], 26), l(&[seg(Noise, 1200.0, 600.0, 0.12, 0.25, 25.0)], 27)]), + Sfx::GateHit => mix(vec![l(&[seg(Square(0.5), 310.0, 290.0, 0.1, 0.15, 25.0)], 28), l(&[seg(Square(0.5), 465.0, 440.0, 0.1, 0.12, 25.0)], 29), l(&[seg(Noise, 5000.0, 3000.0, 0.05, 0.2, 50.0)], 30)]), + Sfx::GateDefenders => l(&[seg(Saw, 440.0, 440.0, 0.25, 0.22, 1.0), seg(Saw, 330.0, 330.0, 0.5, 0.22, 2.0)], 31), + Sfx::LevelUp => l(&arp(Square(0.25), &[523.0, 659.0, 784.0, 1047.0, 1319.0], 0.05, 0.2), 32), + Sfx::Pickup => l(&[seg(Tri, 300.0, 600.0, 0.06, 0.4, 10.0)], 33), + Sfx::Drop => l(&[seg(Tri, 600.0, 250.0, 0.08, 0.4, 10.0)], 34), + Sfx::Payday => l(&[ + seg(Square(0.25), 988.0, 988.0, 0.05, 0.18, 8.0), + seg(Square(0.25), 1319.0, 1319.0, 0.05, 0.18, 8.0), + seg(Square(0.25), 988.0, 988.0, 0.05, 0.18, 8.0), + seg(Square(0.25), 1568.0, 1568.0, 0.15, 0.18, 10.0), + ], 35), + Sfx::FlagSet => l(&[seg(Square(0.5), 392.0, 392.0, 0.08, 0.2, 6.0), seg(Square(0.5), 523.0, 523.0, 0.16, 0.2, 6.0)], 36), + Sfx::Victory => l(&arp(Square(0.25), &[587.0, 740.0, 880.0, 1175.0], 0.08, 0.2), 37), + Sfx::Defeat => l(&[seg(Square(0.5), 220.0, 110.0, 0.6, 0.22, 2.0)], 38), + Sfx::Click => l(&[seg(Square(0.5), 1000.0, 1000.0, 0.02, 0.12, 50.0)], 39), + Sfx::Error => l(&[seg(Square(0.5), 110.0, 105.0, 0.16, 0.2, 4.0)], 40), + } +} + +// One sample buffer per effect, in SFX_ALL order +pub fn generate_all(rate: f32) -> Vec> { + SFX_ALL.iter().map(|&s| def(s, rate)).collect() +} diff --git a/src/sound.rs b/src/sound.rs index 1f40723..e5e1cee 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -1,56 +1,20 @@ -// Sound playback. The game logic reports events (Sfx), they are played here. -// Sounds will be generated later and registered via `SoundBank::set` (WAV bytes). -// Events without a registered sound stay silent. +// Sound effect dispatch: turns game sound events into positioned effect voices +// (volume by distance to the camera, stereo panning along the screen's x axis). +// The audio itself is rendered by audio.rs. +use crate::audio; use crate::game::{Sfx, SfxEvent, SFX_ALL}; -#[cfg(feature = "audio")] -use macroquad::audio::{load_sound_from_bytes, play_sound, PlaySoundParams, Sound}; -#[cfg(not(feature = "audio"))] -use stub::*; - -// Silent stand-in when the "audio" feature is off (native tests) -#[cfg(not(feature = "audio"))] -mod stub { - #[derive(Clone)] - pub struct Sound; - pub struct PlaySoundParams { - pub looped: bool, - pub volume: f32, - } - pub async fn load_sound_from_bytes(_: &[u8]) -> Result { - Err(()) - } - pub fn play_sound(_: &Sound, _: PlaySoundParams) {} -} use macroquad::prelude::*; pub struct SoundBank { - sounds: Vec>, last: Vec, - pub volume: f32, - pub muted: bool, } impl SoundBank { pub fn new() -> SoundBank { - SoundBank { sounds: vec![None; SFX_ALL.len()], last: vec![-1.0; SFX_ALL.len()], volume: 0.7, muted: false } + SoundBank { last: vec![-1.0; SFX_ALL.len()] } } - // register the sound for an event (WAV or OGG bytes) - #[allow(dead_code)] - pub async fn set(&mut self, sfx: Sfx, bytes: &[u8]) -> bool { - match load_sound_from_bytes(bytes).await { - Ok(s) => { - self.sounds[sfx.index()] = Some(s); - true - } - Err(_) => false, - } - } - - // procedurally generated sounds will be registered here - pub async fn generate_all(&mut self) {} - // minimum gap between two identical sounds (avoids a wall of noise) fn min_gap(sfx: Sfx) -> f64 { match sfx { @@ -60,51 +24,25 @@ impl SoundBank { } } - // listener: camera target on the ground; hear: audible radius in tiles - pub fn play(&mut self, ev: SfxEvent, listener: Vec2, hear: f32, now: f64) { - if self.muted || self.volume <= 0.0 { - return; - } + // listener: camera target on the ground; right: screen-right direction on the ground; + // hear: audible radius in tiles + pub fn play(&mut self, ev: SfxEvent, listener: Vec2, right: Vec2, hear: f32, now: f64) { let i = ev.sfx.index(); - let Some(sound) = &self.sounds[i] else { return }; if now - self.last[i] < Self::min_gap(ev.sfx) { return; } - // volume by distance to the listener - let mut vol = self.volume; + let mut vol = 1.0; + let mut pan = 0.0; if let Some((wx, wy)) = ev.pos { - let d = ((wx - listener.x).powi(2) + (wy - listener.y).powi(2)).sqrt(); - let f = 1.0 - ((d - hear * 0.5) / hear).clamp(0.0, 1.0); + let d = vec2(wx, wy) - listener; + let f = 1.0 - ((d.length() - hear * 0.5) / hear).clamp(0.0, 1.0); if f <= 0.05 { return; } vol *= f; + pan = (d.dot(right) / hear).clamp(-1.0, 1.0) * 0.7; } self.last[i] = now; - play_sound(sound, PlaySoundParams { looped: false, volume: vol }); + audio::play_sfx(i, vol, pan); } } - -// helper for sound generation: samples (-1..1, mono) -> WAV (16 bit PCM) -#[allow(dead_code)] -pub fn wav_from_samples(samples: &[f32], rate: u32) -> Vec { - let data_len = (samples.len() * 2) as u32; - let mut out = Vec::with_capacity(44 + data_len as usize); - out.extend_from_slice(b"RIFF"); - out.extend_from_slice(&(36 + data_len).to_le_bytes()); - out.extend_from_slice(b"WAVEfmt "); - out.extend_from_slice(&16u32.to_le_bytes()); - out.extend_from_slice(&1u16.to_le_bytes()); // PCM - out.extend_from_slice(&1u16.to_le_bytes()); // mono - out.extend_from_slice(&rate.to_le_bytes()); - out.extend_from_slice(&(rate * 2).to_le_bytes()); - out.extend_from_slice(&2u16.to_le_bytes()); - out.extend_from_slice(&16u16.to_le_bytes()); - out.extend_from_slice(b"data"); - out.extend_from_slice(&data_len.to_le_bytes()); - for &s in samples { - let v = (s.clamp(-1.0, 1.0) * 32767.0) as i16; - out.extend_from_slice(&v.to_le_bytes()); - } - out -} diff --git a/src/ui.rs b/src/ui.rs index 407385f..405856f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -7,7 +7,6 @@ use crate::i18n::{languages, tr, trf}; use crate::map::*; use crate::render3d::{minimap_color, Cam}; use crate::settings::{key_name, Action, Settings}; -use crate::sound::SoundBank; use macroquad::prelude::*; // UI font scale (setting: 2 = normal, 3 = large); character cell size follows it @@ -237,7 +236,7 @@ impl Ui { UiAction::None } - pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, snd: &SoundBank, settings: &Settings, info: &[(String, Color)], now: f64) { + pub fn draw(&mut self, g: &Game, gfx: &Gfx, cam: &Cam, settings: &Settings, info: &[(String, Color)], now: f64) { self.rects.clear(); let keys: Vec = (0..TOOLS.len()).map(|i| key_name(settings.key(Action::Tool(i)))).collect(); let sw = screen_width(); @@ -280,7 +279,7 @@ impl Ui { let wave_col = if g.wave_timer < 30.0 { RED } else { TXT }; seg(&trf("hud.wave", &[(g.wave + 1).to_string()]), wave_col, &mmss(g.wave_timer), wave_col, &mut x); seg(&tr("hud.army"), TXT, &format!("{}", g.army_value() as i32), TXT, &mut x); - let vol = if snd.muted { tr("hud.off") } else { format!("{}%", (snd.volume * 100.0).round() as i32) }; + let vol = if settings.muted { tr("hud.off") } else { format!("{}%", (settings.volume * 100.0).round() as i32) }; // volume only if it still fits into the bar if x + gfx.measure(&format!("♪ {}", vol), S()) < sw - CW() * 2.0 { seg("♪", DIM, &vol, DIM, &mut x); diff --git a/src/xm.rs b/src/xm.rs new file mode 100644 index 0000000..7334b4f --- /dev/null +++ b/src/xm.rs @@ -0,0 +1,1186 @@ +// Tracker module replayer for FastTracker 2 XM and ProTracker MOD files. +// +// Both formats are loaded into one internal representation (MOD notes are converted from +// periods to XM note numbers). Supported: instruments with sample maps, volume and panning +// envelopes, fadeout, auto-vibrato, linear and Amiga frequency tables, volume column effects +// and the common effects (arpeggio, portamento, tone portamento, vibrato, tremolo, panning, +// sample offset, volume slides, position jump, pattern break/loop/delay, retrigger, note +// cut/delay, speed/tempo, global volume, key off, panning slide, extra fine portamento). + +#[derive(Clone, Default)] +pub struct Sample { + pub data: Vec, + pub loop_start: usize, + pub loop_len: usize, + pub loop_type: u8, // 0 none, 1 forward, 2 ping-pong + pub volume: u8, + pub finetune: i8, + pub panning: u8, + pub relnote: i8, +} + +#[derive(Clone, Default)] +pub struct Envelope { + pub points: Vec<(u16, u16)>, + pub sustain: Option, + pub lp: Option<(usize, usize)>, + pub on: bool, +} + +#[derive(Clone)] +pub struct Instrument { + pub samples: Vec, + pub map: [u8; 96], + pub vol_env: Envelope, + pub pan_env: Envelope, + pub fadeout: u16, + pub vib_type: u8, + pub vib_sweep: u8, + pub vib_depth: u8, + pub vib_rate: u8, +} + +#[derive(Clone, Copy, Default, Debug, PartialEq)] +pub struct Cell { + pub note: u8, // 0 none, 1..96 notes (C-0 = 1), 97 key off + pub inst: u8, + pub vol: u8, // volume column + pub fx: u8, + pub param: u8, +} + +pub struct Pattern { + pub rows: usize, + pub cells: Vec, +} + +pub struct Module { + #[allow(dead_code)] // shown by tools/debugging + pub name: String, + pub channels: usize, + pub order: Vec, + pub restart: usize, + pub patterns: Vec, + pub instruments: Vec, + pub linear: bool, + pub speed: u32, + pub bpm: u32, + pub pans: Vec, // initial channel panning +} + +// ------------------------------------------------------------------ loading + +fn u16le(b: &[u8], o: usize) -> u16 { + b.get(o..o + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} +fn u32le(b: &[u8], o: usize) -> u32 { + b.get(o..o + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} +fn u16be(b: &[u8], o: usize) -> u16 { + b.get(o..o + 2).map_or(0, |s| u16::from_be_bytes([s[0], s[1]])) +} +fn byte(b: &[u8], o: usize) -> u8 { + b.get(o).copied().unwrap_or(0) +} + +pub fn load(b: &[u8]) -> Result { + if b.len() > 17 && &b[0..17] == b"Extended Module: " { + load_xm(b) + } else { + load_mod(b) + } +} + +pub fn load_xm(b: &[u8]) -> Result { + if b.len() < 80 || &b[0..17] != b"Extended Module: " { + return Err("not an XM file".into()); + } + let name = String::from_utf8_lossy(&b[17..37]).trim_end().to_string(); + let hdr_size = u32le(b, 60) as usize; + let song_len = u16le(b, 64) as usize; + let restart = u16le(b, 66) as usize; + let channels = (u16le(b, 68) as usize).clamp(1, 32); + let npat = u16le(b, 70) as usize; + let ninst = u16le(b, 72) as usize; + let flags = u16le(b, 74); + let speed = u16le(b, 76).max(1) as u32; + let bpm = u16le(b, 78).max(32) as u32; + let order: Vec = (0..song_len.min(256)).map(|i| byte(b, 80 + i)).collect(); + let mut o = 60 + hdr_size; + + let mut patterns = Vec::with_capacity(npat); + for _ in 0..npat { + let plen = u32le(b, o) as usize; + let rows = (u16le(b, o + 5) as usize).clamp(1, 256); + let dsize = u16le(b, o + 7) as usize; + o += plen; + let mut cells = vec![Cell::default(); rows * channels]; + if dsize > 0 { + let data = b.get(o..o + dsize).ok_or("truncated pattern")?; + let mut i = 0; + for c in cells.iter_mut() { + if i >= data.len() { + break; + } + let first = data[i]; + i += 1; + let mut next = |cond: bool| -> u8 { + if cond && i < data.len() { + i += 1; + data[i - 1] + } else { + 0 + } + }; + if first & 0x80 != 0 { + c.note = next(first & 1 != 0); + c.inst = next(first & 2 != 0); + c.vol = next(first & 4 != 0); + c.fx = next(first & 8 != 0); + c.param = next(first & 16 != 0); + } else { + c.note = first; + c.inst = next(true); + c.vol = next(true); + c.fx = next(true); + c.param = next(true); + } + } + } + o += dsize; + patterns.push(Pattern { rows, cells }); + } + + let mut instruments = Vec::with_capacity(ninst); + for _ in 0..ninst { + let isize = u32le(b, o) as usize; + let nsmp = u16le(b, o + 27) as usize; + let mut ins = Instrument { + samples: Vec::new(), + map: [0; 96], + vol_env: Envelope::default(), + pan_env: Envelope::default(), + fadeout: 0, + vib_type: 0, + vib_sweep: 0, + vib_depth: 0, + vib_rate: 0, + }; + if nsmp == 0 { + o += isize.max(29); + instruments.push(ins); + continue; + } + let shsize = u32le(b, o + 29) as usize; + for k in 0..96 { + ins.map[k] = byte(b, o + 33 + k); + } + let env = |base: usize, n: usize, sus: u8, ls: u8, le: u8, typ: u8| -> Envelope { + let n = n.min(12); + Envelope { + points: (0..n).map(|k| (u16le(b, base + k * 4), u16le(b, base + k * 4 + 2).min(64))).collect(), + sustain: if typ & 2 != 0 { Some(sus as usize) } else { None }, + lp: if typ & 4 != 0 { Some((ls as usize, le as usize)) } else { None }, + on: typ & 1 != 0 && n > 0, + } + }; + ins.vol_env = env(o + 129, byte(b, o + 225) as usize, byte(b, o + 227), byte(b, o + 228), byte(b, o + 229), byte(b, o + 233)); + ins.pan_env = env(o + 177, byte(b, o + 226) as usize, byte(b, o + 230), byte(b, o + 231), byte(b, o + 232), byte(b, o + 234)); + ins.vib_type = byte(b, o + 235); + ins.vib_sweep = byte(b, o + 236); + ins.vib_depth = byte(b, o + 237); + ins.vib_rate = byte(b, o + 238); + ins.fadeout = u16le(b, o + 239); + o += isize; + // sample headers, then sample data + let mut heads = Vec::new(); + for _ in 0..nsmp { + heads.push(( + u32le(b, o) as usize, + u32le(b, o + 4) as usize, + u32le(b, o + 8) as usize, + byte(b, o + 12), + byte(b, o + 13) as i8, + byte(b, o + 14), + byte(b, o + 15), + byte(b, o + 16) as i8, + )); + o += shsize.max(40); + } + for (len, ls, ll, vol, ft, typ, pan, rel) in heads { + let bits16 = typ & 0x10 != 0; + let raw = b.get(o..(o + len).min(b.len())).unwrap_or(&[]); + o += len; + let mut data = Vec::new(); + if bits16 { + let mut acc: i16 = 0; + for k in 0..raw.len() / 2 { + acc = acc.wrapping_add(i16::from_le_bytes([raw[k * 2], raw[k * 2 + 1]])); + data.push(acc as f32 / 32768.0); + } + } else { + let mut acc: i8 = 0; + for &v in raw { + acc = acc.wrapping_add(v as i8); + data.push(acc as f32 / 128.0); + } + } + let div = if bits16 { 2 } else { 1 }; + let mut s = Sample { + loop_start: ls / div, + loop_len: ll / div, + loop_type: typ & 3, + volume: vol.min(64), + finetune: ft, + panning: pan, + relnote: rel, + data, + }; + if s.loop_type == 3 { + s.loop_type = 1; + } + if s.loop_len == 0 || s.loop_start >= s.data.len() { + s.loop_type = 0; + } + s.loop_len = s.loop_len.min(s.data.len() - s.loop_start.min(s.data.len())); + ins.samples.push(s); + } + instruments.push(ins); + } + Ok(Module { + name, + channels, + order, + restart, + patterns, + instruments, + linear: flags & 1 != 0, + speed, + bpm, + pans: vec![128; channels], + }) +} + +// ProTracker periods for octaves 1..3 (finetune 0), C-1 = 856 +const PT_PERIODS: [u16; 36] = [ + 856, 808, 762, 720, 678, 640, 604, 570, 538, 508, 480, 453, 428, 404, 381, 360, 339, 320, 302, 285, 269, 254, 240, 226, 214, 202, 190, 180, 170, 160, 151, 143, 135, 127, 120, 113, +]; + +pub fn load_mod(b: &[u8]) -> Result { + if b.len() < 1084 { + return Err("not a MOD file".into()); + } + let tag = &b[1080..1084]; + let channels = match tag { + b"M.K." | b"M!K!" | b"FLT4" | b"4CHN" => 4, + b"6CHN" => 6, + b"8CHN" | b"OCTA" | b"CD81" | b"FLT8" => 8, + t if t[2] == b'C' && t[3] == b'H' && t[0].is_ascii_digit() && t[1].is_ascii_digit() => ((t[0] - b'0') * 10 + (t[1] - b'0')) as usize, + t if &t[1..] == b"CHN" && t[0].is_ascii_digit() => (t[0] - b'0') as usize, + _ => return Err("unknown MOD format".into()), + }; + let channels = channels.clamp(1, 32); + let name = String::from_utf8_lossy(&b[0..20]).trim_end_matches('\0').trim_end().to_string(); + let song_len = (byte(b, 950) as usize).clamp(1, 128); + let restart = byte(b, 951) as usize; + let all_orders: Vec = (0..128).map(|i| byte(b, 952 + i)).collect(); + let order: Vec = all_orders[..song_len].to_vec(); + let npat = *all_orders.iter().max().unwrap_or(&0) as usize + 1; + let mut o = 1084; + let mut patterns = Vec::with_capacity(npat); + for _ in 0..npat { + let mut cells = vec![Cell::default(); 64 * channels]; + for c in cells.iter_mut() { + let (b0, b1, b2, b3) = (byte(b, o), byte(b, o + 1), byte(b, o + 2), byte(b, o + 3)); + o += 4; + let period = (((b0 & 0x0F) as u16) << 8) | b1 as u16; + c.inst = (b0 & 0xF0) | (b2 >> 4); + c.fx = b2 & 0x0F; + c.param = b3; + if period > 0 { + let (mut best, mut bd) = (0usize, u16::MAX); + for (k, &p) in PT_PERIODS.iter().enumerate() { + let d = p.abs_diff(period); + if d < bd { + bd = d; + best = k; + } + } + c.note = (best + 37) as u8; // PT C-1 -> XM C-3 + } + // MOD "set volume" via effect C stays an effect + if c.fx == 0 && c.param == 0 { + c.fx = 0; + } + } + patterns.push(Pattern { rows: 64, cells }); + } + let mut instruments = Vec::with_capacity(31); + for s in 0..31 { + let h = 20 + s * 30; + let len = u16be(b, h + 22) as usize * 2; + let ft = (byte(b, h + 24) & 0x0F) as i8; + let ft = if ft > 7 { ft - 16 } else { ft }; + let vol = byte(b, h + 25).min(64); + let ls = u16be(b, h + 26) as usize * 2; + let ll = u16be(b, h + 28) as usize * 2; + let raw = b.get(o..(o + len).min(b.len())).unwrap_or(&[]); + o += len; + let data: Vec = raw.iter().map(|&v| v as i8 as f32 / 128.0).collect(); + let looped = ll > 2 && ls < data.len(); + let mut smp = Sample { + loop_start: if looped { ls } else { 0 }, + loop_len: if looped { ll.min(data.len() - ls) } else { 0 }, + loop_type: if looped { 1 } else { 0 }, + volume: vol, + finetune: ft * 16, + panning: 128, + relnote: 0, + data, + }; + if smp.loop_len == 0 { + smp.loop_type = 0; + } + instruments.push(Instrument { + samples: vec![smp], + map: [0; 96], + vol_env: Envelope::default(), + pan_env: Envelope::default(), + fadeout: 0, + vib_type: 0, + vib_sweep: 0, + vib_depth: 0, + vib_rate: 0, + }); + } + // Amiga LRRL panning, softened + let pans = (0..channels).map(|c| if c % 4 == 0 || c % 4 == 3 { 64 } else { 192 }).collect(); + Ok(Module { name, channels, order, restart: if restart < song_len { restart } else { 0 }, patterns, instruments, linear: false, speed: 6, bpm: 125, pans }) +} + +// ------------------------------------------------------------------ playback + +#[derive(Clone, Default)] +struct Chan { + inst: usize, // 1-based, 0 = none + smp: Option, + note: i32, // real note (0 = C-0) incl. relnote + finetune: i32, + period: f32, + target: f32, + pos: f64, + backwards: bool, + active: bool, + vol: i32, + pan: i32, + key_on: bool, + fade: i32, + venv_pos: u16, + penv_pos: u16, + // effect state + cell: Cell, + arp: u8, + porta_up: u8, + porta_down: u8, + fine_up: u8, + fine_down: u8, + xfine_up: u8, + xfine_down: u8, + tone_speed: u8, + vib_speed: u8, + vib_depth: u8, + vib_pos: u8, + trem_speed: u8, + trem_depth: u8, + trem_pos: u8, + vol_slide: u8, + pan_slide: u8, + offset: u8, + retrig: u8, + loop_row: usize, + loop_count: u8, + delayed: Option, + av_pos: u8, + av_sweep: u32, + // per-tick output + vib_off: f32, + trem_off: i32, + step: f64, + out_l: f32, + out_r: f32, + cur_l: f32, + cur_r: f32, +} + +pub struct Player { + pub module: std::rc::Rc, + rate: f32, + speed: u32, + bpm: u32, + tick: u32, + row: usize, + pos: usize, + pdelay: u32, + pdelay_active: bool, + global: i32, + global_slide: u8, + chans: Vec, + samples_left: f32, + jump: Option, + brk: Option, + loop_jump: Option, + pub loops: u32, // how many times the song wrapped around +} + +fn sine(pos: u8) -> f32 { + (pos as f32 / 64.0 * std::f32::consts::TAU).sin() +} + +impl Player { + pub fn new(module: std::rc::Rc, rate: f32) -> Player { + let n = module.channels; + let mut chans = vec![Chan::default(); n]; + for (c, ch) in chans.iter_mut().enumerate() { + ch.pan = module.pans.get(c).copied().unwrap_or(128) as i32; + ch.fade = 65536; + } + let mut p = Player { + speed: module.speed, + bpm: module.bpm, + module, + rate, + tick: 0, + row: 0, + pos: 0, + pdelay: 0, + pdelay_active: false, + global: 64, + global_slide: 0, + chans, + samples_left: 0.0, + jump: None, + brk: None, + loop_jump: None, + loops: 0, + }; + p.skip_invalid_orders(); + p + } + + fn skip_invalid_orders(&mut self) { + let m = &self.module; + let mut guard = 0; + while guard < 300 && (self.pos >= m.order.len() || m.order[self.pos] as usize >= m.patterns.len()) { + if self.pos >= m.order.len() { + self.pos = if m.restart < m.order.len() { m.restart } else { 0 }; + self.loops += 1; + } else { + self.pos += 1; + } + guard += 1; + } + } + + fn period_of(&self, note: i32, finetune: i32) -> f32 { + if self.module.linear { + (7680 - note * 64 - finetune / 2) as f32 + } else { + 1712.0 * 2f32.powf((48.0 - note as f32 - finetune as f32 / 128.0) / 12.0) + } + } + + fn freq_of(&self, period: f32) -> f32 { + if self.module.linear { + 8363.0 * 2f32.powf((4608.0 - period) / 768.0) + } else if period > 0.0 { + 8363.0 * 1712.0 / period + } else { + 0.0 + } + } + + fn sample<'a>(m: &'a Module, ch: &Chan) -> Option<&'a Sample> { + let ins = m.instruments.get(ch.inst.checked_sub(1)?)?; + ins.samples.get(ch.smp?) + } + + fn env_value(env: &Envelope, pos: u16) -> f32 { + let pts = &env.points; + if pts.is_empty() { + return 64.0; + } + for k in 0..pts.len() - 1 { + let (x0, y0) = pts[k]; + let (x1, y1) = pts[k + 1]; + if pos >= x0 && pos <= x1 { + if x1 == x0 { + return y1 as f32; + } + let t = (pos - x0) as f32 / (x1 - x0) as f32; + return y0 as f32 + (y1 as f32 - y0 as f32) * t; + } + } + pts.last().unwrap().1 as f32 + } + + fn env_advance(env: &Envelope, pos: &mut u16, key_on: bool) { + if !env.on || env.points.is_empty() { + return; + } + if let Some(s) = env.sustain { + if key_on && s < env.points.len() && *pos == env.points[s].0 { + return; + } + } + *pos = pos.saturating_add(1); + if let Some((ls, le)) = env.lp { + if le < env.points.len() && ls <= le && *pos >= env.points[le].0 { + *pos = env.points[ls].0; + } + } + let last = env.points.last().unwrap().0; + if *pos > last { + *pos = last; + } + } + + // A note starts (or restarts) on a channel + fn trigger(&mut self, c: usize, note: u8, offset: Option) { + let m = self.module.clone(); + let ch = &mut self.chans[c]; + let Some(ins) = m.instruments.get(ch.inst.wrapping_sub(1)) else { + ch.active = false; + return; + }; + let si = ins.map[(note as usize - 1).min(95)] as usize; + let Some(s) = ins.samples.get(si) else { + ch.active = false; + return; + }; + ch.smp = Some(si); + ch.note = note as i32 - 1 + s.relnote as i32; + ch.finetune = s.finetune as i32; + let p = self.period_of(self.chans[c].note, self.chans[c].finetune); + let ch = &mut self.chans[c]; + ch.period = p; + ch.target = p; + ch.pos = offset.map_or(0.0, |o| o as f64 * 256.0); + if ch.pos >= s.data.len() as f64 { + ch.active = false; + return; + } + ch.backwards = false; + ch.active = true; + ch.vib_pos = 0; + ch.trem_pos = 0; + } + + fn reset_instrument(&mut self, c: usize) { + let m = self.module.clone(); + let ch = &mut self.chans[c]; + if let Some(s) = Self::sample(&m, ch) { + ch.vol = s.volume as i32; + ch.pan = s.panning as i32; + } + ch.key_on = true; + ch.fade = 65536; + ch.venv_pos = 0; + ch.penv_pos = 0; + ch.av_pos = 0; + ch.av_sweep = 0; + } + + fn key_off(&mut self, c: usize) { + let m = self.module.clone(); + let ch = &mut self.chans[c]; + ch.key_on = false; + let env_on = m.instruments.get(ch.inst.wrapping_sub(1)).map_or(false, |i| i.vol_env.on); + if !env_on { + ch.vol = 0; + } + } + + fn apply_cell(&mut self, c: usize, cell: Cell) { + let m = self.module.clone(); + let tone_porta = cell.fx == 3 || cell.fx == 5 || cell.vol >= 0xF0; + let offset = if cell.fx == 9 { + if cell.param > 0 { + self.chans[c].offset = cell.param; + } + Some(self.chans[c].offset) + } else { + None + }; + if cell.inst > 0 && (cell.inst as usize) <= m.instruments.len() { + self.chans[c].inst = cell.inst as usize; + } + if cell.note == 97 { + self.key_off(c); + } else if (1..=96).contains(&cell.note) { + if tone_porta && self.chans[c].active { + let ins = m.instruments.get(self.chans[c].inst.wrapping_sub(1)); + let rel = ins.and_then(|i| i.samples.get(self.chans[c].smp.unwrap_or(0))).map_or(0, |s| s.relnote as i32); + let ft = self.chans[c].finetune; + self.chans[c].target = self.period_of(cell.note as i32 - 1 + rel, ft); + } else { + self.trigger(c, cell.note, offset); + } + } + if cell.inst > 0 && cell.note != 97 { + self.reset_instrument(c); + } + // volume column (tick 0 part) + let v = cell.vol; + let ch = &mut self.chans[c]; + match v { + 0x10..=0x50 => ch.vol = (v - 0x10) as i32, + 0x80..=0x8F => ch.vol = (ch.vol - (v & 15) as i32).max(0), + 0x90..=0x9F => ch.vol = (ch.vol + (v & 15) as i32).min(64), + 0xA0..=0xAF => ch.vib_speed = v & 15, + 0xB0..=0xBF => { + if v & 15 > 0 { + ch.vib_depth = v & 15; + } + } + 0xC0..=0xCF => ch.pan = ((v & 15) as i32) * 17, + 0xF0..=0xFF => { + if v & 15 > 0 { + ch.tone_speed = (v & 15) << 4; + } + } + _ => {} + } + // effects (tick 0 part) + let p = cell.param; + let (hi, lo) = (p >> 4, p & 15); + match cell.fx { + 0 => self.chans[c].arp = p, + 1 => { + if p > 0 { + self.chans[c].porta_up = p; + } + } + 2 => { + if p > 0 { + self.chans[c].porta_down = p; + } + } + 3 => { + if p > 0 { + self.chans[c].tone_speed = p; + } + } + 4 => { + if hi > 0 { + self.chans[c].vib_speed = hi; + } + if lo > 0 { + self.chans[c].vib_depth = lo; + } + } + 5 | 6 | 0xA => { + if p > 0 { + self.chans[c].vol_slide = p; + } + } + 7 => { + if hi > 0 { + self.chans[c].trem_speed = hi; + } + if lo > 0 { + self.chans[c].trem_depth = lo; + } + } + 8 => self.chans[c].pan = p as i32, + 0xB => { + self.jump = Some(p as usize); + self.brk.get_or_insert(0); + } + 0xC => self.chans[c].vol = (p as i32).min(64), + 0xD => { + self.brk = Some((hi as usize * 10 + lo as usize).min(255)); + if self.jump.is_none() { + self.jump = Some(self.pos + 1); + } + } + 0xE => match hi { + 1 => { + if lo > 0 { + self.chans[c].fine_up = lo; + } + self.chans[c].period -= self.chans[c].fine_up as f32 * 4.0; + } + 2 => { + if lo > 0 { + self.chans[c].fine_down = lo; + } + self.chans[c].period += self.chans[c].fine_down as f32 * 4.0; + } + 6 => { + if lo == 0 { + self.chans[c].loop_row = self.row; + } else if self.chans[c].loop_count == 0 { + self.chans[c].loop_count = lo; + self.loop_jump = Some(self.chans[c].loop_row); + } else { + self.chans[c].loop_count -= 1; + if self.chans[c].loop_count > 0 { + self.loop_jump = Some(self.chans[c].loop_row); + } + } + } + 9 => self.chans[c].retrig = lo, + 0xA => self.chans[c].vol = (self.chans[c].vol + lo as i32).min(64), + 0xB => self.chans[c].vol = (self.chans[c].vol - lo as i32).max(0), + 0xC => { + if lo == 0 { + self.chans[c].vol = 0; + } + } + 0xE => { + if !self.pdelay_active { + self.pdelay = lo as u32; + } + } + _ => {} + }, + 0xF => { + if p > 0 && p < 32 { + self.speed = p as u32; + } else if p >= 32 { + self.bpm = p as u32; + } + } + 16 => self.global = (p as i32).min(64), + 17 => { + if p > 0 { + self.global_slide = p; + } + } + 20 => { + if p == 0 { + self.key_off(c); + } + } + 25 => { + if p > 0 { + self.chans[c].pan_slide = p; + } + } + 27 => { + if lo > 0 { + self.chans[c].retrig = lo; + } + } + 33 => { + match hi { + 1 => { + if lo > 0 { + self.chans[c].xfine_up = lo; + } + self.chans[c].period -= self.chans[c].xfine_up as f32; + } + 2 => { + if lo > 0 { + self.chans[c].xfine_down = lo; + } + self.chans[c].period += self.chans[c].xfine_down as f32; + } + _ => {} + } + } + _ => {} + } + } + + fn process_row(&mut self) { + let m = self.module.clone(); + let pat = &m.patterns[m.order[self.pos] as usize]; + let row = self.row.min(pat.rows - 1); + for c in 0..m.channels { + let cell = pat.cells[row * m.channels + c]; + self.chans[c].cell = cell; + self.chans[c].vib_off = 0.0; + self.chans[c].trem_off = 0; + if cell.fx == 0xE && cell.param >> 4 == 0xD && cell.param & 15 > 0 { + self.chans[c].delayed = Some(cell); + continue; + } + self.chans[c].delayed = None; + self.apply_cell(c, cell); + } + } + + fn effects_tick(&mut self) { + let tick = self.tick; + for c in 0..self.chans.len() { + let cell = self.chans[c].cell; + if let Some(d) = self.chans[c].delayed { + if tick == (d.param & 15) as u32 { + self.chans[c].delayed = None; + self.apply_cell(c, d); + } + continue; + } + let p = cell.param; + let (hi, lo) = (p >> 4, p & 15); + match cell.fx { + 17 => { + let s = self.global_slide; + if s >> 4 > 0 { + self.global = (self.global + (s >> 4) as i32).min(64); + } else { + self.global = (self.global - (s & 15) as i32).max(0); + } + } + 20 => { + if tick == p as u32 { + self.key_off(c); + } + } + _ => {} + } + let ch = &mut self.chans[c]; + // volume column (per tick) + let v = cell.vol; + match v { + 0x60..=0x6F => ch.vol = (ch.vol - (v & 15) as i32).max(0), + 0x70..=0x7F => ch.vol = (ch.vol + (v & 15) as i32).min(64), + 0xB0..=0xBF => { + ch.vib_pos = ch.vib_pos.wrapping_add(ch.vib_speed * 4); + ch.vib_off = sine(ch.vib_pos) * ch.vib_depth as f32 * 8.0; + } + 0xD0..=0xDF => ch.pan = (ch.pan - (v & 15) as i32).max(0), + 0xE0..=0xEF => ch.pan = (ch.pan + (v & 15) as i32).min(255), + 0xF0..=0xFF => tone_porta(ch), + _ => {} + } + match cell.fx { + 1 => ch.period = (ch.period - ch.porta_up as f32 * 4.0).max(1.0), + 2 => ch.period += ch.porta_down as f32 * 4.0, + 3 => tone_porta(ch), + 4 => { + ch.vib_pos = ch.vib_pos.wrapping_add(ch.vib_speed * 4); + ch.vib_off = sine(ch.vib_pos) * ch.vib_depth as f32 * 8.0; + } + 5 => { + tone_porta(ch); + vol_slide(ch); + } + 6 => { + ch.vib_pos = ch.vib_pos.wrapping_add(ch.vib_speed * 4); + ch.vib_off = sine(ch.vib_pos) * ch.vib_depth as f32 * 8.0; + vol_slide(ch); + } + 7 => { + ch.trem_pos = ch.trem_pos.wrapping_add(ch.trem_speed * 4); + ch.trem_off = (sine(ch.trem_pos) * ch.trem_depth as f32 * 4.0) as i32; + } + 0xA => vol_slide(ch), + 0xE => match hi { + 9 => { + if lo > 0 && tick % lo as u32 == 0 { + ch.pos = 0.0; + ch.active = ch.smp.is_some(); + } + } + 0xC => { + if tick == lo as u32 { + ch.vol = 0; + } + } + _ => {} + }, + 25 => { + let s = ch.pan_slide; + if s >> 4 > 0 { + ch.pan = (ch.pan + (s >> 4) as i32).min(255); + } else { + ch.pan = (ch.pan - (s & 15) as i32).max(0); + } + } + 27 => { + if ch.retrig > 0 && tick % ch.retrig as u32 == 0 { + ch.pos = 0.0; + ch.active = ch.smp.is_some(); + } + } + _ => {} + } + } + + fn vol_slide(ch: &mut Chan) { + let s = ch.vol_slide; + if s >> 4 > 0 { + ch.vol = (ch.vol + (s >> 4) as i32).min(64); + } else { + ch.vol = (ch.vol - (s & 15) as i32).max(0); + } + } + fn tone_porta(ch: &mut Chan) { + let sp = ch.tone_speed as f32 * 4.0; + if ch.period < ch.target { + ch.period = (ch.period + sp).min(ch.target); + } else if ch.period > ch.target { + ch.period = (ch.period - sp).max(ch.target); + } + } + } + + // Envelopes, auto-vibrato, arpeggio -> final step and volumes for this tick + fn update_outputs(&mut self) { + let m = self.module.clone(); + let tick = self.tick; + let global = self.global as f32 / 64.0; + for c in 0..self.chans.len() { + let ins = m.instruments.get(self.chans[c].inst.wrapping_sub(1)); + let mut period = self.chans[c].period + self.chans[c].vib_off; + // arpeggio + let arp = if self.chans[c].cell.fx == 0 { self.chans[c].arp } else { 0 }; + if arp != 0 { + let semis = match tick % 3 { + 1 => (arp >> 4) as i32, + 2 => (arp & 15) as i32, + _ => 0, + }; + if self.module.linear { + period -= semis as f32 * 64.0; + } else { + period /= 2f32.powf(semis as f32 / 12.0); + } + } + let mut venv = 64.0; + let mut penv = 32.0; + if let Some(ins) = ins { + let ch = &mut self.chans[c]; + // auto-vibrato + if ins.vib_depth > 0 && ins.vib_rate > 0 { + let sweep = if ins.vib_sweep == 0 { 1.0 } else { (ch.av_sweep as f32 / ins.vib_sweep as f32).min(1.0) }; + ch.av_sweep += 1; + ch.av_pos = ch.av_pos.wrapping_add(ins.vib_rate); + let w = (ch.av_pos as f32 / 256.0 * std::f32::consts::TAU).sin(); + period += w * ins.vib_depth as f32 * 2.0 * sweep; + } + if ins.vol_env.on { + venv = Self::env_value(&ins.vol_env, ch.venv_pos); + Self::env_advance(&ins.vol_env, &mut ch.venv_pos, ch.key_on); + } + if ins.pan_env.on { + penv = Self::env_value(&ins.pan_env, ch.penv_pos); + Self::env_advance(&ins.pan_env, &mut ch.penv_pos, ch.key_on); + } + if !ch.key_on && ins.vol_env.on { + ch.fade = (ch.fade - ins.fadeout as i32).max(0); + } + } + let freq = self.freq_of(period.max(1.0)); + let ch = &mut self.chans[c]; + ch.step = freq as f64 / self.rate as f64; + let vol = ((ch.vol + ch.trem_off).clamp(0, 64)) as f32 / 64.0 * venv / 64.0 * ch.fade as f32 / 65536.0 * global; + let pan = ch.pan as f32 + (penv - 32.0) * (128.0 - (ch.pan as f32 - 128.0).abs()) / 32.0; + let pan = (pan / 255.0).clamp(0.0, 1.0); + ch.out_l = vol * (1.0 - pan).sqrt(); + ch.out_r = vol * pan.sqrt(); + if !ch.active { + ch.out_l = 0.0; + ch.out_r = 0.0; + } + } + } + + fn next_row(&mut self) { + let m = self.module.clone(); + if self.pdelay > 0 { + self.pdelay -= 1; + self.pdelay_active = true; + return; + } + self.pdelay_active = false; + if let Some(r) = self.loop_jump.take() { + self.row = r; + return; + } + if self.jump.is_some() || self.brk.is_some() { + let new_pos = self.jump.take().unwrap_or(self.pos + 1); + let new_row = self.brk.take().unwrap_or(0); + if new_pos <= self.pos { + self.loops += 1; + } + self.pos = new_pos; + self.row = new_row; + for ch in self.chans.iter_mut() { + ch.loop_row = 0; + ch.loop_count = 0; + } + } else { + self.row += 1; + let rows = m.patterns.get(*m.order.get(self.pos).unwrap_or(&0) as usize).map_or(64, |p| p.rows); + if self.row >= rows { + self.row = 0; + self.pos += 1; + for ch in self.chans.iter_mut() { + ch.loop_row = 0; + ch.loop_count = 0; + } + } + } + if self.pos >= m.order.len() { + self.pos = if m.restart < m.order.len() { m.restart } else { 0 }; + self.loops += 1; + } + self.skip_invalid_orders(); + let rows = m.patterns[m.order[self.pos] as usize].rows; + if self.row >= rows { + self.row = 0; + } + } + + fn do_tick(&mut self) { + if self.tick == 0 { + if !self.pdelay_active { + self.process_row(); + } + } else { + self.effects_tick(); + } + self.update_outputs(); + self.tick += 1; + if self.tick >= self.speed { + self.tick = 0; + self.next_row(); + } + } + + // Mix into an interleaved stereo buffer (adds to it) + pub fn render(&mut self, out: &mut [f32], gain: f32) { + let m = self.module.clone(); + let frames = out.len() / 2; + let mut f = 0; + while f < frames { + if self.samples_left <= 0.0 { + self.do_tick(); + self.samples_left += self.rate * 2.5 / self.bpm.max(1) as f32; + } + let n = (self.samples_left.ceil() as usize).clamp(1, frames - f); + for ch in self.chans.iter_mut() { + // short volume ramp towards the tick's target (no clicks) + let ramp = (n as f32).min(64.0); + let dl = (ch.out_l - ch.cur_l) / ramp; + let dr = (ch.out_r - ch.cur_r) / ramp; + if !ch.active && ch.cur_l.abs() < 1e-4 && ch.cur_r.abs() < 1e-4 { + continue; + } + let Some(s) = Self::sample(&m, ch) else { continue }; + let len = s.data.len(); + if len == 0 { + ch.active = false; + continue; + } + let lend = (s.loop_start + s.loop_len) as f64; + for k in 0..n { + if (k as f32) < ramp { + ch.cur_l += dl; + ch.cur_r += dr; + } + if !ch.active { + continue; + } + let i = ch.pos as usize; + let fr = (ch.pos - i as f64) as f32; + let a = s.data[i.min(len - 1)]; + let next = if s.loop_type != 0 && i + 1 >= lend as usize { s.data[s.loop_start] } else { s.data[(i + 1).min(len - 1)] }; + let v = (a + (next - a) * fr) * gain; + let o = (f + k) * 2; + out[o] += v * ch.cur_l; + out[o + 1] += v * ch.cur_r; + if ch.backwards { + ch.pos -= ch.step; + if ch.pos < s.loop_start as f64 { + ch.pos = 2.0 * s.loop_start as f64 - ch.pos; + ch.backwards = false; + } + } else { + ch.pos += ch.step; + match s.loop_type { + 1 if ch.pos >= lend => { + ch.pos -= s.loop_len as f64 * ((ch.pos - lend) / s.loop_len as f64 + 1.0).floor(); + } + 2 if ch.pos >= lend => { + ch.pos = (2.0 * lend - ch.pos - 1.0).max(s.loop_start as f64); + ch.backwards = true; + } + 0 if ch.pos >= len as f64 => ch.active = false, + _ => {} + } + } + } + } + self.samples_left -= n as f32; + f += n; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::rc::Rc; + + const SONGS: [&[u8]; 8] = [ + include_bytes!("../assets/music/title.xm"), + include_bytes!("../assets/music/build1.xm"), + include_bytes!("../assets/music/build2.xm"), + include_bytes!("../assets/music/build3.xm"), + include_bytes!("../assets/music/battle1.xm"), + include_bytes!("../assets/music/battle2.xm"), + include_bytes!("../assets/music/victory.xm"), + include_bytes!("../assets/music/defeat.xm"), + ]; + + #[test] + fn render_all_songs() { + for (k, data) in SONGS.iter().enumerate() { + let m = Rc::new(load(data).expect("load")); + assert_eq!(m.channels, 8); + assert_eq!(m.instruments.len(), 8); + let mut p = Player::new(m, 48000.0); + let mut buf = vec![0.0f32; 48000 * 2]; + let mut peak = 0.0f32; + let mut sum = 0.0f64; + for _ in 0..20 { + buf.iter_mut().for_each(|v| *v = 0.0); + p.render(&mut buf, 1.0); + for &v in &buf { + assert!(v.is_finite()); + peak = peak.max(v.abs()); + sum += (v * v) as f64; + } + } + let rms = (sum / (20.0 * 96000.0)).sqrt(); + println!("song {}: peak {:.3} rms {:.4} loops {}", k, peak, rms, p.loops); + assert!(rms > 0.005, "song {} is silent", k); + } + } +} + +// Writes 30 s renders of all songs as raw stereo f32 files when DC_RENDER_DIR is set +// (used to compare against a reference player). +#[cfg(test)] +mod render_dump { + use super::*; + use std::rc::Rc; + + #[test] + fn dump_renders() { + let Ok(dir) = std::env::var("DC_RENDER_DIR") else { return }; + let names = ["title", "build1", "build2", "build3", "battle1", "battle2", "victory", "defeat"]; + for n in names { + let data = std::fs::read(format!("assets/music/{}.xm", n)).unwrap(); + let mut p = Player::new(Rc::new(load(&data).unwrap()), 48000.0); + let mut buf = vec![0.0f32; 48000 * 2 * 30]; + p.render(&mut buf, 1.0); + let bytes: Vec = buf.iter().flat_map(|v| v.to_le_bytes()).collect(); + std::fs::write(format!("{}/{}.f32", dir, n), bytes).unwrap(); + } + } +} diff --git a/tools/__pycache__/compose_music.cpython-311.pyc b/tools/__pycache__/compose_music.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..882b702cde84c3dd7ce073ff837491c56efd150c GIT binary patch literal 49868 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ΤU+4!Yn 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 literal 0 HcmV?d00001 diff --git a/www/dc_plugin.js b/www/dc_plugin.js index 4ef122c..bedf831 100644 --- a/www/dc_plugin.js +++ b/www/dc_plugin.js @@ -1,4 +1,4 @@ -// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave. +// DungeonCraft browser plugin for miniquad: storage, file download/upload, save-on-leave, audio output. (function () { "use strict"; var pending = null; // value handed over by dc_storage_get @@ -77,5 +77,44 @@ }; } + // ---- audio: the Rust mixer renders on demand (dc_audio_render), started by the first user input + var actx = null; + var stats = { frames: 0, peak: 0 }; + // diagnostics, e.g. for automated tests: state of the audio output + window.dc_audio_stats = function () { + return { state: actx ? actx.state : "not started", frames: stats.frames, peak: stats.peak }; + }; + function startAudio() { + if (typeof wasm_exports === "undefined" || !wasm_exports.dc_audio_render) return; + if (actx) { + if (actx.state !== "running") actx.resume(); + return; + } + var AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + actx = new AC(); + wasm_exports.dc_audio_init(actx.sampleRate); + var node = actx.createScriptProcessor(2048, 0, 2); + node.onaudioprocess = function (e) { + var n = e.outputBuffer.length; + var l = e.outputBuffer.getChannelData(0); + var r = e.outputBuffer.getChannelData(1); + var ptr = wasm_exports.dc_audio_render(n); + if (!ptr) { l.fill(0); r.fill(0); return; } + var buf = new Float32Array(wasm_memory.buffer, ptr, n * 2); + var peak = 0; + for (var i = 0; i < n; i++) { + l[i] = buf[2 * i]; r[i] = buf[2 * i + 1]; + var a = Math.abs(l[i]); if (a > peak) peak = a; + } + stats.frames += n; + stats.peak = Math.max(stats.peak * 0.9, peak); + }; + node.connect(actx.destination); + } + ["mousedown", "keydown", "touchstart"].forEach(function (ev) { + window.addEventListener(ev, startAudio, true); + }); + miniquad_add_plugin({ register_plugin: register, version: 1, name: "dungeoncraft" }); })();