# 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. The three things the picture shows -- the charge going off, the # logo bursting out of it, the dragon rearing and breathing fire -- are recordings, three sounds # from soundbible.com under the Creative Commons Attribution 3.0 licence, kept as FLAC under # assets/samples and credited in LICENSES: Mark DiAngelo's "Explosion Ultra Bass", Mike Koenig's # "Large Fireball", and Daniel Simon's "Dragon Fire Breath and Roar". They replaced the blast and # the flame this script used to synthesise, which never sounded like the things themselves. What # is between them -- the wingbeats, the riser as the grid comes up, the logo leaving, and the bed # -- is still made here from oscillators and noise, in a room that is a convolution, and numpy does # the arithmetic as it does for the model tools. # # 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 REAR_DONE = 4.90 # The head is back where it started; the dragon's fire has gone by here. 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. SAMPLES = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "assets", "samples") EXPLOSION_LEVEL = 1.0 # The recordings against each other: the charge fills the scale, the DRAGON_LEVEL = 1.2 # dragon is driven into the limiter over it, the fireball is a rush FIREBALL_LEVEL = 0.6 # rather than a blow. EXPLOSION_DRIVE = 1.6 # The explosion through a soft clipper, so its body is as loud as its # crack and it hits rather than pops. STING_PEAK = 1.3 # The sting above full scale before the limiter: the limiter bends # everything over its knee towards the ceiling, so this is the loudness # -- the punch -- of the whole intro. It was 1.15, and polite; at 2.2 # the whole first four seconds were one flat wall. EXPLOSION_HOLD = 0.4 # Seconds the explosion's rumble runs at full before it is let go, EXPLOSION_GONE = 2.0 # and where it has gone: the roar is heard over silence, not rumble. # (At 0.8 and 3.2 the rumble buried the roar altogether.) DRAGON_FIRE_AT = 2.75 # Seconds into the dragon recording where its roar turns to fire; the # recording is laid so that moment is the animation's BREATH_START. DRAGON_ROAR_GAIN = 2.0 # The roar starts small in that recording and builds; its start is # brought up so it is heard as the dragon flies in, not only as it rears. DRAGON_FIRE_GAIN = 3.0 # The fire sits some eight decibels under the roar before it, and it is # the fire the picture shows; brought up past it. 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 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 # 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] # A recording, decoded to this script's rate as one channel and brought to full scale, so what is # laid into the sting is the sound and the level is the level asked for. def sample(name): path = os.path.join(SAMPLES, name + ".flac") raw = subprocess.run(["ffmpeg", "-loglevel", "error", "-i", path, "-f", "f32le", "-ac", "1", "-ar", str(RATE), "-"], check=True, capture_output=True).stdout return normalise(np.frombuffer(raw, dtype=" 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("