NoteStack-Test / renderer /scripts /make_launch_music.py
Arslan1997's picture
LETS GOOOOOOOOOOOOOOOOOOOOOOOOO
0d81e80
Raw History Blame Contribute Delete
13.3 kB
"""Launch trailer score + timeline.
Reads renderer/public/launch-vo/durations.json, lays scenes out on bar lines at 120 bpm, writes
renderer/src/compositions/launchTimeline.ts and renderer/public/launch-vo/music.wav.
Shape: uncharted intro (pads, heartbeat) -> launch impact -> driving groove through the feature
galaxies (four on the floor, sidechained pads, plucked arp, bass) -> two bar build -> huge hit on
"Coming soon" and a long shimmering tail. Pure synthesis, no noise sources.
"""
import json
import math
import wave
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[1]
DUR = json.loads((ROOT / "public/launch-vo/durations.json").read_text())
BPM = 120
BEAT = 60 / BPM
BAR = BEAT * 4
VO_START = {"intro": 1.0, "outro": 1.2}
MIN_BARS = {"intro": 4, "outro": 5}
order = ["intro", "launch", "sources", "research", "map", "voice", "audio", "video", "launchkit",
"launchpad", "resurface", "montage", "outro"]
scenes = []
cursor = 0.0
for sid in order:
if sid == "montage":
bars, vo, vo_len = 2, None, None
else:
vo = VO_START.get(sid, 0.35)
vo_len = DUR[sid]
bars = max(MIN_BARS.get(sid, 2), math.ceil((vo + vo_len + 0.45) / BAR))
scenes.append({"id": sid, "start": cursor, "len": bars * BAR, "vo": vo, "voLen": vo_len})
cursor += bars * BAR
TOTAL = cursor
bar_of = {s["id"]: round(s["start"] / BAR) for s in scenes}
print("total", TOTAL, bar_of)
ts = ["// Generated by make_launch_music.py from public/launch-vo/durations.json. Do not edit by hand.",
f"// {BPM} bpm; every scene starts on a bar line so cuts land on the beat.",
f"export const LAUNCH_BPM = {BPM};",
f"export const LAUNCH_SECONDS = {TOTAL};",
"export const LAUNCH_SCENES = ["]
for s in scenes:
vo = "null" if s["vo"] is None else s["vo"]
vl = "null" if s["voLen"] is None else s["voLen"]
ts.append(f' {{ id: "{s["id"]}", start: {s["start"]}, len: {s["len"]}, vo: {vo}, voLen: {vl} }},')
ts.append("] as const;")
ts.append("export type LaunchSceneId = (typeof LAUNCH_SCENES)[number][\"id\"];")
(ROOT / "src/compositions/launchTimeline.ts").write_text("\n".join(ts) + "\n")
# ---------------------------------------------------------------- synthesis
SR = 44100
n = int(SR * (TOTAL + 0.2))
t = np.arange(n) / SR
L = {k: np.zeros(n) for k in ("pad", "bass", "arp", "drums", "fx", "bell")}
R = {k: np.zeros(n) for k in L}
rng = np.random.default_rng(11)
def hz(m: float) -> float:
return 440.0 * 2 ** ((m - 69) / 12)
def add(bus: str, i0: int, sig: np.ndarray, pan: float = 0.0) -> None:
if i0 >= n:
return
sig = sig[: n - i0]
L[bus][i0:i0 + len(sig)] += sig * (1 - pan)
R[bus][i0:i0 + len(sig)] += sig * (1 + pan)
def at(seconds: float) -> int:
return int(seconds * SR)
def bar_t(b: float) -> float:
return b * BAR
INTRO_END = bar_of["launch"]
GROOVE_START = bar_of["sources"]
BUILD = bar_of["montage"]
OUTRO = bar_of["outro"]
END_BAR = TOTAL / BAR
# I vi IV V in D, two bars each; outro resolves on a wide Dadd9.
PROG = [[50, 57, 62, 66, 69], [47, 54, 59, 62, 66], [43, 50, 55, 59, 62], [45, 52, 57, 61, 64]]
INTRO_PROG = [[50, 57, 64, 66, 69], [47, 54, 61, 62, 66]]
OUTRO_CHORD = [38, 50, 57, 62, 64, 66, 69, 74]
def chord_at(b: float) -> list[int]:
if b < INTRO_END:
return INTRO_PROG[int(b // 2) % 2]
if b >= OUTRO:
return OUTRO_CHORD[1:6]
return PROG[int((b - INTRO_END) // 2) % 4]
# Pads: detuned soft saws per two bar chord, slow attack.
def pad(notes, start, dur, gain, bright):
i0 = at(start)
length = at(dur + 2.5)
tt = np.arange(length) / SR
a = np.clip(tt / 1.2, 0, 1)
r = np.clip((dur + 2.5 - tt) / 2.5, 0, 1)
e = (0.5 - 0.5 * np.cos(np.pi * a)) * (0.5 - 0.5 * np.cos(np.pi * r))
for k, m in enumerate(notes):
tone = np.zeros(length)
for cents, ph in ((-8, 0.0), (8, 1.7)):
f = hz(m) * 2 ** (cents / 1200)
for h in range(1, 8):
tone += np.sin(2 * np.pi * f * h * tt + ph * h) * bright ** (h - 1) / h ** 1.3
add("pad", i0, tone * e * gain, pan=(k - 2) * 0.25)
b = 0.0
while b < END_BAR:
notes = chord_at(b)
if b >= OUTRO:
pad(OUTRO_CHORD, bar_t(b), END_BAR * BAR - bar_t(b) - 3.0, 0.05, 0.5)
break
bright = 0.3 if b < INTRO_END else 0.42 + 0.1 * (b - GROOVE_START) / max(1, BUILD - GROOVE_START)
pad(notes, bar_t(b), BAR * 2, 0.05 if b < INTRO_END else 0.042, bright)
b += 2
# Kick: pitched sine sweep + tiny click.
def kick(start, gain=1.0):
length = at(0.45)
tt = np.arange(length) / SR
f = 44 + 90 * np.exp(-tt * 28)
body = np.sin(2 * np.pi * np.cumsum(f) / SR) * np.exp(-tt * 7.5)
click = np.sin(2 * np.pi * 1800 * tt) * np.exp(-tt * 400) * 0.25
add("drums", at(start), (body + click) * 0.5 * gain)
# Hat: inharmonic metallic partials, very short.
def hat(start, gain=1.0, pan=0.25):
length = at(0.07)
tt = np.arange(length) / SR
sig = sum(np.sin(2 * np.pi * f * tt + p) for f, p in ((6230, 0.1), (7310, 1.2), (8420, 2.3), (9870, 0.7), (11200, 1.9)))
add("drums", at(start), sig * np.exp(-tt * 70) * 0.018 * gain, pan)
# Snare/tom tick for the build: short tone burst with a pitch drop.
def tom(start, pitch, gain):
length = at(0.16)
tt = np.arange(length) / SR
f = pitch * (1 + 0.6 * np.exp(-tt * 40))
sig = np.sin(2 * np.pi * np.cumsum(f) / SR) + 0.4 * np.sin(2 * np.pi * np.cumsum(f * 2.3) / SR)
add("drums", at(start), sig * np.exp(-tt * 22) * gain * 0.2, pan=rng.uniform(-0.3, 0.3))
kicks: list[float] = []
# Heartbeat in the intro's second half.
for bb in range(2, INTRO_END):
for off in (0, BEAT * 0.75):
kick(bar_t(bb) + off, 0.35 if off else 0.5)
kicks.append(bar_t(bb) + off)
# Launch: kick on beat one of bar 1, quarters on bar 2.
kick(bar_t(INTRO_END), 1.2)
kicks.append(bar_t(INTRO_END))
for q in range(4):
kick(bar_t(INTRO_END + 1) + q * BEAT, 0.7)
kicks.append(bar_t(INTRO_END + 1) + q * BEAT)
# Groove: four on the floor with offbeat hats and 16th ghost hats.
for bb in range(GROOVE_START, BUILD):
for q in range(4):
s = bar_t(bb) + q * BEAT
kick(s, 0.85)
kicks.append(s)
hat(s + BEAT / 2, 1.0)
hat(s + BEAT / 4, 0.35, -0.25)
hat(s + BEAT * 3 / 4, 0.35, -0.25)
# Build: toms accelerating and rising, no kick.
bs = bar_t(BUILD)
steps = [(0, 8, 8), (1, 16, 16)]
for bar_i, per_bar, _ in steps:
for k in range(per_bar):
s = bs + bar_i * BAR + k * BAR / per_bar
prog = (s - bs) / (2 * BAR)
tom(s, 140 + 160 * prog, 0.4 + 0.8 * prog)
# Outro hit.
kick(bar_t(OUTRO), 1.5)
kicks.append(bar_t(OUTRO))
# Sidechain envelope: pads, bass and arp duck briefly after each kick.
side = np.ones(n)
for k in kicks:
i0 = at(k)
length = min(n - i0, at(0.4))
if length <= 0:
continue
tt = np.arange(length) / SR
side[i0:i0 + length] = np.minimum(side[i0:i0 + length], 1 - 0.55 * np.exp(-tt * 9))
# Bass: offbeat 8ths on the chord root through the groove, long root in the launch bars.
def bass_note(m, start, dur, gain):
length = at(dur + 0.05)
tt = np.arange(length) / SR
f = hz(m)
sig = np.sin(2 * np.pi * f * tt) + 0.35 * np.sin(2 * np.pi * 2 * f * tt) + 0.12 * np.sin(2 * np.pi * 3 * f * tt)
e = np.clip(tt / 0.008, 0, 1) * np.clip((dur + 0.05 - tt) / 0.05, 0, 1) * np.exp(-tt * 1.5)
add("bass", at(start), sig * e * gain)
bass_note(chord_at(INTRO_END)[0] - 12, bar_t(INTRO_END), BAR * 2 - 0.1, 0.22)
for bb in range(GROOVE_START, BUILD):
root = chord_at(bb)[0] - 12
for e8 in range(8):
if e8 % 2 == 1 or e8 == 0:
bass_note(root + (12 if e8 == 7 else 0), bar_t(bb) + e8 * BEAT / 2, BEAT / 2 - 0.03, 0.2)
# Build: sustained root, rising an octave by sliding in steps.
for k in range(8):
bass_note(chord_at(BUILD)[0] - 12 + k, bs + k * BAR / 4, BAR / 4 - 0.02, 0.12 + 0.02 * k)
# Outro: deep sustained sub.
bass_note(38 - 12, bar_t(OUTRO), 6.0, 0.3)
# Plucked arp: 16ths from the launch on, pattern up the chord, brightness grows.
def pluck(m, start, gain, bright, pan):
length = at(0.6)
tt = np.arange(length) / SR
f = hz(m)
sig = np.zeros(length)
for h in range(1, 7):
sig += np.sin(2 * np.pi * f * h * tt) * np.exp(-tt * (6 + h * h * 2.5 / bright)) / h
sig *= np.clip(tt / 0.003, 0, 1)
add("arp", at(start), sig * gain, pan)
k = 0
s = bar_t(INTRO_END)
end_arp = bar_t(OUTRO) + BAR * 2
while s < end_arp:
b_now = s / BAR
notes = chord_at(b_now)
pattern = [notes[1], notes[2], notes[3], notes[4], notes[3] + 12, notes[4], notes[2] + 12, notes[3]]
m = pattern[k % len(pattern)] + 12
if b_now < GROOVE_START:
gain, bright, every = 0.05, 0.7, 2
elif b_now < BUILD:
gain, bright, every = 0.06, 0.8 + 0.6 * (b_now - GROOVE_START) / max(1, BUILD - GROOVE_START), 1
elif b_now < OUTRO:
gain, bright, every = 0.07, 1.6 + (b_now - BUILD), 1
else:
gain, bright, every = 0.05 * max(0, 1 - (b_now - OUTRO) / 2), 1.4, 2
if k % every == 0:
pluck(m, s, gain * (1.0 if k % 4 == 0 else 0.75), bright, 0.45 * math.sin(k * 0.8))
s += BEAT / 4
k += 1
# Bells: sparse in the intro, a cascade on the outro hit.
def bell(m, start, gain, pan=0.0, decay=1.6):
length = at(3.0)
tt = np.arange(length) / SR
f = hz(m)
sig = np.sin(2 * np.pi * f * tt) + 0.35 * np.sin(2 * np.pi * f * 2.76 * tt) * np.exp(-tt * 4)
sig += 0.12 * np.sin(2 * np.pi * f * 5.4 * tt) * np.exp(-tt * 8)
add("bell", at(start), sig * np.exp(-tt * decay) * np.clip(tt / 0.004, 0, 1) * gain, pan)
for i, m in enumerate([81, 76, 78, 74, 81, 83]):
bell(m, 0.8 + i * 1.2, 0.05, 0.4 * math.sin(i * 1.3))
for i, m in enumerate([74, 78, 81, 86, 90, 93, 98]):
bell(m, bar_t(OUTRO) + i * BEAT / 2, 0.06, 0.5 * math.sin(i), decay=1.1)
# FX: risers into the launch and the outro, soft swells into each feature scene, impacts.
def riser(end, dur, gain, top=2400):
length = at(dur)
tt = np.arange(length) / SR
p = tt / dur
f = 180 * (top / 180) ** p
sig = np.zeros(length)
for d in (-5, 0, 7):
sig += np.sin(2 * np.pi * np.cumsum(f * 2 ** (d / 1200)) / SR)
sig *= p ** 2 * gain
add("fx", at(end - dur), sig)
def impact(start, gain):
length = at(3.5)
tt = np.arange(length) / SR
boom = np.sin(2 * np.pi * np.cumsum(30 + 60 * np.exp(-tt * 6)) / SR) * np.exp(-tt * 1.4)
shimmer = sum(np.sin(2 * np.pi * f * tt) * np.exp(-tt * 2.2) for f in (1174.7, 1480, 1760, 2349)) * 0.05
add("fx", at(start), (boom * 0.6 + shimmer) * gain)
riser(bar_t(INTRO_END), BAR * 1.5, 0.06)
impact(bar_t(INTRO_END), 0.9)
for sc in scenes:
sb = round(sc["start"] / BAR)
if GROOVE_START < sb < BUILD:
riser(sc["start"], BEAT * 2, 0.025, top=1600)
riser(bar_t(OUTRO), BAR * 2, 0.09, top=3200)
impact(bar_t(OUTRO), 1.3)
# ---------------------------------------------------------------- mix
for bus in ("pad", "bass", "arp"):
L[bus] *= side
R[bus] *= side
# Ping pong delay on the arp and bells (dotted eighth).
delay = at(BEAT * 0.75)
for bus in ("arp", "bell"):
dl, dr = np.zeros(n), np.zeros(n)
for i in range(4):
shift = delay * (i + 1)
g = 0.35 ** (i + 1)
src_l, src_r = (R[bus], L[bus]) if i % 2 == 0 else (L[bus], R[bus])
dl[shift:] += src_l[:-shift] * g
dr[shift:] += src_r[:-shift] * g
L[bus] += dl * 0.6
R[bus] += dr * 0.6
# Reverb (smooth partial-based IR, no noise).
ir_len = at(3.4)
it = np.arange(ir_len) / SR
irl, irr = np.zeros(ir_len), np.zeros(ir_len)
for f in rng.uniform(200, 2600, 100):
ph = rng.uniform(0, 2 * np.pi, 2)
d = np.exp(-it * rng.uniform(1.0, 2.0))
irl += np.sin(2 * np.pi * f * it + ph[0]) * d
irr += np.sin(2 * np.pi * f * it + ph[1]) * d
irl /= np.max(np.abs(irl))
irr /= np.max(np.abs(irr))
def convolve(x, ir):
size = 1 << int(np.ceil(np.log2(len(x) + len(ir))))
return np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(ir, size), size)[: len(x)]
def norm(x):
return x / (np.max(np.abs(x)) + 1e-9)
LEVEL = {"pad": 0.55, "bass": 0.7, "arp": 0.55, "drums": 0.8, "fx": 0.6, "bell": 0.5}
SEND = {"pad": 0.5, "arp": 0.35, "bell": 0.7, "fx": 0.5, "drums": 0.05, "bass": 0.0}
ml, mr = np.zeros(n), np.zeros(n)
send_l, send_r = np.zeros(n), np.zeros(n)
peak = max(np.max(np.abs(L[b])) + np.max(np.abs(R[b])) for b in L) / 2
for bus in L:
gl, gr = L[bus] / peak * LEVEL[bus], R[bus] / peak * LEVEL[bus]
ml += gl
mr += gr
send_l += gl * SEND[bus]
send_r += gr * SEND[bus]
wet_l = norm(convolve(send_l, irl)) * np.max(np.abs(send_l)) * 0.9
wet_r = norm(convolve(send_r, irr)) * np.max(np.abs(send_r)) * 0.9
ml += wet_l
mr += wet_r
master = np.minimum(1, t / 1.5) * np.clip((TOTAL + 0.2 - t) / 3.5, 0, 1)
ml *= master
mr *= master
pk = max(np.max(np.abs(ml)), np.max(np.abs(mr)))
ml = np.tanh(ml / pk * 1.4) * 0.85
mr = np.tanh(mr / pk * 1.4) * 0.85
out = ROOT / "public/launch-vo/music.wav"
with wave.open(str(out), "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes((np.stack([ml, mr], axis=1) * 32767).astype("<i2").tobytes())
print("music ok", out, round(TOTAL, 2), "s")