#!/usr/bin/env python3
"""
Sonic mark family — 8 variants synthesizer.
Generates raw WAVs; ffmpeg loudnorm applied separately for mastering.

Variants:
1. full         (1.40s, -16 LUFS) — splash, login
2. live_ingress (2.10s, -16 LUFS) — full + ambient fade-in
3. transition   (0.80s, -18 LUFS) — TAP + SUB + REVERB
4. live_egress  (0.60s, -20 LUFS) — reverb tail + ambient fade-out
5. error        (0.35s, -18 LUFS) — TAP + SUB pitched -1 semitone
6. ui_select    (0.18s, -18 LUFS) — TAP + 100ms reverb tail
7. ui_back      (0.12s, -22 LUFS) — TAP attenuated + 80ms tail
8. ui_focus     (0.08s, -24 LUFS) — TAP only, attenuated -24dB
"""
import numpy as np
import soundfile as sf
import subprocess
import sys
from pathlib import Path

SR = 48_000
OUT_DIR = Path("/root/projects/private-iptv-player-foundation/docs/assets/sonic")
OUT_DIR.mkdir(parents=True, exist_ok=True)

# Tonal frequencies — F major just intonation
F1 = 43.65
C2 = 65.48
A2 = 109.13
F2 = 87.30
# Error variant: -1 semitone
SEMITONE_DOWN = 2 ** (-1/12)
F1_ERR = F1 * SEMITONE_DOWN  # 41.20Hz

# === Helpers ===
def db_to_amp(db): return 10 ** (db / 20)

def adsr(total_samples, a_ms, d_ms, sustain_pct, r_ms, sr=SR):
    a = int(a_ms * sr / 1000)
    d = int(d_ms * sr / 1000)
    r = int(r_ms * sr / 1000)
    s = total_samples - a - d - r
    if s < 0:
        ratio = total_samples / max(a + d + r, 1)
        a, d, r = int(a*ratio), int(d*ratio), int(r*ratio)
        s = max(0, total_samples - a - d - r)
    env = np.zeros(total_samples)
    if a > 0: env[:a] = np.linspace(0, 1, a)
    if d > 0: env[a:a+d] = np.linspace(1, sustain_pct, d)
    if s > 0: env[a+d:a+d+s] = sustain_pct
    if r > 0: env[-r:] = np.linspace(sustain_pct, 0, r)
    return env

def sine(freq, n, sr=SR, phase=0):
    t = np.arange(n) / sr
    return np.sin(2 * np.pi * freq * t + phase)

def add_at(mix, layer, start):
    end = min(start + len(layer), len(mix))
    if start >= len(mix): return
    mix[start:end] += layer[:end - start]

# === Layer factories ===
def make_tap(amp_db=-1.0, seed=42):
    """30ms percussion transient"""
    n = int(0.030 * SR)
    tonal = sine(F2, n) * 0.5 + sine(F2*2, n) * 0.25 + sine(F2*3, n) * 0.12
    rng = np.random.default_rng(seed=seed)
    noise = rng.uniform(-1, 1, n)
    noise_hp = noise - np.convolve(noise, np.ones(8)/8, mode='same')
    tap = tonal + noise_hp * 0.4
    a = int(0.002 * SR)
    env = np.zeros(n)
    env[:a] = np.linspace(0, 1, a)
    env[a:] = np.exp(-np.linspace(0, 8, n - a))
    tap *= env
    peak = max(np.max(np.abs(tap)), 1e-9)
    return tap * (db_to_amp(amp_db) / peak)

def make_sub_bass(dur_ms=910, freq=F1, adsr_params=(10, 200, 0.6, 700), amp_db=-3.0):
    n = int(dur_ms / 1000 * SR)
    fundamental = sine(freq, n)
    sub_octave = sine(freq / 2, n) * 0.35
    sub = fundamental + sub_octave
    a, d, s, r = adsr_params
    sub *= adsr(n, a, d, s, r)
    peak = max(np.max(np.abs(sub)), 1e-9)
    return sub * (db_to_amp(amp_db) / peak)

def make_body(dur_ms=780, freq=C2, adsr_params=(80, 200, 0.7, 500), amp_db=-8.0):
    n = int(dur_ms / 1000 * SR)
    sine_layer = sine(freq, n)
    t = np.arange(n) / SR
    saw = 2 * (t * freq - np.floor(t * freq + 0.5))
    saw_lp = np.convolve(saw, np.ones(24)/24, mode='same')
    body = sine_layer * 0.7 + saw_lp * 0.3
    a, d, s, r = adsr_params
    body *= adsr(n, a, d, s, r)
    peak = max(np.max(np.abs(body)), 1e-9)
    return body * (db_to_amp(amp_db) / peak)

def make_swell(dur_ms=500, freq=A2, adsr_params=(200, 100, 0.5, 200), amp_db=-14.0):
    n = int(dur_ms / 1000 * SR)
    fund = sine(freq, n)
    h2 = sine(freq*2, n) * 0.30
    h3 = sine(freq*3, n) * 0.15
    h5 = sine(freq*5, n) * 0.06
    swell = fund + h2 + h3 + h5
    a, d, s, r = adsr_params
    swell *= adsr(n, a, d, s, r)
    peak = max(np.max(np.abs(swell)), 1e-9)
    return swell * (db_to_amp(amp_db) / peak)

def make_ambient_stadium(dur_ms, amp_db=-32.0, fade_in_ms=400, fade_out_ms=400, seed=99):
    """Crowd murmur — pink-ish noise low-passed + slow LFO modulation"""
    n = int(dur_ms / 1000 * SR)
    rng = np.random.default_rng(seed=seed)
    white = rng.uniform(-1, 1, n)
    # Pseudo-pink: cumulative + scale (rough 1/f)
    pink = np.cumsum(white) - np.convolve(np.cumsum(white), np.ones(int(SR*0.1))/int(SR*0.1), mode='same')
    pink /= max(np.max(np.abs(pink)), 1e-9)
    # Low-pass via moving average (~800Hz cutoff)
    kernel_size = max(1, int(SR / 800))
    pink_lp = np.convolve(pink, np.ones(kernel_size)/kernel_size, mode='same')
    pink_lp /= max(np.max(np.abs(pink_lp)), 1e-9)
    # Slow LFO modulation (crowd swelling)
    lfo = 1 + 0.3 * np.sin(2 * np.pi * 0.4 * np.arange(n) / SR)
    ambient = pink_lp * lfo
    # Apply fade in/out
    fi = int(fade_in_ms / 1000 * SR)
    fo = int(fade_out_ms / 1000 * SR)
    env = np.ones(n)
    if fi > 0: env[:fi] = np.linspace(0, 1, fi)
    if fo > 0: env[-fo:] = np.linspace(1, 0, fo)
    ambient *= env
    peak = max(np.max(np.abs(ambient)), 1e-9)
    return ambient * (db_to_amp(amp_db) / peak)

# === Reverb (FFT convolution with synthetic IR) ===
_IR_CACHE = {}
def get_reverb_ir(rt60_s=3.0, sr=SR):
    key = (rt60_s, sr)
    if key in _IR_CACHE: return _IR_CACHE[key]
    rng = np.random.default_rng(seed=123)
    n = int(rt60_s * sr)
    noise = rng.uniform(-1, 1, n)
    decay = np.exp(-np.linspace(0, 6.9, n))
    ir = noise * decay
    er_n = int(0.080 * sr)
    er_boost = np.exp(-np.linspace(0, 2, er_n)) * 1.5
    ir[:er_n] *= er_boost
    _IR_CACHE[key] = ir
    return ir

def apply_reverb(mix, send_db=-12.0, predelay_ms=25, ir_rt60=3.0):
    """Apply reverb to mix; returns (left, right) stereo decorrelated tuple"""
    ir = get_reverb_ir(rt60_s=ir_rt60)
    predelay = int(predelay_ms / 1000 * SR)
    rev_input = np.concatenate([np.zeros(predelay), mix[:-predelay]] if predelay else [mix])
    # Decorrelate L/R
    rng = np.random.default_rng(seed=124)
    ir_l = ir
    ir_r = ir * (1 + rng.uniform(-0.1, 0.1, len(ir)))
    n_out = len(mix) + len(ir) - 1
    X = np.fft.rfft(rev_input, n_out)
    Hl = np.fft.rfft(ir_l, n_out)
    Hr = np.fft.rfft(ir_r, n_out)
    rev_l = np.fft.irfft(X * Hl, n_out)[:len(mix)]
    rev_r = np.fft.irfft(X * Hr, n_out)[:len(mix)]
    amp = db_to_amp(send_db)
    return rev_l * amp, rev_r * amp

# === Variant builders ===
def build_full():
    """1.40s — full intro: all layers"""
    DUR = 1.4
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(),       int(0.30 * SR))
    add_at(mix, make_sub_bass(),  int(0.30 * SR))
    add_at(mix, make_body(),      int(0.40 * SR))
    add_at(mix, make_swell(),     int(0.90 * SR))
    rev_l, rev_r = apply_reverb(mix, send_db=-12)
    return mix + rev_l, mix + rev_r, DUR

def build_live_ingress():
    """2.10s — full intro + ambient stadium fade-in 700ms"""
    DUR = 2.1
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(),       int(0.30 * SR))
    add_at(mix, make_sub_bass(),  int(0.30 * SR))
    add_at(mix, make_body(),      int(0.40 * SR))
    add_at(mix, make_swell(),     int(0.90 * SR))
    # Ambient stadium from 0.70s, fade-in 600ms, holds, fade-out at end
    ambient_dur_ms = int((DUR - 0.70) * 1000)
    ambient = make_ambient_stadium(ambient_dur_ms, amp_db=-30, fade_in_ms=700, fade_out_ms=100)
    add_at(mix, ambient, int(0.70 * SR))
    rev_l, rev_r = apply_reverb(mix, send_db=-12)
    return mix + rev_l, mix + rev_r, DUR

def build_transition():
    """0.80s — TAP + SUB + REVERB (no BODY, no SWELL)"""
    DUR = 0.8
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(amp_db=-2),  int(0.05 * SR))
    add_at(mix, make_sub_bass(dur_ms=600, adsr_params=(10, 150, 0.5, 400), amp_db=-4), int(0.05 * SR))
    rev_l, rev_r = apply_reverb(mix, send_db=-16)
    return mix + rev_l, mix + rev_r, DUR

def build_live_egress():
    """0.60s — reverb tail + ambient fade-out (no new source)"""
    DUR = 0.6
    n = int(DUR * SR)
    mix = np.zeros(n)
    # Tiny sub-bass blip to seed reverb tail
    seed_blip = make_sub_bass(dur_ms=50, adsr_params=(5, 20, 0.3, 25), amp_db=-18)
    add_at(mix, seed_blip, 0)
    # Ambient fade-out
    ambient = make_ambient_stadium(int(DUR * 1000), amp_db=-28, fade_in_ms=0, fade_out_ms=int(DUR * 1000))
    add_at(mix, ambient, 0)
    rev_l, rev_r = apply_reverb(mix, send_db=-8, ir_rt60=2.0)  # heavier reverb
    return mix + rev_l, mix + rev_r, DUR

def build_error():
    """0.35s — TAP + SUB pitched -1 semitone + 200ms reverb tail"""
    DUR = 0.35
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(), 0)
    # SUB pitched down 1 semitone — universal "wrong" signal
    add_at(mix, make_sub_bass(dur_ms=250, freq=F1_ERR, adsr_params=(10, 80, 0.4, 160), amp_db=-4), 0)
    rev_l, rev_r = apply_reverb(mix, send_db=-14, ir_rt60=0.4)  # short tail
    return mix + rev_l, mix + rev_r, DUR

def build_ui_select():
    """0.18s — TAP + 100ms reverb tail"""
    DUR = 0.18
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(amp_db=-3), 0)
    rev_l, rev_r = apply_reverb(mix, send_db=-14, predelay_ms=10, ir_rt60=0.3)
    return mix + rev_l, mix + rev_r, DUR

def build_ui_back():
    """0.12s — TAP attenuated + 80ms reverb tail"""
    DUR = 0.12
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(amp_db=-5), 0)
    rev_l, rev_r = apply_reverb(mix, send_db=-18, predelay_ms=8, ir_rt60=0.25)
    return mix + rev_l, mix + rev_r, DUR

def build_ui_focus():
    """0.08s — TAP only, attenuated -24dB, no reverb"""
    DUR = 0.08
    n = int(DUR * SR)
    mix = np.zeros(n)
    add_at(mix, make_tap(amp_db=-24), 0)
    # No reverb
    return mix, mix.copy(), DUR

# === Master loudnorm via ffmpeg ===
def master_to_lufs(raw_path, out_path, lufs_target):
    cmd = [
        "ffmpeg", "-y", "-i", str(raw_path),
        "-af", f"loudnorm=I={lufs_target}:TP=-1.0:LRA=11:print_format=summary",
        "-ar", str(SR), "-c:a", "pcm_s24le",
        str(out_path),
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        print(f"ERR ffmpeg: {result.stderr[-500:]}", file=sys.stderr)
        return None
    # Extract output LUFS from stderr
    lines = result.stderr.split("\n")
    measured = {}
    for line in lines:
        line = line.strip()
        if "Output Integrated:" in line:
            measured["I"] = line.split(":")[-1].strip()
        if "Output True Peak:" in line:
            measured["TP"] = line.split(":")[-1].strip()
    return measured

# === Variant registry ===
VARIANTS = {
    "mark_full_v1.4":         (build_full,         -16),
    "mark_live_ingress_v1.4": (build_live_ingress, -16),
    "mark_transition_v1.4":   (build_transition,   -18),
    "mark_live_egress_v1.4":  (build_live_egress,  -20),
    "mark_error_v1.4":        (build_error,        -18),
    "ui_select_v1.4":         (build_ui_select,    -18),
    "ui_back_v1.4":           (build_ui_back,      -22),
    "ui_focus_v1.4":          (build_ui_focus,     -24),
}

print(f"Synthesizing {len(VARIANTS)} variants...\n")
print(f"{'Variant':30}{'Dur':>8}{'Target LUFS':>14}{'Measured I':>15}{'TP':>10}")
print("-" * 80)

for name, (builder, lufs_target) in VARIANTS.items():
    left, right, dur = builder()
    # Pre-master normalize to -3 dB headroom
    peak = max(np.max(np.abs(left)), np.max(np.abs(right)), 1e-9)
    headroom = db_to_amp(-3) / peak
    stereo = np.column_stack([left * headroom, right * headroom])
    raw_path = Path(f"/tmp/{name}_raw.wav")
    sf.write(raw_path, stereo, SR, subtype="PCM_24")
    # Master via ffmpeg loudnorm
    out_path = OUT_DIR / f"{name}.wav"
    measured = master_to_lufs(raw_path, out_path, lufs_target)
    raw_path.unlink()
    if measured:
        print(f"{name:30}{dur:>6.2f}s {lufs_target:>11} LUFS {measured.get('I','?'):>14}{measured.get('TP','?'):>10}")
    else:
        print(f"{name:30}{dur:>6.2f}s {lufs_target:>11} LUFS {'FAIL':>14}{'':>10}")

print("\nFiles written to:", OUT_DIR)
for f in sorted(OUT_DIR.glob("*.wav")):
    print(f"  {f.name:35} {f.stat().st_size:>10,} bytes")
