singe/util/makeMenuSound.py
2026-09-13 22:06:39 -05:00

521 lines
22 KiB
Python

# The menu backdrop's sound: the sting the intro runs to, with a bed underneath it that fades out
# as the menu takes the screen. It is written from nothing -- there is no sample library here, and
# an engine shipping media nobody can account for is exactly what the drawn backdrop was made to
# stop -- so every noise below is an oscillator, a burst of noise, or an envelope over one of the
# two. numpy does the arithmetic, as it does for the model tools; the reverb is a convolution and
# wants the FFT.
#
# One file comes out of it, menuIntro.flac, exactly as long as the backdrop's intro. Nothing loops:
# music under a menu that is waiting for someone to choose a game wears out its welcome in about
# fifteen seconds, so the bed fades away once the menu has the screen and what is left is quiet.
# That is also why the recording's looping section carries silence.
#
# No length is written down here. util/renderMenuVideo.py asks the backdrop where its intro ends
# and passes it in, so the sound cannot drift out of step with the picture.
#
# Usage: python3 util/renderMenuVideo.py (which calls makeSound below)
# python3 util/makeMenuSound.py --intro 9.90 --out assets
import argparse
import math
import os
import subprocess
import wave
import numpy as np
RATE = 44100
SEED = 20260913 # Two runs write the same file, so a re-render can be compared with the
# last one and the difference is the picture, not the dice.
TAIL = 0.006 # Seconds an envelope is given to reach silence before it is cut off.
BED_PEAK = 0.34 # How loud the bed is. Everything else is fitted around it, because it
# is the one thing playing under the whole intro.
PEAK = 0.97 # Where the limiter tops out.
KNEE = 0.80 # and where it starts to bend. Below this nothing is touched at all.
# High, so the charge's transient comes through as it was made rather
# than rounded off: the limiter is here to catch the last of it, not to
# flatten the loudest moment in the file into the same shape as the rest.
# The intro's marks, in seconds, from assets/Backdrop.singe. They are here rather than read out of
# it because the Lua is the picture's copy and this is the sound's; what has to agree between the
# two -- where the whole thing ends -- is passed in instead of guessed at.
FLY_START = 0.30
FLY_END = 1.90
FLAP_FAST = 13.0
FLAP_SLOW = 3.4
BREATH_START = 3.46
BREATH_END = 4.35
GRID_UP = 4.30
GRID_LIT = 5.60
LIFT_FROM = 5.90
LIFT_TO = 7.30 # The menu has the screen from here, and the music starts leaving.
# The tempo comes out of the picture: four bars land exactly on LIFT_TO, so the last chord of the
# phrase is the one the menu arrives on.
BEATS = 4
BARS_TO_MENU = 4
ROOT = 220.0 # A3. The grid is magenta and the sun is orange; the key is A minor.
HARMONICS = 12 # Partials in the sawtooth. Twelve keeps the top of the arpeggio, the
# highest note here, inside half the sample rate, so nothing aliases.
# The chord for each bar in turn, as semitones from the root, and the bass note beneath it.
CHORDS = (((0, 3, 7, 12), -12), ((-4, 0, 5, 8), -16), ((-5, 0, 3, 7), -17), ((-4, 0, 5, 8), -16))
ARPEGGIO = (0, 7, 12, 15, 12, 7, 12, 3) # Sixteenths, the same shape over every chord.
# The room the sting is heard in. A blast with nothing around it is a click: what makes it big is
# the second and a half of room that answers it.
ROOM_SECONDS = 2.6
ROOM_DECAY = 0.85 # Seconds the room falls by a factor of e.
ROOM_DARK = 1500 # It loses its top as it goes, the way a room does, and a long way off
# only the bottom of a blast is left at all.
ROOM_PREDELAY = 0.014 # The dry sound is heard on its own first, or it arrives already blurred.
CHARGE_DRIVE = 0.9 # How hard the blast and the flame are saturated. Gently: it is for
FLAME_DRIVE = 1.3 # grit, and anything more flattens the very dynamics that are the punch.
ROOM_SLAPS = ((0.061, 0.30), (0.113, 0.20), (0.187, 0.12)) # Distinct returns off whatever is out
# there. A blast in the open is heard once and then answered; a smooth
# tail on its own is a plate reverb, which is a studio and not a place.
def seconds(count):
return np.arange(count) / RATE
def note(semitones):
return ROOT * (2.0 ** (semitones / 12.0))
# Convolution through the FFT. Every fixed filter here is one of these -- a one pole low pass is
# just a decaying exponential to convolve with -- which keeps the whole file to array arithmetic
# instead of a per sample loop, and makes the reverb affordable at all.
def convolve(signal, kernel):
size = 1
while size < len(signal) + len(kernel):
size *= 2
out = np.fft.irfft(np.fft.rfft(signal, size) * np.fft.rfft(kernel, size), size)
return out[:len(signal)]
# A one pole low pass, as the exponential it is. The kernel is cut off where it has fallen below
# a hundred thousandth, which is inaudible and keeps the transform small.
def lowpass(signal, cutoff):
pole = math.exp(-2.0 * math.pi * cutoff / RATE)
length = min(int(math.log(1e-5) / math.log(pole)) + 1, len(signal))
kernel = (1.0 - pole) * pole ** np.arange(length)
return convolve(signal, kernel)
def highpass(signal, cutoff):
return signal - lowpass(signal, cutoff)
def bandpass(signal, low, high):
return highpass(lowpass(signal, high), low)
# A low pass whose cutoff moves, which no single convolution can do: a blast is a bright crack
# that turns into a rumble, and that turn is the filter closing. One pole, one multiply a sample,
# written out because the recursion cannot be vectorised.
def sweepLowpass(signal, cutoffs):
poles = np.exp(-2.0 * math.pi * np.clip(cutoffs, 10.0, RATE / 2.2) / RATE)
out = np.empty(len(signal))
last = 0.0
for i, pole in enumerate(poles):
last = signal[i] * (1.0 - pole) + last * pole
out[i] = last
return out
# A two pole state variable filter with a moving cutoff, which rings at the cutoff as the resonance
# goes up. The ring is the whole point of a riser: a swept resonance is what makes noise sound
# like it is climbing towards something.
def resonant(signal, cutoffs, resonance):
out = np.empty(len(signal))
steps = 2.0 * np.sin(np.pi * np.clip(cutoffs, 10.0, RATE / 2.2) / RATE)
damping = 1.0 / resonance
low = 0.0
band = 0.0
for i, step in enumerate(steps):
high = signal[i] - low - band * damping
band += step * high
low += step * band
out[i] = low
return out
# A slow random wander between 0 and 1: noise with everything above a few hertz taken off it.
# This is what the fire and the blast are modulated by. The first attempt used a pair of sine
# waves instead and that is precisely what made them sound made up: nothing in a fire repeats.
def flutter(count, rate, rng):
shape = lowpass(noise(count, rng), rate)
shape -= shape.min()
worst = shape.max()
return shape / worst if worst > 0.0 else shape
# Sparse pops: rubble coming down, or the spitting inside a flame. An impulse every so often at
# a random moment and a random size, each one smeared into a short burst of its own. Fire and
# debris are made of these, and a filtered hiss without them is a hiss.
def crackle(count, perSecond, rng, low, high, length=0.05):
train = np.zeros(count)
at = rng.integers(0, count, size=max(int(perSecond * count / RATE), 1))
np.add.at(train, at, rng.uniform(0.2, 1.0, size=len(at)) * rng.choice((-1.0, 1.0), size=len(at)))
burst = noise(int(length * RATE), rng) * np.exp(-seconds(int(length * RATE)) / (length / 4.0))
return bandpass(convolve(train, burst), low, high)
# A band pass whose band moves, for a flame whose resonance wanders. Two sweeping one poles: the
# lower one taken off the upper leaves what is between them.
def sweepBandpass(signal, low, high):
return sweepLowpass(signal, high) - sweepLowpass(signal, low)
# An envelope follower: fast to rise, slow to fall, which is how a compressor hears a sound and
# how the blast gets to push everything else out of its way.
def follow(signal, attack, release):
out = np.empty(len(signal))
rise = math.exp(-1.0 / (attack * RATE))
fall = math.exp(-1.0 / (release * RATE))
last = 0.0
for i, value in enumerate(np.abs(signal)):
pole = rise if value > last else fall
last = value * (1.0 - pole) + last * pole
out[i] = last
return out
# Everything that is not the blast, pushed out of the blast's way and let back in. Punch is
# contrast: a loud sound with nothing standing next to it is heard as louder than the same sound
# with the rest of the mix holding its level underneath.
def duck(signal, trigger, amount, attack, release):
envelope = follow(trigger, attack, release)
worst = envelope.max()
return signal * (1.0 - amount * envelope / worst) if worst > 0.0 else signal
# The room, as an impulse to convolve with: noise that dies away, darkening as it goes, with the
# first few milliseconds left empty so the dry sound arrives before its reflections do.
def room(rng):
count = int(ROOM_SECONDS * RATE)
impulse = rng.standard_normal(count) * np.exp(-seconds(count) / ROOM_DECAY)
for at, level in ROOM_SLAPS:
impulse[int(at * RATE)] += level
impulse = lowpass(impulse, ROOM_DARK)
impulse[:int(ROOM_PREDELAY * RATE)] = 0.0
return impulse / math.sqrt(float(np.sum(impulse * impulse)))
# An oscillator whose frequency is given a sample at a time.
def sweep(frequencies):
return np.sin(2.0 * math.pi * np.cumsum(frequencies) / RATE)
# A band limited sawtooth, built one harmonic at a time.
def saw(t, frequency):
out = np.zeros(len(t))
for h in range(1, HARMONICS + 1):
if frequency * h < RATE / 2.0:
out += np.sin(2.0 * math.pi * frequency * h * t) / h
return out * 2.0 / math.pi
def noise(count, rng):
return rng.standard_normal(count)
# An envelope that rises in attack seconds and falls away over decay, fast to start and slow to
# finish: a struck sound rather than a triangle. An exponential never actually reaches zero, so
# the last few milliseconds are taken down by hand; cutting one off where it still had a tenth of
# its level left is a click, and the bed has hundreds of these in it.
def hit(attack, decay, length):
count = int(length * RATE)
t = seconds(count)
rise = np.clip(t / max(attack, 1e-6), 0.0, 1.0)
fall = np.exp(-np.clip(t - attack, 0.0, None) / decay)
close = np.clip((count - 1 - np.arange(count)) / (TAIL * RATE), 0.0, 1.0)
return rise * fall * close
# A window that comes up, holds, and goes down again: for the parts of the intro that have a
# length of their own rather than a decay.
def swell(length, rise, fall):
count = int(length * RATE)
up = np.clip(np.arange(count) / (rise * RATE), 0.0, 1.0)
down = np.clip((count - 1 - np.arange(count)) / (fall * RATE), 0.0, 1.0)
return np.minimum(up, down)
# Lays a signal into the take at a time, as long as whatever is shorter.
def lay(into, signal, start, level=1.0):
first = max(int(start * RATE), 0)
length = min(len(signal), len(into) - first)
if length > 0:
into[first:first + length] += signal[:length] * level
# Where the dragon's wings reach the bottom of a beat, worked out the way assets/Backdrop.singe
# works it out: the flap is sin(t * rate) with the rate easing from fast to slow as the logo flies
# out of the blast, so the gusts land on the animation rather than near it.
def flapTimes(until):
t = np.arange(0.0, until, 1.0 / 240.0)
span = np.clip((t - FLY_START) / (FLY_END - FLY_START), 0.0, 1.0)
value = np.sin(t * (FLAP_FAST + (FLAP_SLOW - FLAP_FAST) * (1.0 - (1.0 - span) ** 3)))
falling = (value[:-1] > 0.0) & (value[1:] <= 0.0) & (t[:-1] > FLY_START)
return t[:-1][falling]
# The charge. This is the one sound in the intro that has to be felt rather than heard, and the
# first two attempts were not. The first peaked the meter with a sine at thirty hertz, which moves
# no air at all on the speaker a cabinet or a laptop actually has. The second had the weight and
# still sounded made up, for the reason every synthesised blast sounds made up: smooth envelopes
# over steady noise. Nothing about an explosion is smooth or steady. What is here now is five
# layers with the detail put back -- a crack, a slam through the middle of the bass, a drop that
# falls away twice over, a body whose level flickers at random as it closes, and rubble coming
# down afterwards -- and then the sum of them driven into a soft clipper, which is what a blast
# does to whatever is recording it and what turns five layers into one sound rather than five.
def chargeTake(length, rng):
count = int(length * RATE)
t = seconds(count)
out = np.zeros(count)
# The crack: a fraction of a millisecond to full scale, and gone in twenty. This is the part
# that is heard as the thing having happened suddenly.
out += highpass(noise(count, rng), 1600) * hit(0.0002, 0.02, length) * 1.8
# The slam: two hundred to nine hundred hertz, gone in a twentieth of a second. It is this
# band, not the sub, that a small speaker turns into "loud".
out += bandpass(noise(count, rng), 200, 900) * hit(0.0008, 0.05, length) * 1.8
# The drop. Two decays rather than one -- most of it goes in a tenth of a second and the rest
# takes a second to follow -- because a single exponential is heard as a synthesiser's kick.
# Saturated, so the harmonics carry the pitch that a small speaker cannot make.
drop = sweep(33.0 + 150.0 * np.exp(-t / 0.16)) * (0.72 * np.exp(-t / 0.085) + 0.28 * np.exp(-t / 0.85))
out += np.tanh(drop * 3.4) / math.tanh(3.4) * 1.0
# The body: noise with its top closing, flickering at random as it goes. The flicker is the
# difference between a blast and a cymbal.
body = sweepLowpass(noise(count, rng), 4200.0 * np.exp(-t / 0.26) + 80.0) * hit(0.001, 0.5, length)
out += body * (0.35 + 0.65 * flutter(count, 26, rng)) * 1.3
# Rubble.
out += crackle(count, 110, rng, 160, 3000) * hit(0.12, 1.5, length) * 0.8
# A little grit, and only a little. Driving the sum of the layers hard is how the second
# attempt lost its punch altogether: everything from the crack to the last of the rubble came
# out at the same level, which is a wall and not a blast. The sum is brought to full scale
# first so the amount of drive is the amount asked for rather than however many layers happen
# to be sounding at that instant.
return drive(out, CHARGE_DRIVE)
# The dragon's fire. Filtered noise with a tremolo on it is the textbook fake flame, and that is
# what this was: a band of noise wobbled by two sine waves. A flame is three things happening at
# once and none of them is periodic -- air catching alight, a roar whose loudness wanders at random,
# and the spitting inside it -- and the resonance of the column moves the whole time, which is why
# the band pass here is swept by a random walk rather than fixed.
def flameTake(length, rng):
count = int(length * RATE)
t = seconds(count)
out = np.zeros(count)
# The air catching: a bright burst that closes almost at once.
out += sweepLowpass(noise(count, rng), 6000.0 * np.exp(-t / 0.07) + 320.0) * hit(0.005, 0.11, length) * 1.1
# The roar, and the column's own resonance wandering over it.
loud = 0.30 + 0.70 * flutter(count, 9, rng)
out += lowpass(noise(count, rng), 420) * loud * 1.5
centre = 520.0 * (1.0 + 1.4 * flutter(count, 5, rng))
out += sweepBandpass(noise(count, rng), centre * 0.55, centre * 2.2) * loud * 1.2
# The spitting, and the hiss of the jet. Both go with the roar rather than running underneath
# it at a level of their own: what a flame does is surge, and everything in it surges together.
out += crackle(count, 190, rng, 700, 7000) * loud * 0.5
out += highpass(noise(count, rng), 4500) * loud * 0.35
return drive(out, FLAME_DRIVE)
# The sting: the charge and everything that comes out of it, in a room. The bed is not in here;
# it goes underneath afterwards.
def stingTake(length, rng):
count = int(length * RATE)
out = np.zeros(count)
charge = np.zeros(count)
lay(charge, chargeTake(min(3.0, length), rng), 0.0, 1.0)
# The logo coming out of the blast: a rush that rises as it arrives and stops when it stops.
lay(out, bandpass(noise(int(1.65 * RATE), rng), 300, 4000) * swell(1.65, 0.5, 0.45), FLY_START, 0.26)
# Wings.
gust = bandpass(noise(int(0.35 * RATE), rng), 180, 2200) * hit(0.03, 0.12, 0.35)
for at in flapTimes(length):
lay(out, gust, at, 0.16)
# The flame.
burn = BREATH_END - BREATH_START
lay(out, flameTake(burn, rng) * swell(burn, 0.05, 0.4), BREATH_START, 0.5)
# The grid coming up out of the dark, twice over: a note that rises with it, and noise through
# a resonance climbing the same way, which is the sound of something being switched on.
climb = GRID_LIT - GRID_UP
count = int(climb * 1.1 * RATE)
ramp = np.clip(seconds(count) / climb, 0.0, 1.0)
lay(out, sweep(note(-12) * (1.0 + ramp * 3.0)) * swell(climb * 1.1, 0.9, 0.5), GRID_UP, 0.11)
lay(out, resonant(noise(count, rng), 180.0 * (1.0 + ramp * 32.0), 9.0) * swell(climb * 1.1, 1.0, 0.35), GRID_UP, 0.11)
# The logo leaving.
lay(out, bandpass(noise(int((LIFT_TO - LIFT_FROM) * RATE), rng), 2000, 11000) * swell(LIFT_TO - LIFT_FROM, 0.5, 0.6), LIFT_FROM, 0.08)
# Everything else gets out of the charge's way and comes back over the next quarter second.
out = duck(out, charge, 0.8, 0.003, 0.22) + charge
space = room(rng)
return out + convolve(out, space) * 0.42
# The bed: a pad, a bass and an arpeggio, bar after bar for as long as it is wanted. It is played
# straight through rather than made once and repeated, so the echo and the pad carry across a bar
# line the way they would if somebody played it.
def bedTake(length, beat, rng):
count = int(length * RATE)
out = np.zeros(count)
sixteenth = beat / 4.0
held = swell(BEATS * beat + 0.25, 0.35, 0.5)
plucked = hit(0.004, sixteenth * 1.6, sixteenth * 4)
struck = hit(0.01, beat * 0.7, beat * 2)
heldT = seconds(len(held))
pluckT = seconds(len(plucked))
struckT = seconds(len(struck))
for bar in range(int(math.ceil(length / (BEATS * beat)))):
chord, bass = CHORDS[bar % len(CHORDS)]
barAt = bar * BEATS * beat
# The pad: the chord held for the whole bar, soft, slow to arrive, and detuned against
# itself so it moves rather than sits there.
for semitone in chord:
lay(out, (saw(heldT, note(semitone)) + saw(heldT, note(semitone) * 1.004)) * held, barAt, 0.045)
# The bass: one note a bar, and another on the last beat to lean into the next one.
for at, level in ((barAt, 0.5), (barAt + 3 * beat, 0.34)):
lay(out, np.sin(2.0 * math.pi * note(bass) * struckT) * struck, at, level)
lay(out, saw(struckT, note(bass) * 2) * struck, at, level * 0.25)
# The arpeggio: sixteenths, plucked, riding on top.
for step in range(BEATS * 4):
semitone = chord[0] + ARPEGGIO[step % len(ARPEGGIO)]
lay(out, saw(pluckT, note(semitone) * 2) * plucked, barAt + step * sixteenth, 0.06)
# A quarter note echo, and a breath of air under all of it.
echo = int(round(beat * RATE))
for _ in range(3):
out[echo:] += out[:-echo] * 0.32
out += lowpass(noise(count, rng), 900) * 0.012
return out
# Saturation, over a signal brought to full scale first. tanh on its own is only a soft clipper --
# how much it does depends entirely on how loud what goes into it happens to be -- and that is not
# a control, it is an accident.
def drive(signal, amount):
signal = normalise(signal, 1.0)
return np.tanh(signal * amount) / math.tanh(amount)
def normalise(signal, peak):
worst = float(np.max(np.abs(signal)))
return signal * (peak / worst) if worst > 0.0 else signal
# The music leaves as the menu arrives: full level until LIFT_TO, then away to nothing by the end
# of the file. A cosine rather than a straight line, because a straight fade is heard as a shove
# at the start and a long nothing at the end.
def fade(signal, at):
first = int(at * RATE)
away = (1.0 + np.cos(math.pi * np.arange(len(signal) - first) / (len(signal) - first))) / 2.0
signal[first:] *= away
return signal
# A soft limiter, so the loudest moment can be loud without deciding how loud everything else is.
# Below the knee nothing is touched; above it the curve bends over towards the ceiling, which is
# what lets the charge sit at the top of the scale without the rest of the intro being scaled down
# to make room for its peak.
def limit(signal):
over = np.abs(signal) > KNEE
room = PEAK - KNEE
signal = signal.copy()
signal[over] = np.sign(signal[over]) * (KNEE + room * np.tanh((np.abs(signal[over]) - KNEE) / room))
return signal
# A little width: the same sound a few samples apart is enough for a menu, and it stays mono
# compatible, which matters on a cabinet with one speaker.
def stereo(signal, shift=48):
late = np.concatenate((np.zeros(shift), signal[:-shift]))
return np.stack((signal * 0.92 + late * 0.08, signal * 0.92 - late * 0.08), axis=1)
# FLAC rather than a compressed format: it is what the video's audio track is muxed from, and a
# lossy encoder pads both ends of what it encodes, which the engine's seeks would find.
def writeFlac(path, frames):
temp = path + ".wav"
data = np.clip(frames, -1.0, 1.0)
with wave.open(temp, "wb") as out:
out.setnchannels(2)
out.setsampwidth(2)
out.setframerate(RATE)
out.writeframes((data * 32767.0).astype("<i2").tobytes())
subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", temp, "-c:a", "flac", path], check=True)
os.unlink(temp)
# Writes the file and answers its path. Called by util/renderMenuVideo.py with the length the
# backdrop itself reported, so nothing here has to know how long the intro is.
def makeSound(introSeconds, folder):
rng = np.random.default_rng(SEED)
path = os.path.join(folder, "menuIntro.flac")
beat = LIFT_TO / (BARS_TO_MENU * BEATS)
bed = normalise(bedTake(introSeconds, beat, rng), BED_PEAK)
# Only a little above full scale: enough that the loudest moment meets the limiter and nothing
# else does. At 1.5 the limiter was holding the whole first second at the same level, which
# took the shape out of the blast as surely as over-driving it had.
sting = normalise(stingTake(introSeconds, rng), 1.15)
# The bed comes up under the flame and is established by the time the grid is.
rising = np.clip((seconds(len(bed)) - BREATH_START) / (GRID_UP - BREATH_START), 0.0, 1.0)
writeFlac(path, stereo(fade(limit(sting + bed * rising), LIFT_TO)))
return path
def main():
parser = argparse.ArgumentParser(description="write the menu backdrop's sound")
parser.add_argument("--intro", type=float, required=True, help="seconds the backdrop's intro lasts")
parser.add_argument("--out", default=os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets"))
args = parser.parse_args()
print(makeSound(args.intro, args.out))
if __name__ == "__main__":
main()