diff --git a/audio/make_sfx.py b/audio/make_sfx.py index 635c671..4b3934c 100644 --- a/audio/make_sfx.py +++ b/audio/make_sfx.py @@ -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(" 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)) diff --git a/audio/sfx_building_death.wav b/audio/sfx_building_death.wav index 8a76045..d1b6b64 100644 Binary files a/audio/sfx_building_death.wav and b/audio/sfx_building_death.wav differ diff --git a/audio/sfx_ship_death.wav b/audio/sfx_ship_death.wav index c99e280..131c613 100644 Binary files a/audio/sfx_ship_death.wav and b/audio/sfx_ship_death.wav differ diff --git a/audio/sfx_shot.wav b/audio/sfx_shot.wav index 5c479ad..cc9fbcb 100644 Binary files a/audio/sfx_shot.wav and b/audio/sfx_shot.wav differ