#146: shots, deaths and collapses are heard
Three events get a sound: a shot leaving a gun, a vessel dying, a structure coming down. The sounds are SYNTHESISED rather than downloaded - audio/make_sfx.py generates all three from scratch, and is committed alongside them. The ticket asked for free sources and the good ones are in its comments (Kenney's CC0 packs, OpenGameArt's CC0 sets, Freesound filtered to CC0), but a generated sound has no licence to track, no attribution to keep, and no binary in the repository whose provenance nobody can check a year from now. It also makes the sounds DATA: too sharp a shot is a number in that file and a re-run, not a hunt through a library. Dropping in a pack file later means replacing a .wav and nothing else. A voice POOL, not a player, because these overlap constantly - a fleet firing is several shots inside one shot's length. Twelve voices, and when they are all busy the one furthest through its own sound is recycled, which is the one with the least left to lose. Parented to the tree root, for the reason that matters: every one of these sounds is started by something that is being freed on the same frame. A dying ship cannot own the sound of its own death. Levels are per sound rather than shared - a shot fires constantly and a death happens once, so equal volumes are not proportionate ones - and shots get a little pitch spread so a stream of them does not flatten into one machine-gun note. The deaths get none: a wobbling explosion sounds broken rather than varied. The test goes at the failures a play-through would hide for weeks: firing three times the pool size must leave exactly the pool's worth sounding and no leaked voices, and the ship-death case is checked from a node that really has been freed on that frame.
This commit is contained in:
parent
080e3bb928
commit
940540d60c
14 changed files with 433 additions and 0 deletions
88
audio/make_sfx.py
Normal file
88
audio/make_sfx.py
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
#!/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))
|
||||
Loading…
Reference in a new issue