DocDoeAI / scripts /generate_ad_music.py
asnannp's picture
deploy: sync backend to Space root (learn-lesson HF cache fix)
4616098
Raw
History Blame Contribute Delete
7.05 kB
"""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]}")