# Sopra — Implementation Plan

> Object-oriented, FMOD/Wwise-style sound engine for **meep**, built on the WebAudio API.
> Status: implemented through **P5** (P0–P4 build-out + P5 spatial scaling to 100k emitters). Not yet
> wired into the production engine config — the legacy `SoundEmitter` path still ships; the native
> `AudioEmitter`/`AudioEmitterSystem` is fully built and tested but registered only in tests, awaiting
> the cut-over (register in `makeMirEngineConfig` + `AudioEventTrigger` + serialization wiring; see §9).

This plan was produced from a full read of the current sound engine + a deep survey of the
surrounding meep architecture (ECS core, asset system, serialization, precedents, game-side
coupling) and an FMOD/Wwise ⇄ WebAudio feasibility study. Every "verified" claim below was
checked against source.

**Source layout** (`engine/sound/sopra/`): `SopraEngine.js` (orchestrator) at the root;
`definition/` = authored, immutable data (`EventDescription`, `BusDefinition`, `ParameterDefinition`,
`SopraPanningModel`, `VoiceStealMode`, plus `clip/` and `effect/` families); `runtime/` = transient
runtime + services (`EventInstance`, `Voice`, `VoicePool`, `BusGraph`, `VoiceManager`,
`ParameterStore`, `SopraPlaybackContext`); `asset/` = buffer providers; `serialization/` = `sopraJSON`
+ registry (binary adapters stay co-located with their classes); `util/` = `buildAttenuationCurve`,
`fadeOutAndStop`, `MockAudioContext`; `legacy/` = the SoundEmitter→sopra translator. Specs sit next to
the code they cover.

---

## 0. Status

- **P0 — Core skeleton + ser/de + bus inserts (no ECS): ✅ DONE** (26 tests green via
  `npx jest --config jest.conf.json engine/sound/sopra`).
  Delivered under `sopra/`: the finished clip hierarchy (`AbstractAudioClip`, `ContainerAudioClip`,
  `SampleAudioClip`, `SilenceAudioClip`, `SequenceContainerAudioClip`, `RandomContainerAudioClip`),
  `EventDescription`, `BusDefinition`, `ParameterDefinition` (3D attenuation reuses the existing
  `AnimationCurve`), the effect hierarchy
  (`AbstractAudioEffect`, `EqEffect`, `CompressorEffect`), runtime (`SopraEngine`, `BusGraph`,
  `Voice`, `VoicePool`, `EventInstance`, `ParameterStore`), `BufferProvider` +
  `AssetManagerBufferProvider`/`StubBufferProvider`, the click-safe `fadeOutAndStop` (with the
  Firefox fallback, D1), polymorphic binary adapters (co-located) + JSON dispatchers (`sopraJSON`) +
  `populateSopraSerializationRegistry`, and a `MockAudioContext` test double. `volume2dB`/`dB2Volume`
  are now exported. Specs: `sopraSerialization.spec.js`, `SopraEngine.spec.js`,
  `fadeOutAndStop.spec.js`.
  Deferred to **P3** (integration, not P0): registering the adapters in
  `GameBinarySerializationRegistry` and constructing `SopraEngine` in `makeMirEngineConfig`. P0 keeps
  the legacy engine fully untouched.
- **P1 — Timeline playback + containers + parameters: ✅ DONE** (sopra suite now 35 tests green).
  Delivered: a polymorphic `AbstractAudioClip.planTimeline(env, gainDb, pitchCents, offset)` on each
  clip (deterministic flatten of the clip graph to timed leaf plays, gain/pitch inheritance resolved
  once; no `instanceof` switching) + `collectSampleClips`, `SopraPlaybackContext` (seeded RNG + random-container
  avoid-repeat history — network-deterministic per D5), Sequence/Random/Silence playback through
  `EventInstance`, per-trigger pitch/gain randomization on `SampleAudioClip`, and parameter→bus-volume
  automation via the reused `AnimationCurve`.
  NOTE: the pumping **lookahead Scheduler** moved to **P2**, where virtualization actually needs it
  (re-spawning virtual voices in-phase). P1 schedules a *finite* timeline eagerly with WebAudio's own
  sample-accurate future `start(when)` — correct and simpler for bounded events.
- **P2 — Voice management + spatialization + cursor scheduling: ✅ DONE** (sopra suite now 43 tests
  green). Cursor-based playback (each frame `EventInstance.update(now, listener)` evaluates the
  timeline against the playhead, spawning leaves within a lookahead window only while audible);
  **virtualization** — out-of-range instances stop their voices, keep the cursor, and revive at the
  correct child + buffer-offset (D3); **3D spatialization** — per-instance `attenuationGain → panner →
  bus` chain (D4), attenuation = `curve.evaluate(distance)`, with `buildAttenuationCurve` reproducing
  the legacy falloff from `interpolate_irradiance_*`; HRTF/EqualPower panner choice; and a
  **VoiceManager** (per-event `maxInstances` + steal oldest/quietest/none; defaults a no-op).
  NOTES: the lookahead scheduler is realised as the per-instance cursor evaluation (a global priority
  queue is a P4 optimization if profiling warrants); StereoPanner / "no-panner" modes deferred to P4.
- **P3 — ECS binding / strangler cut-over: P3.1–P3.6 DONE (P3.7 cleanup remains).** `SoundEngine` owns
  the sopra engine via `createSopra(bufferProvider)`; `SoundEmitterSystem(assetManager, soundEngine)`
  (required 2-arg ctor — no switch, no `sopra === null` branch) creates it through an
  `AssetManagerBufferProvider`, reuses the single `SoundAssetLoader` (D7), and drives `sopra.setListener`
  + `sopra.update` from its tick. **sopra is now the single renderer:** every `SoundEmitter` routes
  through a per-entity `SoundEmitterComponentContext` translation record (legacy tracks → sopra
  `EventInstance`s; live `emitter.volume × track.volume` → instance gain; shared `transform.position`
  Vector3; live `track.volume` sets also plumb to the instance gain via `__soundRuntime`). The locked
  public API (`SoundEmitter`: tracks/channel/volume/flags/distanceMin/Max; `SoundTrack`:
  on.ended/flags/volume/time/url — `duration` was dropped, never written) is preserved as
  deprecate-and-plumb; the `nodes` getters + `setVolumeOverTime` throw, while
  `distanceRolloff`/`buildNodes`/`endTrack` and the rest of the non-contract surface were removed
  outright. Channel volume = sopra bus volume (`get/setChannelVolume` proxy; legacy mix intact).
  `CombatEndMusicProcess.switchMusic` now crossfades via `fadeOutAllTracks(1)` + `tracks.add`. All 10
  `new SoundEmitterSystem(...)` call sites updated; the dead `SoundEmitterChannel` (singular) +
  `SoundTrackNodes` classes are deleted; full `engine/sound` suite green (62 tests).
  STILL PENDING (P3.7): single listener-feeder convergence and the remaining `@deprecated` JSDoc sweep.
- **P4 — build-out: STARTED.** Switch + Blend parameter-driven containers DONE
  (`SwitchContainerAudioClip` = discrete RTPC-keyed single-child pick; `BlendContainerAudioClip` =
  per-child parameter→gain curves, all audible layers play simultaneously). Parameter access threaded
  into the planTimeline `env` (`getParameter`, read once at trigger). Both fully serialized (JSON +
  binary + registry) and folded the dormant `material/` concepts (surface-switch / composition) into
  general primitives. v1 Blend is a **trigger-time snapshot**, not live re-blend as the parameter
  sweeps (follow-up below). **Reverb sends DONE:** `BusDefinition.sends` is now wired at build time
  (post-fader copy → send gain → target bus input), and `ReverbEffect` (`ConvolverNode` + a
  procedurally-generated decaying-noise IR — no IR asset, synchronous build) is a new bus insert.
  **Mixer snapshots DONE:** `MixerSnapshot` (named per-bus target gains) + `SopraEngine.applySnapshot`
  (instant or click-safe ramped blend, via `BusGraph.rampVolume`) + `captureSnapshot`. **Emulated
  ducking DONE:** `DuckingRule` + `SopraEngine.addDucker`/`clearDuckers` — play-state sidechain (duck a
  target bus while any instance plays on a trigger bus; `setTargetAtTime` attack/release via
  `BusGraph.approachVolume`). **Native migration STARTED (additive-first):** native `AudioEmitter`
  component (holds a full `EventDescription` directly) + `AudioEmitterSystem` done at the engine level —
  shares the one sopra (idempotent `createSopra`), autoplay/stop/volume/position/listener, fully tested;
  coexists with the untouched legacy `SoundEmitter`. (The autoplay-every-emitter behavior here is
  **superseded by P5** spatial management — see below.) Remaining: `AudioEventTrigger` + game wiring
  (config/editor/serialization registries) + the `SoundEmitter`→`LegacySoundEmitter` rename & call-site
  flip (see §9). Sound banks were evaluated and DROPPED — the ECS data model (components + dataset +
  AssetManager) already covers every bank role.
  Full `engine/sound` suite green.
- **P5 — spatial scaling to 100k emitters: ✅ DONE (P5.1–P5.6; see §15 for the full record).** A BVH
  broadphase + live/dormant split so the engine carries far more 3D emitters than can sound at once. A
  registered-but-dormant emitter costs only a BVH leaf (no instance, no nodes, no per-frame work); each
  tick the nearest in-range emitters — up to a global voice **budget** (default 64) — are promoted to
  live instances and the rest demoted. **P5.1** `SpatialAudioIndex` (BVH cull, reactive leaf refit via
  `position.onChanged`, allocation-free `queryAudible`). **P5.2** `LiveEmitterSet` (promote/budget/
  distance-priority stealing/rank-hysteresis). **P5.3** demotion policy (hard cut when culled out of
  range, click-safe fade on contention). **P5.4** continuous-clock phase reconstruction (`EventInstance.startTime`
  + looping-voice buffer-offset wrap). **P5.5** a cull throttle was added then **reverted** — the cull
  runs every tick (BVH is microseconds). **P5.6** `AudioEmitterSystem` wired to `LiveEmitterSet`:
  looping-3D autoplay → spatially managed; 2D + finite-3D-one-shot → direct; non-autoplay → inert;
  per-emitter `AudioEmitter.volume` carried through promote/demote. Two `.skip`-ped 100k stress tests
  prove only `budget` instances exist regardless of N and the BVH prunes the far field (per-frame
  `refresh + tick` ≈ 5 ms at 100k). Full `engine/sound` suite green (298, 2 skipped).

## 1. Recommendation in one paragraph

Build sopra as an **ECS-agnostic runtime audio engine** (`SopraEngine` + its services) that is
fully unit-testable with a mock `AudioContext`, then bind it to meep with a **thin ECS layer**, and
keep the legacy `SoundEmitter` / `createSound` / channel surface alive as a **translating facade**
during migration (the *strangler* pattern). There is always **exactly one renderer** (sopra);
legacy classes become translators, never a second engine. The architecture grafts the best of three
evaluated stances: the *ECS-agnostic, clock-injected core* (testability), the *clean component/system
topology* (meep-idiomatic), and the *zero-churn facade* (ships v1 with no call-site / save / settings
/ editor edits).

The **spine** is the thing meep lacks and FMOD/Wwise are built on: a **definition / instance split**.
Today `SoundTrack` conflates the spec (`url`/`time`/`volume`/flags) with the live, single-use
`AudioBufferSourceNode`. Sopra splits this into immutable **definitions** (the `*AudioClip` tree),
transient pooled **instances/voices**, and engine-owned **services** (mixer bus tree, scheduler,
parameter store, voice manager).

---

## 2. Engine philosophy this plan must respect (verified)

**meep ECS** (`engine/ecs/`)
- `System` subclasses declare `dependencies = [ClassA, ClassB]`; the engine auto-delivers
  `link(...componentsInDependencyOrder, entity)` / `unlink(...)` (arity is validated as
  `deps.length + 1`). `components_used = [ResourceAccessSpecification.from(Klass, access)]` is a
  scheduling hint that also auto-registers component columns.
- `simulate(dt)` runs a **fixed-step lock-step pass** (`fixedUpdate(fixedStep)`) then **one
  variable pass** (`update(dt)`); `getFixedStepAlpha()` gives the sub-step remainder for
  interpolation. `fixedStepTick` only advances in the fixed pass.
- Components are **plain serializable object instances** stored column-wise
  (`components[componentIndex][entityId]`). Runtime/WebAudio objects must NOT live on the
  component — they live in a system-owned `this.data[entity]` context (the established
  `SoundEmitterComponentContext` discipline).
- Singletons (the listener) are fetched via `ecd.getAnyComponent(Klass)`. Gameplay→sound uses the
  per-entity event bus (`addEntityEventListener` / `sendEvent`), as `SoundControllerSystem` does.

**Conventions**: every persisted type has `toJSON`/`fromJSON` **and** a versioned
`BinaryClassSerializationAdapter` registered by `typeName`; polymorphic trees recurse through
`ObjectBasedClassSerializationAdapter` + the shared `objectAdapter` (the behavior-tree precedent);
`Signal` for events, `List` for observable collections, `Vector1` for change-notifying scalars,
flags as bitmask enums, `assert.*` (free in prod).

**User memories (hard constraints)**
- **Uniform control flow** — no `null = auto / value = override` sentinels.
  ⚠️ `AbstractAudioClip.parent` / `.channel` are exactly this antipattern; resolve inheritance
  **once** at voice instantiation (flatten), so the runtime tick never sees a sentinel.
- **Reuse over micro-opt** — prefer tested primitives; lead with end-to-end measurement.
- **Correctness first, no bandaids**; **black-box tests only** (assert observable outcomes — which
  buffer, which bus, what gain, what `when`, how many real voices — never call counts);
  **no silent catch** (expected failure → sentinel/defer; unexpected → throw); asserts are free.

---

## 3. The current engine and its gaps

| Layer | Current | FMOD/Wwise gap |
|---|---|---|
| Definition | `SoundEmitter` (List<SoundTrack>, channel, distance, attenuation enum, flags) | no event/instance split |
| Playback | `SoundTrack` **conflates** spec + live single-use source + state | — |
| Mixer | **flat** 3 channel `GainNode`s (effects 1.2 / music 0.1 / ambient) → master `Gain → DynamicsCompressor → destination` | no nestable buses, no inserts, no sends |
| Spatial | per-emitter `PannerNode` (rolloff=0) + custom attenuation `GainNode` via `interpolate_irradiance_*`; BVH hearing-range cull | attenuation is a fixed 3-value enum, not a curve |
| Voices | **plays every track of every in-range emitter**; only cull is BVH | no limits / priority / stealing / virtualization |
| Params | none | no RTPC / automation |
| Timing | `track.time += timeDelta` each frame; `Suspended` flag works around Chrome disconnected-source time-freeze | no lookahead scheduler; sequence/loop precision at mercy of frame rate |
| Containers | `sopra/` stubs exist with **no** selection logic, scheduler, instance, or serialization | random / sequence / blend / switch are empty classes |

Latent bug to *not* faithfully reproduce: `SoundEmitterSystem.update` line ~370 is
`if (soundTrack.setFlag(...) && ...)` — `setFlag` returns `undefined`, so the suspended-track-end
branch is dead code and it sets `Suspended|Playing` on every track every frame. Port the **intent**
(logical time advances while virtual; revive in phase), not the mechanism.

---

## 4. The sopra model — three layers

### 4.1 Definition layer (immutable, serialized, `typeName` + adapter + `toJSON`/`fromJSON`)

```
AbstractAudioClip            (finish the stub: typeName, isAudioClip tag, .from(), compare/equals/hash/clone)
├── SampleAudioClip          NEW leaf — the only buffer-referencing clip: assetRef(path|alias),
│                            gainDb, pitch(cents), loop + loopStart/loopEnd, pitchRandom, gainRandom
├── SilenceAudioClip         (stub) duration → pure schedule-cursor offset, emits no source
├── ContainerAudioClip       (stub) children[]
│   ├── SequenceContainerAudioClip   plays children in order (scheduled, not onended-chained)
│   ├── RandomContainerAudioClip     (stub) avoid_repeating_last via recent-history ring + seededRandom
│   ├── BlendContainerAudioClip      [deferred] parameter-driven crossfade
│   └── SwitchContainerAudioClip     [deferred] discrete parameter selects child (subsumes material/)
```

Plus three non-clip definition types:
- **`EventDescription`** — the triggerable unit: `label, rootClip, busId, maxInstances, priority,
  stealMode, virtualThresholdDb, is3D, attenuation: AnimationCurve, distanceMin/Max`.
  (FMOD `EventDescription` / Wwise Actor-Mixer object.)
- **`BusDefinition`** — `{ id, parentId, gainDb, effects[], sends[] }`. In v1, `effects[]` is **live**
  (an ordered chain of `AbstractAudioEffect` — `EqEffect`/`CompressorEffect`, see D10); `sends[]` ships
  as a **data shape only** (reverb sends are P4) so adding them later is non-breaking.
- **`AbstractAudioEffect`** (+ `EqEffect`, `CompressorEffect`) — serialized bus-insert effects with a
  `build(ctx) → { input, output }` contract (see D10).
- **`AnimationCurve`** (reused from `engine/animation/curve/`, a cubic-Hermite keyframe curve) —
  used for **both** parameter automation (RTPC) and 3D distance attenuation. `EventDescription.attenuation`
  is an `AnimationCurve`; since it has no `typeName`/binary adapter of its own, the event adapter
  serializes its keyframes inline. (Supersedes the originally-planned bespoke `AutomationCurve`.)
- **`ParameterDefinition`** — `{ name, range, default, scope }`.

> **Uniform-flow fix:** `parent`/`channel`/`gain`/`pitch` inheritance is resolved **once** when an
> `EventInstance` is created (top-down flatten). The runtime never branches on a `null`-means-inherit
> sentinel.

### 4.2 Runtime layer (transient, pooled, **never serialized**)

- **`EventInstance`** — one trigger: resolved position, parameter overrides, **schedule cursor**,
  active `Voice`s, one-shot|persistent lifetime. Owns **its** `PannerNode` + attenuation `GainNode`
  (see decision D4). Modeled on `ParticleEmitter.build()`, *not* the behavior-tree re-armed node
  (which cannot produce concurrent voices).
- **`Voice`** — one throwaway `AudioBufferSourceNode` + a **pooled** trim `GainNode`. The source is
  the only unavoidable per-play allocation (it is single-use).

### 4.3 Services (single instances hung off `SoundEngine`, no ECS imports)

- **`SopraEngine`** — ECS-agnostic public API: `playEvent / playOneShot / createInstance / stop`,
  `bus(id)`, `setParameter / getParameter`, `setListener(pose)`, `update(now)`. Constructed with
  `(audioContext, destinationNode, bufferProvider)`; **reuses the existing `AudioContext` + master
  chain verbatim — never a second context.**
- **`BusGraph`** — instantiates the `BusDefinition` tree into chained `GainNode`s; root →
  `SoundEngine.destination`; **seeds the default `effects`/`music`/`ambient` buses with the exact
  legacy mix (Effects 1.2, Music 0.1)**; exposes `get/setChannelVolume` (linear, see D6).
- **`Scheduler`** — lookahead queue over `AudioContext.currentTime`; each `update(now)` drains events
  due before `now + lookahead` and calls `start(when, offset, duration)`. Replaces `time += timeDelta`
  and obsoletes the `Suspended` hack. (Reuse `BinaryHeap`.)
- **`ParameterStore`** — `Map<name, number>` + per-instance overrides + `onChanged` `Signal`s; samples
  bound `AnimationCurve`s → `setTargetAtTime`.
- **`VoiceManager`** — active-voice registries keyed by `eventId` and `busId`; per-event
  `maxInstances` + per-bus limits + priority + steal-oldest/quietest (by post-attenuation gain);
  **virtualization** (stop+disconnect source, keep advancing the instance cursor, revive in phase).
- **`VoicePool`** — `ObjectPoolFactory` of pre-wired trim-gain chains.
- **`BufferProvider`** (interface) + **`AssetManagerBufferProvider`** — `get(ref) → Promise<AudioBuffer>`.
  Prod wraps `AssetManager.promise(resolveAlias(ref) || ref, 'audio')` (shared immutable buffer);
  a stub impl in tests is what makes the core ECS-free unit-testable.
- **`fadeOutAndStop`** — the ONE click-safe fade primitive (see D1). No call site hand-rolls fades.

### 4.4 ECS binding (thin)

- **`SopraEmitter`** (component) `+ Transform` — plain serializable
  `{ eventId, paramOverrides, busOverride, volume: Vector1, flags }`. Runtime `EventInstance` lives
  in `system.data[entity]`.
- **`SopraEmitterSystem`** `dependencies = [SopraEmitter, Transform]` — `link` → `createInstance`
  + `BVH.link`; `update(dt)` → BVH cull around the listener (reuse `BVHQueryIntersectsSphere` +
  `bvh_query_user_data_generic` + `IncrementalDeltaSet`), push position/params, `engine.update(now)`;
  `unlink` → stop + release. Hosts `playOneShot(eventId, position, overrides)`.
- **`SoundListener` / `SoundListenerSystem`** — **reused verbatim**; read via `getAnyComponent`,
  forwarded through `SopraEngine.setListener`.
- BVH out-of-range = **virtual** (not disconnect); the `VoiceManager` decides actual realness under
  budget. This turns the Chrome `Suspended` hack into standard virtual-voice behavior.

---

## 5. Key design decisions (incl. the 5 critique revisions)

- **D1 — `fadeOutAndStop` must NOT hard-depend on `cancelAndHoldAtTime`.** It is unimplemented in
  Firefox (and used nowhere in meep today). Feature-detect it; otherwise use the portable idiom:
  read `param.value`, `cancelScheduledValues(now)`, `setValueAtTime(currentValue, now)`, then ramp.
  Always ramp to an **epsilon, never 0** (exponential-ramp-to-0 throws / clicks), and schedule
  `stop()` just after the gain reaches ~epsilon. Test the no-`cancelAndHoldAtTime` path with a mock.
- **D2 — `playOneShot` owns a max-lifetime timeout (default 60s) AND releases on asset-load
  failure.** `createSound` today is belt-and-suspenders (`on.ended` **and** `Sequence[Delay(60),Die]`).
  A one-shot whose asset 404s must still release — the no-silent-catch sentinel path, not a hang.
- **D3 — Virtualization revive uses the instance logical cursor, not `t % dur`.** `t % dur` is only
  correct for a single looping leaf. For a `Sequence`/`Random`/one-shot, re-derive the active leaf(s)
  and their offsets from the cursor at `currentTime`, then `start(when, leafOffset)` each.
- **D4 — `PannerNode` + attenuation `GainNode` are per-`EventInstance` (shared by its leaf voices),
  not per-`Voice`.** This matches today's per-emitter sharing (`buildNodes` builds one chain; all
  tracks feed the single emitter volume node). Per-voice panners would multiply HRTF cost (the
  dominant spatialization cost) on multi-track emitters — a regression for the dense scenes we care
  about. Only the source node + trim gain are per-voice. Panner-type choice (HRTF / equalpower /
  `StereoPanner` for 2D-UI / none) + distance/budget downgrade lives at the instance level.
- **D5 — Random/Switch selection is network-deterministic (decided).** Seed `seededRandom` from a
  replicated source (`entity id + fixedStepTick`), not `Math.random`. Because the seed depends on
  `fixedStepTick` (which only advances in the fixed pass), a selection decision must be made in
  `fixedUpdate(fixedStep)` **or** stamped with the `fixedStepTick` read once per frame — the
  selection must never depend on the variable `update(dt)` cadence. Keep `setRandomSeed` on the
  container voice (the `WeightedRandomBehavior` precedent) so tests are reproducible.
- **D6 — Channel-volume facade stays linear end-to-end.** The settings sliders are `{min:0,max:1}`
  linear and `Effects` defaults to **1.2** (above max). Store/return the raw linear gain the slider
  expects; convert to dB only internally if a bus needs it. Preserve 1.2 / 0.1 exactly.
- **D7 — Single owner of the `SoundAssetLoader` registration.** `AssetManager.registerLoader` throws
  on duplicate type. The rewired `SoundEmitterSystem` already registers it; `SopraEngine` reuses it,
  does not re-register. Make construction order in `makeMirEngineConfig` explicit.
- **D8 — Clip-tree adapters model the behavior-tree spine, NOT the legacy `SoundEmitter` adapter.**
  The legacy adapter hand-rolls track ser/de inline and is **not** polymorphic. New container
  adapters extend `ObjectBasedClassSerializationAdapter` so children recurse by `typeName`.
- **D9 — Resolve the dormant overlaps.** `material/` (weighted `SoundMaterialComposition` + terrain
  splat detector) and `asset/SoundAssetPlaybackSpec` are dormant (zero usages/tests). Fold them into
  `Switch`/weighted containers in P4; do not maintain three parallel spec hierarchies.
- **D10 — Bus insert effects are live in v1 (per the scope decision).** Add an `AbstractAudioEffect`
  base mirroring `AbstractAudioClip` style (`typeName`, `toJSON`/`fromJSON`, adapter, `compare`/
  `equals`/`hash`/`clone`) with a `build(ctx) → { input, output }` contract, plus two concrete
  effects for v1: `EqEffect` (`BiquadFilterNode`, automatable `frequency`/`Q`/`gain`) and
  `CompressorEffect` (`DynamicsCompressorNode`). `BusGraph` wires each bus as
  `input → effect[0] → … → effect[n] → output`; an empty `effects[]` is a direct `input === output`.
  The existing **master** `DynamicsCompressor` (in `SoundEngine`) stays master-glue only — buses must
  not double-compress by default. `WaveShaper`/occlusion-lowpass remain nice-to-have (P4+).

---

## 6. Reuse map (reuse-over-micro-opt)

| Existing meep code | Used for |
|---|---|
| `sound/SoundEngine.js` (AudioContext, master `Gain→Compressor→destination`, suspend/resume) | injected into `SopraEngine`; never a 2nd context |
| `sound/ecs/SoundListener*.js` | listener pose, reused **verbatim** |
| `core/bvh2/bvh3/*` + `BVHQueryIntersectsSphere` + `IncrementalDeltaSet` | hearing-range cull = virtualization trigger |
| `core/model/object/ObjectPoolFactory.js` | voice trim-gain pool; `ParticlePool`/`BitSet` if fixed-capacity budget wanted |
| `core/math/random/seededRandom.js` (Mulberry32) + `weightedRandomFromArray.js` | deterministic random/weighted selection; avoid-repeat ring |
| `Signal`, `Vector1` (onChanged), `List`, `combine_hash`/`computeHashFloat`/`computeStringHash` | params, gains, child collections, voice `onended`, clip hashing |
| `engine/asset/*` (`AssetManager`, `SoundAssetLoader`, `loadSoundTrackAsset` alias path, `AssetPreloader`) | `AssetManagerBufferProvider`; preload via existing asset manifests (no bank concept) |
| `engine/animation/curve/AnimationCurve.js` + `Keyframe.js` | the curve type for RTPC automation **and** 3D distance attenuation (`EventDescription.attenuation`) — reused instead of a bespoke curve |
| `core/math/physics/irradiance/interpolate_irradiance_*` | reference falloff shapes when authoring `AnimationCurve` attenuation presets (P2) |
| `sound/volume2dB.js` + `dB2Volume.js` (**export them**) | dB↔linear at facade edges, gain randomization, ducking math |
| `ecs/storage/binary/object/*` + `SequenceBehaviorSerializationAdapter` shape | polymorphic recursive clip-tree ser/de by `typeName` |
| `intelligence/behavior/*` (`Behavior`, `CompositeBehavior`, `BehaviorSystem`) | definition node contract + system lifecycle shape (but split def/instance) |
| `SoundEmitterComponentContext` discipline | runtime objects in `data[entity]`; `Suspended` plumbing → `VoiceManager` virtualization |

---

## 7. Serialization plan

- Each definition type gets a `typeName`, a `BinaryClassSerializationAdapter` at **version 0**, and a
  parallel `toJSON`/`fromJSON`. Container adapters extend `ObjectBasedClassSerializationAdapter`.
- Register all in `GameBinarySerializationRegistry.initializeGameBinarySerializationRegistry`
  (`registerAdapters([...])`) **and** in `GameClassRegistry` — the single registration sites, called
  before `config.apply` in `GameBootstrap`.
- Design byte layouts to be **upgrade-friendly from day one** (varint-counted, append-only fields),
  per the `SoundEmitterSerializationUpgrader_1_2` lesson (which just appends a default `volume`).
- **The legacy `SoundEmitterSerializationAdapter` (v2) + upgraders `_0_1`/`_1_2` stay registered
  verbatim** — old saves deserialize into a `SoundEmitter` exactly as today; the facade translates at
  link time. No save-format migration in v1.

---

## 8. Migration / back-compat (the four surfaces)

Invariant: always exactly one renderer (sopra); legacy classes are translators.

1. **`createSound`** (#1 fire-and-forget, ~7 high-traffic sites). Keep the signature
   `{position, url|track, positioned, channel, volume, timeout}` and self-destruct contract
   byte-for-byte. Reimplement the body to build an ad-hoc `EventDescription` (root =
   `SampleAudioClip`, `busId = channel`, `is3D = positioned`, `gainDb = volume2dB(volume)`) and call
   `playOneShot(desc, {position})`. **Preserve the full safety net** (D2). `GameSounds.js` named
   tracks become named `EventDescription`s keeping their export names.
2. **`SoundEmitter` + `fromJSON` + manual entity builds** (footsteps/voice/impacts, boss ambience,
   music stems, title music, achievements, dialogue). v1: keep the exact component shape & `fromJSON`;
   the rewired `SoundEmitterSystem.link` translates each `SoundTrack` → `SampleAudioClip` + a
   persistent `EventInstance` on the channel-bus (attenuation enum → 3-point `AutomationCurve`).
   `tracks.on.added/removed` spawn/stop instances so `emitter.tracks.add/addAll` keep working.
   Direct AudioParam reaches get equivalents: `setVolumeOverTime`/`emitter.volume.onChanged` →
   live voice gain; `resetSoundEmitterTracks` (`t.time=0`) → `instance.seek(0)` **and** write
   `track.time` back from the live cursor so readers stay consistent; `hideEntityGracefully` 2.7s
   fade / `SoundEmitterVolumeBehavior` → `instance.fadeOutAndStop`. `CombatEndMusicProcess` reads
   `track.nodes.volume.gain` directly → expose a **temporary bridge GainNode** at
   `SoundTrack.nodes.volume` during cut-over, then migrate it to a `bus.fadeTo`/voice-crossfade
   primitive and drop the bridge in P4 (do not keep the bridge as steady state).
3. **`SoundController`** (UnitMaker only). Keep its v0 adapter frozen in v1. P4: provide
   `SopraEventTrigger` preserving the `{tracks, startEvent, stopEvent, loop, volume, channel}` rule
   shape; migrate UnitMaker last.
4. **Channels + settings UI** (must preserve). Seed default buses with the exact legacy mix; keep
   `get/setChannelVolume` on the (rewired) `SoundEmitterSystem` proxying to bus gain **in the linear
   domain** (D6). Sliders bind unchanged. **Editor** (`SoundEmitterController`, symbolic display,
   reset-tracks) keeps working because the component shape is preserved.

---

## 9. Phased plan

> Each phase ships behind black-box `*.spec.js` run from `H:/git/moh` root. P0–P2 need **no ECS**.

**P0 — Core skeleton + ser/de + bus inserts (no ECS).**
Finish clip classes; add `SampleAudioClip`, `EventDescription`, `BusDefinition`,
`ParameterDefinition` (3D attenuation reuses the existing `AnimationCurve`), and the effect hierarchy
`AbstractAudioEffect`/`EqEffect`/`CompressorEffect`
(D10); `BusGraph` (default buses at legacy mix, wiring each bus's `input→effects→output` insert
chain); `EventInstance` + `Voice` (pooled gain); `SopraEngine.playEvent/playOneShot/createInstance/
stop/bus`; `BufferProvider` + stub; **export `volume2dB`/`dB2Volume`**; `fadeOutAndStop` **with the
Firefox fallback (D1)**; one adapter per definition + effect type registered.
*Exit:* a `SampleAudioClip` event plays to the right bus at the right gain, **through a bus insert
chain (EQ→Compressor) wired in the correct order**, **and** every definition + effect round-trips
equal (binary + JSON) — in Jest with a mock `AudioContext`, no ECS. Firefox fade path tested. Legacy
engine untouched and still active. **✅ DONE — 26 tests green (see §0).**

**P1 — Timeline playback + containers + parameters. ✅ DONE — sopra suite 35 tests green.**
A polymorphic `AbstractAudioClip.planTimeline()` (overridden per clip) deterministically flattens the clip graph to timed leaf plays (gain/pitch
inheritance resolved once); `Random` (seeded + avoid-repeat ring via `SopraPlaybackContext`) and
`Sequence` (cumulative offsets; `Silence` = offset arithmetic); `ParameterStore` + `AnimationCurve`
bound to bus volume (same curve type used for 3D attenuation); per-trigger pitch/gain randomization.
D5 resolved (deterministic). The pumping lookahead `Scheduler` was MOVED TO P2 (it's only needed for
virtualization / indefinite content); P1 schedules finite timelines eagerly via WebAudio future
`start(when)`.
*Exit (met):* sequence schedules children back-to-back with no gap; random avoids last N for a fixed
seed; `setParameter` observably moves a live gain — black-box, no ECS.

**P2 — VoiceManager + spatialization + cursor scheduling. ✅ DONE — sopra suite 43 tests green.**
Cursor-based per-instance scheduling realises the lookahead model (each frame
`EventInstance.update(now, listener)` spawns leaves within a lookahead window) — a global priority
queue is deferred to P4 as an optimization. `VoiceManager` with per-event `maxInstances` + priority +
steal (oldest/quietest/none). **Cursor-based virtualization** (D3): out-of-range instances stop their
voices, advance the cursor, and revive at the correct child + buffer-offset. Per-`EventInstance`
`PannerNode` (rolloff=0) + attenuation `GainNode` driven by `attenuation.evaluate(distance)` (D4);
`buildAttenuationCurve` authors legacy-equivalent curves from `interpolate_irradiance_*`;
HRTF/EqualPower panner choice (StereoPanner / no-panner deferred to P4). Per-bus limits also deferred
to P4 (per-event covers the exit criteria).
*Exit (met):* over-limit steals the correct victim; a virtualized sequence revives at the correct
child+offset; attenuation matches the legacy curve at sample distances. Default limits are a no-op /
stealing is opt-in.
*Exit:* over-limit steals the correct victim; a virtualized **sequence** (not just a loop) revives at
the correct child+offset; attenuation matches the legacy curve at sample distances. **Default limits
are a no-op / stealing is opt-in** (so dense scenes can't regress before measurement).

**P3 — ECS binding + facade cut-over (zero call-site / save / settings / editor change).**
`SopraEmitterSystem` (+ `playOneShot`); reuse `SoundListener*`; construct `SopraEngine` in
`makeMirEngineConfig` from `sound.context/.destination`; **single loader owner (D7)**. Rewire
`SoundEmitterSystem` to translate tracks→clips→persistent instances (same constructor); `createSound`
→ `playOneShot` (D2); `GameSounds` as named events; `get/setChannelVolume` proxy (linear, D6);
temporary `track.nodes.volume` bridge + `track.time` write-back. Legacy v2 adapter/upgraders +
`SoundController`/`SoundListener` + editor untouched; remove old `SoundTrack` node-rendering once the
regression suite is green.
*Exit:* all ~25 call sites, saves, sliders, and the editor behave identically; sopra is the only
renderer.

**P4 — Native migration + FMOD/Wwise build-out (post-strangler).**

> **DONE — Switch/Blend containers.** `SwitchContainerAudioClip` (discrete RTPC-keyed: rounds+clamps a
> parameter to a child index) and `BlendContainerAudioClip` (per-child parameter→linear-gain curves;
> every child with gain > 0 plays simultaneously, scaled). Parameter access added to the planTimeline
> `env.getParameter` (read once at trigger). Full JSON+binary serialization + registry; supersedes the
> dormant `material/` subsystem's surface-switch / composition concepts. **Limitation:** Blend is a
> trigger-time snapshot — it does NOT re-blend live as the parameter later sweeps. **Live re-blend
> follow-up:** have plays carry an optional `{gainParam, gainCurve}`, bind the spawned voice's trim
> gain to the `ParameterStore` (needs an *unbind* on voice retire — ParameterStore.bind currently has
> no removal). Also still dormant: delete the `material/` files + `asset/SoundAssetPlaybackSpec` once
> confirmed unreferenced.

> **DONE — Reverb / aux sends.** `BusGraph.build` now wires `BusDefinition.sends` (previously a
> data-only shape): for each send it taps the source bus's output → a send-level `GainNode` →
> `targetBus.input` (post-fader copy; the dry path to the parent is unchanged). Throws on an unknown
> send target. `ReverbEffect` is a new `ConvolverNode` insert whose impulse response is generated
> procedurally at build (decaying noise; params `decaySeconds`/`decayPower`) — so build stays
> synchronous with no IR-asset dependency. A "reverb bus" = a bus with a `ReverbEffect` insert that
> other buses send to. **Follow-ups:** live send-level control (sends are static at build); authored-IR
> source from the BufferProvider (would make build async); pre-fader sends.

**Native migration** (user naming: `AudioEmitter` [rename `SoundEmitter`→`LegacySoundEmitter`],
`AudioEventTrigger` — keep "sopra" internal-only). Decided **additive-first**: `AudioEmitter` holds a
full `EventDescription`.

> **DONE — additive foundation (engine-level, not yet wired into the game).** `AudioEmitter`
> (`engine/sound/ecs/audio/`) holds a full `EventDescription` + a live `volume` (Vector1) + `autoplay`;
> serializable (JSON). `AudioEmitterSystem` shares the single sopra (`SoundEngine.createSopra` made
> idempotent) + self-registers the `SoundAssetLoader` if absent (standalone-capable, guarded), autoplays
> on link with `oneShot` derived from `event.rootClip.loops()` (finite events self-release at end,
> looping events persist), plumbs `volume`→instance gain + `transform.position`→instance, and ticks
> sopra + listener. Coexists with the untouched legacy `SoundEmitter`. Black-box tested.
> **RENAME DROPPED (owner change of plans):** no `SoundEmitter`→`LegacySoundEmitter` rename. Instead
> the legacy stack — `SoundEmitter`, `SoundEmitterSystem`, `SoundController`, `SoundControllerSystem` —
> is annotated `@deprecated` (pointing at `AudioEmitter`/`AudioEmitterSystem`/the forthcoming
> `AudioEventTrigger`). The classes keep their names; callers migrate organically.
> **NOT yet done (own pass):** `AudioEventTrigger` (replaces `SoundController`); wiring `AudioEmitter`
> into `GameClassRegistry` / `GameBinarySerializationRegistry` / the editor (`AudioEmitterController`,
> symbolic display) / `makeMirEngineConfig`; consolidating the sopra tick to one owner once both systems
> run together; migrating call sites to `AudioEmitter`.
>
> **Post-review fixes landed (P4 review):** D1 — `oneShot` now derived from `event.rootClip.loops()`
> (looping AudioEmitter events no longer die at the 60s lifetime backstop; added `loops()` to the clip
> hierarchy). D2 (keystone) — `BusGraph` now keeps an authoritative per-bus `nominal` gain; `getVolume`/
> `getVolumeDb` read it, `setVolume`/`rampVolume` write it, and `approachVolume` (ducking) deliberately
> does NOT — so snapshot `captureSnapshot`, duck nominal-capture/restore, and settings read-back are
> correct under automation (was: stale `gain.value`, which WebAudio never updates under ramps). IR2 —
> `AudioEmitterSystem` self-registers the `SoundAssetLoader` (guarded). **Deferred (do at wiring time,
> not band-aided):** IR1 single-owner sopra tick (the double-tick is idempotent-within-frame today and
> only matters once both systems are in one config); D3 multi-ducker aggregation; IR4 production
> serialization wiring; the remaining quick-fixes (D4–D9, C3).

Flip call sites to `AudioEmitter`; `AudioEventTrigger` replaces `SoundController`; migrate
`CombatEndMusicProcess` to bus/voice crossfade and drop the bridge; `AudioEmitterController` editor
panel; `AudioEmitter` v0 adapter + save-translation path. Then the deferred features behind their own
tests: **~~Snapshots/mixer-states~~ (DONE); ~~Blend/Switch containers~~ (DONE); additional bus insert
effects (occlusion lowpass / `WaveShaper`) beyond the v1 EQ/Compressor; ~~reverb sends~~ (DONE);
~~emulated ducking~~ (DONE); scatterer; adaptive-music timeline.**

> **DONE — Emulated ducking.** `DuckingRule` definition (trigger/target bus, `duckDb`, `attack`,
> `release`) + ser/de + registry. `SopraEngine.addDucker`/`clearDuckers`; evaluated each `update()`:
> edge-triggered — when the trigger bus has ≥1 live instance the target ducks (`setTargetAtTime` toward
> nominal+duckDb over attack; nominal captured at engage), restoring over release when the trigger goes
> quiet. **Play-state** sidechain (active-instance count, NOT signal level — WebAudio has no native
> sidechain) via the new `BusGraph.approachVolume`. Trigger match is by direct `instance.busId` (child
> buses not counted in v1). Follow-up: true signal-following duck via `AudioWorklet`.

> **DONE — Mixer snapshots.** `MixerSnapshot` definition (id + per-bus target `gainDb`) with full
> ser/de + registry. `SopraEngine.applySnapshot(snapshot, {duration})` snaps (duration 0 →
> `setVolume`) or click-safely ramps (`BusGraph.rampVolume` → shared `rampGain`) each listed bus to its
> target — the FMOD mix-state blend. `captureSnapshot(id, busIds)` reads the current mix (for
> save/restore). Throws on an unknown bus.

> **Sound banks — DROPPED (not a feature).** A "bank" is FMOD/Wwise's catch-all for serialization +
> load-unit + media package + by-id namespace, needed only because that middleware has no host data
> model. meep already has every piece: `EventDescription`s are serializable definitions that live as
> component / `EntityComponentDataset` data; load/unload is the scene/dataset boundary; media is the
> `AssetManager`'s concern (cache + preload manifests). The only residual — shared "play by id" events
> — is at most a small `AudioEventLibrary` *component* (id→`EventDescription` map), and even that is
> optional since `playEvent` already accepts a description object directly.

---

## 10. Testing strategy (black-box only)

- **Layer 1 — ECS-free core** (the payoff of the agnostic design): inject a mock `AudioContext`
  (records `createGain`/`createBufferSource`/`connect`/`start(when,offset,duration)`/gain
  automation as an observable graph + schedule), a stub `BufferProvider` (fixed-duration buffers),
  a deterministic clock, and a seeded RNG. Cover: definition round-trip; routing to the right bus at
  the right dB; sequence gapless scheduling (`when` = prev `when` + prev `duration`); random
  avoid-last-N **for a fixed seed (deterministic, D5)**; parameter moves gain; over-limit steals
  correct victim; **virtualized sequence revives at correct child+offset**; attenuation matches
  `interpolate_irradiance_*`; **a bus insert chain wires EQ→Compressor in order (empty `effects[]` is
  pass-through; the master compressor is not double-applied)**; `fadeOutAndStop` never ramps to 0,
  stops after ~epsilon, **and the no-`cancelAndHoldAtTime` fallback path**.
- **Layer 2 — ECS binding**: an entity with `SopraEmitter+Transform` plays a 3D event, attenuates
  with distance, goes virtual outside BVH range and revives on re-entry; sliders move bus gains.
- **Layer 3 — back-compat regression** (the strangler safety net): `createSound` self-destructs on
  end **and** on the timeout/asset-failure path; `SoundEmitter.fromJSON` is audibly equivalent;
  `emitter.tracks.add` spawns a voice; `resetSoundEmitterTracks` restarts audibly; channel volume
  round-trips at **1.2 / 0.1** through the linear proxy; an old save deserializes via the frozen v2
  adapter and plays; single `SoundAssetLoader` registration when both engines are constructed.

Determinism: seed `seededRandom` from the test (and, per D5, from a replicated source if
network-deterministic audio is chosen).

---

## 11. Risks

| Risk | Sev | Mitigation |
|---|---|---|
| Game code pokes `track.nodes.volume.gain` directly (CombatEndMusicProcess) | high | temporary bridge GainNode in P3; migrate to bus/voice crossfade in P4; regression test |
| `cancelAndHoldAtTime` absent in Firefox | high | D1 feature-detect + portable fallback + test the fallback |
| One-shot leak on asset 404 | high | D2 max-lifetime timeout + release-on-failure sentinel |
| Per-voice HRTF cost regression on multi-track emitters | high | D4 panner per-instance, shared across leaf voices |
| Voice stealing silences sounds the old "play everything" engine played | med | default limits a no-op; stealing opt-in; measure densest scene first |
| Scheduler/virtualization regresses loop sync or clicks | med | one fade primitive; absolute `when` off `currentTime`; revive-phase tests |
| New ser/de drifts from conventions | med | D8 model on object adapter; version 0 + day-1 upgrader plan; P0 round-trip specs |
| Uniform-flow flatten changes resolved gain/bus vs legacy | med | resolve once at instance creation; assert resolved bus/gain/pitch in tests |
| Duplicate `audio` loader registration crash | low | D7 single owner |
| `track.time` becomes dead state, breaks readers | med | write `track.time` back from the live cursor each frame |
| Scope creep (reverb/ducking/snapshots in v1) | med | hard v1 line = spine + facades; ship effects[]/sends[] data shape only |

---

## 12. Decisions

**Locked by the owner:**
1. **Migration aggressiveness — Facade-first strangler.** One renderer always; `SoundEmitter`/
   `createSound`/channels become translators; sites migrate post-cut-over.
2. **v1 scope — Spine + bus inserts.** Spine (split + buses + scheduler + voice manager + params +
   random/sequence + spatialization + facades) **plus live EQ/Compressor insert effects on buses**
   (`BiquadFilterNode` / `DynamicsCompressorNode`). Reverb sends / ducking / snapshots remain
   P4 (banks dropped — see §9). (See D10 for the effect hierarchy and where it lands.)
3. **Random/Switch selection — Network-deterministic.** Seed `seededRandom` from a replicated source
   (entity id + `fixedStepTick`); selection is sampled where the fixed tick is stable (see D5).

**Still the owner's call (defaults assumed; revisit any time):**
4. **`SoundEmitter` endgame** — assume **keep as a frozen compat reader** through P3/P4; delete only
   after full migration + a tested save-translation path. Removing a frozen ~80-line adapter is low value.
5. **Voice budget defaults** — assume **high/no-op defaults + opt-in stealing**, measured in the
   densest scene before enabling.
6. **Bus tree source of truth** — assume **both**: code-seeded legacy default (1.2/0.1) overridable by
   serialized scene data.

---

## 13. FMOD/Wwise concept → sopra coverage

**v1 (core):** definition/instance split · nestable bus tree · **bus insert effects (EQ/Compressor)** ·
lookahead scheduler · parameters/RTPC + automation curves · voice manager
(limits/priority/stealing/virtualization) · random + sequence containers · custom 3D attenuation
curves · one-shot vs persistent · pitch/gain randomization · gapless loop regions
(`loopStart/loopEnd`).

**P4 / deferred (important):** ~~banks~~ (DROPPED — ECS data model covers it) ·
~~snapshots/mixer-states~~ **DONE** · ~~blend + switch containers (fold in `material/`)~~ **DONE** ·
~~reverb sends (`ConvolverNode`)~~ **DONE** · ~~emulated ducking~~ **DONE** · additional insert effects
(occlusion lowpass / `WaveShaper`).

**Nice-to-have:** scatterer/multi-spawn ambience · `AudioWorklet` (true sidechain + accurate meters)
· `AnalyserNode` dev meters · `OfflineAudioContext` baking · adaptive-music timeline
(transitions/stingers/sustain points).

---

## 14. P3 detailed integration sub-plan — route `SoundEmitter` through sopra (real, not a veneer)

Goal: the legacy `SoundEmitter` path genuinely renders through sopra. We do **not** keep 100%
back-compat; instead we **deprecate-and-plumb** — the old public API stays as `@deprecated` members
that translate into sopra (so call sites are unchanged), and is removed later. Where plumbing reaches
private state, we rewrite the call site.

### 14.1 Architecture — strangler at the existing spine
`SoundEmitterSystem` is already the seam: it owns `this.sopra` and ticks `sopra.setListener` +
`sopra.update` each frame (currently inert). Translation happens **there, at link time** — no new
system. Per-entity runtime state lives in the (gutted) `SoundEmitterComponentContext`, repurposed from
a WebAudio renderer into a **translation record**.

- **link(emitter,transform,entity)** (sopra branch): translate the emitter to sopra
  `EventDescription`s and `playEvent` a persistent `EventInstance` per persistent/looping track; store
  them in a per-entity `Map<SoundTrack, EventInstance>`.
- **tracks.on.added / on.removed** stay the hook: added → `playEvent` a new instance; removed →
  `instance.stop()`. Preserves the live-mutation idiom (`SoundController`, `CombatEndMusicProcess`,
  `injectMusic`, `StoryManager`).
- **emitter.volume.onChanged** → `instance.setGainDb(volume2dB(v))`; **setVolumeOverTime /
  fadeOutAllTracks** → `instance.fadeToGainDb` / `instance.fadeOutAndStop`.
- **update()**: push `transform.position` into each owned instance (sopra already does distance
  attenuation + panner + virtualization). The BVH stays as a cheap **far-cull pre-gate**.
- **unlink** → stop all the entity's instances.
- **createSound** (+ ~9 one-shot sites) → `sopra.playOneShot`, via a kept `@deprecated` facade that
  still returns a destroyable `Entity` (so handle-dependent callers are unchanged) and wires
  `instance.onEnded → builder.destroy`.

**Component model:** `SoundEmitter`/`SoundTrack` are **kept as `@deprecated` data components**,
translated at link time — lowest churn (editor, serialization adapters, `GameClassRegistry`, the 8
`fromJSON` sites all keep working). No `SopraEmitter` component in v1. **One persistent `SoundTrack` →
one `EventInstance`** (both already own an independent playhead/gain/loop/ended-signal); the emitter
maps to a *set* of instances reconciled via the existing `on.added/on.removed` signals.

### 14.2 Sopra gaps to fill first (engine-only, ECS-untouched)
The engine is fire-and-forget today (content frozen at `#resolve`, no live gain/fade/seek; 2D voices
route straight to the bus). Add the **live-control surface** without making content mutable:
1. **`EventInstance` `#instanceGain`** — one always-on trim gain that *all* voices route through
   (uniform 2D/3D chain head: `instanceGain → [panner if 3D] → bus`). Parity with the legacy
   `nodes.volume`, not new cost. *(do first)*
2. **`EventInstance.setGainDb(db)` / `get gainDb`** — composes with `#attenuationGain` (separate node)
   so user volume × attenuation multiply.
3. **`EventInstance.fadeToGainDb(target,dur,startAfter)` + public `fadeOutAndStop(dur,startAfter)`** —
   refactor `fadeOutAndStop.js` into a shared `rampGain` helper; stop scheduled via an audio-clock
   deadline checked in `update()` (not `setTimeout`).
4. **`EventInstance.seek(seconds)` / `restart()`** — re-base `startedAt`, retire live voices so
   `#evaluate` respawns at the new offset; **must not** reseed `playbackContext` (preserve avoid-repeat
   determinism).
5. **`SopraEngine.crossfade(oldInstance, idOrDesc, opts)`** — start new at epsilon, ramp up while
   `old.fadeOutAndStop`.
6. **Pure translators** in `sopra/legacy/`: `soundEmitterToEventDescriptions(emitter)` +
   `attenuationFunctionToCurve(fn,min,max)` (dispatch `SoundAttenuationFunction` →
   `interpolate_irradiance_*` → `buildAttenuationCurve`; `Attenuation` flag clear → flat-1 curve;
   `is3D ← Spatialization`; `SoundPanningModelType → SopraPanningModel`).

**Gain composition rule (as built):** the translator bakes **NO** gain (`description.gainDb = 0`); the
per-instance `#instanceGain` carries the full live product `volume2dB(emitter.volume × track.volume)`,
re-applied on `emitter.volume.onChanged` and on a live `track.volume` set (via the track's
`__soundRuntime` back-ref); the **bus** carries the channel mix (the legacy 1.2/0.1 already lives on
sopra buses). Baking the product into the description *and* the instance gain would double-count — so
the description carries none.

### 14.3 Phased sequence (bottom-up; the 47 sopra tests + 2 legacy specs stay green throughout)
- **P3.2 — live-control primitives** *(risk: med)*: `#instanceGain`, `setGainDb`, `fadeToGainDb` +
  public `fadeOutAndStop`, `seek`/`restart`, `SopraEngine.crossfade` + unit tests.
- **P3.3 — pure translators** *(low)*: `soundEmitterToEventDescriptions` + `attenuationFunctionToCurve`
  + unit tests (curves match `interpolate_irradiance_*` at sampled distances); not yet wired.
- **P3.4 — one-shots** *(med)*: `GameSounds` → registered `EventDescription`s; `createSound` facade →
  `playOneShot` returning a destroyable Entity; legacy path kept when sopra unreachable.
- **P3.5 — persistent-emitter spine** *(high)*: rewire `SoundEmitterSystem.link` + gut
  `SoundEmitterComponentContext` to a translation record; plumb volume/fade/stop; BVH far-cull kept;
  `get/setChannelVolume` → bus proxies.
- **P3.6 — hard live-mutation sites** *(med)*: `CombatEndMusicProcess.switchMusic` → `sopra.crossfade`;
  `resetSoundEmitterTracks` → `restart()`; verify `SoundController`/`injectMusic`/`StoryManager` via the
  add/remove plumb.
- **P3.7 — listener convergence + cleanup** *(med)*: single sopra listener feeder; `@deprecated` JSDoc;
  delete dead WebAudio in the sopra branch.

### 14.4 Deletions vs retained-`@deprecated`
- **Delete now (sopra branch):** `SoundEmitter.buildNodes`/`nodes`/`getTargetNode`/
  `writeAttenuationVolume`/panner-half of `updatePosition`; `SoundEmitterComponentContext` WebAudio
  body (connect/disconnect/suspend/resume); `SoundTrack.start`/`suspend`/`nodes`/`SoundTrackNodes`;
  the per-track time-advance + `endTrack` loop; `CombatEndMusicProcess` hand-rolled crossfade;
  `resetSoundEmitterTracks` `time=0` body.
- **Retain `@deprecated`:** `SoundEmitter` + serializable fields, `SoundTrack`, `GameSounds` constants,
  `SoundEmitterChannel`/`channels` (editor dropdown), `get/setChannelVolume`, `fadeOutAllTracks`/
  `stopAllTracks`, `createSound`, `SoundController`/`System`, **both serialization adapters**
  (save-compat — never delete at cutover).
- **Later (dedicated change):** remove the `sopra === null` legacy WebAudio fallback once prototypes
  are sound-free or sopra-capable; then retire `SoundEmitter` for a native sopra component.

### 14.5 Testing & risks
Black-box only; existing 47 sopra tests + `SoundEmitterSystem.spec` + `SoundEmitterSerializationAdapter.spec`
(both exercise the `sopra === null` path) stay green every phase. New coverage per phase via
`MockAudioContext` + `StubBufferProvider`. **Not unit-testable** (verify in-browser): AudioContext
bootstrap, autoplay-resume, audible result, dat.gui editor.

Top risks: **save-compat** (keep both adapters round-tripping — high); **gain composition** double-fold
(one rule, dB↔linear round-trip test — med); **createSound handle** (return a real Entity that stops
its instance on destroy — med); **`#instanceGain` topology change** perturbing the 47 tests (unity gain,
re-run suite immediately — med); **`seek` reseeding RNG** (must not — med).

### 14.6 Open decisions (recommendations in **bold**)
1. Music crossfade ownership → **keep `CombatEndMusicProcess` locating the music emitter + calling
   `sopra.crossfade`** (a `MusicDirector` is later scope).
2. Prototypes → **keep the `sopra === null` branch** through P3 (strangler safety net); delete later.
3. `createSound` return → **real `Entity`** (callers unchanged); revisit only if the UI-click path shows cost.
4. Per-track volume fold → **`description.gainDb` per instance + `#instanceGain` for live volume**.
5. `resetSoundEmitterTracks` → **`restart()`** (not stop+replay — preserves RNG/loading).
6. `SoundController` → **keep the add/remove plumb** in v1 (single consumer: `UnitMaker`).

### 14.7 LOCKED API contract + no-switch refinement (owner-directed; supersedes the above where they differ)

**No switch.** There is no `sopra === null` fallback. `SoundEmitterSystem` ALWAYS routes through sopra.
Constructor becomes `SoundEmitterSystem(assetManager, soundEngine)` (context/destination derived from
it; it always `createSopra`s). All 11 construction sites pass the `SoundEngine` (the 10 prototypes/
`testEffect` pass `sound`/`engine.sound`; the unit spec passes a `MockAudioContext`-backed stub whose
`createSopra` builds a real `SopraEngine` on the mock + a `StubBufferProvider`). The legacy WebAudio
renderer is **deleted outright**, not guarded: per-track time-advance loop, `SoundTrackNodes`,
connect/disconnect virtualization, the channel `GainNode`s, `buildNodes`. (The two background-added
`SoundEmitterSystem.spec` tests cover that deleted loop and are replaced by sopra-routing tests.)

**Preserved public API — the whole contract. Everything else is removed or throws.**

`SoundEmitter`:

| member | plumbing into sopra |
|---|---|
| `tracks` (List) | add → `playEvent` + map; remove → `instance.stop()`; reconciled via `tracks.on.added/removed` |
| `channel` (string) | `EventDescription.busId` (read at translation; `""` → `effects`) |
| `volume` (Vector1) | `onChanged` → each owned `instance.setGainDb(volume2dB(emitter.volume × track.volume))` |
| `flags` | `Spatialization` → `is3D`; `Attenuation` → attenuation curve (read at translation) |
| `distanceMin` / `distanceMax` | `EventDescription.distanceMin/Max` |

`SoundTrack`:

| member | plumbing into sopra |
|---|---|
| `on.ended` (`{ended:Signal}`) | the track's `EventInstance.onEnded` → `on.ended.send1(track)` |
| `flags` | `Loop` → `clip.loop`; `UsingAliasURL` → `clip.usingAlias`; `StartWhenReady` → autoplay |
| `volume` (number) | NOT baked at translation; carried live in the owning instance gain (`emitter.volume × track.volume`), re-applied on a live set via `__soundRuntime` |
| `time` (number) | written back from the instance playhead each frame; rewind via `restart()` (used by `resetSoundEmitterTracks`) |
| `url` (string) | `SampleAudioClip.url` |

**Node access → fail loud, explicitly NOT public API** (as built). The property getters
`SoundEmitter.nodes` and `SoundTrack.nodes`, plus `SoundTrack.setVolumeOverTime`, are throwing stubs
(`get nodes() { throw new Error("... sopra owns the audio graph"); }`) — these are the members external
code could read. The internal builders that had **no external caller** (`buildNodes` / `getTargetNode` /
`writeAttenuationVolume` / `start` / `suspend` / `initializeNodes`) and the `SoundTrackNodes` class were
removed outright. Any straggler (e.g. `CombatEndMusicProcess` reaching `track.nodes.volume.gain`) breaks
loudly → rewritten (it now uses `fadeOutAllTracks(1)` + `tracks.add`).

**Kept as trivial `@deprecated` plumbing** (not in the contract, but behavioral with real callers, 1:1
to instance ops): `SoundEmitter.fadeOutAllTracks` → `instance.fadeOutAndStop` per owned instance;
`stopAllTracks` → owner `stopAll`. (Backs `hideEntityGracefully` / `AnimatedActions` unchanged.)

**Kept for save-compat (data only, unchanged):** both serialization adapters + the component's
serializable fields, including `isPositioned` (a thin `flags` wrapper retained for the editor +
serialization). **Removed entirely** (callers migrated): the `SoundEmitterComponentContext` WebAudio
body (gutted to a translation record), `endTrack`, `distanceRolloff`, the dead-but-unwritten
`SoundTrack.duration` (dropped from the locked surface), and the rest of the non-contract surface.

This also resolves §14.6 #2 (no `sopra === null` branch) and revises §14.4 (the node members move from
"retained @deprecated" to "throwing stubs"; there is no legacy fallback to retain).

## 15. P5 — spatial scaling to 100k emitters (BVH broadphase + live/dormant split)

**Goal.** Support ~100,000 registered 3D `AudioEmitter`s with per-frame work and WebAudio node count
bounded by a small audible budget `K`, **not** by the registered count. The fix is to decouple a
*registered emitter* (cheap data + one BVH leaf) from a *live `EventInstance`* (WebAudio nodes): only
emitters whose own audible sphere reaches the listener — capped to `K` — are ever live.

### 15.1 Principle — broadphase / narrowphase (mirrors `PhysicsSystem`)
- **Broadphase (new, cheap, ALL emitters):** a dynamic BVH of emitter audible-spheres; one point query
  at the listener returns the emitters that *can possibly* be heard.
- **Narrowphase (existing, ≤K live):** the current `EventInstance` per-frame exact
  distance/attenuation + virtualization (`virtualThresholdDb`) decides which live instances actually
  hold a source `Voice`. Unchanged.

### 15.2 Three tiers

| Tier | Count (island) | Cost each | Where |
|---|---|---|---|
| **Registered / dormant** | ~100k | 1 record + 1 BVH leaf — **no `EventInstance`, no nodes, no per-frame update** | `SpatialAudioIndex` |
| **Live** | ≤ `K` (default **64**) | an `EventInstance` (panner chain), ticked each frame | sopra + the live set |
| **Audible voice** | ≤ K, usually fewer | a `Voice` (source node) | existing virtualization |

### 15.3 Reuse (this layer is thin — the BVH does the heavy lifting)
- `core/bvh2/bvh3/BVH.js` + **`BvhClient`** (`link(tree,data)`/`unlink()`/`resize(x0..z1)`/`write_bounds`)
  — one leaf per emitter; the same per-entity handle `RenderSystem`/`PhysicsSystem` already use.
- `bvh_query_user_data_generic(out, 0, bvh, bvh.root, BVHQueryIntersectsSphere.from([lx,ly,lz, 0]))` —
  the per-frame cull (point-in-leaf collect). `bvh_query_user_data_nearest_to_point` available if a
  nearest-N selector is preferred over collect-then-select.
- Existing `EventInstance` virtualization / `seek` / `fadeOutAndStop`, the `VOICE_POOL`, and
  `SoundListener`+`Transform` — all unchanged.

### 15.4 Key trick — leaf AABB = the emitter's OWN audible region (no global radius — owner-locked)
Each emitter's leaf is sized to its audible sphere: `AABB = position ± event.distanceMax`. The cull is
then a **point query at the listener**, returning exactly the emitters whose `distanceMax` reaches the
listener — honoring each emitter's own range, with no hardcoded global radius.

### 15.5 Reactive leaf maintenance (NO per-tick iteration — owner-locked)
Everything in meep is dynamic; there is **no static/dynamic flag and no per-tick refit loop**. On
`add`, the leaf subscribes to the emitter's `transform.position.onChanged` (the position channel of
`Transform.subscribe`); the handler does `bvhClient.resize(position ± distanceMax)`. Unsubscribe on
`remove`. Cost is proportional to *actual movement*, not emitter count: a forest of still trees refits
zero leaves/frame; 100k handlers that never fire are just memory. (Live instances separately read the
shared `position` Vector3 each frame for their panner — but only ≤K of those exist.)

### 15.6 `SpatialAudioIndex` + `LiveEmitterSet` (two thin pieces) [as-built — the sketch's single class split in two]
- **`SpatialAudioIndex`** (cull-only): owns the emitter `BVH` + records `{ entity, event, position,
  bvhClient, onMove }`.
  - `add(entity, event, position)` → record + `bvhClient.link(bvh, entity)` (leaf user_data = the entity
    id, so queries return entity ids) + `resize(pos ± distanceMax)` + subscribe `position.onChanged`.
  - `remove(entity)` → unsubscribe + `bvhClient.unlink()` + drop the record.
  - `queryAudible(out, out_offset, listenerPosition)` → writes candidate entity ids into the caller's
    reused buffer from `out_offset`, returns the match count (allocation-free; meep out-buffer convention).
- **`LiveEmitterSet`** (the live/dormant lifecycle): wraps the index; owns the live map
  `{ entity → EventInstance }` + per-emitter records `{ event, position, gainDb, playingSince }`; config
  `{ budget = 64, liveStickiness = 0.8, fadeOutSeconds = 0.15 }`.
  - `add(entity, event, position, gainDb)` → register with the index + record (dormant).
  - `remove(entity)` → demote (hard cut) if live, then `index.remove`.
  - `setGainDb(entity, gainDb)` → update the record + the live instance if any (survives re-promotion).
  - `refresh(listenerPosition)` → cull + budget + diff, **every tick** (§15.7).

Non-3D emitters (and finite-3D one-shots) skip the index entirely — the ECS layer plays them directly
(§15.13 P5.6); only persistent looping 3D emitters are spatially managed.

### 15.7 `LiveEmitterSet.refresh` — every tick [as-built]
1. **Cull:** `queryAudible` point query → candidate entity ids (reused buffer); an exact spherical
   refine then drops the conservative-cube corners (`distance > distanceMax`).
2. **Budget select:** rank in-range candidates by **effective distance** — a live emitter competes as
   `distance × liveStickiness` so it does not flicker promote/demote at the cutoff (rank hysteresis) —
   and take the nearest `budget`. (The sketch's extra `event.priority` / attenuation-gain tie-breakers
   were not needed; pure distance + stickiness.)
3. **Diff** vs the current live set (`#live`) → **demote** leavers, **promote** entrants; at budget a
   closer entrant steals the farthest live slot. The diff is re-derived from `#live` each call (so a
   denied or self-ended promotion is retried automatically), with demote-before-promote ordering.
4. `sopra.update(now)` — the caller's, right after `refresh` — ticks only the ≤budget live instances.

**No throttle (§15.10):** this runs on every tick. The BVH query is microseconds and the diff is
re-derived from `#live`, so there is nothing to gate; the scratch containers are reused so the per-tick
diff is allocation-light apart from the rank entries.

### 15.8 Promotion / demotion + voice slots (allocated at go-live — owner-confirmed)
`K` is the voice-slot pool, allocated at promotion (the only moment WebAudio cost is incurred).
- **promote:** `sopra.playEvent(event, { position, oneShot, startTime })` where `startTime = playingSince`
  reconstructs the phase (§15.9 — via the `EventInstance.startTime` option, not a post-hoc `seek`), then
  apply the emitter's `gainDb`, then store the instance.
- **demote — two cases (owner-locked):**
  - **culled out of range** (the emitter left its own audible sphere → already ~0 gain / virtual):
    **hard cut** (`instance.stop()`). A fade is pointless/wrong — it is already inaudible.
  - **contention / stolen** (a closer emitter evicts a still-in-range, audible live one): **fade**
    (`fadeOutAndStop`) to avoid a click.
- **stealing = distance priority:** at budget, a closer entrant demotes (steals the slot of) the
  farthest live record.

This sits *on top of*, not replacing, sopra's per-event `maxInstances` (polyphony among the live) and
per-instance virtualization (source-voice gating). **Constraint (P5.2):** `budget` and `maxInstances`
are independent composed caps. For content-equal ambience you want up to N copies of audible at once,
that event's `maxInstances` must be ≥ N, else sopra gates the budget — `stealMode None` denies the
promote (emitter stays dormant, retried next refresh) and `stealMode Oldest` makes the two layers fight
(churn). Set managed ambience `maxInstances` high (P5.6 wiring).

### 15.9 Phase reconstruction (continuous world clock — owner-decided; supersedes the entity-id phaseOffset idea)
Dormant emitters don't simulate playback, but the loop conceptually keeps running on the world clock.
Record `playingSince` (audio clock) **once at registration**; on promotion the instance starts with
logical `startTime = playingSince`, so its playhead is `now − playingSince` and the looping voice's
buffer offset wraps into the loop region. A bird you approach is mid-song (continuity); a re-promotion
after a cull resumes at **total elapsed since link** (the loop kept running while you were away).
Deterministic from real time — **no engine-invented random phase**. Decorrelation/variety is the content
author's job (existing per-trigger pitch/gain randomization, RandomContainer), not an engine concern.
(Rejected the earlier auto entity-id phaseOffset: opinionated/magic, and for sparse chirps the artifact
is imperceptible; phase-lock matters mainly for tonal loops, which the author can vary.)

### 15.10 Churn control (promotion has real cost)
- **Hysteresis (as-built):** rank hysteresis via `liveStickiness` — a live emitter competes at
  `distance × liveStickiness` (default 0.8), so a newcomer must be clearly closer to steal its slot; this
  kills promote/demote flicker at the budget cutoff. (The sketch's leaf-AABB / `distanceMax + margin`
  approach was not needed — range-edge churn is inaudible, gain ≈ 0 there; the audible flicker is at the
  *budget* cutoff, which the rank discount handles.) Demote-by-cull is at exactly `distanceMax` (the
  spherical refine).
- **Cull every tick — NO throttling (owner-decided, P5.5 reverted).** `refresh()` runs the full cull
  on every `update` tick. The BVH point-query is O(log n + candidates) (microseconds; the 100k stress
  shows the whole per-frame `refresh + tick` is ~5 ms), and the promote/demote diff is re-derived from
  `#live` each call, so there is no reason to throttle. A throttle was briefly added and removed — it was
  unnecessary complexity (and introduced a contention-fade gap edge). The diff is allocation-light: the
  scratch containers (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused
  across calls. (`IncrementalDeltaSet` was considered for the diff but does not fit: `#live` changes
  out-of-band — instances self-end on asset failure, and promotions can be denied by polyphony caps — so
  the diff must be re-derived from `#live` each frame, which a maintained delta set would fight.)
- **Pool the per-instance output chain** (instanceGain → attenuationGain → panner) — **DEFERRED
  (measure-first).** Original rationale was the "1000 live panners / panner not torn down on virtualize"
  cost; the P5.1–P5.2 live/dormant model already bounds live panner chains to `budget` (≤64), and
  hysteresis minimises promote/demote frequency, so this is now a constant-factor micro-opt on ≤64
  chains churned only on genuine live-set changes. Per project guidance (reuse-over-micro-opt, measure
  first, treat constant-factor diffs as noise) this is held until profiling shows promote/demote
  node-churn is actually hot. AudioBufferSourceNodes are single-use and stay pooled via `VOICE_POOL`;
  only Gain/Panner nodes would be poolable.

### 15.11 What changes where
- `SopraEngine` core: **unchanged**; its `update` is now O(K) (only live instances are active). No new
  public surface (`playEvent`/`stop`/`seek` exist).
- **New `SpatialAudioIndex`** (BVH cull + records, thin over the BVH + queries) and **`LiveEmitterSet`**
  (the live/dormant lifecycle: budget + promote/demote/steal + per-emitter gain) layered on it.
- `LiveEmitterSet.refresh` culls every tick (no throttling — §15.10), with reused scratch so the diff is
  allocation-light. The per-instance output-chain pool is **deferred** (measure-first — §15.10): not
  needed for the 100k target since live chains are already bounded to `budget`.
- `AudioEmitterSystem` (DONE, P5.6): `link/unlink` → `liveSet.add/remove` for managed (looping 3D
  autoplay) emitters; `update` → `liveSet.refresh(listener)` + `sopra.update`. 2D + finite-3D-one-shot
  autoplay take the direct play path; non-autoplay is inert. No more unconditional autoplay-per-emitter.
- `AudioEmitter`: **no** `dynamic` flag. `VoiceManager`: unchanged.

### 15.12 Scorecard (target, 100k emitters)
- Per frame: 1 BVH point query (O(log n + candidates)) + top-K select + O(K) live ticks. **Independent
  of 100k.**
- WebAudio: ≤K panner chains + ≤K voices.
- Memory: 100k records + 100k BVH leaves (~a few MB, typed-array backed) + shared events/buffers. Leaf
  refits ∝ movement, not count.

### 15.13 Rollout (incremental, test-first; MockAudioContext + synthetic emitter field + movable mock listener)
- **P5.1 — DONE** (meep `73f9becc1`). `SpatialAudioIndex` (`engine/sound/ecs/audio/SpatialAudioIndex.js`)
  cull only: BVH add/remove + reactive `position.onChanged` refit + point query (`BVHQueryIntersectsSphere`
  radius 0) → candidate entity ids; leaf user_data = entity id; leaf AABB = `position ± distanceMax`;
  reused query object + result buffer. 12 black-box tests (in/out per-emitter range, reactive refit both
  ways, removal+detach, boundary-inclusive on-axis, repeated-query buffer reuse, 1000-emitter cull). No
  promotion yet.
- **P5.2 — DONE** (meep `a39ecbbe6`). `LiveEmitterSet` (`engine/sound/ecs/audio/LiveEmitterSet.js`)
  composes the index: `refresh(listener)` promotes nearest-in-range up to `budget` (64), demotes the
  rest; distance-priority stealing; rank hysteresis via live-stickiness; exact spherical refine over the
  conservative cube; demote-frees-before-promote-fills; zombie-safe demote (delete-before-stop) + self-
  end cleanup. Hard cut on demote (fade-on-contention = P5.3). Documented budget↔maxInstances
  dependency + pinned by a test. 12 black-box tests.
- **P5.3 — DONE** (meep `1e0a0deee`). Demotion policy in `LiveEmitterSet`: refresh builds an `inRange`
  set (exact distanceMax); a demoted live emitter still in range is faded (`fadeOutAndStop`,
  `fadeOutSeconds` default 0.15) — audible, would click — while one that left range is hard-stopped
  (already inaudible). `remove()` (unlink) hard-cuts. Slot frees immediately (delete-before-fade); the
  onEnded `=== instance` guard makes re-promote-during-fade safe. Tests: cut-on-cull, fade-on-contention,
  re-promote-during-fade no-clobber. Adversarial review → CLEAN.
- **P5.4 — DONE** (meep `ad96e3a00`). Continuous-clock phase reconstruction (see §15.9, owner-decided —
  no random phaseOffset). `EventInstance` gained a logical `startTime` option (playhead = now − startTime)
  + a looping-voice buffer-offset wrap (also fixes long-dormant virtualization revival).
  `LiveEmitterSet` records `playingSince` once at add and promotes with it, so a re-promotion after a
  cull resumes at total elapsed since link. Zombie guard for a finite event whose play time already
  elapsed (self-stops synchronously during start). Tests: resume-at-elapsed + offset-wrap,
  continuity-across-cull, seek-wrap, finite-elapsed guard. Adversarial review → CLEAN.
- **P5.5 — cull throttling ADDED then REVERTED** (added meep `8264bc328`, reverted later). A throttle
  (`cullIntervalSeconds`/`cullMoveThreshold`) was briefly added, then removed at the owner's direction:
  the BVH point-query is microseconds and the diff is re-derived from `#live` each call, so there is no
  reason not to cull on every `update` tick (100k stress: ~5 ms per `refresh + tick`). What remains from
  P5.5: `refresh()` culls every tick with an **allocation-light diff** — the scratch containers
  (`#ranked`, `#inRange`, `#target`, `#demoteScratch`, `#candidates`) are reused across calls; the
  comparator is module-static. `IncrementalDeltaSet` was considered for the diff but does not fit (`#live`
  changes out-of-band: self-end on asset failure, denial by polyphony caps — the diff must re-derive from
  `#live`). The +9 throttle tests were removed. **Output-chain pool still DEFERRED (measure-first)** —
  see §15.10 (live/dormant already bounds panners to budget; held until profiling shows churn is hot).
- **P5.6 — DONE** (meep `d4ba84cd4`). Wired `AudioEmitterSystem` to `LiveEmitterSet`. Routing fixed at
  link: **managed** = `autoplay && is3D && rootClip.loops()` → `liveSet.add(entity, event, position,
  volume2dB(volume))`, dormant until refresh; **direct** = any other autoplay (2D, or finite 3D one-shot)
  → immediate `#play`; **inert** = non-autoplay. (Finite 3D one-shots are deliberately direct, not
  managed — a dead one-shot would otherwise re-promote+self-stop every cull and occupy a budget slot:
  the P5.6 review's main RISK.) `update` resolves the `SoundListener`, then refreshes the live set BEFORE
  `sopra.update` (fresh promote spatializes same frame); a listener entity missing its `Transform` is
  tolerated as "no listener" (guard) rather than throwing every frame. Per-emitter gain: `AudioEmitter.volume`
  carried by `LiveEmitterSet` (`add` 4th arg + `setGainDb`), replayed on every promotion (survives
  demote→re-promote). `instanceFor(entity)` exposes the active instance for either path; ctor takes
  `liveEmitterSetOptions`. **100k stress (both `.skip`):** `AudioEmitterSystem.spec` proves only `budget`
  hold a live instance (node count O(budget), not O(N)) + listener-walks-away frees all; `SpatialAudioIndex.spec`
  proves the cull returns only the in-range candidates (~200), not N → per-frame work O(log N + k). 298
  engine/sound green (34 suites), 2 skipped. Adversarial workflow review (6 dims / 21 agents): no
  BUG-level defects; acted on the confirmed RISKs + doc fixes.
- **Remaining (post-P5):** register `AudioEmitterSystem` in `makeMirEngineConfig` (production wiring; it
  is currently constructed only in tests) and the standing background follow-ups (P5.5 output-chain pool
  if profiling demands it; IR1/IR4; dormant-material cleanup).

### 15.14 Locked decisions (owner)
- Budget **K = 64** (live instances; virtualization trims actual voices further).
- **No global radius** — cull by per-emitter `event.distanceMax` (leaf = `pos ± distanceMax`).
- **No static/dynamic distinction** — everything is dynamic; leaves refit reactively via
  `transform.position.onChanged`, never per-tick iteration.
- **Demotion:** hard **cut** when culled out of range (already inaudible); **fade** only on contention
  (stealing a still-audible live instance).
