#!/usr/bin/env python3
"""Procedural SFX score for a brand-explainer film.

SINGLE SOURCE OF TRUTH: the timeline is imported FROM the engine — never
hardcode beat times here. Sound design only (pops/ticks/whooshes/thumps),
never music or narration; the music bed and VO are mixed by mix.py.

The generic score covers the engine's generic motion (cards landing, text
typing, score counting). Bespoke per-beat moves may add events via an optional
`sfx_events(E, ev)` in the project's accents.py.

Env: BRAND_PACK, BRAND_PROJECT (same as the engine).
Writes <project>/artifacts/audio/sfx.wav
"""
import importlib.util
import math
import os
import random
import struct
import sys
import wave

SR = 48000
random.seed(11)
PROJ = os.environ.get("BRAND_PROJECT", "")
if not PROJ:
    sys.exit("gen_sfx: set BRAND_PROJECT")
OUT = os.path.join(PROJ, "artifacts", "audio", "sfx.wav")

_here = os.path.dirname(os.path.abspath(__file__))
sys.argv = ["gen_sfx", "16x9"]
_spec = importlib.util.spec_from_file_location("_engine", os.path.join(_here, "engine.py"))
E = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = E
_spec.loader.exec_module(E)
B = list(E.BEAT_STARTS)
OUTRO = E.TOTAL - E.OUTRO_D
TOTAL = E.TOTAL
N_FEATURES = len(E.BEATS_SPEC)


def pop(g=1.0):
    n = int(0.10 * SR)
    o = []
    for i in range(n):
        t = i / SR
        f = 320 - 170 * (i / n)
        o.append(g * math.sin(2 * math.pi * f * t) * math.exp(-t * 42))
    return o


def tick(g=1.0):
    n = int(0.03 * SR)
    o = []
    for i in range(n):
        t = i / SR
        s = math.sin(2 * math.pi * 1900 * t) + 0.3 * (random.random() * 2 - 1)
        o.append(g * 0.7 * s * math.exp(-t * 220))
    return o


def click(g=1.0):
    n = int(0.05 * SR)
    o = []
    for i in range(n):
        t = i / SR
        o.append(g * 0.8 * math.sin(2 * math.pi * 820 * t) * math.exp(-t * 120))
    return o


def whoosh(g=1.0, dur=0.35, rev=False):
    n = int(dur * SR)
    raw = [(random.random() * 2 - 1) for _ in range(n)]
    o = []
    acc = 0.0
    K = 9
    for i in range(n):
        acc += raw[i]
        if i >= K:
            acc -= raw[i - K]
        e = math.sin(math.pi * i / n) ** 2
        o.append(g * 0.5 * (acc / K) * e)
    return o[::-1] if rev else o


def thump(g=1.0):
    n = int(0.16 * SR)
    o = []
    for i in range(n):
        t = i / SR
        s = math.sin(2 * math.pi * 88 * t) + 0.2 * math.sin(2 * math.pi * 176 * t)
        o.append(g * s * math.exp(-t * 28))
    return o


def thud(g=1.0):
    n = int(0.20 * SR)
    o = []
    for i in range(n):
        t = i / SR
        o.append(g * math.sin(2 * math.pi * 62 * t) * math.exp(-t * 19))
    return o


def drop(g=1.0):
    n = int(0.45 * SR)
    o = []
    for i in range(n):
        t = i / SR
        f = 540 - 300 * (i / n)
        o.append(g * 0.5 * math.sin(2 * math.pi * f * t) * math.exp(-t * 6))
    return o


def wash(g=1.0):
    return whoosh(g * 0.8, 0.5)


GEN = {"pop": pop, "tick": tick, "click": click, "whoosh": whoosh,
       "thump": thump, "thud": thud, "drop": drop, "wash": wash}

events = []


def ev(t, k, g=1.0):
    events.append((t, k, g))


# title — the mark arrives, the word lands
ev(B[0] + 0.55, "pop", 0.75)
ev(B[0] + 0.92, "thump", 0.9)
ev(B[0] + 1.32, "tick", 0.4)

# feature beats — illustration rises, two command cards land and type on
for bi in range(1, 1 + N_FEATURES):
    b = B[bi]
    ev(b + 0.15, "whoosh", 0.32)
    ev(b + 0.62, "pop", 0.7)
    for k in range(5):
        ev(b + 0.80 + k * 0.075, "tick", 0.20)
    ev(b + 1.42, "pop", 0.62)
    for k in range(5):
        ev(b + 1.60 + k * 0.075, "tick", 0.20)

# MY TAKE — score card, counter, bar, two verdict cards.
# Skipped when the pack sets MYTAKE="0": B has one fewer entry then, and
# B[1 + N_FEATURES] would either raise or land the score-card cues on the outro.
if getattr(E, "MYTAKE_ON", True):
    mt = B[1 + N_FEATURES]
    ev(mt + 0.20, "pop", 0.85)
    for k in range(9):
        ev(mt + 0.50 + k * 0.14, "click", 0.22)
    ev(mt + 1.75, "whoosh", 0.3)
    ev(mt + 1.80, "pop", 0.6)
    ev(mt + 2.30, "pop", 0.6)
    ev(mt + 3.10, "thump", 0.5)

# MEET-<brand> outro — mark, rows, the QR
ev(OUTRO + 0.15, "thump", 0.7)
for k in range(3):
    ev(OUTRO + 0.92 + k * 0.42, "pop", 0.62)
ev(OUTRO + 1.60, "click", 0.7)
ev(OUTRO + 2.20, "tick", 0.45)

# bespoke per-beat accents from the project hook
_acc = os.path.join(PROJ, "accents.py")
if os.path.isfile(_acc):
    _aspec = importlib.util.spec_from_file_location("brand_accents_sfx", _acc)
    _amod = importlib.util.module_from_spec(_aspec)
    _aspec.loader.exec_module(_amod)
    if hasattr(_amod, "sfx_events"):
        _amod.sfx_events(E, ev)

N = int(TOTAL * SR)
buf = [0.0] * N
for t, k, g in events:
    s = GEN[k](g)
    i0 = int(t * SR)
    for j, v in enumerate(s):
        if 0 <= i0 + j < N:
            buf[i0 + j] += v

peak = max(abs(v) for v in buf) or 1.0
norm = min(1.0, 0.85 / peak)
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with wave.open(OUT, "wb") as w:
    w.setnchannels(2)
    w.setsampwidth(2)
    w.setframerate(SR)
    fr = bytearray()
    for v in buf:
        sm = int(max(-1, min(1, v * norm)) * 32767)
        fr += struct.pack("<hh", sm, sm)
    w.writeframes(bytes(fr))
print(f"sfx: {len(events)} events over {TOTAL}s -> {OUT}")
