#150, #151: the sounds were a toy and a puff, and both were fair

"Shot sounds sound like a toy" and "death sounds from ship are really
cheap", and there was no arguing with either. The shot was a bare
descending sine, which is the sound every toy ray gun has ever made. The
death was one band of filtered noise with a sine under it - thin, and over
before the eye had finished with the explosion.

The synthesis is rewritten rather than tuned, because the shape was the
problem:

  LAYERS, not a waveform. A 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, so the explosions now run three clocks - the crack is
  done in a fifth of the length, the roar runs most of it, and sparse
  debris keeps falling afterwards so there is an aftermath rather than a
  stop.

  A SUB. Below about 80Hz is where "big" lives; without it an explosion is
  a hiss however loud you play it. Measured: 74% of a ship death's energy
  and 86% of a structure's now sits under 200Hz, against almost none
  before.

  SATURATION. Soft-clipping folds in harmonics, so a sound reads as loud
  rather than merely being loud - and stops peak normalisation flattening
  everything into the same quiet mush.

  DETUNED PARTIALS on the shot. Three of them at deliberately wrong ratios
  beat against each other and read as a mechanism; the single sine was the
  whole of the toy problem. A noise transient and a sub thump under it give
  it something to hit with.

Lengths went up with the weight: the ship death from 0.85s to 1.45s and the
structure from 1.6s to 2.3s, both audible to the end rather than gone in the
first third.
This commit is contained in:
Loïc Wisniewski 2026-08-29 14:39:33 +02:00
commit 9cf032c203
4 changed files with 124 additions and 43 deletions

View file

@ -2,15 +2,33 @@
"""Generates the game's sound effects, from scratch.
Forgejo #146 asked for free SFX sources, and the ones worth using are listed
in the ticket - Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound's CC0
filter. 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.
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
@ -23,66 +41,129 @@ 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.89)) * 32767))
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 %6d bytes" % (name, len(samples) / RATE, len(frames)))
print("audio/%s.wav %5.2fs %7d bytes" % (name, len(samples) / RATE, len(frames)))
def _env(i, n, attack, decay_power):
"""Fast attack, curved decay. `attack` is a fraction of the whole."""
a = max(1, int(n * attack))
if i < a:
return i / a
return (1.0 - (i - a) / max(1, n - a)) ** decay_power
def _decay(i, n, power):
return (1.0 - i / n) ** power
def shot(seconds=0.16, start_hz=1500.0, end_hz=320.0):
"""A short descending zap. The fleet's guns are energy weapons, so this
is a swept tone rather than a crack - it has to sit under a stream of
itself without turning into noise."""
n = int(RATE * seconds)
out = []
phase = 0.0
for i in range(n):
t = i / n
hz = start_hz * (end_hz / start_hz) ** t
phase += 2.0 * math.pi * hz / RATE
# A little square in it, so it reads as synthetic rather than as a flute.
tone = 0.7 * math.sin(phase) + 0.3 * (1.0 if math.sin(phase) > 0 else -1.0)
out.append(tone * _env(i, n, 0.02, 2.2))
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 explosion(seconds, low_hz, brightness, rumble_hz):
"""Noise through a one-pole low-pass, plus a falling sine underneath it.
The sine is what gives it a size: without it a burst of noise is a hiss,
however loud."""
n = int(RATE * seconds)
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
phase = 0.0
rng = random.Random(7)
for i in range(n):
t = i / n
# The filter opens for an instant and closes as it decays, which is
# what a real blast does - the crack first, the roar after.
cutoff = brightness * (1.0 - t) ** 1.6 + 0.02
cutoff = cutoff_at(i / n)
last += cutoff * (rng.uniform(-1.0, 1.0) - last)
hz = low_hz * (rumble_hz / low_hz) ** t
phase += 2.0 * math.pi * hz / RATE
body = 0.55 * math.sin(phase)
out.append((last * 1.4 + body) * _env(i, n, 0.004, 1.7))
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 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)]
if __name__ == "__main__":
_write("sfx_shot", shot())
# A vessel: sharp, bright, over quickly.
_write("sfx_ship_death", explosion(0.85, 220.0, 0.55, 45.0))
# A structure: slower, deeper, and it keeps falling down afterwards.
_write("sfx_building_death", explosion(1.6, 130.0, 0.34, 26.0))
# 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))