Every WAV is synthesized from maths by tools/gen_sounds.py (run through Blender for its bundled numpy) -- sines, noise and envelopes, seeded and reproducible, licence-free by construction. Loops (engine, skid, boost, siren) are built from integer-Hz components over exactly 1 s so the seam is mathematically silent; noise beds get a crossfaded seam. Seam continuity is verified numerically. Wiring: pitch-tracked engine loop on every car (idle burble to top-end scream, bump while boosting), skid loop off slip, player boost whoosh, small/big crash by jolt with glass on wrecks, two-tone siren on pursuit cops (derby brawlers stay silent), Crashbreaker boom, gearbox clunk/bog, win/lose stings on takedowns, outruns, escapes, busts and derby eliminations. Loop flags live in code (sfx.gd), not per-file .import settings, so a regenerated wav never silently loses its loop. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
157 lines
5.6 KiB
Python
157 lines
5.6 KiB
Python
"""Procedural sound kit: every WAV the game plays, synthesized from maths.
|
|
|
|
Run: /Applications/Blender.app/Contents/MacOS/Blender -b -P tools/gen_sounds.py
|
|
(Blender only for its bundled numpy -- no bpy used)
|
|
|
|
Licence-free by construction: sines, noise and envelopes, no recordings.
|
|
Deterministic (seeded), so a rebuild is byte-for-byte reproducible.
|
|
|
|
Loop discipline: anything the game loops (engine, skid, boost, siren) is built
|
|
from integer-Hz components over an exact 1 s so the seam is mathematically
|
|
silent; noise beds get a crossfaded seam instead. One-shots just decay to zero.
|
|
"""
|
|
import math
|
|
import os
|
|
import wave
|
|
|
|
import numpy as np
|
|
|
|
SR = 22050
|
|
OUT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "assets", "sounds")
|
|
rng = np.random.default_rng(1982)
|
|
|
|
|
|
def t(dur):
|
|
return np.arange(int(SR * dur)) / SR
|
|
|
|
|
|
def env_exp(dur, tau):
|
|
return np.exp(-t(dur) / tau)
|
|
|
|
|
|
def noise(dur):
|
|
return rng.standard_normal(int(SR * dur))
|
|
|
|
|
|
def band(x, lo, hi, peaks=()):
|
|
"""FFT bandpass with optional resonant peaks [(hz, width, gain)]."""
|
|
spec = np.fft.rfft(x)
|
|
f = np.fft.rfftfreq(len(x), 1 / SR)
|
|
mask = ((f >= lo) & (f <= hi)).astype(float)
|
|
edge = (hi - lo) * 0.15 + 1.0
|
|
mask = np.convolve(mask, np.hanning(9) / np.hanning(9).sum(), mode="same")
|
|
for hz, width, gain in peaks:
|
|
mask += gain * np.exp(-((f - hz) / width) ** 2)
|
|
return np.fft.irfft(spec * mask, len(x))
|
|
|
|
|
|
def seam(x, fade=0.06):
|
|
"""Crossfade the tail into the head so a noise bed loops cleanly."""
|
|
n = int(SR * fade)
|
|
w = np.linspace(0, 1, n)
|
|
x[:n] = x[:n] * w + x[-n:] * (1 - w)
|
|
return x[:-n]
|
|
|
|
|
|
def norm(x, peak=0.85):
|
|
return x / (np.max(np.abs(x)) + 1e-9) * peak
|
|
|
|
|
|
def write(name, x):
|
|
x16 = (np.clip(x, -1, 1) * 32767).astype("<i2")
|
|
with wave.open(os.path.join(OUT, name + ".wav"), "wb") as w:
|
|
w.setnchannels(1)
|
|
w.setsampwidth(2)
|
|
w.setframerate(SR)
|
|
w.writeframes(x16.tobytes())
|
|
print("%-14s %5.2fs peak %.2f" % (name, len(x) / SR, np.max(np.abs(x))))
|
|
|
|
|
|
os.makedirs(OUT, exist_ok=True)
|
|
|
|
# engine: integer-Hz harmonic stack of 56 Hz with a firing-pulse AM -- the
|
|
# arcade "brap". All components integer Hz over exactly 1 s = seamless loop.
|
|
tt = t(1.0)
|
|
eng = np.zeros(len(tt))
|
|
for h, a in ((1, 1.0), (2, 0.62), (3, 0.42), (4, 0.30), (6, 0.16), (8, 0.09)):
|
|
eng += a * np.sin(2 * math.pi * 56 * h * tt + h)
|
|
eng += 0.5 * np.sin(2 * math.pi * 28 * tt) # sub
|
|
eng *= 1.0 + 0.38 * np.sin(2 * math.pi * 28 * tt - 0.7) # firing pulses
|
|
bed = band(noise(1.0), 40, 900)
|
|
eng[: len(seam(bed))] += 0.10 * norm(seam(bed), 1.0)[: len(eng)]
|
|
write("engine", norm(eng, 0.7))
|
|
|
|
# skid: screechy banded noise with resonances and a 15 Hz tremolo
|
|
sk = band(noise(1.0), 900, 3200, peaks=((1250, 90, 6.0), (1900, 120, 4.0)))
|
|
sk *= 1.0 + 0.3 * np.sin(2 * math.pi * 15 * t(1.0))
|
|
write("skid", norm(seam(sk), 0.6))
|
|
|
|
# boost: airy rushing band with a slow 6 Hz surge
|
|
bo = band(noise(1.0), 250, 1600) + 0.5 * band(noise(1.0), 2500, 6000)
|
|
bo *= 1.0 + 0.25 * np.sin(2 * math.pi * 6 * t(1.0))
|
|
write("boost", norm(seam(bo), 0.55))
|
|
|
|
# siren: Aussie two-tone, 620/470 Hz halves with soft crossfades, odd harmonics
|
|
tt = t(1.0)
|
|
gate = 0.5 * (1 + np.tanh(np.sin(2 * math.pi * 1 * tt) * 24)) # smooth 0/1 square
|
|
si = np.zeros(len(tt))
|
|
for f0, g in ((620, gate), (470, 1 - gate)):
|
|
tone = sum(np.sin(2 * math.pi * f0 * k * tt) / k for k in (1, 3, 5))
|
|
si += g * tone
|
|
write("siren", norm(si, 0.5))
|
|
|
|
# crashes: thump sweep + banded burst (+ clatter echoes on the big one)
|
|
def thump(dur, f_hi, f_lo, tau):
|
|
ph = 2 * math.pi * np.cumsum(np.linspace(f_hi, f_lo, int(SR * dur))) / SR
|
|
return np.sin(ph) * env_exp(dur, tau)
|
|
|
|
cs = thump(0.35, 150, 48, 0.09)
|
|
cs += 0.8 * band(noise(0.35), 200, 4200) * env_exp(0.35, 0.05)
|
|
cs += 0.25 * (np.sin(2 * math.pi * 810 * t(0.35)) + np.sin(2 * math.pi * 1130 * t(0.35))) * env_exp(0.35, 0.03)
|
|
write("crash_small", norm(cs, 0.8))
|
|
|
|
cb = thump(0.9, 120, 34, 0.22)
|
|
cb += 0.9 * band(noise(0.9), 120, 3800) * env_exp(0.9, 0.13)
|
|
for d, a in ((0.16, 0.4), (0.29, 0.3), (0.45, 0.2)): # clatter
|
|
i = int(SR * d)
|
|
burst = band(noise(0.12), 400, 3000) * env_exp(0.12, 0.03) * a
|
|
cb[i:i + len(burst)] += burst
|
|
write("crash_big", norm(cb, 0.9))
|
|
|
|
# glass: a shower of tiny bright pings over a sizzle
|
|
gl = 0.25 * band(noise(0.6), 4000, 9500) * env_exp(0.6, 0.12)
|
|
for _ in range(42):
|
|
at = rng.uniform(0, 0.4)
|
|
f = rng.uniform(3800, 8800)
|
|
ping = np.sin(2 * math.pi * f * t(0.05)) * env_exp(0.05, 0.012) * rng.uniform(0.2, 0.55)
|
|
i = int(SR * at)
|
|
gl[i:i + len(ping)] += ping
|
|
write("glass", norm(gl, 0.6))
|
|
|
|
# crashbreaker: deep sweep + saturated noise whomp
|
|
bm = thump(1.2, 95, 28, 0.35)
|
|
bm += 1.2 * band(noise(1.2), 30, 700) * env_exp(1.2, 0.18)
|
|
write("boom", norm(np.tanh(bm * 2.2), 0.95))
|
|
|
|
# stings: little square arpeggios (win up, lose down)
|
|
def sting(notes, dur=0.16, gap=0.02):
|
|
parts = []
|
|
for f in notes:
|
|
tone = sum(np.sin(2 * math.pi * f * k * t(dur)) / k for k in (1, 3)) * env_exp(dur, 0.09)
|
|
parts += [tone, np.zeros(int(SR * gap))]
|
|
return np.concatenate(parts)
|
|
|
|
write("sting_win", norm(sting([392, 523, 659, 784]), 0.55))
|
|
write("sting_lose", norm(sting([392, 311, 233], dur=0.22), 0.5))
|
|
|
|
# gearbox: clean clunk vs blown-shift bog
|
|
ck = thump(0.14, 95, 60, 0.03) + 0.5 * band(noise(0.14), 1500, 5000) * env_exp(0.14, 0.006)
|
|
write("clunk", norm(ck, 0.7))
|
|
|
|
ph = 2 * math.pi * np.cumsum(np.linspace(130, 55, int(SR * 0.45))) / SR
|
|
bg = np.sign(np.sin(ph)) * env_exp(0.45, 0.16)
|
|
bg *= 1.0 + 0.5 * np.sin(2 * math.pi * 27 * t(0.45))
|
|
write("bog", norm(bg * 0.5 + 0.3 * band(noise(0.45), 80, 500) * env_exp(0.45, 0.1), 0.6))
|
|
|
|
print("sound kit written to", os.path.abspath(OUT))
|