#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
352 lines
14 KiB
Python
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())
|