File size: 7,046 Bytes
4616098
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
"""Cinematic, MUSICAL audio bed for the DocDoe ad reel β€” pure numpy, no noise.

A building trailer-style underscore in A-minor: a chord progression that
crossfades through the reel (tension -> lift -> build -> resolve), a clean
sub pulse that intensifies, tonal upward sweeps at each cut (no white noise),
deep impact booms on hero beats, and a light reverb for space. Mixed UNDER VO.

Reads scene timing from public/ad-vo/manifest.json.
  backend/.venv/Scripts/python.exe backend/scripts/generate_ad_music.py
"""
from __future__ import annotations

import json
import wave
from pathlib import Path

import numpy as np

ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "public" / "ad-music"
OUT.mkdir(parents=True, exist_ok=True)
SR = 44100
FPS = 30
PAD = 0.95
XFADE = 12

manifest = json.loads((ROOT / "public" / "ad-vo" / "manifest.json").read_text())
scene_frames = [round((s["duration"] + PAD) * FPS) for s in manifest]
starts_f, acc = [], 0
for sf in scene_frames:
    starts_f.append(acc)
    acc += sf - XFADE
total_f = sum(scene_frames) - XFADE * (len(scene_frames) - 1)
TOTAL = total_f / FPS
starts = [f / FPS for f in starts_f]
n = int(TOTAL * SR) + SR
t = np.arange(n) / SR

NOTE = {"A2": 110.0, "C3": 130.81, "Cs3": 138.59, "D3": 146.83, "E3": 164.81,
        "F2": 87.31, "F3": 174.61, "G2": 98.0, "G3": 196.0, "A3": 220.0, "B2": 123.47, "E2": 82.41}


def voice(freq: float, length: int) -> np.ndarray:
    """Warm pad voice: detuned partials, soft harmonics."""
    tt = np.arange(length) / SR
    s = np.zeros(length)
    for h, amp in ((1, 1.0), (2, 0.32), (3, 0.16), (4, 0.07)):
        det = 1 + 0.0018 * (h - 1)
        s += amp * (np.sin(2 * np.pi * freq * h * det * tt) + np.sin(2 * np.pi * freq * h / det * tt))
    return s / 3.2


def chord_bed(notes: list[str]) -> np.ndarray:
    buf = np.zeros(n)
    for nm in notes:
        buf += voice(NOTE[nm], n)
    return buf / max(len(notes), 1)


# Section chords (crossfaded) β€” i VI III VII resolve in A minor.
# section index aligns to scene index.
SECTION_CHORDS = [
    ["A2", "E3", "A3"],         # hook  β€” sparse minor
    ["A2", "C3", "E3"],         # problem β€” full minor (tension)
    ["F2", "C3", "F3"],         # intro β€” VI lift (hope)
    ["F2", "A2", "C3"],         # upload β€” F warm
    ["C3", "E3", "G3"],         # features β€” III brighten
    ["C3", "G3", "E3"],         # pyq β€” C build
    ["G2", "D3", "G3"],         # video β€” VII tension/rise
    ["A2", "Cs3", "E3"],        # cta β€” A MAJOR resolve (picardy lift)
]

mix = np.zeros(n)

# crossfade chord pads across sections
sec_bounds = starts + [TOTAL]
pad_total = np.zeros(n)
gate_sum = np.zeros(n)
for i, chord in enumerate(SECTION_CHORDS):
    s0 = sec_bounds[i]
    s1 = sec_bounds[i + 1] if i + 1 < len(sec_bounds) else TOTAL
    g = np.zeros(n)
    a0, a1 = int(s0 * SR), int(min(s1, TOTAL) * SR)
    xf = int(0.8 * SR)
    g[a0:a1] = 1.0
    # ramp in/out for crossfade
    if a0 > 0:
        lo = max(0, a0 - xf)
        g[lo:a0] = np.linspace(0, 1, a0 - lo)
    g[a1:a1 + xf] = np.linspace(1, 0, min(xf, n - a1))
    pad_total += chord_bed(chord) * g
    gate_sum += g
gate_sum = np.clip(gate_sum, 1e-6, None)
pad = pad_total / gate_sum

# global intensity arc: swells from quiet -> full by the build, dips slightly, peaks at CTA
arc = np.interp(t, [0, starts[2], starts[4], starts[6], starts[7], TOTAL],
                   [0.35, 0.5, 0.8, 0.95, 1.0, 0.9])
fade = np.clip(np.minimum(t / 1.5, (TOTAL - t) / 2.0), 0, 1)
breath = 0.85 + 0.15 * np.sin(2 * np.pi * 0.08 * t)
mix += pad * arc * fade * breath * 0.17

# ── Sub pulse β€” intensifies after the build (faster + louder) ──
def pulse_at(at, lvl, freq=55.0, dur=0.45):
    i0 = int(at * SR)
    L = int(dur * SR)
    if i0 + L > n:
        L = n - i0
    tt = np.arange(L) / SR
    env = np.exp(-tt * 7.0)
    mix[i0:i0 + L] += np.sin(2 * np.pi * freq * tt) * env * lvl

beat = 1.5
bt = 0.0
build_start = starts[4]
while bt < TOTAL - 0.4:
    intense = bt >= build_start
    pulse_at(bt, 0.16 if intense else 0.09)
    if intense:
        # add off-beat for drive
        pulse_at(bt + beat / 2, 0.08, freq=82.0, dur=0.22)
    bt += beat

# ── Tonal upward sweep risers before each cut (musical, not noisy) ──
def sweep(center, root=110.0):
    dur = 0.85
    i0 = int(max(0, center - dur) * SR)
    L = int(dur * SR)
    if i0 + L > n:
        L = n - i0
    tt = np.arange(L) / SR
    fr = root * (2 ** (tt / dur))  # rise one octave
    phase = 2 * np.pi * np.cumsum(fr) / SR
    env = (tt / dur) ** 2
    mix[i0:i0 + L] += np.sin(phase) * env * 0.05

for s in starts[1:]:
    sweep(s)

# ── Deep impact booms on hero beats: intro(2), build(4), pyq(5), cta(7) ──
def boom(at, lvl):
    i0 = int(at * SR)
    dur = 1.4
    L = int(dur * SR)
    if i0 + L > n:
        L = n - i0
    tt = np.arange(L) / SR
    fr = 90 * np.exp(-tt * 4) + 36
    phase = 2 * np.pi * np.cumsum(fr) / SR
    mix[i0:i0 + L] += np.sin(phase) * np.exp(-tt * 2.6) * lvl

for idx, lvl in ((2, 0.4), (4, 0.36), (5, 0.4), (7, 0.55)):
    if idx < len(starts):
        boom(starts[idx] + 0.04, lvl)

# ── light reverb (a couple of decayed taps) for space ──
def tap(delay_s, decay):
    d = int(delay_s * SR)
    out = np.zeros(n)
    out[d:] = mix[:n - d] * decay
    return out

mix = mix + tap(0.09, 0.22) + tap(0.17, 0.13)

# ── Sidechain duck: pull the bed DOWN whenever the VO is speaking so the
#    voice always cuts through (the pro-mix move). Built from word timings. ──
duck = np.ones(n)
VO_LEAD = 2 / FPS  # audio starts +2 frames after scene start (matches comp)
for i, seg in enumerate(manifest):
    base = starts[i] + VO_LEAD
    for wd in seg.get("words", []):
        a = int((base + wd["start"]) * SR)
        b = int((base + wd["end"]) * SR)
        if b <= a:
            continue
        duck[max(0, a):min(n, b)] = 0.42  # duck depth during speech
# smooth the duck (attack/release) via one-pole filters
atk = np.exp(-1.0 / (0.05 * SR))
rel = np.exp(-1.0 / (0.28 * SR))
smooth = np.ones(n)
prev = 1.0
for i in range(n):
    target = duck[i]
    coef = atk if target < prev else rel
    prev = target + (prev - target) * coef
    smooth[i] = prev
mix = mix * smooth

# master: gentle saturation + headroom
mix = np.tanh(mix * 1.05)
mix = mix / (np.max(np.abs(mix)) + 1e-9) * 0.66

# ── stereo width: Haas + subtle inverted-detune between channels ──
haas = int(0.012 * SR)
left = mix.copy()
right = np.zeros(n)
right[haas:] = mix[: n - haas]
# widen mids slightly
left = 0.92 * left + 0.08 * right
right = 0.92 * right + 0.08 * left
stereo = np.empty(n * 2, dtype="<i2")
stereo[0::2] = (np.clip(left, -1, 1) * 32767).astype("<i2")
stereo[1::2] = (np.clip(right, -1, 1) * 32767).astype("<i2")

with wave.open(str(OUT / "bed.wav"), "wb") as w:
    w.setnchannels(2)
    w.setsampwidth(2)
    w.setframerate(SR)
    w.writeframes(stereo.tobytes())

print(f"bed.wav {round(TOTAL,2)}s | stereo+ducked | sections={len(SECTION_CHORDS)} | cuts@{[round(s,1) for s in starts]}")