#!/usr/bin/env python3
"""Synthesize the m3te candy-vibe SFX bank (P10.1).

BUILD-TIME TOOL ONLY. The shipped game never runs this; it only loads the
finished WAVs this script writes under assets/audio/. Pure Python stdlib
synthesis (no numpy) -> standard 16-bit PCM WAV -> afconvert into the exact
canonical iOS System-Sound format clear.wav already uses (mono, 44100 Hz,
LEI16, WAVE_FORMAT_EXTENSIBLE).

Timbre: a bright glossy bell — fundamental plus a few decaying partials and a
slightly detuned shimmer top, a fast attack, exponential decay, and a tiny
upward pitch blip at onset for the "candy pop" feel. Every cue sits well above
clear.wav's ~784 Hz fundamental. combo1..combo5 climb a pentatonic run.

Usage: python3 tools/synth_audio.py            # writes the whole bank
       python3 tools/synth_audio.py --keep-tmp # leave the intermediate PCM
"""
import math
import os
import struct
import subprocess
import sys
import tempfile
import wave

SR = 44100

# Equal-tempered note frequencies (A4 = 440 Hz).
C6, D6, E6, G6, A6, C7 = 1046.50, 1174.66, 1318.51, 1567.98, 1760.00, 2093.00

OUT_DIR = os.path.join(os.path.dirname(__file__), "..", "assets", "audio")

# A glossy candy bell: fundamental + gently decaying harmonics + a slightly
# detuned high partial for shimmer. Kept clean (no harsh odd-only stack).
GLOSSY = [(1.0, 1.00), (2.0, 0.34), (3.0, 0.13), (4.02, 0.06), (6.01, 0.03)]
# Softer/darker partial set for the gentle "lose" stinger.
GENTLE = [(1.0, 1.00), (2.0, 0.22), (3.0, 0.07)]


def note(freq, dur, partials=GLOSSY, decay=0.18, blip=0.06, blip_tau=0.012,
         attack=0.004):
    """One glossy bell note as a list of floats (mono, length dur*SR).

    decay: exponential amplitude time-constant (s).
    blip: fractional upward pitch offset at onset, settling with blip_tau.
    """
    n = int(dur * SR)
    out = [0.0] * n
    phases = [0.0] * len(partials)
    for i in range(n):
        t = i / SR
        # Onset pitch blip: starts ~blip above target, settles fast.
        fmul = 1.0 + blip * math.exp(-t / blip_tau)
        amp = math.exp(-t / decay)
        if t < attack:                       # linear attack from 0 (no click)
            amp *= t / attack
        s = 0.0
        for k, (ratio, level) in enumerate(partials):
            phases[k] += 2.0 * math.pi * freq * ratio * fmul / SR
            s += level * math.sin(phases[k])
        out[i] = amp * s
    return out


def mix(layers):
    """Overlay (freq, start_s, samples) layers onto one buffer."""
    end = max(int(start * SR) + len(buf) for _, start, buf in layers)
    out = [0.0] * end
    for _, start, buf in layers:
        off = int(start * SR)
        for i, v in enumerate(buf):
            out[off + i] += v
    return out


def finalize(samples, peak=0.89, fade=0.008):
    """Soft-clip for warmth, normalize, and fade the tail to avoid a click."""
    samples = [math.tanh(1.3 * s) for s in samples]
    m = max(1e-9, max(abs(s) for s in samples))
    g = peak / m
    samples = [s * g for s in samples]
    fn = int(fade * SR)
    n = len(samples)
    for i in range(min(fn, n)):                 # linear tail fade
        samples[n - 1 - i] *= i / fn
    return samples


def write_pcm16(path, samples):
    with wave.open(path, "wb") as w:
        w.setnchannels(1)
        w.setsampwidth(2)
        w.setframerate(SR)
        frames = bytearray()
        for s in samples:
            v = int(max(-1.0, min(1.0, s)) * 32767.0)
            frames += struct.pack("<h", v)
        w.writeframes(bytes(frames))


def to_canonical(tmp_pcm, out_wav):
    # Match clear.wav exactly: WAVE / LEI16 / 44100 / mono via afconvert.
    subprocess.run(
        ["afconvert", "-f", "WAVE", "-d", "LEI16@44100", "-c", "1",
         tmp_pcm, out_wav],
        check=True,
    )


# --- the bank -------------------------------------------------------------

def glide_note(f0, f1, dur, partials=GLOSSY, decay=0.12, attack=0.004):
    """A note whose pitch glides f0 -> f1 across its length (whoosh)."""
    n = int(dur * SR)
    out = [0.0] * n
    phases = [0.0] * len(partials)
    for i in range(n):
        t = i / SR
        frac = t / dur
        freq = f0 * (1.0 - frac) + f1 * frac
        amp = math.exp(-t / decay)
        if t < attack:
            amp *= t / attack
        s = 0.0
        for k, (ratio, level) in enumerate(partials):
            phases[k] += 2.0 * math.pi * freq * ratio / SR
            s += level * math.sin(phases[k])
        out[i] = amp * s
    return out


def build_swap():
    # Soft glossy tick/whoosh: short, gentle attack, a downward pitch glide.
    return finalize(glide_note(D6, C6 * 0.94, 0.13, decay=0.06, attack=0.006,
                               partials=[(1.0, 1.0), (2.0, 0.20), (3.0, 0.07)]),
                    peak=0.80)


def build_match():
    # Bright candy pop: punchy E6 with a strong onset blip.
    return finalize(note(E6, 0.17, decay=0.10, blip=0.10, blip_tau=0.010))


def build_combo(freq):
    # One step of the ascending chime run: glossy, a touch of ring.
    return finalize(note(freq, 0.22, decay=0.16, blip=0.05, blip_tau=0.010))


def build_win():
    # Triumphant ascending arpeggio C6-E6-G6-C7, notes ring together.
    notes = [(C6, 0.00), (E6, 0.07), (G6, 0.14), (C7, 0.21)]
    layers = [(f, t, note(f, 0.30, decay=0.20, blip=0.04)) for f, t in notes]
    return finalize(mix(layers), peak=0.92)


def build_lose():
    # Gentle descending stinger G6-E6-C6, soft and dark.
    notes = [(G6, 0.00), (E6, 0.11), (C6, 0.22)]
    layers = [(f, t, note(f, 0.28, partials=GENTLE, decay=0.18, blip=0.0))
              for f, t in notes]
    return finalize(mix(layers), peak=0.72)


BANK = {
    "swap": build_swap,
    "match": build_match,
    "combo1": lambda: build_combo(C6),
    "combo2": lambda: build_combo(D6),
    "combo3": lambda: build_combo(E6),
    "combo4": lambda: build_combo(G6),
    "combo5": lambda: build_combo(A6),
    "win": build_win,
    "lose": build_lose,
}


def main():
    keep = "--keep-tmp" in sys.argv[1:]
    out_dir = os.path.normpath(OUT_DIR)
    for name, build in BANK.items():
        samples = build()
        with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tf:
            tmp = tf.name
        write_pcm16(tmp, samples)
        out = os.path.join(out_dir, name + ".wav")
        to_canonical(tmp, out)
        if not keep:
            os.remove(tmp)
        print("wrote %s (%.3f s)" % (out, len(samples) / SR))


if __name__ == "__main__":
    main()
