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 0000000..882b702 Binary files /dev/null and b/tools/__pycache__/compose_music.cpython-311.pyc differ 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" }); })();