ShitboxInfinity/tools/gen_sounds.py
m3ultra 837015dc64 The sensation of speed: nine lies at once
Burnout 3 ran about 110 km/h and felt like 300. None of this makes the
car faster; all of it makes the car feel faster.

Screen pass (src/speed_fx.gd, shader authored in code like everything
else, layer 0 so the HUD stays sharp):
  - RADIAL BLUR smearing the world outward from the crosshair
  - SPEED LINES, the Sonic trick: 300 radial lanes, randomised phase,
    streaming outward, sparse while merely quick and a storm on boost
  - CHROMATIC ABERRATION folded into the blur as a per-channel radius
  - TUNNEL VIGNETTE squeezing the frame under boost

Camera (game.gd _speed_cam): drops and hugs the boot as speed rises
(2.4/6.0 m -> 1.75/5.1), lags on hard acceleration so the car pulls
away from the lens, aims further up the road, dutch-rolls into the
steering (doubled mid-drift), and gains speed-scaled micro-shake on top
of the impact shake. FOV runs 72 -> 96 with speed, +12 on boost, and
PUNCHES +9 the instant boost lights -- the punch is what sells the
shove.

Plus motes streaming past the lens (Fx.rusher, parented to the camera
in local space -- the radial blur smears each speck into a streak for
free) and a procedural WIND loop whose volume and pitch ride the same
curve, so the ears agree with the eyes.

Everything keys off one number, km/h, with a squared response: nothing
happens at town speed, then it piles on. Effects cut in at 55 km/h and
max at 165.

Two things measurement caught that reading wouldn't: bolting chromatic
aberration on AFTER the blur samples sharp red/blue against blurred
green and fringes every edge magenta (it looked like a broken display,
not a fast car) -- the fix is per-channel sample radii inside the blur
loop; and the first pass was simply too much everywhere, so boost is
now the visual event and cruising fast is merely urgent.

Smoke: at ~160 km/h the lens must open >8 deg, the screen pass must
engage and become visible, the motes must emit, and pressing boost must
kick the FOV further still.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:51:09 +10:00

165 lines
6.0 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))
# wind: the rush past the windows. Low banded noise with a slow swell -- kept
# dull on purpose so the pitch/volume ramp in car.gd does the talking rather
# than the timbre. Seam-crossfaded like the other noise beds.
wd = band(noise(1.0), 90, 1100, peaks=((260, 120, 2.2),))
wd += 0.35 * band(noise(1.0), 1200, 4200)
wd *= 1.0 + 0.18 * np.sin(2 * math.pi * 3 * t(1.0))
write("wind", norm(seam(wd), 0.5))
# 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))