Python 362 lines
"""Synthesised placeholder SFX for the v1.0.0 cut.
Pure stdlib — no numpy, no scipy, no third-party audio libs — so this
script runs anywhere the game's .venv can run. Each sound is a short
chiptune-style waveform written as a 16-bit mono PCM WAV into
``assets/audio/sfx/``. The audio.py loader looks up sounds by name
(`audio.play("shoot")` → `assets/audio/sfx/shoot.wav`); the names below
match every existing call site plus the ones wired in v0.9.0.
The SFX are intentionally chiptune-thin — the meme-flavoured pass that
the design notes call out is a post-v1.0 polish slot; these are the
"every action has feedback" baseline.
Re-run via ``./.venv/bin/python scripts/gen_sfx.py``. Idempotent: each
call rewrites the WAVs to disk so a tuning tweak ships by running the
script and committing the changed assets.
"""
import math
import os
import random
import struct
import wave
SAMPLE_RATE = 44100
SFX_DIR = os.path.join("assets", "audio", "sfx")
# Equal-temperament pitches in Hz. Just the notes I reach for in the
# arpeggios below — adding more is one line of math.
PITCH = {
"A3": 220.0, "C4": 261.63, "D4": 293.66, "E4": 329.63,
"F4": 349.23, "G4": 392.0, "A4": 440.0, "B4": 493.88,
"C5": 523.25, "D5": 587.33, "E5": 659.25, "G5": 783.99,
"A5": 880.0, "C6": 1046.5, "E6": 1318.51, "G6": 1567.98,
}
def _frames(seconds):
return int(SAMPLE_RATE * seconds)
def _envelope(n, attack=0.01, release=0.05, sustain=1.0):
"""Linear AR envelope; sustain is held between attack and release."""
a = max(1, int(SAMPLE_RATE * attack))
r = max(1, int(SAMPLE_RATE * release))
out = [0.0] * n
for i in range(n):
if i < a:
out[i] = (i / a) * sustain
elif i > n - r:
out[i] = max(0.0, ((n - i) / r)) * sustain
else:
out[i] = sustain
return out
def _sine(freq, n, phase=0.0):
w = 2 * math.pi * freq / SAMPLE_RATE
return [math.sin(w * i + phase) for i in range(n)]
def _square(freq, n, duty=0.5):
period = SAMPLE_RATE / freq
return [1.0 if (i % period) / period < duty else -1.0 for i in range(n)]
def _triangle(freq, n):
period = SAMPLE_RATE / freq
out = []
for i in range(n):
t = (i % period) / period
out.append(4 * abs(t - 0.5) - 1)
return out
def _noise(n, rng):
return [rng.uniform(-1.0, 1.0) for _ in range(n)]
def _sweep(f0, f1, n, wave_fn=_square):
"""Linear pitch sweep from f0 to f1 over n samples."""
out = [0.0] * n
phase = 0.0
for i in range(n):
f = f0 + (f1 - f0) * (i / max(1, n - 1))
phase += 2 * math.pi * f / SAMPLE_RATE
if wave_fn is _square:
out[i] = 1.0 if math.sin(phase) > 0 else -1.0
elif wave_fn is _triangle:
out[i] = (2 / math.pi) * math.asin(math.sin(phase))
else:
out[i] = math.sin(phase)
return out
def _mix(*tracks):
"""Sum equal-length tracks, clip to [-1, 1]."""
n = max(len(t) for t in tracks)
out = [0.0] * n
for t in tracks:
for i, v in enumerate(t):
out[i] += v
peak = max((abs(v) for v in out), default=1.0)
if peak > 1.0:
out = [v / peak for v in out]
return out
def _concat(*chunks):
out = []
for c in chunks:
out.extend(c)
return out
def _apply(samples, env):
n = min(len(samples), len(env))
return [samples[i] * env[i] for i in range(n)]
def _write(name, samples, gain=0.6):
"""Write a 16-bit mono WAV at the project's sample rate."""
path = os.path.join(SFX_DIR, name + ".wav")
with wave.open(path, "wb") as f:
f.setnchannels(1)
f.setsampwidth(2)
f.setframerate(SAMPLE_RATE)
peak = max((abs(v) for v in samples), default=1.0)
norm = gain / max(0.01, peak)
frames = b"".join(
struct.pack("<h", max(-32767, min(32767, int(v * norm * 32767))))
for v in samples)
f.writeframes(frames)
# --- Individual SFX ---------------------------------------------------
def sfx_shoot(rng):
"""Short downward laser chirp + a click body."""
n = _frames(0.10)
body = _sweep(900, 280, n, _square)
env = _envelope(n, 0.005, 0.06)
return _apply(body, env)
def sfx_hit(rng):
"""Generic taken-hit thud: low square + noise burst."""
n = _frames(0.09)
thud = _square(160, n)
grit = _noise(n, rng)
sig = [0.7 * thud[i] + 0.4 * grit[i] for i in range(n)]
env = _envelope(n, 0.003, 0.07)
return _apply(sig, env)
def sfx_boss_hit(rng):
"""Heavier hit: lower pitch, longer tail."""
n = _frames(0.18)
thud = _square(110, n)
grit = _noise(n, rng)
sig = [0.8 * thud[i] + 0.5 * grit[i] for i in range(n)]
env = _envelope(n, 0.005, 0.14)
return _apply(sig, env)
def sfx_enemy_death(rng):
"""Pitched-down noise burst — the air going out."""
n = _frames(0.22)
sweep = _sweep(620, 110, n, _square)
grit = _noise(n, rng)
sig = [0.6 * sweep[i] + 0.4 * grit[i] for i in range(n)]
env = _envelope(n, 0.005, 0.18)
return _apply(sig, env)
def sfx_boss_death(rng):
"""Big descending sweep + grit, double-layered for weight."""
n = _frames(0.85)
s1 = _sweep(520, 60, n, _square)
s2 = _sweep(310, 40, n, _triangle)
grit = _noise(n, rng)
sig = [0.6 * s1[i] + 0.5 * s2[i] + 0.35 * grit[i] for i in range(n)]
env = _envelope(n, 0.01, 0.6)
return _apply(sig, env)
def sfx_player_death(rng):
"""Sad descending three-note motif (E4 → C4 → A3)."""
parts = []
for note in ("E4", "C4", "A3"):
n = _frames(0.18)
sig = _triangle(PITCH[note], n)
env = _envelope(n, 0.01, 0.12)
parts.append(_apply(sig, env))
return _concat(*parts)
def sfx_level_complete(rng):
"""Major arpeggio C5 E5 G5 C6 — the win chime."""
parts = []
for note in ("C5", "E5", "G5", "C6"):
n = _frames(0.14)
sig = _triangle(PITCH[note], n)
env = _envelope(n, 0.005, 0.10)
parts.append(_apply(sig, env))
n = _frames(0.36)
tail = _triangle(PITCH["C6"], n)
parts.append(_apply(tail, _envelope(n, 0.005, 0.30)))
return _concat(*parts)
def sfx_slow(rng):
"""Wizard slow — descending vibrato chord, time-warp feel."""
n = _frames(0.55)
a = _sweep(PITCH["G4"], PITCH["C4"], n, _triangle)
b = _sweep(PITCH["E5"], PITCH["A4"], n, _triangle)
sig = [0.7 * a[i] + 0.5 * b[i] for i in range(n)]
env = _envelope(n, 0.04, 0.30)
return _apply(sig, env)
def sfx_shield(rng):
"""Penguin shield — rising glass shimmer."""
n = _frames(0.32)
shimmer = _sweep(PITCH["A4"], PITCH["E6"], n, _triangle)
chime = _triangle(PITCH["C5"], n)
sig = [0.6 * shimmer[i] + 0.4 * chime[i] for i in range(n)]
env = _envelope(n, 0.02, 0.22)
return _apply(sig, env)
def sfx_volley(rng):
"""Elf volley — rapid double-tap chirp."""
parts = []
for f in (880, 1320):
n = _frames(0.07)
sig = _sweep(f, f * 1.6, n, _square)
env = _envelope(n, 0.005, 0.05)
parts.append(_apply(sig, env))
parts.append([0.0] * _frames(0.02))
return _concat(*parts)
def sfx_dash(rng):
"""Shiggy dash — quick whoosh."""
n = _frames(0.18)
sig = _sweep(180, 90, n, _triangle)
grit = _noise(n, rng)
mixed = [0.6 * sig[i] + 0.35 * grit[i] for i in range(n)]
env = _envelope(n, 0.005, 0.14)
return _apply(mixed, env)
def sfx_sprint(rng):
"""Wolf sprint — short rising whoosh that sustains."""
n = _frames(0.35)
sig = _sweep(140, 320, n, _triangle)
grit = _noise(n, rng)
mixed = [0.55 * sig[i] + 0.3 * grit[i] for i in range(n)]
env = _envelope(n, 0.04, 0.20)
return _apply(mixed, env)
def sfx_lever_click(rng):
"""Lever pull — two-click clack (engage + settle)."""
parts = []
for f in (640, 480):
n = _frames(0.04)
sig = _square(f, n)
env = _envelope(n, 0.001, 0.03)
parts.append(_apply(sig, env))
parts.append([0.0] * _frames(0.03))
return _concat(*parts)
def sfx_plate_press(rng):
"""Pressure plate — low compressed clunk."""
n = _frames(0.12)
sig = _square(110, n)
grit = _noise(n, rng)
mixed = [0.6 * sig[i] + 0.3 * grit[i] for i in range(n)]
env = _envelope(n, 0.002, 0.10)
return _apply(mixed, env)
def sfx_gate_open(rng):
"""Gate retracting — slow rising chord."""
n = _frames(0.45)
a = _sweep(PITCH["C4"], PITCH["G4"], n, _triangle)
b = _sweep(PITCH["E4"], PITCH["B4"], n, _triangle)
grit = _noise(_frames(0.45), rng)
sig = [0.55 * a[i] + 0.4 * b[i] + 0.2 * grit[i] for i in range(n)]
env = _envelope(n, 0.04, 0.30)
return _apply(sig, env)
def sfx_key_pickup(rng):
"""Key pickup — bright two-tone chime (C5 → G5)."""
parts = []
for note in ("C5", "G5"):
n = _frames(0.10)
sig = _triangle(PITCH[note], n)
env = _envelope(n, 0.003, 0.08)
parts.append(_apply(sig, env))
return _concat(*parts)
def sfx_menu_select(rng):
"""Menu navigation blip — single thin tick."""
n = _frames(0.05)
sig = _square(880, n)
env = _envelope(n, 0.001, 0.04)
return _apply(sig, env)
def sfx_menu_confirm(rng):
"""Menu confirm — short rising blip (G5 → C6)."""
parts = []
for note in ("G5", "C6"):
n = _frames(0.06)
sig = _square(PITCH[note], n)
env = _envelope(n, 0.002, 0.05)
parts.append(_apply(sig, env))
return _concat(*parts)
SFX = {
"shoot": (sfx_shoot, 0.45),
"hit": (sfx_hit, 0.55),
"boss_hit": (sfx_boss_hit, 0.60),
"enemy_death": (sfx_enemy_death, 0.55),
"boss_death": (sfx_boss_death, 0.70),
"player_death": (sfx_player_death, 0.50),
"level_complete": (sfx_level_complete, 0.55),
"slow": (sfx_slow, 0.55),
"shield": (sfx_shield, 0.55),
"volley": (sfx_volley, 0.55),
"dash": (sfx_dash, 0.55),
"sprint": (sfx_sprint, 0.55),
"lever_click": (sfx_lever_click, 0.50),
"plate_press": (sfx_plate_press, 0.55),
"gate_open": (sfx_gate_open, 0.55),
"key_pickup": (sfx_key_pickup, 0.55),
"menu_select": (sfx_menu_select, 0.40),
"menu_confirm": (sfx_menu_confirm, 0.45),
}
def main():
os.makedirs(SFX_DIR, exist_ok=True)
rng = random.Random(0xCAFE) # deterministic — rerun gives same bytes
for name, (fn, gain) in SFX.items():
samples = fn(rng)
_write(name, samples, gain=gain)
path = os.path.join(SFX_DIR, name + ".wav")
print(f" wrote {path} ({len(samples)} samples, "
f"{len(samples) / SAMPLE_RATE:.2f}s)")
if __name__ == "__main__":
main()