"""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="