#!/usr/bin/env python3
"""Synthesise the transition sound pack locally.

Why synthesis instead of a stock library: transition sound is the one asset class
where licensing is most often ignored and least often checkable. A whoosh built
from filtered noise here is provably original, byte-identical on every machine,
free to ship inside the skill, and — the part that actually matters for the edit —
parameterised, so a 9-frame whip and a 16-frame push get sounds of exactly the
right length instead of one library file crudely trimmed.

Every sound is built from the same three ingredients real transition design uses:
a noise band that sweeps, a tonal element that bends, and an envelope that peaks
where the cut is. Each preset documents which of the three carries it.

Output: 48 kHz stereo 24-bit WAV, peak-normalised with headroom, plus a JSON
manifest recording the exact parameters and a SHA-256 per file.
"""
from __future__ import annotations

import argparse
import hashlib
import json
import math
import struct
import sys
from dataclasses import dataclass, field
from pathlib import Path

import numpy as np

sys.path.insert(0, str(Path(__file__).resolve().parent))
from skill_config import SFX, emit, utf8_stdout  # noqa: E402

RATE = 48_000


# ------------------------------------------------------------------- primitives

def rng(seed: int) -> np.random.Generator:
    """Deterministic noise: the same pack must hash identically everywhere."""
    return np.random.default_rng(seed)


def envelope(length: int, attack: float, decay: float, hold: float = 0.0,
             curve: float = 2.0) -> np.ndarray:
    """Percussive envelope. `attack`/`hold`/`decay` are fractions of `length`."""
    attack_n = max(1, int(length * attack))
    hold_n = max(0, int(length * hold))
    decay_n = max(1, length - attack_n - hold_n)
    rise = np.linspace(0.0, 1.0, attack_n) ** (1.0 / max(0.2, curve))
    plateau = np.ones(hold_n)
    fall = np.linspace(1.0, 0.0, decay_n) ** curve
    out = np.concatenate([rise, plateau, fall])
    return out[:length] if out.size >= length else np.pad(out, (0, length - out.size))


def sweep_noise(length: int, low_start: float, low_end: float,
                high_start: float, high_end: float, seed: int,
                resonance: float = 0.0) -> np.ndarray:
    """Band-passed white noise whose band sweeps over time.

    Implemented in the frequency domain over short overlapping windows: a
    time-varying IIR filter would need a per-sample loop in Python, and an STFT
    gives smoother sweeps at a fraction of the cost.
    """
    window_size = 1024
    hop = window_size // 4
    window = np.hanning(window_size)
    # Analyse a padded buffer and keep only the middle. Without the padding the
    # first and last hop of the output are covered by a single window, the
    # overlap-add weight there is near zero, and dividing by it produces a huge
    # spike — which turns every whoosh into a click at t=0.
    pad = window_size
    padded = length + 2 * pad
    noise = rng(seed).normal(0.0, 1.0, padded)
    out = np.zeros(padded)
    weight = np.zeros(padded)
    frequencies = np.fft.rfftfreq(window_size, 1.0 / RATE)
    for start in range(0, padded - window_size + 1, hop):
        centre_sample = start + window_size / 2 - pad
        progress = min(1.0, max(0.0, centre_sample / max(1, length)))
        low = low_start + (low_end - low_start) * progress
        high = high_start + (high_end - high_start) * progress
        low, high = min(low, high), max(low, high)
        centre = math.sqrt(max(20.0, low) * max(40.0, high))
        band = np.exp(-((np.log2(np.maximum(frequencies, 20.0) / centre)) ** 2)
                      / (2 * (max(0.2, math.log2(max(1.05, high / max(20.0, low))) / 2) ** 2)))
        if resonance > 0:
            band = band + resonance * np.exp(
                -((frequencies - centre) ** 2) / (2 * (centre * 0.05) ** 2)
            )
        spectrum = np.fft.rfft(noise[start:start + window_size] * window) * band
        out[start:start + window_size] += np.fft.irfft(spectrum, window_size)
        weight[start:start + window_size] += window ** 2
    steady = float(np.median(weight[pad:pad + length])) or 1.0
    return out[pad:pad + length] / steady


def bend_tone(length: int, start_hz: float, end_hz: float, harmonics: int = 3,
              curve: float = 1.0, noise_mix: float = 0.0, seed: int = 0) -> np.ndarray:
    """A pitch-bending tone. Phase is integrated so the sweep has no clicks."""
    time = np.arange(length) / RATE
    progress = np.linspace(0.0, 1.0, length) ** curve
    frequency = start_hz + (end_hz - start_hz) * progress
    phase = 2 * np.pi * np.cumsum(frequency) / RATE
    signal = np.zeros(length)
    for harmonic in range(1, harmonics + 1):
        signal += np.sin(phase * harmonic) / harmonic ** 1.4
    if noise_mix > 0:
        signal += noise_mix * rng(seed + 991).normal(0, 0.4, length)
    _ = time
    return signal


def one_pole_low(signal: np.ndarray, cutoff: float) -> np.ndarray:
    """Cheap smoothing filter, used to take the fizz off synthetic noise."""
    alpha = math.exp(-2 * math.pi * cutoff / RATE)
    out = np.empty_like(signal)
    accumulator = 0.0
    for index, value in enumerate(signal):
        accumulator = alpha * accumulator + (1 - alpha) * value
        out[index] = accumulator
    return out


def spectral_tilt(signal: np.ndarray, low_gain_db: float, high_gain_db: float,
                  pivot_hz: float = 900.0) -> np.ndarray:
    """One-shot spectral tilt via FFT — a shelf without a per-sample loop."""
    spectrum = np.fft.rfft(signal)
    frequencies = np.fft.rfftfreq(signal.size, 1.0 / RATE)
    ratio = np.clip(np.log2(np.maximum(frequencies, 20.0) / pivot_hz) / 4.0, -1.0, 1.0)
    gain_db = np.where(ratio < 0, low_gain_db * -ratio, high_gain_db * ratio)
    return np.fft.irfft(spectrum * (10 ** (gain_db / 20.0)), signal.size)


def stereo_spread(signal: np.ndarray, amount: float = 0.0,
                  pan_from: float = 0.0, pan_to: float = 0.0) -> np.ndarray:
    """Turn mono into stereo with an optional pan move and Haas widening.

    A whip pan that moves left-to-right in vision should move in the mix too;
    that is most of what makes a synthetic transition read as designed.
    """
    length = signal.size
    if amount > 0:
        delay = max(1, int(RATE * 0.00035 * amount))
        left = signal
        right = np.concatenate([np.zeros(delay), signal[:-delay]])
    else:
        left = right = signal
    pan = np.linspace(pan_from, pan_to, length)
    left_gain = np.cos((pan + 1) * np.pi / 4)
    right_gain = np.sin((pan + 1) * np.pi / 4)
    return np.stack([left * left_gain, right * right_gain], axis=1)


def normalise(stereo: np.ndarray, peak_db: float = -1.5) -> np.ndarray:
    peak = float(np.max(np.abs(stereo))) or 1.0
    return stereo * (10 ** (peak_db / 20.0) / peak)


def declick(stereo: np.ndarray, milliseconds: float = 3.0) -> np.ndarray:
    """Force both ends to zero so a sound never clicks when placed on a track.

    The ramp is capped at a fraction of the sound so an 80 ms tick does not lose
    its transient to a 3 ms fade — that alone cost 2 dB of peak level.
    """
    ramp = max(2, int(RATE * milliseconds / 1000))
    ramp = min(ramp, max(2, int(stereo.shape[0] * 0.04)))
    ramp = min(ramp, stereo.shape[0] // 2)
    fade = np.linspace(0.0, 1.0, ramp)[:, None]
    stereo[:ramp] *= fade
    stereo[-ramp:] *= fade[::-1]
    return stereo


def write_wav(path: Path, stereo: np.ndarray, bits: int = 24) -> None:
    """24-bit PCM WAV without a soundfile dependency."""
    path.parent.mkdir(parents=True, exist_ok=True)
    clipped = np.clip(stereo, -1.0, 1.0)
    frames = clipped.shape[0]
    if bits == 24:
        scaled = np.round(clipped * (2 ** 23 - 1)).astype(np.int32)
        raw = bytearray()
        for sample in scaled.reshape(-1):
            raw += int(sample).to_bytes(3, "little", signed=True)
        payload = bytes(raw)
        bytes_per_sample = 3
    else:
        scaled = np.round(clipped * 32767).astype("<i2")
        payload = scaled.tobytes()
        bytes_per_sample = 2
    channels = clipped.shape[1]
    byte_rate = RATE * channels * bytes_per_sample
    header = (
        b"RIFF" + struct.pack("<I", 36 + len(payload)) + b"WAVE"
        + b"fmt " + struct.pack("<IHHIIHH", 16, 1, channels, RATE, byte_rate,
                                channels * bytes_per_sample, bits)
        + b"data" + struct.pack("<I", len(payload))
    )
    path.write_bytes(header + payload)
    _ = frames


# ---------------------------------------------------------------------- presets

@dataclass
class Sound:
    id: str
    seconds: float
    role: str
    use: str
    peak_db: float = -1.5
    tags: list[str] = field(default_factory=list)


def build(sound_id: str, seconds: float, seed: int, pan: tuple[float, float] = (0.0, 0.0)) -> np.ndarray:
    """Return the stereo buffer for one preset id."""
    n = int(RATE * seconds)

    if sound_id == "whoosh_soft":
        # Carried by the noise band: wide, slow, low resonance.
        body = sweep_noise(n, 180, 90, 2600, 900, seed)
        body *= envelope(n, 0.34, 0.66, curve=1.7)
        body = spectral_tilt(body, 2.0, -4.0)
        return stereo_spread(body, 0.7, pan[0], pan[1])

    if sound_id == "whoosh_fast":
        body = sweep_noise(n, 300, 140, 7000, 1600, seed, resonance=0.25)
        body *= envelope(n, 0.16, 0.84, curve=2.6)
        return stereo_spread(body, 1.0, pan[0], pan[1])

    if sound_id == "whip_pan":
        # Hard, directional, with a doppler-ish tonal edge under the noise.
        body = sweep_noise(n, 400, 120, 9000, 1200, seed, resonance=0.4)
        body *= envelope(n, 0.22, 0.78, curve=2.1)
        tone = bend_tone(n, 900, 160, harmonics=2, curve=0.7) * 0.25
        tone *= envelope(n, 0.2, 0.8, curve=2.0)
        return stereo_spread(body + tone, 1.0, pan[0] or -0.85, pan[1] or 0.85)

    if sound_id == "riser":
        # Tonal, upward, long. Sits under the last frames before a cut.
        tone = bend_tone(n, 180, 1500, harmonics=5, curve=2.2, noise_mix=0.12, seed=seed)
        air = sweep_noise(n, 800, 4000, 4000, 14000, seed + 7) * 0.5
        combined = tone * 0.7 + air
        combined *= envelope(n, 0.85, 0.15, curve=1.2)
        return stereo_spread(combined, 0.5, -0.25, 0.25)

    if sound_id == "riser_short":
        tone = bend_tone(n, 260, 1900, harmonics=4, curve=2.6, noise_mix=0.1, seed=seed)
        tone *= envelope(n, 0.82, 0.18, curve=1.1)
        return stereo_spread(tone, 0.4, -0.2, 0.2)

    if sound_id == "impact_low":
        # Envelope-carried: a fast body over a decaying sub.
        sub = bend_tone(n, 120, 42, harmonics=1, curve=0.5)
        sub *= envelope(n, 0.006, 0.994, curve=2.4)
        body = sweep_noise(n, 60, 40, 1400, 260, seed) * 0.5
        body *= envelope(n, 0.004, 0.996, curve=4.0)
        return stereo_spread(sub * 0.9 + body, 0.15)

    if sound_id == "impact_tight":
        body = sweep_noise(n, 120, 70, 3200, 500, seed) * 0.8
        body *= envelope(n, 0.003, 0.997, curve=5.0)
        sub = bend_tone(n, 180, 60, harmonics=1) * 0.6
        sub *= envelope(n, 0.004, 0.996, curve=3.0)
        return stereo_spread(body + sub, 0.1)

    if sound_id == "sub_drop":
        sub = bend_tone(n, 90, 28, harmonics=1, curve=0.7)
        sub *= envelope(n, 0.02, 0.98, curve=1.6)
        return stereo_spread(sub, 0.0)

    if sound_id == "boom_cinematic":
        sub = bend_tone(n, 70, 30, harmonics=2, curve=0.8)
        sub *= envelope(n, 0.01, 0.99, curve=1.4)
        tail = sweep_noise(n, 40, 30, 900, 160, seed + 3) * 0.35
        tail *= envelope(n, 0.05, 0.95, curve=1.8)
        return stereo_spread(sub + tail, 0.3, -0.15, 0.15)

    if sound_id == "flash_white":
        # Very short, bright, symmetrical: reads as an exposure spike.
        body = sweep_noise(n, 2000, 1200, 16000, 7000, seed)
        body *= envelope(n, 0.25, 0.75, curve=2.2)
        body = spectral_tilt(body, -8.0, 4.0)
        return stereo_spread(body, 0.6, -0.3, 0.3)

    if sound_id == "click_tick":
        body = sweep_noise(n, 900, 700, 8000, 3000, seed)
        body *= envelope(n, 0.02, 0.98, curve=6.0)
        return stereo_spread(body, 0.2)

    if sound_id == "shutter_mech":
        # Two closely spaced clicks plus a low sweep: camera/machine language.
        body = np.zeros(n)
        for offset, gain in ((0.03, 1.0), (0.085, 0.7), (0.13, 0.4)):
            start = int(n * offset)
            span = min(n - start, int(RATE * 0.02))
            if span <= 0:
                continue
            tick = sweep_noise(span, 700, 500, 9000, 2500, seed + int(offset * 1000))
            body[start:start + span] += tick * envelope(span, 0.02, 0.98, curve=6.0) * gain
        low = sweep_noise(n, 90, 60, 1200, 320, seed + 21) * 0.35
        low *= envelope(n, 0.2, 0.8, curve=2.0)
        return stereo_spread(body + low, 0.35, -0.2, 0.2)

    if sound_id == "digital_glitch":
        base = rng(seed).normal(0, 1, n)
        # Sample-and-hold at an audible rate is what makes it read as digital.
        step = max(1, int(RATE / 1400))
        held = np.repeat(base[::step], step)[:n]
        held = np.pad(held, (0, max(0, n - held.size)))[:n]
        gate = (rng(seed + 5).random(max(1, n // 240)) > 0.35).astype(float)
        gate = np.repeat(gate, 240)[:n]
        gate = np.pad(gate, (0, max(0, n - gate.size)))[:n]
        body = held * gate * envelope(n, 0.05, 0.95, curve=1.4)
        body = spectral_tilt(body, -6.0, 2.0)
        return stereo_spread(body, 1.0, -0.6, 0.6)

    if sound_id == "tape_stop":
        tone = bend_tone(n, 420, 40, harmonics=4, curve=1.8, noise_mix=0.06, seed=seed)
        tone *= envelope(n, 0.04, 0.96, curve=1.3)
        return stereo_spread(tone, 0.3)

    if sound_id == "reverse_cymbal":
        body = sweep_noise(n, 1200, 2600, 9000, 16000, seed)
        body *= np.linspace(0.02, 1.0, n) ** 2.1
        return stereo_spread(body, 0.9, -0.4, 0.4)

    if sound_id == "cloth_sweep":
        body = sweep_noise(n, 200, 140, 2200, 800, seed)
        body *= envelope(n, 0.4, 0.6, curve=1.5)
        body = spectral_tilt(one_pole_low(body, 6000), 1.0, -6.0)
        return stereo_spread(body, 0.8, -0.5, 0.5)

    if sound_id == "paper_swipe":
        body = sweep_noise(n, 700, 500, 6500, 2600, seed)
        body *= envelope(n, 0.22, 0.78, curve=2.4)
        return stereo_spread(body, 0.7, -0.45, 0.45)

    if sound_id == "digital_sweep":
        tone = bend_tone(n, 700, 2400, harmonics=6, curve=1.6) * 0.5
        air = sweep_noise(n, 1500, 3000, 8000, 15000, seed) * 0.6
        body = (tone + air) * envelope(n, 0.35, 0.65, curve=1.8)
        return stereo_spread(body, 0.6, -0.35, 0.35)

    if sound_id == "sparkle":
        body = np.zeros(n)
        generator = rng(seed)
        for _ in range(14):
            start = int(generator.random() * n * 0.7)
            span = min(n - start, int(RATE * 0.05))
            if span <= 8:
                continue
            frequency = 2600 + generator.random() * 5200
            grain = bend_tone(span, frequency, frequency * 0.72, harmonics=2)
            body[start:start + span] += grain * envelope(span, 0.02, 0.98, curve=3.4) * 0.5
        return stereo_spread(body, 0.9, -0.7, 0.7)

    if sound_id == "pop_ui":
        tone = bend_tone(n, 520, 1150, harmonics=2, curve=0.6)
        tone *= envelope(n, 0.08, 0.92, curve=3.4)
        return stereo_spread(tone, 0.2)

    if sound_id == "text_tick":
        tone = bend_tone(n, 1500, 1200, harmonics=1)
        tone *= envelope(n, 0.03, 0.97, curve=5.0)
        noise = sweep_noise(n, 1800, 1400, 9000, 5000, seed) * 0.35
        noise *= envelope(n, 0.02, 0.98, curve=6.0)
        return stereo_spread(tone * 0.6 + noise, 0.15)

    if sound_id == "air_tail":
        body = sweep_noise(n, 300, 200, 5200, 1400, seed)
        body *= envelope(n, 0.05, 0.95, curve=1.5)
        return stereo_spread(body, 0.8, 0.2, -0.2)

    # ---------------------------------------------------------- flash & glitch

    if sound_id == "flash_pop":
        # An exposure spike is heard as a transient, not as a swell: almost all
        # of the energy has to land in the first 15 ms or it reads as a whoosh.
        body = sweep_noise(n, 3000, 1400, 17000, 6000, seed)
        body *= envelope(n, 0.012, 0.988, curve=5.5)
        body = spectral_tilt(body, -10.0, 5.0)
        tick = bend_tone(n, 4200, 2300, harmonics=1) * 0.3
        tick *= envelope(n, 0.006, 0.994, curve=7.0)
        return stereo_spread(body + tick, 0.55, -0.28, 0.28)

    if sound_id == "glitch_stutter":
        # Repeats at a fixed grid: an irregular stutter sounds like a dropout,
        # a gridded one sounds intentional.
        body = np.zeros(n)
        generator = rng(seed)
        slots = 7
        for index in range(slots):
            start = int(n * index / slots)
            span = min(n - start, int(n / slots * 0.72))
            if span <= 16:
                continue
            grain = generator.normal(0, 1, span)
            step = max(1, int(RATE / (900 + index * 420)))
            grain = np.repeat(grain[::step], step)[:span]
            grain = np.pad(grain, (0, max(0, span - grain.size)))[:span]
            gain = 1.0 - index / (slots * 1.4)
            body[start:start + span] += grain * envelope(span, 0.02, 0.98, curve=3.0) * gain
        body = spectral_tilt(body, -5.0, 3.0)
        return stereo_spread(body, 1.0, -0.75, 0.75)

    if sound_id == "data_burst":
        base = rng(seed).normal(0, 1, n)
        step = max(1, int(RATE / 3200))
        held = np.repeat(base[::step], step)[:n]
        held = np.pad(held, (0, max(0, n - held.size)))[:n]
        carrier = np.sin(2 * np.pi * 2400 * np.arange(n) / RATE)
        # A plateau, not a decay: a burst of data is a sustained event and a
        # pure exponential tail here measures — and sounds — like a click.
        body = held * (0.6 + 0.4 * carrier) * envelope(n, 0.06, 0.34, hold=0.6, curve=1.4)
        return stereo_spread(spectral_tilt(body, -8.0, 4.0), 0.85, -0.5, 0.5)

    if sound_id == "bass_drop":
        # Click plus sub: the click is what makes a sub audible on a phone, and
        # without it the whole sound disappears on the device most people watch on.
        sub = bend_tone(n, 110, 33, harmonics=1, curve=0.55)
        sub *= envelope(n, 0.012, 0.988, curve=1.9)
        click = sweep_noise(n, 300, 200, 5200, 900, seed + 11) * 0.4
        click *= envelope(n, 0.002, 0.998, curve=8.0)
        return stereo_spread(sub + click, 0.08)

    if sound_id == "riser_tension":
        tone = bend_tone(n, 150, 2600, harmonics=6, curve=2.9, noise_mix=0.14, seed=seed)
        air = sweep_noise(n, 600, 5200, 3000, 16000, seed + 4) * 0.55
        # Accelerating tremolo: the pulse speeding up is the tension, not the pitch.
        phase = np.cumsum(np.linspace(6.0, 26.0, n)) / RATE
        pulse = 0.72 + 0.28 * np.sin(2 * np.pi * phase)
        body = (tone * 0.65 + air) * pulse * envelope(n, 0.9, 0.1, curve=1.05)
        return stereo_spread(body, 0.55, -0.3, 0.3)

    if sound_id == "laser_zap":
        tone = bend_tone(n, 5200, 420, harmonics=3, curve=2.8)
        tone *= envelope(n, 0.02, 0.98, curve=4.2)
        return stereo_spread(tone, 0.5, -0.4, 0.4)

    if sound_id == "metal_hit":
        body = np.zeros(n)
        for partial, gain in ((1.0, 1.0), (2.76, 0.6), (5.4, 0.35), (8.9, 0.2)):
            body += bend_tone(n, 420 * partial, 400 * partial, harmonics=1) * gain
        body *= envelope(n, 0.003, 0.997, curve=2.2)
        strike = sweep_noise(n, 200, 140, 8000, 1600, seed) * 0.4
        strike *= envelope(n, 0.002, 0.998, curve=7.0)
        return stereo_spread(body * 0.5 + strike, 0.3, -0.2, 0.2)

    if sound_id == "reverse_whoosh":
        body = sweep_noise(n, 140, 420, 1800, 9000, seed)
        body *= np.linspace(0.01, 1.0, n) ** 2.4
        return stereo_spread(body, 0.9, -0.5, 0.5)

    if sound_id == "vinyl_scratch":
        tone = bend_tone(n, 260, 900, harmonics=3, curve=1.0)
        back = bend_tone(n, 900, 200, harmonics=3, curve=1.0)
        half = n // 2
        body = np.concatenate([tone[:half], back[half:]])[:n]
        grit = rng(seed).normal(0, 1, n) * 0.18
        body = (body + grit) * envelope(n, 0.08, 0.92, curve=2.0)
        return stereo_spread(one_pole_low(body, 5200), 0.5, -0.3, 0.3)

    if sound_id == "ui_swipe":
        body = sweep_noise(n, 900, 2200, 5000, 11000, seed)
        body *= envelope(n, 0.3, 0.7, curve=2.2)
        return stereo_spread(body, 0.75, -0.55, 0.55)

    # ------------------------------------------------------- атмосферы и фактуры
    #
    # Отдельный класс, и он не про акценты. Это то, что лежит под всем роликом на
    # −34..−40 дБ и чего зритель не слышит как звук — он слышит его отсутствие.
    # Дешёвая запись звучит дёшево во многом потому, что между фразами наступает
    # цифровая тишина, которой не бывает ни в одной комнате мира. Тонкий слой
    # атмосферы убирает этот провал, и монтаж сразу читается как сведённый.
    #
    # Все они зациклены по построению: конец сшивается с началом кроссфейдом
    # внутри самого буфера, иначе на стыке петли слышен щелчок.

    def loopable(buffer: np.ndarray, blend: float = 0.25) -> np.ndarray:
        """Сшить хвост с головой, чтобы петля не щёлкала."""
        span = int(len(buffer) * blend)
        if span < 16:
            return buffer
        ramp = np.linspace(0.0, 1.0, span)
        head, tail = buffer[:span].copy(), buffer[-span:].copy()
        buffer = buffer[:-span]
        buffer[:span] = tail * (1 - ramp) + head * ramp
        return buffer

    if sound_id == "room_tone":
        # Комната: очень низкий рокот плюс приглушённый широкополосный шум.
        generator = rng(seed)
        low = one_pole_low(generator.normal(0, 1, n), 120)
        air = one_pole_low(generator.normal(0, 1, n), 2400) * 0.25
        body = loopable(low * 0.9 + air)
        return stereo_spread(spectral_tilt(body, 6.0, -14.0), 0.45, -0.2, 0.2)

    if sound_id == "electronics_hum":
        # Работающее железо: гармоники вентилятора плюс широкополосный поток.
        base = np.arange(n) / RATE
        hum = sum(np.sin(2 * np.pi * f * base) / (i + 1)
                  for i, f in enumerate((92.0, 184.0, 276.0)))
        fan = one_pole_low(rng(seed).normal(0, 1, n), 900) * 0.55
        body = loopable(hum * 0.22 + fan)
        return stereo_spread(body, 0.35, -0.15, 0.15)

    if sound_id == "server_room":
        generator = rng(seed)
        wide = one_pole_low(generator.normal(0, 1, n), 3200)
        rumble = one_pole_low(generator.normal(0, 1, n), 90) * 1.3
        body = loopable(wide * 0.5 + rumble)
        return stereo_spread(spectral_tilt(body, 3.0, -6.0), 0.8, -0.5, 0.5)

    if sound_id == "keyboard_typing":
        # Нерегулярные щелчки: ровный ритм читается как метроном, а не как работа.
        body = np.zeros(n)
        generator = rng(seed)
        position = 0
        while position < n - 800:
            span = min(n - position, int(RATE * 0.012))
            click = sweep_noise(span, 800, 600, 9000, 3000, seed + position)
            body[position:position + span] += (
                click * envelope(span, 0.02, 0.98, curve=6.5) * (0.5 + generator.random() * 0.5))
            position += int(RATE * (0.06 + generator.random() * 0.13))
        return stereo_spread(one_pole_low(body, 7000), 0.5, -0.3, 0.3)

    if sound_id == "city_distant":
        generator = rng(seed)
        wash = one_pole_low(generator.normal(0, 1, n), 700)
        # Медленное дыхание уровня — далёкий транспорт, а не ровный шум.
        swell = 0.75 + 0.25 * np.sin(2 * np.pi * 0.09 * np.arange(n) / RATE)
        body = loopable(wash * swell)
        return stereo_spread(spectral_tilt(body, 4.0, -10.0), 0.9, -0.6, 0.6)

    if sound_id == "tension_bed":
        # Тянущийся низкий слой под напряжённый блок. Не риз: он не нарастает.
        tone = bend_tone(n, 58, 58, harmonics=3, noise_mix=0.05, seed=seed)
        shimmer = one_pole_low(rng(seed + 3).normal(0, 1, n), 1600) * 0.3
        body = loopable(tone * 0.6 + shimmer)
        return stereo_spread(body, 0.6, -0.35, 0.35)

    raise SystemExit(f"Unknown sound id: {sound_id}")


CATALOG: tuple[Sound, ...] = (
    Sound("whoosh_soft", 0.55, "noise", "Мягкая смена смыслового блока. Под push и slide.",
          tags=["push", "slide", "calm"]),
    Sound("whoosh_fast", 0.34, "noise", "Быстрая склейка, поворот, ускорение.",
          tags=["whip", "fast"]),
    Sound("whip_pan", 0.3, "noise", "Резкий поворот камеры. Панорама слева направо.",
          tags=["whip", "directional"]),
    Sound("riser", 1.4, "tone", "Нагнетание перед сильной склейкой. Ставится ДО стыка.",
          tags=["riser", "build"]),
    Sound("riser_short", 0.7, "tone", "Короткое нагнетание для 8–14 кадров.",
          tags=["riser", "build"]),
    Sound("impact_low", 0.75, "envelope", "Удар на склейке. Основной акцент.",
          tags=["impact", "hit"]),
    Sound("impact_tight", 0.4, "envelope", "Сухой короткий удар без хвоста.",
          tags=["impact", "hit", "dry"]),
    Sound("sub_drop", 1.1, "tone", "Подложка низа под резкую смену. Не слышна на телефоне — проверяй.",
          tags=["sub", "impact"]),
    Sound("boom_cinematic", 1.8, "tone", "Кинематографический бум. Максимум один-два на ролик.",
          tags=["impact", "cinematic"]),
    Sound("flash_white", 0.22, "noise", "Вспышка через белое. Очень короткая.",
          tags=["flash", "accent"]),
    Sound("click_tick", 0.09, "envelope", "Щелчок под появление элемента.",
          tags=["ui", "click"]),
    Sound("shutter_mech", 0.42, "envelope", "Механический затвор, диафрагма, техника.",
          tags=["mechanical", "shutter"]),
    Sound("digital_glitch", 0.24, "noise", "Цифровой сбой. Не длиннее 250 мс.",
          tags=["glitch", "error"]),
    Sound("tape_stop", 0.5, "tone", "Останов плёнки. Обрыв мысли, пауза.",
          tags=["retro", "stop"]),
    Sound("reverse_cymbal", 1.2, "noise", "Обратная тарелка перед открытием сцены.",
          tags=["riser", "reverse"]),
    Sound("cloth_sweep", 0.4, "noise", "Тканевый свайп под маску и cover-переход.",
          tags=["mask", "cover"]),
    Sound("paper_swipe", 0.3, "noise", "Бумажный свайп под карточку и титр.",
          tags=["mask", "card"]),
    Sound("digital_sweep", 0.45, "noise", "Цифровой свип под интерфейс и данные.",
          tags=["tech", "ui"]),
    Sound("sparkle", 0.7, "tone", "Искры под появление акцента. Легко переборщить.",
          tags=["accent", "sparkle"]),
    Sound("pop_ui", 0.16, "tone", "Поп под всплывающий элемент или слово.",
          tags=["ui", "pop"]),
    Sound("text_tick", 0.08, "envelope", "Тик под появление слова в субтитрах.",
          tags=["ui", "caption"]),
    Sound("air_tail", 0.6, "noise", "Воздушный хвост после склейки.",
          tags=["tail", "air"]),
    # Declared as an envelope gesture, not a noise one: the crest check would
    # correctly reject it as "a click" — which is exactly what a flash is.
    Sound("flash_pop", 0.18, "envelope", "Вспышка: весь удар в первые 15 мс. Под flash_punch.",
          tags=["flash", "accent", "transient"]),
    Sound("glitch_stutter", 0.36, "noise", "Гридованное заикание. Под rgb_glitch и block_shuffle.",
          tags=["glitch", "digital", "stutter"]),
    Sound("data_burst", 0.28, "noise", "Всплеск данных с несущей. Под цифровые переходы.",
          tags=["glitch", "tech", "data"]),
    Sound("bass_drop", 1.0, "tone", "Суб с щелчком: слышен и на телефоне, а не только в наушниках.",
          tags=["impact", "sub", "drop"]),
    Sound("riser_tension", 1.6, "tone", "Нагнетание с ускоряющимся пульсом. Ставится ДО стыка.",
          tags=["riser", "build", "tension"]),
    Sound("laser_zap", 0.24, "tone", "Резкий спад тона. Появление элемента, цифра, укол.",
          tags=["ui", "accent", "zap"]),
    Sound("metal_hit", 0.9, "tone", "Металлический удар с негармоническими обертонами.",
          tags=["impact", "metal", "hit"]),
    Sound("reverse_whoosh", 0.8, "noise", "Обратный свист перед склейкой. Тянет к стыку.",
          tags=["reverse", "riser", "whoosh"]),
    Sound("vinyl_scratch", 0.45, "tone", "Скрэтч: разворот мысли, ретро-акцент.",
          tags=["retro", "scratch", "stop"]),
    Sound("ui_swipe", 0.32, "noise", "Лёгкий свайп интерфейса под карточку или титр.",
          tags=["ui", "card", "swipe"]),
    # Атмосферы: длинные, ровные, зацикливаемые. Живут на −34..−40 дБ под всем
    # роликом. Их не слышат — слышат их отсутствие.
    Sound("room_tone", 6.0, "noise", "Комната. Убирает цифровую тишину между фразами.",
          tags=["ambience", "bed", "room"]),
    Sound("electronics_hum", 6.0, "noise", "Работающее железо: вентилятор и поток.",
          tags=["ambience", "tech", "hardware"]),
    Sound("server_room", 6.0, "noise", "Серверная: широкий поток и низкий рокот.",
          tags=["ambience", "tech", "datacenter"]),
    # Роль envelope, а не noise: печать — это буквально последовательность
    # щелчков, и проверка крест-фактора справедливо назвала бы её кликом.
    Sound("keyboard_typing", 4.0, "envelope", "Печать. Щелчки неровные — ровные звучат метрономом.",
          tags=["ambience", "foley", "work"]),
    Sound("city_distant", 6.0, "noise", "Далёкий город с медленным дыханием уровня.",
          tags=["ambience", "outdoor", "city"]),
    Sound("tension_bed", 6.0, "tone", "Низкий тянущийся слой под напряжённый блок.",
          tags=["ambience", "bed", "tension"]),
)


# ------------------------------------------------------------------ verification

def analyse(stereo: np.ndarray) -> dict:
    """Objective description of a rendered sound, per channel and summed."""
    mono = stereo.mean(axis=1)
    peak = float(np.max(np.abs(stereo))) or 1e-9
    rms = float(np.sqrt(np.mean(mono ** 2))) or 1e-9
    # Crest against the loudest 200 ms, not the whole file. Measured over the
    # whole file, a 1.2 s riser and a 20 ms click both score ~25 dB, so the
    # figure says nothing about which one it is.
    window = min(mono.size, int(RATE * 0.2))
    energy = np.convolve(mono ** 2, np.ones(window) / window, mode="valid")
    loud_rms = float(np.sqrt(np.max(energy))) if energy.size else rms
    loud_rms = loud_rms or 1e-9
    smoothing = max(1, mono.size // 200)
    smoothed = np.convolve(np.abs(mono), np.ones(smoothing) / smoothing, mode="same")
    spectrum = np.abs(np.fft.rfft(mono * np.hanning(mono.size)))
    frequencies = np.fft.rfftfreq(mono.size, 1.0 / RATE)
    centroid = float((spectrum * frequencies).sum() / max(1e-9, spectrum.sum()))
    left = float(np.sqrt(np.mean(stereo[:, 0] ** 2)))
    right = float(np.sqrt(np.mean(stereo[:, 1] ** 2)))
    return {
        "peak_dbfs": round(20 * math.log10(peak), 2),
        "rms_dbfs": round(20 * math.log10(rms), 2),
        "crest_db": round(20 * math.log10(peak / loud_rms), 2),
        "crest_full_db": round(20 * math.log10(peak / rms), 2),
        "centroid_hz": round(centroid),
        "envelope_peak_at": round(float(np.argmax(smoothed)) / mono.size, 3),
        "stereo_balance_db": round(20 * math.log10(max(left, 1e-9) / max(right, 1e-9)), 2),
    }


def verify(sound: Sound, stereo: np.ndarray) -> list[str]:
    """Flag sounds that do not behave like what they claim to be.

    These thresholds exist because the first build of this pack passed every
    functional test while actually producing clicks: an overlap-add bug put all
    the energy in the first millisecond, and only a crest-factor check catches
    that without listening.
    """
    stats = analyse(stereo)
    problems: list[str] = []
    if not -3.0 <= stats["peak_dbfs"] <= -0.5:
        problems.append(f"{sound.id}: peak {stats['peak_dbfs']} dBFS is outside -3..-0.5")
    # A sustained noise or tone gesture cannot have a percussive crest factor.
    if sound.role in ("noise", "tone") and stats["crest_db"] > 22.0:
        problems.append(
            f"{sound.id}: crest {stats['crest_db']} dB — this is a click, not a "
            f"{sound.role} gesture"
        )
    if sound.role == "envelope" and stats["crest_db"] > 40.0:
        problems.append(f"{sound.id}: crest {stats['crest_db']} dB is degenerate")
    if sound.id.startswith("riser") or sound.id == "reverse_cymbal":
        if stats["envelope_peak_at"] < 0.55:
            problems.append(
                f"{sound.id}: peaks at {stats['envelope_peak_at']:.0%} of its length; "
                f"a riser must peak at the end"
            )
    elif sound.role in ("envelope",) and stats["envelope_peak_at"] > 0.3:
        problems.append(f"{sound.id}: transient peaks late ({stats['envelope_peak_at']:.0%})")
    if stats["centroid_hz"] < 40:
        problems.append(f"{sound.id}: spectral centroid {stats['centroid_hz']} Hz is inaudible on a phone")
    return problems


def main() -> None:
    utf8_stdout()
    parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
    parser.add_argument("--out-dir", default=str(SFX))
    parser.add_argument("--only", help="Comma-separated sound ids")
    parser.add_argument("--seed", type=int, default=20260730,
                        help="Noise seed; the same seed reproduces the pack byte for byte")
    parser.add_argument("--bits", type=int, choices=[16, 24], default=24)
    parser.add_argument("--variants", type=int, default=1,
                        help="Extra takes per sound with different noise, for variety")
    parser.add_argument("--force", action="store_true", help="Rebuild existing files")
    parser.add_argument("--list", action="store_true", help="Print the catalog and exit")
    args = parser.parse_args()

    if args.list:
        emit({"sounds": [
            {"id": s.id, "seconds": s.seconds, "role": s.role, "tags": s.tags, "use": s.use}
            for s in CATALOG
        ]})
        return

    wanted = CATALOG
    if args.only:
        keys = {value.strip() for value in args.only.split(",") if value.strip()}
        wanted = tuple(sound for sound in CATALOG if sound.id in keys)
        if not wanted:
            raise SystemExit(f"No matching sound ids. Known: {', '.join(s.id for s in CATALOG)}")

    out_dir = Path(args.out_dir)
    records = []
    problems: list[str] = []
    for index, sound in enumerate(wanted):
        for take in range(max(1, args.variants)):
            suffix = "" if take == 0 else f"_v{take + 1}"
            path = out_dir / f"{sound.id}{suffix}.wav"
            seed = args.seed + index * 137 + take * 9991
            stats: dict | None = None
            if path.exists() and not args.force:
                payload = path.read_bytes()
            else:
                # Declick first, normalise second: a fade applied after
                # normalisation silently pulls the peak back down.
                buffer = normalise(declick(build(sound.id, sound.seconds, seed)),
                                   sound.peak_db)
                stats = analyse(buffer)
                problems.extend(verify(sound, buffer))
                write_wav(path, buffer, args.bits)
                payload = path.read_bytes()
            records.append({
                "measured": stats,
                "id": sound.id,
                "take": take + 1,
                "file": path.relative_to(Path(args.out_dir).parent.parent).as_posix()
                if str(out_dir).startswith(str(SFX.parent.parent)) else str(path),
                "seconds": sound.seconds,
                "role": sound.role,
                "tags": sound.tags,
                "use": sound.use,
                "seed": seed,
                "sample_rate": RATE,
                "bits": args.bits,
                "bytes": len(payload),
                "sha256": hashlib.sha256(payload).hexdigest(),
            })

    manifest = {
        "version": "1.0",
        "generator": "scripts/generate_sfx.py",
        "license": "Синтезировано этим скриптом. Оригинальный контент, права не требуются.",
        "reproducible": "Одинаковый --seed даёт побайтово одинаковые файлы.",
        "sample_rate": RATE,
        "sounds": records,
        "verification": {
            "checked": sum(1 for item in records if item.get("measured")),
            "problems": problems,
            "criteria": (
                "peak -3..-0.5 dBFS; crest <=22 dB for noise/tone gestures; risers "
                "peak in their last 45%; transients peak in their first 30%."
            ),
        },
        "mixing_note": (
            "SFX ставится в отдельную шину и дакируется под речь, а не поверх неё. "
            "Пиковый уровень пака -1.5 dBFS: в миксе он опускается до -18…-12 dBFS."
        ),
    }
    (out_dir / "sfx_manifest.json").write_text(
        json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
    )
    emit({
        "status": "ok" if not problems else "failed_verification",
        "out_dir": str(out_dir.resolve()),
        "files": len(records),
        "total_kilobytes": round(sum(item["bytes"] for item in records) / 1024, 1),
        "manifest": str((out_dir / "sfx_manifest.json").resolve()),
        "problems": problems,
    })
    if problems:
        raise SystemExit(2)


if __name__ == "__main__":
    main()
