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]}")
|