Own XM/MOD replayer, audio mixer with crossfades, synthesised chip sound effects, music director
- src/xm.rs: FastTracker 2 XM and ProTracker MOD replayer (verified against libopenmpt) - src/audio.rs: music players with crossfade + effect voices, rendered on demand - src/sfx.rs: 29 synthesised chip sound effects - www/dc_plugin.js: AudioContext started on first input, streams samples from WASM - music follows the game: title, rotating build tracks, battle, gate assault, jingles - macroquad audio removed; all tests run natively with cargo test Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2dba65be8b
commit
925be17e82
13 files changed
+1711
-205
No files matched your search
+155
@@ -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()
|
||||
}
|
||||
Reference in new issue
Block a user