88 lines
3.2 KiB
Python
88 lines
3.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 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.
|
||
|
|
|
||
|
|
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.
|
||
|
|
|
||
|
|
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.89)) * 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)))
|
||
|
|
|
||
|
|
|
||
|
|
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 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))
|
||
|
|
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)
|
||
|
|
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
|
||
|
|
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))
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
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))
|