Own XM/MOD replayer, audio mixer with crossfades, synthesised chip sound effects, music director

- src/xm.rs: FastTracker 2 XM and ProTracker MOD replayer (verified against libopenmpt)
- src/audio.rs: music players with crossfade + effect voices, rendered on demand
- src/sfx.rs: 29 synthesised chip sound effects
- www/dc_plugin.js: AudioContext started on first input, streams samples from WASM
- music follows the game: title, rotating build tracks, battle, gate assault, jingles
- macroquad audio removed; all tests run natively with cargo test

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Richard ZinkandClaude Opus 5.5 committed 2026-11-03 06:10:28 +01:00
1 parent 2dba65be8b
commit 925be17e82
13 files changed
+1711 -205

No files matched your search

+155
View File
@@ -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<f32> {
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<f32>>) -> Vec<f32> {
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<Seg> {
notes.iter().map(|&f| seg(w, f, f, each, vol, 6.0)).collect()
}
fn def(s: Sfx, r: f32) -> Vec<f32> {
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<Vec<f32>> {
SFX_ALL.iter().map(|&s| def(s, rate)).collect()
}