NoteStack-Test / renderer /scripts /make_demo_music.py
Arslan1997's picture
adding new demo + landing page improvements
bfe94d3
Raw History Blame Contribute Delete
6.5 kB
"""Sombre space score for the landing demo: dark minor pads, slow sparse bells, a deep drone, long dim reverb.
Pure additive synthesis, no noise sources. Writes renderer/public/demo-vo/music.wav (stereo, 44.1 kHz)."""
import wave
from pathlib import Path
import numpy as np
SR = 44100
TOTAL = 47.5
OUT = Path(__file__).resolve().parents[1] / "public" / "demo-vo" / "music.wav"
n = int(SR * TOTAL)
t = np.arange(n) / SR
left = np.zeros(n)
right = np.zeros(n)
def hz(midi: float) -> float:
return 440.0 * 2 ** ((midi - 69) / 12)
def env(start: float, dur: float, attack: float, release: float) -> np.ndarray:
"""Smooth (raised cosine) attack and release, zero outside the note."""
e = np.zeros(n)
i0, i1 = int(start * SR), min(n, int((start + dur + release) * SR))
if i0 >= n:
return e
tt = t[i0:i1] - start
a = np.clip(tt / attack, 0, 1)
a = 0.5 - 0.5 * np.cos(np.pi * a)
r = np.clip((start + dur + release - t[i0:i1]) / release, 0, 1)
r = 0.5 - 0.5 * np.cos(np.pi * r)
e[i0:i1] = a * r
return e
def pad_voice(freq: float, e: np.ndarray, brightness: np.ndarray, pan: float, gain: float) -> None:
"""Soft saw-like tone: a few harmonics with rolloff, two detuned copies for width, slow vibrato."""
idx = np.nonzero(e)[0]
if not len(idx):
return
sl = slice(idx[0], idx[-1] + 1)
tt = t[sl]
vib = 1 + 0.0015 * np.sin(2 * np.pi * 0.21 * tt + freq)
tone = np.zeros(len(tt))
for cents, ph in ((-7, 0.0), (7, 1.3)):
f = freq * 2 ** (cents / 1200) * vib
phase = 2 * np.pi * np.cumsum(f) / SR + ph
for h in range(1, 7):
tone += np.sin(h * phase) * (brightness[sl] ** (h - 1)) / h**1.4
tone *= e[sl] * gain
left[sl] += tone * (1 - pan)
right[sl] += tone * (1 + pan)
def bell(freq: float, start: float, gain: float, pan: float) -> None:
"""Glassy bell: fundamental plus an inharmonic partial, fast decay."""
i0 = int(start * SR)
if i0 >= n:
return
length = min(n - i0, int(2.4 * SR))
tt = np.arange(length) / SR
decay = np.exp(-tt * 1.5)
tone = np.sin(2 * np.pi * freq * tt) + 0.35 * np.sin(2 * np.pi * freq * 2.76 * tt) * np.exp(-tt * 5)
tone += 0.12 * np.sin(2 * np.pi * freq * 5.4 * tt) * np.exp(-tt * 9)
attack = np.clip(tt / 0.004, 0, 1)
tone *= decay * attack * gain
left[i0:i0 + length] += tone * (1 - pan)
right[i0:i0 + length] += tone * (1 + pan)
# Progression in D minor (aeolian), ~5.2 s per chord, falling to a bare resolved chord for the outro.
CHORDS = [
(0.0, [50, 57, 60, 64, 65]), # Dm9 D A C E F
(5.2, [46, 53, 57, 60, 65]), # Bbmaj7 Bb F A C F
(10.4, [43, 50, 53, 58, 62]), # Gm7 G D F Bb D
(15.6, [45, 52, 55, 60, 64]), # A7sus feel A E G C E
(20.8, [50, 57, 60, 65, 69]), # Dm add11 D A C F A
(26.0, [46, 53, 57, 60, 64]), # Bbmaj7#11 Bb F A C E
(31.2, [43, 50, 55, 58, 62]), # Gm6 G D G Bb D
(36.4, [50, 57, 60, 64, 65]), # Dm9 outro D A C E F
]
CHORD_LEN = 5.2
# Filter brightness rises slowly through the piece and blooms at the outro.
brightness = 0.17 + 0.08 * np.clip(t / TOTAL, 0, 1) + 0.04 * np.clip((t - 36) / 4, 0, 1)
brightness *= 1 + 0.08 * np.sin(2 * np.pi * 0.05 * t)
for start, notes in CHORDS:
last = start == CHORDS[-1][0]
dur = (TOTAL - start - 3.0) if last else CHORD_LEN
e = env(start, dur, attack=1.8, release=2.6 if not last else 3.0)
for i, midi in enumerate(notes):
pad_voice(hz(midi), e, brightness, pan=(i - 2) * 0.22, gain=0.055 if i else 0.07)
# Sub bass: root an octave down, pure sine, gentle.
root = hz(notes[0] - 12)
idx = np.nonzero(e)[0]
sl = slice(idx[0], idx[-1] + 1)
sub = np.sin(2 * np.pi * root * t[sl]) * e[sl] * 0.15
left[sl] += sub
right[sl] += sub
# Bell arpeggio from the second scene on: slow quarter notes at 56 bpm, up and down the chord an octave up.
step = 60 / 56
rng = np.random.default_rng(3)
beat = 4.0
k = 0
while beat < TOTAL - 3.5:
chord = [c for c in CHORDS if c[0] <= beat][-1][1]
pattern = chord[1:] + chord[-2:0:-1]
midi = pattern[k % len(pattern)] + 12
accent = 1.0 if k % 4 == 0 else 0.7
thin = 0.55 if 20 < beat < 26 else 1.0 # breathe during the audio overview scene
if rng.random() > 0.3: # many rests so it does not feel mechanical
bell(hz(midi), beat, gain=0.036 * accent * thin, pan=0.5 * np.sin(k * 0.9))
beat += step
k += 1
# Ping pong delay on everything (dotted eighth), dark feedback.
delay = int(SR * step * 1.5)
fb = 0.3
dl, dr = np.zeros(n), np.zeros(n)
for i in range(3):
shift = delay * (i + 1)
g = fb ** (i + 1)
src_l, src_r = (right, left) if i % 2 == 0 else (left, right)
dl[shift:] += src_l[:-shift] * g
dr[shift:] += src_r[:-shift] * g
left += dl * 0.5
right += dr * 0.5
# Long reverb: convolution with a smooth decaying impulse (built from low passed, decaying partials,
# not white noise, so it stays silky).
ir_len = int(SR * 4.2)
it = np.arange(ir_len) / SR
ir_l = np.zeros(ir_len)
ir_r = np.zeros(ir_len)
for f in rng.uniform(120, 1300, 90):
ph = rng.uniform(0, 2 * np.pi, 2)
d = np.exp(-it * rng.uniform(0.8, 1.6))
ir_l += np.sin(2 * np.pi * f * it + ph[0]) * d
ir_r += np.sin(2 * np.pi * f * it + ph[1]) * d
ir_l /= np.max(np.abs(ir_l))
ir_r /= np.max(np.abs(ir_r))
def convolve(x: np.ndarray, ir: np.ndarray) -> np.ndarray:
size = 1 << int(np.ceil(np.log2(len(x) + len(ir))))
y = np.fft.irfft(np.fft.rfft(x, size) * np.fft.rfft(ir, size), size)
return y[: len(x)]
wet_l = convolve(left, ir_l)
wet_r = convolve(right, ir_r)
wet_l /= np.max(np.abs(wet_l)) + 1e-9
wet_r /= np.max(np.abs(wet_r)) + 1e-9
dry = max(np.max(np.abs(left)), np.max(np.abs(right)))
mix_l = left / dry * 0.75 + wet_l * 0.45
mix_r = right / dry * 0.75 + wet_r * 0.45
# Master: fade in, fade out, gentle soft clip, normalize.
master = np.minimum(1, t / 2.5) * np.minimum(1, (TOTAL - t) / 3.0)
mix_l *= master
mix_r *= master
peak = max(np.max(np.abs(mix_l)), np.max(np.abs(mix_r)))
mix_l = np.tanh(mix_l / peak * 1.1) * 0.8
mix_r = np.tanh(mix_r / peak * 1.1) * 0.8
stereo = np.stack([mix_l, mix_r], axis=1)
with wave.open(str(OUT), "wb") as w:
w.setnchannels(2)
w.setsampwidth(2)
w.setframerate(SR)
w.writeframes((stereo * 32767).astype("<i2").tobytes())
print("music ok", OUT)