// Frostcall Arena sound bank. Every combat event is a LAYERED sound: recorded takes from the sfxkit (pools with no
// immediate repeat, pitch +-8 %, volume +-15 %) plus procedural WebAudio layers (cracks, zaps, whooshes, booms) that
// cost no download and are re-rolled on every play. Everything lands on the sfx bus, under a limiter.
import type { Object3D } from "three";
import { SFX_CATALOG, type Sfx, type SfxCatalog, type SfxEntry } from "@/lib/sfxkit/player";

type Pt = { x: number; y: number; z: number };
export type At = Object3D | Pt;
type Layer = { id: string; p?: number; v?: number };
type Stoppable = { stop(fade?: number): void };

const J = (x: number, r: number) => x * (1 + (Math.random() * 2 - 1) * r);
const R = (a: number, b: number) => a + Math.random() * (b - a);
const pitchJ = (p = 1) => J(p, 0.08);
const volJ = (v = 1) => J(v, 0.15);

/** A set of variants that never returns the same one twice in a row. */
class Pool {
  private last = -1;
  constructor(readonly v: Layer[]) {}
  next(): Layer {
    let k = Math.floor(Math.random() * this.v.length);
    if (this.v.length > 1 && k === this.last) k = (k + 1) % this.v.length;
    this.last = k;
    return this.v[k];
  }
}

/**
 * Round 2: 42 more recorded takes (CC0, public/sfx/aaa, credits in public/sfx/SOURCES.json), each its own catalog id
 * "aaa_<name>" so a pool can pick any of them. The catalog is the sfxkit one plus these; Game.ts hands it to the player.
 */
const AAA_FILES: Record<string, string[]> = {
  ice: ["ice_shatter_1", "ice_shatter_2", "ice_break_3", "ice_spell_4"],
  magic: ["spell_cast_1", "spell_whoosh_2", "spell_item_3", "spell_impact_1", "spell_impact_2", "zap_1", "zap_2"],
  fire: ["fireball_whoosh", "fire_boom_1", "fire_boom_2"],
  sword: ["sword_slash_1", "sword_slash_2", "sword_slash_3", "sword_hit_flesh", "sword_hit_armor", "sword_clash"],
  body: ["punch_1", "punch_2", "thud_1"],
  male: ["m_grunt_1", "m_grunt_2", "m_grunt_3", "m_hurt_4", "m_hurt_5", "m_hurt_6", "m_grunt_7", "m_die_1", "m_die_2"],
  female: ["f_hurt_1", "f_hurt_2", "f_ouch_3", "f_grunt_4", "f_grunt_5", "f_grunt_6", "f_die_1"],
  crowd: ["crowd_cheer_4", "crowd_gasp_1", "crowd_gasp_2"],
};
const A = (n: string) => `aaa_${n}`;
const AAA_IDS = Object.values(AAA_FILES).flat().map(A);
const AAA_SOUNDS: SfxEntry[] = Object.entries(AAA_FILES).flatMap(([cat, names]) => names.map((n) => ({
  id: A(n), category: "aaa", tags: ["aaa", cat], files: [`aaa/${n}.ogg`], loop: false,
  bus: "sfx" as const, volume: cat === "crowd" ? 0.7 : 0.8, voices: 4, spatial: cat !== "crowd",
  licence: "CC0-1.0", credit: "freesound.org CC0, see public/sfx/SOURCES.json",
})));
export const AAA_CATALOG: SfxCatalog = { ...SFX_CATALOG, sounds: [...SFX_CATALOG.sounds, ...AAA_SOUNDS] };

/** A layer of one aaa take. */
const L = (n: string, v = 1, p = 1): Layer => ({ id: A(n), v, p });

// recorded layers, each a pool of 4 to 8 variants: real takes first, round-1 files at another pitch fill the rest
const P = {
  body: new Pool([
    L("punch_1", 1), L("punch_2", 1), L("thud_1", 0.9), L("thud_1", 0.8, 1.25), L("sword_hit_flesh", 0.8),
    { id: "impact_flesh", p: 0.8, v: 0.9 }, { id: "impact_flesh", p: 1.2, v: 0.7 }, { id: "impact_wood", p: 0.75, v: 0.6 },
  ]),
  ice: new Pool([
    L("ice_shatter_1", 0.8), L("ice_shatter_2", 0.9), L("ice_break_3", 1), L("ice_shatter_2", 0.7, 1.3), L("ice_break_3", 0.9, 0.8),
    { id: "impact_glass", p: 1.0, v: 0.55 }, { id: "impact_glass", p: 1.4, v: 0.45 },
  ]),
  metal: new Pool([
    L("sword_hit_armor", 0.9), L("sword_clash", 1), L("sword_hit_armor", 0.8, 0.8), L("sword_clash", 1, 1.2),
    { id: "impact_metal", p: 0.7, v: 0.6 }, { id: "impact_metal", p: 1.1, v: 0.5 },
  ]),
  fire: new Pool([
    L("fire_boom_1", 0.9), L("fire_boom_2", 1), L("fire_boom_1", 0.8, 0.75), L("fire_boom_2", 0.9, 1.25),
    { id: "impact_concrete", p: 0.55, v: 0.7 }, { id: "splash_big", p: 0.6, v: 0.45 },
  ]),
  /** Staff-cast breath and the arcane / frost launch. */
  cast: new Pool([L("spell_cast_1", 0.8), L("spell_whoosh_2", 0.9), L("ice_spell_4", 1), L("spell_item_3", 0.9, 0.9), L("spell_whoosh_2", 0.8, 1.2)]),
  /** Spell lands: arcane, magic impacts, zaps (counterspell, silence, polymorph pop). */
  zap: new Pool([L("zap_1", 0.9), L("zap_2", 1), L("spell_impact_1", 0.8), L("spell_impact_2", 0.8), L("zap_1", 0.8, 1.35), L("zap_2", 0.9, 0.8)]),
  /** Fireball leaving a staff. */
  fireLaunch: new Pool([L("fireball_whoosh", 0.9), L("fireball_whoosh", 0.8, 0.8), L("fireball_whoosh", 0.8, 1.25), L("spell_whoosh_2", 0.7, 0.6)]),
  /** Blade and fist swings. */
  slash: new Pool([L("sword_slash_1", 0.9), L("sword_slash_2", 1), L("sword_slash_3", 1), L("sword_slash_2", 0.9, 0.8), L("sword_slash_1", 0.8, 1.2)]),
  /** Blade lands on a body. */
  slice: new Pool([L("sword_hit_flesh", 1), L("sword_hit_armor", 0.8, 1.2), L("sword_hit_flesh", 0.9, 0.8), L("punch_1", 0.8, 0.8)]),
  big: new Pool([
    L("fire_boom_1", 0.9, 0.6), L("fire_boom_2", 0.9, 0.6), L("thud_1", 1, 0.7), L("spell_impact_2", 0.7, 0.7),
    { id: "car_crash", p: 0.6, v: 0.45 }, { id: "impact_concrete", p: 0.4, v: 1 },
  ]),
  boom: new Pool([L("fire_boom_1", 1, 0.5), L("fire_boom_2", 1, 0.5), L("thud_1", 1, 0.6), { id: "monster_roar", p: 0.5, v: 0.3 }, { id: "impact_concrete", p: 0.35, v: 1 }]),
  cheer: new Pool([L("crowd_cheer_4", 0.9), L("crowd_cheer_4", 0.8, 1.12), { id: "crowd_cheer", v: 0.8 }, { id: "crowd_cheer", p: 1.1, v: 0.7 }, { id: "crowd_roar_goal", v: 0.6 }, { id: "crowd_applause", v: 0.6 }]),
  ooh: new Pool([L("crowd_gasp_1", 1), L("crowd_gasp_2", 1), L("crowd_gasp_1", 0.9, 1.1), L("crowd_gasp_2", 0.9, 0.9), { id: "crowd_ooh", v: 0.8 }]),
  scream: new Pool([L("f_die_1", 0.9, 1.15), L("f_die_1", 0.8, 0.95), { id: "ghost_wail", p: 1.3, v: 0.8 }, { id: "creature_attack", p: 1.4, v: 0.6 }, L("m_die_1", 0.8, 1.2)]),
};

/** One voice (a person): hurt cries, effort grunts for attacks, a death cry. Each unit type gets different recordings. */
interface Voice { hurt: Pool; effort: Pool; die: Pool }
const V: Record<string, Voice> = {
  // the mage: a young man, grunts and cries from three different recordings
  player: {
    hurt: new Pool([L("m_hurt_4", 1), L("m_grunt_1", 1), L("m_hurt_6", 0.9), L("m_grunt_3", 1), L("m_hurt_4", 0.9, 1.1)]),
    effort: new Pool([L("m_grunt_3", 0.8), L("m_grunt_1", 0.7, 1.1)]),
    die: new Pool([L("m_die_1", 1, 1.1)]),
  },
  // the warrior: gruff and rough
  warrior: {
    hurt: new Pool([L("m_grunt_7", 1, 0.85), L("m_grunt_2", 1, 0.9), L("m_hurt_5", 0.9, 0.85), L("m_grunt_7", 0.9, 0.75)]),
    effort: new Pool([L("m_grunt_2", 0.9, 0.85), L("m_grunt_7", 1, 0.8), L("m_hurt_5", 0.7, 0.8)]),
    die: new Pool([L("m_die_1", 1, 0.9), L("m_die_2", 1, 1)]),
  },
  // the fire mage: a woman, sharp
  pyromancer: {
    hurt: new Pool([L("f_hurt_1", 1), L("f_hurt_2", 1), L("f_grunt_5", 1), L("f_hurt_1", 0.9, 1.1)]),
    effort: new Pool([L("f_grunt_4", 1), L("f_grunt_5", 0.9, 1.1), L("f_grunt_6", 0.9)]),
    die: new Pool([L("f_die_1", 1, 1.05)]),
  },
  // the dark priest: a second woman, softer
  priestess: {
    hurt: new Pool([L("f_ouch_3", 1), L("f_grunt_6", 1), L("f_hurt_2", 0.9, 0.9), L("f_ouch_3", 0.9, 0.9)]),
    effort: new Pool([L("f_grunt_6", 0.9, 0.9), L("f_ouch_3", 0.6, 1.1)]),
    die: new Pool([L("f_die_1", 1, 0.9)]),
  },
  // the elite gladiator: deep, from another man again
  gladiator: {
    hurt: new Pool([L("m_hurt_6", 1, 0.8), L("m_grunt_2", 1, 0.75), L("m_hurt_5", 0.9, 0.72), L("m_grunt_1", 0.9, 0.78)]),
    effort: new Pool([L("m_grunt_2", 1, 0.72), L("m_hurt_6", 0.8, 0.75), L("m_grunt_1", 0.9, 0.7)]),
    die: new Pool([L("m_die_2", 1, 0.8), L("m_die_1", 1, 0.75)]),
  },
};
const voiceOf = (k: string): Voice => V[k] ?? V.warrior;

/** Voice (pitch, chance per hit) of each enemy type. */
const VOICE: Record<string, { p: number; chance: number }> = {
  warrior: { p: 1, chance: 0.3 }, pyromancer: { p: 1, chance: 0.35 }, priestess: { p: 1, chance: 0.4 }, gladiator: { p: 1, chance: 0.2 },
};
const ARMORED = new Set(["warrior", "gladiator"]);

export const BANK_IDS = [
  "impact_flesh", "impact_wood", "impact_concrete", "impact_glass", "impact_metal", "splash_small", "splash_big", "car_bump", "car_crash",
  "spray_boost", "nitro_boost", "wind_rush", "puzzle_group_complete", "checkpoint", "puzzle_error", "pickup", "ui_click",
  "player_hurt", "player_death", "people_ouch", "people_effort", "people_hey", "ghost_wail", "creature_attack", "monster_roar",
  "step_concrete", "step_concrete_run", "crowd_cheer", "crowd_roar_goal", "crowd_applause", "crowd_ooh", "crowd_boo",
  "whistle_referee", "sting_gameover", "amb_stadium",
  ...AAA_IDS,
];

export interface DamageInfo { dstKind: string; srcKind: string; school: string; crit: boolean; amount: number; absorbed: number; dot: boolean; at?: At }

export class SoundBank {
  private c: AudioContext | null = null;
  private bus!: AudioNode;
  private verb!: ConvolverNode;
  private white!: AudioBuffer;
  private brown!: AudioBuffer;
  private ends = new Float64Array(32);
  private endI = 0;
  private lastDuck = -9;
  private lastHurt = -9;
  private lastVoice = new Map<string, number>();
  private cast = new Map<string, Stoppable>();
  private whirl = new Map<string, { gain: GainNode; end: number }>();
  private orb: { panner: PannerNode; follow: Object3D; stop: (f?: number) => void } | null = null;
  private lastCrowd = -9;
  private lastStepSide = 0;

  constructor(private sfx: Sfx) {}

  /** Call once the Sfx exists: builds the limiter, the arena reverb and the noise buffers, so nothing is made mid-fight. */
  init() {
    const c = this.sfx.ctx;
    if (!c || this.c) return;
    this.c = c;
    const priv = this.sfx as unknown as { master: GainNode | null; buses: Map<string, GainNode> };
    this.bus = priv.buses.get("sfx") ?? c.destination;
    // master limiter: nothing clips, big hits glue instead of jumping out
    if (priv.master) {
      const lim = c.createDynamicsCompressor();
      lim.threshold.value = -9; lim.knee.value = 8; lim.ratio.value = 14; lim.attack.value = 0.002; lim.release.value = 0.18;
      priv.master.disconnect();
      priv.master.connect(lim);
      lim.connect(c.destination);
    }
    const sr = c.sampleRate, len = sr * 2;
    this.white = c.createBuffer(1, len, sr);
    this.brown = c.createBuffer(1, len, sr);
    const w = this.white.getChannelData(0), b = this.brown.getChannelData(0);
    let n = 0;
    for (let i = 0; i < len; i++) { w[i] = Math.random() * 2 - 1; n = (n + 0.02 * w[i]) / 1.02; b[i] = n * 3.5; }
    // arena tail: 1.1 s decaying stereo noise
    const il = Math.floor(sr * 1.1), imp = c.createBuffer(2, il, sr);
    for (let ch = 0; ch < 2; ch++) { const d = imp.getChannelData(ch); for (let i = 0; i < il; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / il, 2.6); }
    this.verb = c.createConvolver();
    this.verb.buffer = imp;
    const wet = c.createGain();
    wet.gain.value = 0.55;
    this.verb.connect(wet);
    wet.connect(this.bus);
  }

  /** Per frame: the orb hum follows the orb. */
  update() {
    const o = this.orb, c = this.c;
    if (!o || !c) return;
    const e = o.follow.matrixWorld.elements, t = c.currentTime;
    if (o.panner.positionX) { o.panner.positionX.setValueAtTime(e[12], t); o.panner.positionY.setValueAtTime(e[13], t); o.panner.positionZ.setValueAtTime(e[14], t); }
  }

  dispose() { this.orb?.stop(0); this.orb = null; }

  // ------------------------------------------------------------------ plumbing

  private get ok() { return !!this.c; }

  /** Recorded layer from a pool (or a fixed layer). */
  private lib(l: Layer, o: { v?: number; p?: number; at?: At; delay?: number } = {}) {
    this.sfx.play(l.id, {
      volume: volJ((l.v ?? 1) * (o.v ?? 1)), pitch: pitchJ((l.p ?? 1) * (o.p ?? 1)), at: o.at, delay: o.delay, jitter: { pitch: 0, volume: 0 },
    });
  }
  private libId(id: string, o: { v?: number; p?: number; at?: At; delay?: number } = {}) { this.lib({ id }, o); }

  /** Voice budget for the procedural layers (phones): at most 26 alive at once. */
  private slot(t: number, dur: number): boolean {
    let live = 0;
    for (let i = 0; i < this.ends.length; i++) if (this.ends[i] > t) live++;
    if (live >= 26) return false;
    this.ends[this.endI] = t + dur;
    this.endI = (this.endI + 1) % this.ends.length;
    return true;
  }

  /** Entry node of one procedural sound: optional 3D panner, reverb send. */
  private route(at: At | undefined, wet: number): GainNode {
    const c = this.c!;
    const g = c.createGain();
    let tail: AudioNode = g;
    if (at) {
      const p = c.createPanner();
      p.panningModel = "equalpower"; p.distanceModel = "inverse"; p.refDistance = 2.5; p.maxDistance = 80; p.rolloffFactor = 1.1;
      const t = c.currentTime;
      let x: number, y: number, z: number;
      if ("matrixWorld" in at) { const e = at.matrixWorld.elements; x = e[12]; y = e[13]; z = e[14]; } else { x = at.x; y = at.y; z = at.z; }
      if (p.positionX) { p.positionX.setValueAtTime(x, t); p.positionY.setValueAtTime(y, t); p.positionZ.setValueAtTime(z, t); }
      g.connect(p);
      tail = p;
    }
    tail.connect(this.bus);
    if (wet > 0) { const s = c.createGain(); s.gain.value = wet; g.connect(s); s.connect(this.verb); }
    return g;
  }

  private env(t: number, a: number, peak: number, d: number, hold = 0): GainNode {
    const g = this.c!.createGain(), pk = Math.max(1e-4, peak);
    g.gain.setValueAtTime(1e-4, t);
    g.gain.exponentialRampToValueAtTime(pk, t + Math.max(0.001, a));
    if (hold > 0) g.gain.setValueAtTime(pk, t + a + hold);
    g.gain.exponentialRampToValueAtTime(1e-4, t + a + hold + Math.max(0.005, d));
    return g;
  }
  private shaper(k: number): WaveShaperNode {
    const ws = this.c!.createWaveShaper(), cv = new Float32Array(256), th = Math.tanh(k);
    for (let i = 0; i < 256; i++) cv[i] = Math.tanh(k * ((i / 255) * 2 - 1)) / th;
    ws.curve = cv;
    return ws;
  }

  private tone(out: AudioNode, t: number, o: { w?: OscillatorType; f0: number; f1?: number; a?: number; peak: number; d: number; hold?: number; drive?: number; lp?: number; hp?: number; bp?: [number, number]; vib?: [number, number] }) {
    const c = this.c!, osc = c.createOscillator();
    osc.type = o.w ?? "sine";
    osc.frequency.setValueAtTime(Math.max(10, o.f0), t);
    const total = (o.a ?? 0.004) + (o.hold ?? 0) + o.d;
    if (o.f1) osc.frequency.exponentialRampToValueAtTime(Math.max(10, o.f1), t + total);
    let n: AudioNode = osc;
    if (o.drive) { const s = this.shaper(o.drive); n.connect(s); n = s; }
    if (o.bp) { const f = c.createBiquadFilter(); f.type = "bandpass"; f.frequency.value = o.bp[0]; f.Q.value = o.bp[1]; n.connect(f); n = f; }
    if (o.lp) { const f = c.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = o.lp; n.connect(f); n = f; }
    if (o.hp) { const f = c.createBiquadFilter(); f.type = "highpass"; f.frequency.value = o.hp; n.connect(f); n = f; }
    const e = this.env(t, o.a ?? 0.004, o.peak, o.d, o.hold ?? 0);
    n.connect(e); e.connect(out);
    if (o.vib) { const l = c.createOscillator(), lg = c.createGain(); l.frequency.value = o.vib[0]; lg.gain.value = o.vib[1]; l.connect(lg); lg.connect(osc.frequency); l.start(t); l.stop(t + total + 0.05); }
    osc.start(t); osc.stop(t + total + 0.05);
  }

  private noise(out: AudioNode, t: number, o: { b?: boolean; type?: BiquadFilterType; f0: number; f1?: number; q?: number; a?: number; peak: number; d: number; hold?: number; drive?: number }) {
    const c = this.c!, s = c.createBufferSource();
    s.buffer = o.b ? this.brown : this.white;
    s.loop = true;
    const total = (o.a ?? 0.003) + (o.hold ?? 0) + o.d;
    const f = c.createBiquadFilter();
    f.type = o.type ?? "bandpass"; f.Q.value = o.q ?? 0.8;
    f.frequency.setValueAtTime(Math.max(20, o.f0), t);
    if (o.f1) f.frequency.exponentialRampToValueAtTime(Math.max(20, o.f1), t + total);
    let n: AudioNode = f;
    s.connect(f);
    if (o.drive) { const w = this.shaper(o.drive); f.connect(w); n = w; }
    const e = this.env(t, o.a ?? 0.003, o.peak, o.d, o.hold ?? 0);
    n.connect(e); e.connect(out);
    s.start(t, Math.random() * 1.8); s.stop(t + total + 0.05);
  }

  // ------------------------------------------------------------------ recipes (procedural layers)

  /** Sharp ice crack: a scatter of bright ticks and a glassy ping. */
  private crack(out: AudioNode, t: number, g: number, p: number, big = false) {
    const n = (big ? 5 : 3) + Math.floor(Math.random() * 3);
    let at = 0;
    for (let i = 0; i < n; i++) {
      at += 0.004 + Math.random() * 0.02;
      this.noise(out, t + at, { type: "bandpass", f0: R(2600, 7500) * p, q: R(3, 9), a: 0.001, peak: g * (0.9 - i * 0.08), d: R(0.015, 0.05) });
    }
    for (let i = 0; i < (big ? 4 : 2); i++) this.tone(out, t + R(0.005, 0.05), { f0: R(2400, 6200) * p, peak: g * 0.16, d: R(0.15, 0.4) });
  }
  /** Low body thump. */
  private thump(out: AudioNode, t: number, f: number, g: number, d = 0.2) {
    this.tone(out, t, { f0: f * 1.7, f1: f * 0.45, peak: g, d, a: 0.003 });
    this.noise(out, t, { type: "lowpass", f0: 520, f1: 120, a: 0.002, peak: g * 0.6, d: 0.07 });
  }
  /** Deep boom with a noise tail. */
  private boom(out: AudioNode, t: number, g: number, p: number, len = 0.9) {
    this.tone(out, t, { f0: 95 * p, f1: 26, peak: g, d: len * 0.85, a: 0.004, drive: 2 });
    this.noise(out, t, { b: true, type: "lowpass", f0: 360, f1: 70, peak: g * 0.9, d: len * 0.8, a: 0.004 });
  }
  /** Rising/falling air. */
  private whoosh(out: AudioNode, t: number, f0: number, f1: number, dur: number, g: number, q = 1.1, brown = false) {
    this.noise(out, t, { b: brown, type: "bandpass", f0, f1, q, a: dur * 0.35, peak: g, d: dur * 0.65 });
  }
  /** Electric snap: a falling saw plus sparks. */
  private zap(out: AudioNode, t: number, g: number, p: number) {
    this.tone(out, t, { w: "sawtooth", f0: 2200 * p, f1: 140 * p, peak: g * 0.55, d: 0.13, drive: 5, hp: 300 });
    this.noise(out, t, { type: "bandpass", f0: 4200 * p, q: 1.5, peak: g * 0.5, d: 0.1, a: 0.001 });
    for (let i = 0; i < 4; i++) this.noise(out, t + R(0.01, 0.16), { type: "highpass", f0: 5000, peak: g * 0.35, d: 0.02, a: 0.001 });
  }
  /** Glittering shimmer: staggered high partials. */
  private shimmer(out: AudioNode, t: number, g: number, p: number, dur: number, n = 7) {
    const r = [1, 1.25, 1.5, 1.875, 2, 2.5, 3, 3.75];
    for (let i = 0; i < n; i++) this.tone(out, t + (i / n) * dur * R(0.6, 1.1), { f0: 1100 * p * r[Math.floor(Math.random() * r.length)], peak: g * R(0.05, 0.1), d: R(0.25, 0.7) });
  }
  /** Bell chime on a rising arpeggio. */
  private chime(out: AudioNode, t: number, g: number, p: number) {
    [1, 1.26, 1.5, 2].forEach((m, i) => {
      this.tone(out, t + i * 0.07, { f0: 660 * p * m, peak: g * 0.2, d: 0.7 });
      this.tone(out, t + i * 0.07, { f0: 660 * p * m * 2.76, peak: g * 0.05, d: 0.3 });
    });
  }
  /** Metal ring (inharmonic partials). */
  private clang(out: AudioNode, t: number, g: number, p: number) {
    const f = R(480, 760) * p;
    [[1, 0.5, 0.5], [2.76, 0.35, 0.34], [5.4, 0.22, 0.22], [8.93, 0.12, 0.14]].forEach(([m, a, d]) => this.tone(out, t, { f0: f * m, peak: g * a * 0.5, d }));
    this.noise(out, t, { type: "highpass", f0: 3500, peak: g * 0.4, d: 0.03, a: 0.001 });
  }
  /** Blade through air. */
  private swing(out: AudioNode, t: number, g: number, p: number) {
    this.noise(out, t, { type: "bandpass", f0: 2400 * p, f1: 520 * p, q: 1.6, a: 0.04, peak: g, d: 0.16 });
  }
  /** Fire roar: rumble, rising air, crackle. */
  private roar(out: AudioNode, t: number, g: number, p: number, dur: number) {
    this.noise(out, t, { b: true, type: "bandpass", f0: 260 * p, f1: 900 * p, q: 0.7, a: dur * 0.5, peak: g, d: dur * 0.6 });
    this.tone(out, t, { w: "sawtooth", f0: 70 * p, f1: 110 * p, peak: g * 0.35, a: dur * 0.5, d: dur * 0.6, lp: 300, drive: 2 });
    for (let i = 0; i < 6; i++) this.noise(out, t + R(0, dur), { type: "bandpass", f0: R(1500, 4500), q: 3, peak: g * 0.3, d: 0.025, a: 0.001 });
  }
  private explode(out: AudioNode, t: number, g: number, p: number) {
    this.boom(out, t, g, p, 0.8);
    this.noise(out, t, { type: "lowpass", f0: 3600, f1: 260, peak: g * 0.8, d: 0.5, a: 0.002, drive: 2 });
    for (let i = 0; i < 5; i++) this.noise(out, t + R(0.03, 0.4), { type: "bandpass", f0: R(1200, 4000), q: 3, peak: g * 0.3, d: 0.03, a: 0.001 });
  }
  /** Dark shadow whomp. */
  private whomp(out: AudioNode, t: number, g: number, p: number) {
    this.tone(out, t, { w: "sawtooth", f0: 220 * p, f1: 55, peak: g * 0.6, d: 0.45, lp: 500, drive: 3 });
    this.noise(out, t, { b: true, type: "lowpass", f0: 600, f1: 90, peak: g * 0.7, d: 0.4 });
  }

  // ------------------------------------------------------------------ events

  private go(dur: number, at: At | undefined, wet: number, fn: (out: AudioNode, t: number) => void, delay = 0) {
    const c = this.c;
    if (!c) return;
    const t = c.currentTime + delay;
    if (!this.slot(t, dur)) return;
    fn(this.route(at, wet), t);
  }

  private duck(db: number, s: number) {
    const now = this.c?.currentTime ?? 0;
    if (now - this.lastDuck < 0.35) return;
    this.lastDuck = now;
    this.sfx.duck("music", db, s, 0.5);
  }

  /** The player's spell starts its wind-up (frost hum, arcane wobble) or an enemy starts a cast. */
  castStart(unit: string, id: string, school: string, total: number, at?: At) {
    if (!this.ok) return;
    this.cast.get(unit)?.stop(0.05);
    const dur = Math.max(0.3, total);
    if (school === "physical") {
      if (id === "charge") this.lib(voiceOf(unit.split("-")[0]).effort.next(), { at, p: 0.9, v: 1 });
      else { this.lib(voiceOf(unit.split("-")[0]).effort.next(), { at, v: 0.8 }); this.lib(P.slash.next(), { at, v: 0.7, delay: 0.04 }); }
      return;
    }
    if (school !== "holy" && school !== "shadow") this.lib(P.cast.next(), { at, v: 0.55 });
    const c = this.c!, t = c.currentTime;
    if (!this.slot(t, dur + 0.3)) return;
    const out = this.route(at, 0.25);
    const stopNodes: AudioScheduledSourceNode[] = [];
    const g = c.createGain();
    g.gain.setValueAtTime(1e-4, t);
    g.gain.exponentialRampToValueAtTime(0.5, t + dur * 0.8);
    g.connect(out);
    const add = (o: OscillatorNode | AudioBufferSourceNode) => { o.start(t); stopNodes.push(o); };
    const osc = (w: OscillatorType, f0: number, f1: number, gain: number, lp?: number) => {
      const o = c.createOscillator(), og = c.createGain();
      o.type = w; o.frequency.setValueAtTime(f0, t); o.frequency.exponentialRampToValueAtTime(f1, t + dur);
      og.gain.value = gain;
      let n: AudioNode = o;
      if (lp) { const f = c.createBiquadFilter(); f.type = "lowpass"; f.frequency.value = lp; n.connect(f); n = f; }
      n.connect(og); og.connect(g);
      add(o);
    };
    const hiss = (f0: number, f1: number, q: number, gain: number) => {
      const s = c.createBufferSource(), f = c.createBiquadFilter(), sg = c.createGain();
      s.buffer = this.white; s.loop = true; f.type = "bandpass"; f.Q.value = q;
      f.frequency.setValueAtTime(f0, t); f.frequency.exponentialRampToValueAtTime(f1, t + dur);
      sg.gain.value = gain;
      s.connect(f); f.connect(sg); sg.connect(g);
      s.start(t, Math.random() * 1.5); stopNodes.push(s);
    };
    if (school === "frost") { osc("sine", 280, 620, 0.35); osc("triangle", 560, 1240, 0.12); hiss(3000, 7000, 4, 0.06); }
    else if (school === "arcane") { osc("triangle", 330, 700, 0.3); osc("sine", 332, 705, 0.25); hiss(2000, 5000, 3, 0.05); }
    else if (school === "fire") { hiss(200, 900, 0.7, 0.35); osc("sawtooth", 70, 120, 0.2, 300); }
    else if (school === "shadow") { osc("sawtooth", 160, 70, 0.22, 380); hiss(300, 120, 1, 0.12); this.libId("ghost_wail", { at, v: 0.45, p: 0.8 }); }
    else { osc("sine", 520, 800, 0.2); osc("sine", 783, 1200, 0.1); } // holy
    // crackle ticks while it builds
    const n = Math.round(dur * 6);
    for (let i = 0; i < n && school !== "holy"; i++) this.noise(out, t + R(0, dur), { type: "highpass", f0: R(4500, 8000), peak: R(0.04, 0.12), d: 0.018, a: 0.001 });
    this.cast.set(unit, {
      stop: (fade = 0.08) => {
        const now = c.currentTime;
        g.gain.cancelScheduledValues(now);
        g.gain.setValueAtTime(Math.max(1e-4, g.gain.value), now);
        g.gain.exponentialRampToValueAtTime(1e-4, now + fade);
        for (const s of stopNodes) { try { s.stop(now + fade + 0.02); } catch { /* ended */ } }
      },
    });
    for (const s of stopNodes) { try { s.stop(t + dur + 0.25); } catch { /* ok */ } }
  }
  castEnd(unit: string, ok: boolean) {
    const s = this.cast.get(unit);
    if (!s) return;
    this.cast.delete(unit);
    s.stop(ok ? 0.12 : 0.05);
  }

  /** The player's spell leaves the staff. */
  release(id: string, at?: At) {
    if (!this.ok) return;
    switch (id) {
      case "frostbolt":
        this.go(0.7, at, 0.3, (o, t) => { this.whoosh(o, t, 900, 3600, 0.3, 0.45); this.zap(o, t, 0.28, 1.4); this.shimmer(o, t, 0.9, 1.2, 0.3, 5); });
        this.libId("spray_boost", { p: 1.6, v: 0.45 });
        break;
      case "icelance":
        this.go(0.5, at, 0.25, (o, t) => { this.whoosh(o, t, 1600, 6500, 0.18, 0.5); this.zap(o, t, 0.4, 1.9); this.crack(o, t + 0.02, 0.4, 1.3); });
        this.libId("nitro_boost", { p: 1.9, v: 0.3 });
        break;
      case "nova":
        this.go(1.1, at, 0.45, (o, t) => { this.boom(o, t, 0.8, 1.1, 0.7); this.crack(o, t, 0.8, 0.9, true); this.crack(o, t + 0.06, 0.6, 1.2, true); this.shimmer(o, t + 0.05, 1.2, 1, 0.7, 9); this.whoosh(o, t, 4000, 600, 0.5, 0.4); });
        this.lib(P.ice.next(), { v: 1.1 }); this.lib(P.ice.next(), { delay: 0.05, p: 0.8 });
        this.duck(-4, 0.5);
        break;
      case "polymorph":
        this.go(0.9, at, 0.35, (o, t) => { this.tone(o, t, { f0: 400, f1: 1800, peak: 0.25, d: 0.5, vib: [18, 120] }); this.shimmer(o, t, 1, 1.4, 0.5, 8); this.zap(o, t + 0.3, 0.3, 1.2); });
        this.libId("puzzle_group_complete", { v: 0.6, p: 1.2 });
        break;
      case "counterspell":
        this.go(0.5, at, 0.3, (o, t) => { this.zap(o, t, 0.75, 1.5); this.clang(o, t, 0.5, 1.8); this.tone(o, t, { f0: 1500, f1: 300, peak: 0.3, d: 0.15, w: "square", lp: 3000 }); });
        break;
      case "blink":
        this.go(0.7, at, 0.3, (o, t) => { this.whoosh(o, t, 300, 5000, 0.18, 0.7, 1.4); this.whoosh(o, t + 0.17, 4800, 400, 0.4, 0.5, 1.2); this.shimmer(o, t + 0.1, 0.9, 1.5, 0.35, 6); });
        this.libId("nitro_boost", { p: 1.5, v: 0.45 });
        break;
      case "barrier":
        this.go(1.4, at, 0.5, (o, t) => { this.shimmer(o, t, 1.4, 0.9, 0.9, 12); this.crack(o, t, 0.4, 1.1); this.tone(o, t, { f0: 220, f1: 330, peak: 0.2, a: 0.15, d: 0.8 }); });
        this.lib(P.ice.next(), { v: 0.6, p: 1.3 });
        this.libId("checkpoint", { v: 0.4, p: 1.3 });
        break;
      case "iceblock":
        this.go(1.2, at, 0.4, (o, t) => { this.crack(o, t, 0.9, 0.7, true); this.crack(o, t + 0.1, 0.7, 0.9, true); this.boom(o, t, 0.8, 0.8, 0.8); this.noise(o, t + 0.05, { type: "bandpass", f0: 1800, f1: 300, q: 1, peak: 0.4, d: 0.5 }); });
        this.lib(P.ice.next(), { v: 1, p: 0.7 }); this.lib(P.body.next(), { v: 0.5, p: 0.7, delay: 0.06 });
        this.duck(-4, 0.5);
        break;
      case "coldsnap":
        this.go(1.1, at, 0.4, (o, t) => { this.shimmer(o, t, 1.4, 1.3, 0.8, 12); this.zap(o, t, 0.4, 1.6); this.whoosh(o, t, 500, 5000, 0.5, 0.35); });
        this.libId("puzzle_group_complete", { p: 1.5, v: 0.6 });
        break;
      case "orb":
        this.go(1, at, 0.4, (o, t) => { this.boom(o, t, 0.5, 1.4, 0.5); this.whoosh(o, t, 400, 3000, 0.5, 0.5); this.shimmer(o, t, 1.2, 0.8, 0.6, 8); });
        break;
    }
  }

  /** Frozen orb: a humming loop that follows the orb. Returns a handle to stop it. */
  orbLoop(follow: Object3D): Stoppable | null {
    const c = this.c;
    if (!c) return null;
    this.orb?.stop(0.1);
    const g = c.createGain(), p = c.createPanner();
    p.panningModel = "equalpower"; p.distanceModel = "inverse"; p.refDistance = 4; p.maxDistance = 80; p.rolloffFactor = 0.9;
    const t = c.currentTime;
    g.gain.setValueAtTime(1e-4, t);
    g.gain.exponentialRampToValueAtTime(0.5, t + 0.25);
    g.connect(p); p.connect(this.bus);
    const srcs: AudioScheduledSourceNode[] = [];
    const lp = c.createBiquadFilter();
    lp.type = "lowpass"; lp.frequency.value = 900; lp.connect(g);
    for (const [w, f, dt] of [["sawtooth", 98, 0], ["sawtooth", 98.9, 0], ["triangle", 196.5, 0]] as [OscillatorType, number, number][]) {
      const o = c.createOscillator(), og = c.createGain();
      o.type = w; o.frequency.value = f; og.gain.value = w === "triangle" ? 0.22 : 0.16; o.detune.value = dt;
      o.connect(og); og.connect(lp); o.start(t); srcs.push(o);
    }
    // crystalline overtone with a slow tremolo + airy breath
    const hi = c.createOscillator(), hg = c.createGain(), lfo = c.createOscillator(), lg = c.createGain();
    hi.type = "sine"; hi.frequency.value = 1568; hg.gain.value = 0.05; lfo.frequency.value = 7; lg.gain.value = 0.04;
    lfo.connect(lg); lg.connect(hg.gain); hi.connect(hg); hg.connect(g); hi.start(t); lfo.start(t); srcs.push(hi, lfo);
    const br = c.createBufferSource(), bf = c.createBiquadFilter(), bg = c.createGain();
    br.buffer = this.white; br.loop = true; bf.type = "bandpass"; bf.frequency.value = 3200; bf.Q.value = 2; bg.gain.value = 0.05;
    br.connect(bf); bf.connect(bg); bg.connect(g); br.start(t); srcs.push(br);
    const e = follow.matrixWorld.elements;
    if (p.positionX) { p.positionX.setValueAtTime(e[12], t); p.positionY.setValueAtTime(e[13], t); p.positionZ.setValueAtTime(e[14], t); }
    const stop = (fade = 0.3) => {
      const now = c.currentTime;
      g.gain.cancelScheduledValues(now);
      g.gain.setValueAtTime(Math.max(1e-4, g.gain.value), now);
      g.gain.exponentialRampToValueAtTime(1e-4, now + Math.max(0.02, fade));
      for (const s of srcs) { try { s.stop(now + fade + 0.05); } catch { /* ended */ } }
      if (this.orb && this.orb.stop === stop) this.orb = null;
    };
    this.orb = { panner: p, follow, stop };
    return { stop };
  }

  /** A projectile leaves an enemy staff. */
  enemyProj(kind: string, at?: At) {
    if (!this.ok) return;
    if (kind === "fireball") {
      this.go(1, at, 0.35, (o, t) => this.roar(o, t, 0.6, 1, 0.55));
      this.lib(P.fireLaunch.next(), { at, v: 0.9 });
    } else if (kind === "shadowbolt") {
      this.go(0.8, at, 0.4, (o, t) => { this.whomp(o, t, 0.5, 1); this.whoosh(o, t, 500, 150, 0.4, 0.4, 1, true); });
      this.libId("ghost_wail", { p: 0.7, v: 0.4, at });
    }
  }

  /** The spell lands. Frost = crystalline burst, fire = explosion, shadow = dark whomp. */
  projHit(kind: string, at: At | undefined, crit: boolean) {
    if (!this.ok) return;
    if (kind === "frostbolt" || kind === "icelance") {
      this.go(0.6, at, 0.3, (o, t) => { this.crack(o, t, 0.7, kind === "icelance" ? 1.3 : 1, crit); this.shimmer(o, t, 0.8, 1.2, 0.3, 5); });
      this.lib(P.ice.next(), { at, v: kind === "icelance" ? 0.9 : 0.75 });
      if (crit || kind === "icelance") this.lib(P.ice.next(), { at, v: 0.6, p: 1.15, delay: 0.03 });
    } else if (kind === "fireball") {
      this.go(1.1, at, 0.45, (o, t) => this.explode(o, t, 0.85, 1));
      this.lib(P.fire.next(), { at }); this.lib(P.fire.next(), { at, delay: 0.04, v: 0.7 });
      this.duck(-4, 0.5);
    } else {
      this.go(0.8, at, 0.4, (o, t) => this.whomp(o, t, 0.8, 1));
      this.lib(P.zap.next(), { at, p: 0.7, v: 0.8 });
      this.lib(P.body.next(), { at, p: 0.7 });
    }
  }
  projBlock(at?: At) {
    if (!this.ok) return;
    this.go(0.5, at, 0.4, (o, t) => { this.clang(o, t, 0.6, 0.6); this.crack(o, t, 0.4, 0.7); });
    this.lib(P.metal.next(), { at, v: 0.6, p: 0.7 });
  }

  /** Damage landed: body + material + sweetener, bigger on crits (frost crit = shatter). Voices are rare. */
  damage(d: DamageInfo) {
    if (!this.ok || d.dot) return;
    const toPlayer = d.dstKind === "player";
    const now = this.c!.currentTime;
    const big = d.crit || d.amount >= (toPlayer ? 120 : 140);
    if (d.amount <= 0) {
      // absorbed by the barrier: a short icy ping
      if (d.absorbed > 0) { this.go(0.4, d.at, 0.3, (o, t) => { this.shimmer(o, t, 1, 1.4, 0.15, 4); this.crack(o, t, 0.3, 1.5); }); this.lib(P.ice.next(), { at: d.at, v: 0.35, p: 1.4 }); }
      return;
    }
    const at = toPlayer ? undefined : d.at;
    // 1. body
    this.lib(P.body.next(), { at, v: big ? 1.1 : 0.85 });
    // 2. material by school / attacker
    let dur = 0.5;
    switch (d.school) {
      case "frost":
        this.lib(P.ice.next(), { at, v: big ? 1 : 0.75 });
        this.go(dur, at, 0.3, (o, t) => { this.thump(o, t, 90, 0.5); this.crack(o, t, big ? 0.9 : 0.55, R(0.9, 1.3), big); });
        break;
      case "fire":
        this.lib(P.fire.next(), { at, v: 0.7 });
        this.go(dur, at, 0.3, (o, t) => { this.thump(o, t, 70, 0.6); this.noise(o, t, { type: "bandpass", f0: 1800, f1: 400, q: 1, peak: 0.4, d: 0.25 }); });
        break;
      case "shadow":
        this.lib(P.body.next(), { at, v: 0.5, p: 0.6 });
        this.go(0.6, at, 0.4, (o, t) => this.whomp(o, t, 0.55, 1));
        break;
      case "physical":
        if (ARMORED.has(d.srcKind)) { this.lib(P.metal.next(), { at, v: big ? 1 : 0.8 }); this.go(0.6, at, 0.35, (o, t) => { this.clang(o, t, big ? 0.9 : 0.6, R(0.8, 1.2)); this.thump(o, t, 80, 0.6); }); }
        else { this.lib(P.slice.next(), { at, v: big ? 1 : 0.8 }); this.go(dur, at, 0.2, (o, t) => this.thump(o, t, 100, 0.7)); }
        break;
      default: // arcane, holy
        this.lib(P.zap.next(), { at, v: big ? 1 : 0.75 });
        this.go(dur, at, 0.3, (o, t) => { this.zap(o, t, 0.5, 1.1); this.thump(o, t, 95, 0.45); });
    }
    // 3. sweetener: a short sparkle on ordinary hits
    if (!big && Math.random() < 0.6) this.go(0.3, at, 0.3, (o, t) => this.shimmer(o, t + 0.01, 0.5, R(1, 1.6), 0.12, 3));
    // crits and shatters: a bigger layered hit plus a low boom
    if (big) {
      this.go(1, at, 0.4, (o, t) => { this.boom(o, t, d.crit ? 0.8 : 0.55, R(0.9, 1.15), 0.7); if (d.school === "frost") { this.crack(o, t + 0.02, 0.9, 0.8, true); this.shimmer(o, t + 0.04, 1.2, 1, 0.5, 8); } });
      this.lib(P.big.next(), { at, v: 0.5 });
      this.duck(-5, 0.45);
      if (now - this.lastCrowd > 1.5 && !toPlayer) { this.lastCrowd = now; this.lib(P.ooh.next(), { v: 0.7, delay: 0.1 }); }
    }
    // 4. hurt voice: rare, a different grunt each time, per-unit and global cooldown
    this.voice(d, big, now);
  }

  private voice(d: DamageInfo, big: boolean, now: number) {
    const toPlayer = d.dstKind === "player";
    if (toPlayer) {
      if (now - this.lastHurt < 0.9) return;
      if (!big && Math.random() > 0.35) return;
      this.lastHurt = now;
      const l = V.player.hurt.next();
      this.lib(l, { v: big ? 0.95 : 0.7, p: big ? 0.92 : 1, delay: 0.03 });
      return;
    }
    const vc = VOICE[d.dstKind];
    if (!vc) return;
    const last = this.lastVoice.get(d.dstKind) ?? -9;
    if (now - last < 1.3 || Math.random() > (big ? 0.7 : vc.chance)) return;
    this.lastVoice.set(d.dstKind, now);
    this.lib(voiceOf(d.dstKind).hurt.next(), { at: d.at, p: vc.p * R(0.95, 1.06), v: big ? 1 : 0.8, delay: 0.04 });
  }

  heal(at?: At, enemy = false) {
    if (!this.ok) return;
    this.go(1, at, 0.45, (o, t) => this.chime(o, t, enemy ? 0.8 : 1, enemy ? 0.8 : 1));
    this.libId("pickup", { at, v: 0.3, p: enemy ? 0.9 : 1.2 });
  }

  /** Crowd control lands. */
  cc(cat: string, at?: At) {
    if (!this.ok) return;
    switch (cat) {
      case "root": this.go(0.9, at, 0.35, (o, t) => { this.crack(o, t, 0.8, 0.8, true); this.tone(o, t, { f0: 180, f1: 60, peak: 0.4, d: 0.3 }); }); this.lib(P.ice.next(), { at, v: 0.8, p: 0.85 }); break;
      case "stun": this.go(0.8, at, 0.35, (o, t) => { this.boom(o, t, 0.6, 1.2, 0.4); this.clang(o, t, 0.5, 1.5); }); this.lib(P.metal.next(), { at, v: 0.7 }); break;
      case "silence": this.go(0.5, at, 0.3, (o, t) => this.zap(o, t, 0.5, 0.9)); this.lib(P.zap.next(), { at, v: 0.8 }); break;
      case "disorient": this.lib(P.scream.next(), { at, v: 0.8 }); break;
      default: break; // polymorph has its own sound
    }
  }
  polyOn(at?: At) {
    if (!this.ok) return;
    this.go(0.8, at, 0.4, (o, t) => { this.tone(o, t, { f0: 1800, f1: 300, peak: 0.25, d: 0.4, vib: [24, 200] }); this.shimmer(o, t, 1, 1.6, 0.4, 8); });
    this.libId("puzzle_group_complete", { at, v: 0.5, p: 1.7 });
  }
  polyBreak(at?: At) {
    if (!this.ok) return;
    this.go(0.4, at, 0.3, (o, t) => { this.crack(o, t, 0.6, 1.4); this.tone(o, t, { f0: 900, f1: 200, peak: 0.25, d: 0.15, w: "triangle" }); });
  }
  interrupt(at?: At) {
    if (!this.ok) return;
    this.go(0.6, at, 0.35, (o, t) => { this.zap(o, t, 0.8, 1.6); this.clang(o, t, 0.5, 2); this.crack(o, t, 0.5, 1.4); });
    this.lib(P.metal.next(), { at, v: 0.7, p: 1.3 });
    this.lib(P.zap.next(), { at, v: 0.9, p: 1.1 });
    this.lib(P.ooh.next(), { v: 0.8, delay: 0.08 });
    this.duck(-4, 0.4);
  }
  immune(at?: At) {
    if (!this.ok) return;
    this.go(0.4, at, 0.3, (o, t) => this.clang(o, t, 0.4, 1.7));
  }

  // enemy actions ----------------------------------------------------
  enemyAct(act: string, kind: string, at?: At) {
    if (!this.ok) return;
    switch (act) {
      case "windup": case "swing": case "pummel": case "throat":
        this.go(0.4, at, 0.25, (o, t) => this.swing(o, t, 0.7, kind === "gladiator" ? 0.7 : 1));
        this.lib(P.slash.next(), { at, v: 0.8, delay: 0.05 });
        this.lib(voiceOf(kind).effort.next(), { at, v: 0.85 });
        if (act !== "windup") this.go(0.6, at, 0.3, (o, t) => this.clang(o, t, 0.5, 0.8), 0.12);
        break;
      case "chargeTell":
        this.lib(voiceOf(kind).effort.next(), { at, p: 0.85, v: 1 });
        this.go(0.7, at, 0.3, (o, t) => this.whoosh(o, t, 200, 1800, 0.5, 0.5));
        break;
      case "fireblast":
        this.go(0.9, at, 0.4, (o, t) => this.explode(o, t, 0.6, 1.2));
        this.lib(P.fire.next(), { at, v: 0.6 });
        break;
      case "pain":
        this.go(0.7, at, 0.4, (o, t) => this.whomp(o, t, 0.5, 1.3));
        break;
    }
  }
  scream(at?: At) {
    if (!this.ok) return;
    this.lib(P.scream.next(), { at, v: 1 });
    this.go(1.2, at, 0.5, (o, t) => { this.tone(o, t, { w: "sawtooth", f0: 1100, f1: 500, peak: 0.25, d: 0.8, bp: [1400, 1.2], vib: [14, 60] }); this.whoosh(o, t, 600, 3000, 0.7, 0.3); });
  }
  /** Whirlwind: a looping air-slash that lives as long as the ticks keep coming. */
  whirlTick(unit: string, at?: At) {
    const c = this.c;
    if (!c) return;
    const now = c.currentTime;
    let w = this.whirl.get(unit);
    if (!w || w.end < now) {
      if (!this.slot(now, 3)) return;
      const out = this.route(at, 0.3), g = c.createGain();
      g.gain.setValueAtTime(1e-4, now);
      g.gain.exponentialRampToValueAtTime(0.7, now + 0.12);
      g.connect(out);
      const s = c.createBufferSource(), f = c.createBiquadFilter(), l = c.createOscillator(), lg = c.createGain();
      s.buffer = this.white; s.loop = true; f.type = "bandpass"; f.frequency.value = 1100; f.Q.value = 1.4;
      l.frequency.value = 5.5; lg.gain.value = 450; l.connect(lg); lg.connect(f.frequency);
      s.connect(f); f.connect(g);
      s.start(now, Math.random()); l.start(now);
      w = { gain: g, end: now + 0.7 };
      this.whirl.set(unit, w);
      const stopAt = now + 3.2;
      s.stop(stopAt); l.stop(stopAt);
      this.lib(voiceOf(unit.split("-")[0]).effort.next(), { at, v: 0.9 });
    }
    // each tick extends the loop by 0.7 s; when ticks stop it fades
    w.end = now + 0.7;
    w.gain.gain.cancelScheduledValues(now);
    w.gain.gain.setValueAtTime(Math.max(0.3, w.gain.gain.value), now);
    w.gain.gain.linearRampToValueAtTime(0.7, now + 0.05);
    w.gain.gain.setValueAtTime(0.7, now + 0.5);
    w.gain.gain.exponentialRampToValueAtTime(1e-4, now + 0.7);
    this.go(0.3, at, 0.2, (o, t) => this.clang(o, t, 0.3, R(1, 1.4)));
  }
  chargeHit(at?: At) {
    if (!this.ok) return;
    this.go(1, at, 0.4, (o, t) => { this.boom(o, t, 0.9, 1, 0.6); this.noise(o, t, { type: "lowpass", f0: 1500, f1: 200, peak: 0.6, d: 0.3 }); });
    this.lib(P.big.next(), { v: 0.8 }); this.lib(P.body.next(), { v: 1, p: 0.6 });
    this.duck(-5, 0.5);
  }
  shockwave(at?: At) {
    if (!this.ok) return;
    this.go(1.4, at, 0.5, (o, t) => { this.boom(o, t, 1, 0.8, 1); this.whoosh(o, t, 3000, 200, 0.6, 0.5); });
    this.lib(P.boom.next(), { v: 0.9 });
    this.duck(-6, 0.7);
  }
  enemyBlink(at?: At) {
    if (!this.ok) return;
    this.go(0.6, at, 0.35, (o, t) => { this.whoosh(o, t, 300, 4000, 0.2, 0.5); this.roar(o, t + 0.1, 0.3, 1.3, 0.3); });
  }

  // arena moments ------------------------------------------------------
  kill(kind: string, at?: At) {
    if (!this.ok) return;
    this.go(1.2, at, 0.5, (o, t) => this.boom(o, t, 0.5, 0.9, 0.9));
    this.lib(voiceOf(kind).die.next(), { at, v: 1 });
    this.lib(P.cheer.next(), { v: 1, delay: 0.15 });
    this.sfx.duck("ambience", 3, 2.2);
    this.duck(-3, 0.8);
  }
  crowdCheer() { this.lib(P.cheer.next(), { v: 0.7 }); this.sfx.duck("ambience", 2, 1.8); }
  whistle(boss: boolean) {
    this.libId("whistle_referee");
    this.go(1.4, undefined, 0.5, (o, t) => this.boom(o, t, 0.5, 0.8, 1), 0.05);
    this.lib({ id: "crowd_roar_goal", v: 0.8 }, { delay: 0.2 });
    this.sfx.duck("ambience", 3, 2);
    if (boss) { this.libId("monster_roar", { delay: 0.5, p: 0.8 }); this.go(1.5, undefined, 0.6, (o, t) => this.boom(o, t, 0.9, 0.7, 1.3), 0.5); }
  }
  roundEnd(won: boolean) {
    if (!this.ok) return;
    if (won) { this.libId("crowd_applause"); this.lib(P.cheer.next(), { delay: 0.2 }); this.go(1.2, undefined, 0.5, (o, t) => this.chime(o, t, 0.8, 0.8)); this.sfx.duck("ambience", 3, 3); }
    else { this.libId("crowd_boo"); this.libId("sting_gameover", { delay: 0.6 }); this.go(1.8, undefined, 0.6, (o, t) => this.boom(o, t, 0.8, 0.6, 1.5)); }
  }
  playerDeath() {
    if (!this.ok) return;
    this.libId("player_death", { v: 1 });
    this.go(1.5, undefined, 0.5, (o, t) => this.boom(o, t, 0.7, 0.7, 1.2));
    this.duck(-6, 1);
  }
  fail() { if (this.ok) this.go(0.3, undefined, 0.1, (o, t) => this.tone(o, t, { w: "triangle", f0: 220, f1: 150, peak: 0.25, d: 0.18 })); }
  coin(at?: At) {
    if (!this.ok) return;
    this.go(0.8, at, 0.3, (o, t) => this.chime(o, t, 0.7, R(1.3, 1.7)));
    this.libId("pickup", { v: 0.7 });
  }
  ui(kind: "click" | "confirm" = "click") {
    this.libId("ui_click", { v: kind === "click" ? 0.6 : 0.8, p: kind === "click" ? 1 : 1.2 });
    if (this.ok && kind === "confirm") this.go(0.5, undefined, 0.2, (o, t) => this.chime(o, t, 0.5, 1.2));
  }

  /** Footsteps on sand: a soft grit shuffle under a muffled thud, alternating feet. */
  step(run: boolean) {
    if (!this.ok) return;
    this.lastStepSide ^= 1;
    const side = this.lastStepSide ? 1 : 0.88;
    this.go(0.25, undefined, 0.08, (o, t) => {
      this.noise(o, t, { type: "bandpass", f0: R(1100, 2200) * side, f1: 500, q: 0.7, a: 0.008, peak: run ? 0.5 : 0.35, d: R(0.07, 0.13) });
      this.noise(o, t, { b: true, type: "lowpass", f0: 260, peak: run ? 0.5 : 0.35, d: 0.08, a: 0.002 });
      for (let i = 0; i < 2; i++) this.noise(o, t + R(0.01, 0.07), { type: "highpass", f0: 4500, peak: 0.07, d: 0.015, a: 0.001 });
    });
    if (Math.random() < 0.5) this.libId(run ? "step_concrete_run" : "step_concrete", { v: 0.18, p: R(0.55, 0.7) });
  }
}
