File size: 6,495 Bytes
bfe94d3
3f79c45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfe94d3
3f79c45
 
 
 
 
 
 
 
bfe94d3
3f79c45
bfe94d3
 
 
 
 
 
 
 
3f79c45
 
 
 
bfe94d3
3f79c45
 
 
 
 
 
 
 
 
 
 
 
bfe94d3
3f79c45
 
 
bfe94d3
 
3f79c45
bfe94d3
3f79c45
 
 
 
 
 
 
bfe94d3
 
3f79c45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bfe94d3
3f79c45
 
 
bfe94d3
3f79c45
bfe94d3
3f79c45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
"""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)