spaceship/audio/make_sfx.py
Loïc Wisniewski 78836882af Sharpen upgrades, mark the convoy's destination, and give the world sound
#204 the Beam Collimator widens the beam instead of adding range to one that
already crosses the screen; beam width becomes a per-shot vessel stat.
#205 the Energy Propulser gains three upgrades - dash recharge, dash reach,
and boosted-cruise drain - which turns three constants into vessel stats.
#206 three further doctrines carry the fleet from ten to twenty.
#207 the prologue's World Ship gets a marker, through the scanner's own
guidance arrow rather than a second one.
#208 #210 #213 #214 four new synthesised sounds: the breach opening, cargo
reaching the hold, a rock breaking, and a gas bomb blooming.
#215 building health bars are larger, plated and framed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 12:16:17 +02:00

352 lines
14 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 pickup(seconds=0.10):
"""A unit of cargo arriving in the hold (#210).
This one fires more often than everything else in the file put together
- every rock a drill turns out, every load a transporter lands - so it
is built to be missable. Short, quiet, and NARROW: two clean partials a
fifth apart with a couple of milliseconds of tick in front. No noise bed
and no sub, because those are what make a repeated sound tiring."""
n = int(RATE * seconds)
tick = [value * _decay(i, n, 22.0) * 0.30
for i, value in enumerate(_noise(n, 61, lambda t: 0.9 - 0.4 * t))]
tone = [0.0] * n
for hz, level in ((1180.0, 0.55), (1770.0, 0.22)):
phase = 0.0
for i in range(n):
phase += 2.0 * math.pi * hz / RATE
# Rings up and straight back down: an arrival, not a note held.
tone[i] += math.sin(phase) * level * _decay(i, n, 3.4)
mixed = _mix(tick, tone)
return [_saturate(value, 1.5) * _attack(i, 0.0012)
for i, value in enumerate(mixed)]
def asteroid_break(seconds=0.62):
"""A rock coming apart (#213).
Deliberately NOT the ship explosion with different numbers. Stone has no
fuel in it: there is a hard dry crack, a short gravel roar, and a fall of
fragments, with only enough low end to give it mass. The sub that makes a
vessel's death read as big is exactly what would make this read as an
explosion rather than a rock."""
n = int(RATE * seconds)
crack_n = int(n * 0.14)
crack = [value * _decay(i, crack_n, 6.5) * 0.95
for i, value in enumerate(_noise(crack_n, 71, lambda t: 1.0 - 0.35 * t))]
crack += [0.0] * (n - crack_n)
# Higher and drier than an explosion's roar - grit, not fire.
gravel = [value * _decay(i, n, 2.4) * 0.6
for i, value in enumerate(_noise(n, 73, lambda t: 0.55 * (1.0 - t) ** 1.1 + 0.05))]
thud = [value * _decay(i, n, 2.6) * 0.45
for i, value in enumerate(_sweep(n, 190.0, 70.0, 0.6))]
# Fragments, denser and shorter than an explosion's debris, and pitched
# up where small hard things live.
rng = random.Random(77)
shards = [0.0] * n
for _ in range(int(seconds * 90)):
at = rng.randint(int(n * 0.10), n - 1)
length = rng.randint(120, 620)
pitch = rng.uniform(420.0, 2100.0)
for k in range(min(length, n - at)):
shards[at + k] += (math.sin(2.0 * math.pi * pitch * k / RATE)
* (1.0 - k / length) ** 3.5
* 0.16 * rng.uniform(0.4, 1.0))
mixed = _mix(crack, gravel, thud, shards)
return [_saturate(value, 2.4) * _attack(i, 0.001)
for i, value in enumerate(mixed)]
def gas_blast(seconds=1.05):
"""A gas bomb blooming (#214).
The opposite shape to a hull exploding: almost no crack, a soft low
whoomph that swells rather than starting at full volume, and a long
hiss that outlives it as the cloud spreads. The swell is the point - it
is the sound of an area filling, and it matches a bloom the player
watches expand rather than a flash they see once."""
n = int(RATE * seconds)
# Rises over the first fifth, then falls away: a bloom, not a bang.
def bloom(i):
t = i / n
return min(1.0, t / 0.18) * (1.0 - t) ** 1.3
whoomph = [value * bloom(i) * 1.1
for i, value in enumerate(_sweep(n, 150.0, 46.0, 0.5))]
body = [value * bloom(i) * 0.85
for i, value in enumerate(_noise(n, 83, lambda t: 0.22 * (1.0 - t) + 0.02))]
# The cloud itself: bright, thin, and still going after the low end has
# gone. Cut off far later than the rest, which is what "it is still out
# there" sounds like.
hiss = [value * min(1.0, i / (RATE * 0.05)) * _decay(i, n, 1.05) * 0.42
for i, value in enumerate(_noise(n, 89, lambda t: 0.75 - 0.25 * t))]
mixed = _mix(whoomph, body, hiss)
return [_saturate(value, 2.2) * _attack(i, 0.004)
for i, value in enumerate(mixed)]
def black_hole(seconds=2.6):
"""The vortex opening in the prologue (#208).
Something tearing rather than detonating. A pair of tones sliding in
OPPOSITE directions - one climbing to a thin scream, one falling into
the sub - so the sound pulls apart in the middle, plus a rising roar
that swallows both. It has to hold under the beat's shake, so it swells
across most of its length instead of decaying from the first sample."""
n = int(RATE * seconds)
def swell(i, peak=0.72, power=1.6):
t = i / n
if t <= peak:
return (t / peak) ** 1.25
return ((1.0 - t) / (1.0 - peak)) ** power
# Up into a scream, and down into the floor. The gap between them opening
# is the whole idea.
tearing_up = [value * swell(i) * 0.34
for i, value in enumerate(_sweep(n, 240.0, 1650.0, 1.35))]
tearing_down = [value * swell(i) * 0.95
for i, value in enumerate(_sweep(n, 190.0, 28.0, 0.85))]
# The roar closes over the top of them, brightening as it goes.
roar = [value * swell(i, 0.8, 1.3) * 0.8
for i, value in enumerate(_noise(n, 101, lambda t: 0.05 + 0.5 * t ** 1.5))]
# A slow beat in the low end, so it churns instead of humming.
churn = [0.0] * n
for i in range(n):
churn[i] = math.sin(2.0 * math.pi * 37.0 * i / RATE) \
* math.sin(2.0 * math.pi * 4.3 * i / RATE) * swell(i) * 0.5
mixed = _mix(tearing_up, tearing_down, roar, churn)
return [_saturate(value, 2.8) * _attack(i, 0.02)
for i, value 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))
_write("sfx_pickup", pickup())
_write("sfx_asteroid_break", asteroid_break())
_write("sfx_gas_blast", gas_blast())
_write("sfx_black_hole", black_hole())