| """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 = [ |
| ["A2", "E3", "A3"], |
| ["A2", "C3", "E3"], |
| ["F2", "C3", "F3"], |
| ["F2", "A2", "C3"], |
| ["C3", "E3", "G3"], |
| ["C3", "G3", "E3"], |
| ["G2", "D3", "G3"], |
| ["A2", "Cs3", "E3"], |
| ] |
|
|
| mix = np.zeros(n) |
|
|
| |
| 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 |
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| 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: |
| |
| pulse_at(bt + beat / 2, 0.08, freq=82.0, dur=0.22) |
| bt += beat |
|
|
| |
| 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)) |
| 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) |
|
|
| |
| 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) |
|
|
| |
| 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) |
|
|
| |
| |
| duck = np.ones(n) |
| VO_LEAD = 2 / FPS |
| 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 |
| |
| 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 |
|
|
| |
| mix = np.tanh(mix * 1.05) |
| mix = mix / (np.max(np.abs(mix)) + 1e-9) * 0.66 |
|
|
| |
| haas = int(0.012 * SR) |
| left = mix.copy() |
| right = np.zeros(n) |
| right[haas:] = mix[: n - haas] |
| |
| 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]}") |
|
|