215 lines
8.2 KiB
Python
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))
|