spaceship/audio/make_sfx.py
2026-09-03 22:00:26 +02:00

215 lines
8.2 KiB
Python

#!/usr/bin/env python3
"""Generates the game's sound effects, from scratch.
Forgejo #146 asked for free SFX sources, and the ones worth using are listed
in that ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound
filtered to CC0. What ships here is synthesised instead, for one reason: a
generated sound has no licence, no attribution to keep track of, and no
binary in the repository whose provenance nobody can check a year from now.
It also means the sounds are DATA rather than assets. If a shot is too sharp
or an explosion too short, the fix is a number in this file and a re-run,
not a hunt through a sound library.
#150 and #151 are why the first pass was thrown away. "Shot sounds sound
like a toy" and "death sounds from ship are really cheap", and both were
fair: the shot was a bare descending sine, which is the sound every toy ray
gun has ever made, and the death was one band of filtered noise with a sine
under it - thin, and gone before the eye had finished with the explosion.
What the rewrite adds, and why each part is there:
* LAYERS, not a waveform. A real bang is a crack, then a body, then a
tail, and they have different lengths. One envelope over one source
cannot be any of those things.
* A sub. Below about 80Hz is where "big" lives; without it an explosion is
a hiss however loud it is played.
* SATURATION. Soft-clipping folds in harmonics and stops the peak
normalisation below from turning everything into the same quiet mush.
* Detuned partials on the shot, so it is a machine rather than a flute -
the single sine was the whole of the toy problem.
Run: python3 audio/make_sfx.py
"""
import math
import random
import struct
import wave
RATE = 44100
def _write(name, samples):
peak = max(1e-9, max(abs(s) for s in samples))
frames = b"".join(struct.pack("<h", int(max(-1.0, min(1.0, s / peak * 0.92)) * 32767))
for s in samples)
with wave.open("audio/%s.wav" % name, "w") as w:
w.setnchannels(1)
w.setsampwidth(2)
w.setframerate(RATE)
w.writeframes(frames)
print("audio/%s.wav %5.2fs %7d bytes" % (name, len(samples) / RATE, len(frames)))
def _decay(i, n, power):
return (1.0 - i / n) ** power
def _attack(i, seconds):
"""A few milliseconds of ramp, so nothing starts on a click."""
a = max(1, int(RATE * seconds))
return min(1.0, i / a)
def _saturate(x, drive):
"""Soft clip. Adds the harmonics that make a sound read as loud rather
than merely being loud."""
return math.tanh(x * drive) / math.tanh(drive)
def _mix(*layers):
n = max(len(layer) for layer in layers)
out = [0.0] * n
for layer in layers:
for i, v in enumerate(layer):
out[i] += v
return out
def _noise(n, seed, cutoff_at):
"""White noise through a one-pole low-pass whose cutoff is a function of
position. Sweeping the cutoff down is what turns a hiss into a roar."""
rng = random.Random(seed)
out = []
last = 0.0
for i in range(n):
cutoff = cutoff_at(i / n)
last += cutoff * (rng.uniform(-1.0, 1.0) - last)
out.append(last)
return out
def _sweep(n, start_hz, end_hz, curve=1.0):
out = []
phase = 0.0
for i in range(n):
t = (i / n) ** curve
hz = start_hz * (end_hz / start_hz) ** t
phase += 2.0 * math.pi * hz / RATE
out.append(math.sin(phase))
return out
def shot(seconds=0.22):
"""An energy weapon discharging: a bright transient, three detuned
partials falling together, and a short sub thump under it.
The partials are what stop it being a toy. One sine sliding down is a
"pew"; three of them at slightly wrong ratios beat against each other
and read as something with a mechanism inside it."""
n = int(RATE * seconds)
crack = [v * _decay(i, n, 14.0) * 0.5
for i, v in enumerate(_noise(n, 11, lambda t: 0.85 - 0.5 * t))]
body = [0.0] * n
for ratio, level, curve in ((1.0, 0.6, 0.9), (1.49, 0.3, 1.05), (2.02, 0.18, 0.8)):
part = _sweep(n, 900.0 * ratio, 190.0 * ratio, curve)
for i, v in enumerate(part):
body[i] += v * level * _decay(i, n, 2.6)
sub = [v * _decay(i, n, 3.2) * 0.55
for i, v in enumerate(_sweep(n, 220.0, 60.0, 0.7))]
mixed = _mix(crack, body, sub)
return [_saturate(v, 2.0) * _attack(i, 0.0015) for i, v in enumerate(mixed)]
def dialog(seconds=0.24):
"""A short radio handshake when a transmission line appears.
Two rising digital tones establish the signal; a small static tail keeps
it in the same physical world as the scan-line presentation without
turning every revealed character into a click."""
n = int(RATE * seconds)
tone = [0.0] * n
for start, end, level, delay in ((520.0, 820.0, 0.55, 0.0),
(940.0, 1380.0, 0.32, 0.045)):
begin = int(RATE * delay)
part = _sweep(n - begin, start, end, 0.8)
for i, value in enumerate(part):
at = begin + i
tone[at] += value * level * _decay(i, len(part), 2.8)
static = [value * _decay(i, n, 2.1) * 0.16
for i, value in enumerate(_noise(n, 17, lambda t: 0.55 - 0.42 * t))]
mixed = _mix(tone, static)
return [_saturate(value, 1.8) * _attack(i, 0.002)
for i, value in enumerate(mixed)]
def explosion(seconds, sub_from, sub_to, roar_seed, debris_level, drive):
"""A blast in three layers on three clocks: the crack is over in a
fifth of a second, the roar runs most of the length, and the debris
keeps falling afterwards."""
n = int(RATE * seconds)
crack_n = int(n * 0.18)
crack = [v * _decay(i, crack_n, 5.0) * 0.9
for i, v in enumerate(_noise(crack_n, 3, lambda t: 0.95 - 0.55 * t))]
crack += [0.0] * (n - crack_n)
roar = [v * _decay(i, n, 1.5)
for i, v in enumerate(_noise(n, roar_seed, lambda t: 0.30 * (1.0 - t) ** 1.4 + 0.012))]
# The sub is the size of the thing. It falls slowly and outlasts the
# crack by a long way.
sub = [v * _decay(i, n, 1.15) * 1.25
for i, v in enumerate(_sweep(n, sub_from, sub_to, 0.55))]
# Debris: sparse, dry ticks over the back half, so the sound has an
# aftermath rather than simply stopping.
rng = random.Random(roar_seed + 40)
debris = [0.0] * n
for _ in range(int(seconds * 34)):
at = rng.randint(int(n * 0.25), n - 1)
length = rng.randint(300, 1400)
pitch = rng.uniform(140.0, 900.0)
for k in range(min(length, n - at)):
debris[at + k] += (math.sin(2.0 * math.pi * pitch * k / RATE)
* (1.0 - k / length) ** 3.0
* debris_level * rng.uniform(0.5, 1.0))
mixed = _mix(crack, roar, sub, debris)
return [_saturate(v, drive) * _attack(i, 0.001) for i, v in enumerate(mixed)]
def boss_scream(seconds=1.65):
"""A huge synthetic creature waking: a rough falling throat tone with
an unstable upper rasp. Long enough to carry the reveal shake, without
borrowing a human/animal recording whose licence would need tracking."""
n = int(RATE * seconds)
throat = [0.0] * n
phase = 0.0
for i in range(n):
t = i / n
hz = 118.0 * (52.0 / 118.0) ** (t ** 0.72)
hz *= 1.0 + 0.07 * math.sin(t * math.pi * 17.0)
phase += 2.0 * math.pi * hz / RATE
envelope = min(1.0, t / 0.06) * (1.0 - t) ** 0.75
throat[i] = (math.sin(phase) * 0.75 + math.sin(phase * 2.03) * 0.32
+ math.sin(phase * 3.97) * 0.16) * envelope
rasp_noise = _noise(n, 189, lambda t: 0.34 * (1.0 - t) + 0.025)
rasp = [value * min(1.0, i / (RATE * 0.025)) * _decay(i, n, 1.15) * 0.68
for i, value in enumerate(rasp_noise)]
mixed = _mix(throat, rasp)
return [_saturate(value, 3.2) for value in mixed]
if __name__ == "__main__":
_write("sfx_shot", shot())
_write("sfx_dialog", dialog())
_write("sfx_boss_scream", boss_scream())
# A vessel coming apart: bright, fast, with real low end under it.
_write("sfx_ship_death", explosion(1.45, 260.0, 38.0, 5, 0.16, 2.6))
# A structure: longer, deeper, and it keeps coming down afterwards.
_write("sfx_building_death", explosion(2.3, 150.0, 24.0, 9, 0.24, 3.1))