# Sopra

An object-oriented, data-driven sound engine for the meep ECS, loosely modelled on FMOD / Wwise
concepts (events, a mixer bus tree, RTPC parameters, voice stealing, snapshots, ducking) but built
around meep's own data model instead of an opaque middleware bank.

Sopra is **ECS-agnostic at its core**. The engine (`SopraEngine` + its services) knows nothing about
entities; it is a self-contained audio renderer driven by a single clock. A thin ECS layer
(`AudioEmitter` + `AudioEmitterSystem`, outside this folder under `sound/ecs/audio/`) binds it to the
game. You can use the core standalone (tools, previews, tests) with nothing but a `BaseAudioContext`
and a `BufferProvider`.

> The name "sopra" is internal. The player-facing / integration layer uses familiar terms
> (`AudioEmitter`, `AudioEventTrigger`); "sopra" never appears in user-facing API.

---

## The one idea you must understand: definition vs. runtime

Everything in Sopra is split into two halves. Get this and the rest follows.

| | **Definition** (authored data) | **Runtime** (transient playback) |
|---|---|---|
| What | `EventDescription`, the `*AudioClip` graph, `BusDefinition`, `*AudioEffect`, `ParameterDefinition`, `MixerSnapshot`, `DuckingRule` | `EventInstance`, `Voice`, the live WebAudio node graph |
| Lifetime | Immutable, shared, **serializable** (save/load, network) | Created on trigger, pooled, **never serialized** |
| Holds | Spec only — urls, gains in dB, curves, child clips, routing | WebAudio nodes, the resolved timeline, the playhead, RNG state |
| Lives in | `definition/` | `runtime/` |

A definition is a recipe. Playing it spawns a transient `EventInstance` that reads the recipe once,
resolves a concrete timeline of leaf plays, and drives WebAudio nodes. The same definition can back
many simultaneous instances. **Definitions never hold playback state** — that is the rule the whole
architecture defends.

```
EventDescription (definition)
        │  engine.playEvent(...)
        ▼
   EventInstance (runtime)  ──spawns──►  Voice ──►  bus chain ──►  destination
```

---

## Directory map

```
sopra/
  SopraEngine.js              the facade: owns the bus graph, parameters, active instances, the clock
  definition/                 immutable, serializable authored data (+ co-located *SerializationAdapter.js)
    EventDescription.js         the triggerable unit (root clip + routing + 3D + voice config)
    BusDefinition.js            one node in the mixer tree (gain, effect chain, aux sends)
    ParameterDefinition.js      a declared RTPC parameter
    MixerSnapshot.js            a named set of per-bus target gains
    DuckingRule.js              an emulated sidechain duck (trigger bus → target bus)
    SopraPanningModel.js        HRTF | EqualPower
    VoiceStealMode.js           None | Oldest | Quietest
    clip/                       the clip graph — what actually makes sound
      AbstractAudioClip.js        base; planTimeline() + loops() + collectSampleClips()
      SampleAudioClip.js          a single audio asset (the only buffer-referencing leaf)
      SilenceAudioClip.js         dead air (a gap in a sequence)
      SequenceContainerAudioClip.js   plays children in order
      RandomContainerAudioClip.js     plays one child at weighted random (avoid-repeat)
      SwitchContainerAudioClip.js     plays one child chosen by a parameter (discrete)
      BlendContainerAudioClip.js      plays several children, gains driven by a parameter (continuous)
    effect/                     bus insert effects
      AbstractAudioEffect.js      base; build(ctx) → { input, output }
      EqEffect.js                 BiquadFilter
      CompressorEffect.js         DynamicsCompressor
      ReverbEffect.js             Convolver (procedural impulse response)
  runtime/                    transient playback state
    EventInstance.js            one triggered playing of an event (cursor-based scheduler)
    Voice.js / VoicePool.js     one pooled AudioBufferSourceNode + its gain/detune
    BusGraph.js                 instantiates BusDefinitions into chained WebAudio nodes
    VoiceManager.js             per-event instance limits + voice stealing
    ParameterStore.js           live RTPC values + change bindings
    SopraPlaybackContext.js     per-source seeded RNG + random-container history (determinism)
  asset/                      how the engine gets decoded buffers
    BufferProvider.js           the interface (tryGet sync / get async)
    AssetManagerBufferProvider.js   production: pulls from the meep AssetManager
    StubBufferProvider.js       in-memory, for tests / offline
  serialization/              sopraJSON (type-tag JSON dispatch) + the binary registry + test harness
  util/                       buildAttenuationCurve, fadeOutAndStop, MockAudioContext
  legacy/                     SoundEmitter → sopra translator (strangler migration only)
```

The ECS binding lives **outside** this folder, under `sound/ecs/audio/`: `AudioEmitter.js` (component),
`AudioEmitterSystem.js` (system), and the spatial-scaling layer `SpatialAudioIndex.js` (BVH cull) +
`LiveEmitterSet.js` (live/dormant budget) that lets it carry ~100k 3D emitters (see ECS integration).

---

## Quick start (standalone)

The core needs three things: an audio context, a destination node, and a `BufferProvider`.

```js
import { SopraEngine } from "./SopraEngine.js";
import { BufferProvider } from "./asset/BufferProvider.js";
import { EventDescription } from "./definition/EventDescription.js";
import { SampleAudioClip } from "./definition/clip/SampleAudioClip.js";

// 1. Tell Sopra how to obtain decoded AudioBuffers. Here, a trivial in-memory provider.
class MapBufferProvider extends BufferProvider {
    constructor(map) { super(); this.map = map; }                  // Map<url, AudioBuffer>
    tryGet(url, usingAlias) { return this.map.get(url) ?? null; }  // sync fast path (already decoded)
    get(url, usingAlias) {                                         // async path (rejects if unavailable)
        const b = this.map.get(url);
        return b ? Promise.resolve(b) : Promise.reject(new Error(`missing ${url}`));
    }
}

const ctx = new AudioContext();
const buffers = new Map([["click.ogg", await ctx.decodeAudioData(bytes)]]);
const engine = new SopraEngine(ctx, ctx.destination, new MapBufferProvider(buffers));

// 2. Describe an event (a definition). Reuse it as many times as you like.
const click = EventDescription.from("ui.click", SampleAudioClip.from("click.ogg"), { busId: "effects" });

// 3. Play it. playOneShot auto-releases when the sound finishes.
engine.playOneShot(click);

// 4. Drive the engine once per frame — this is mandatory (see "The update loop").
function frame() { engine.update(); requestAnimationFrame(frame); }
frame();
```

`SopraEngine` ships with a default bus tree — `master` → (`effects`, `music`, `ambient`) — at the
legacy mix, so `busId: "effects"` works out of the box.

---

## The clip graph

`EventDescription.rootClip` is a tree of `AbstractAudioClip` nodes. Each node implements
`planTimeline()`, which the instance calls **once** at trigger time to flatten the tree into a list of
timed leaf plays. Gain (dB) and pitch (cents) accumulate additively down the tree and are resolved
there — the runtime never sees an "inherit" sentinel.

Only `SampleAudioClip` references a buffer; everything else arranges, selects, or layers.

### SampleAudioClip — a single asset (leaf)

```js
SampleAudioClip.from("explosion.ogg", {
    gain: -3,          // dB, added to the event/parent gain
    pitch: 0,          // cents
    loop: false,
    loopStart: 0, loopEnd: 0,
    pitchRandom: 50,   // ± cents, randomised per trigger (deterministic, see below)
    gainRandom: 2,     // ± dB,    randomised per trigger
    usingAlias: false  // if true, `url` is an AssetManager alias, not a path
});
```

A `loop: true` sample plays forever — it makes its event a **persistent source** (see one-shot
vs. persistent below).

### SilenceAudioClip — a gap

```js
SilenceAudioClip.from(0.5); // 0.5s of dead air, e.g. between sequence steps
```

### SequenceContainerAudioClip — children in order

```js
SequenceContainerAudioClip.from([
    SampleAudioClip.from("intro.ogg"),
    SilenceAudioClip.from(0.25),
    SampleAudioClip.from("loop.ogg", { loop: true })   // a looping child is terminal: the sequence
]);                                                    // cannot advance past it
```

### RandomContainerAudioClip — one child at weighted random

Picks one child per trigger, avoiding the last N picks. Weights are **per-child, one entry per child**
(default `1` = uniform); a weight of `0` means "never picked".

```js
RandomContainerAudioClip.from(
    [SampleAudioClip.from("step1.ogg"), SampleAudioClip.from("step2.ogg"), SampleAudioClip.from("step3.ogg")],
    { avoidRepeatingLast: 1, weights: [3, 1, 1] }      // step1 ~3× as likely; never the same twice in a row
);
```

Selection is **deterministic** given a seed (see Determinism) — critical for networked play.

### SwitchContainerAudioClip — one child by a parameter (discrete)

Reads a parameter once at trigger, rounds + clamps it to a child index. Classic surface-switch:

```js
SwitchContainerAudioClip.from(
    [grassClip, stoneClip, woodClip],
    { parameter: "surface", defaultValue: 0 }          // surface 0→grass, 1→stone, 2→wood
);
```

### BlendContainerAudioClip — several children, gains by a parameter (continuous)

Reads a parameter once at trigger and plays every child whose blend gain is > 0, each scaled by its
per-child `AnimationCurve` (parameter value → linear gain). A child with no curve is always full.

```js
import { AnimationCurve } from "../../animation/curve/AnimationCurve.js";

BlendContainerAudioClip.from(
    [calmLayer, stormLayer],
    {
        parameter: "weather",
        blends: [
            AnimationCurve.linear(0, 1, 1, 0),         // calm: full at 0, silent at 1
            AnimationCurve.linear(0, 0, 1, 1)          // storm: silent at 0, full at 1
        ]
    }
);
```

> v1 Blend is a **trigger-time snapshot**: the mix is fixed when the event starts; it does not
> re-blend live as the parameter sweeps afterwards. Live per-voice re-blend is a planned follow-up.

---

## Events

`EventDescription` is the triggerable unit. It wraps a root clip with routing, 3D, and voice config.

```js
import { SopraPanningModel } from "./definition/SopraPanningModel.js";
import { VoiceStealMode } from "./definition/VoiceStealMode.js";

EventDescription.from("monster.roar", rootClip, {
    busId: "effects",          // which mixer bus this routes to
    gainDb: 0,                 // event master gain
    is3D: true,                // enable spatialization
    panningModel: SopraPanningModel.HRTF,
    distanceMin: 2,            // panner reference distance
    distanceMax: 60,           // beyond this the source is culled (virtualized)
    attenuation: AnimationCurve.linear(0, 1, 60, 0),  // distance → linear gain multiplier
    maxInstances: 8,           // concurrency cap for this event
    priority: 0,
    stealMode: VoiceStealMode.Oldest,
    virtualThresholdDb: -60    // below this post-attenuation gain a voice goes virtual
});
```

Non-3D events ignore the 3D fields; `attenuation` defaults to a flat-1 curve (no attenuation).

An `EventDescription` is a plain object — hold it wherever you like (a module constant, a component
field, a map of your own) and pass it straight to the playback methods:

```js
engine.playOneShot(roar, { position: monsterPos });
```

---

## Playback and instance control

`playEvent` / `playOneShot` / `createInstance` / `crossfade` all take an `EventDescription` directly —
there is no id registry. Keep a reference to the description and pass it in.

```js
// fire-and-forget; auto-releases when the content finishes
engine.playOneShot(click);

// keep the handle to control it; persistent until you stop it
const inst = engine.playEvent(music, { busId: "music" });

// build without starting (e.g. to configure first), then start manually
const i = engine.createInstance(music);
i.start(engine.currentTime);
```

`playEvent` returns the `EventInstance`, or **`null`** if the play was denied by the voice limit
(steal mode `None`). Always handle the null.

### One-shot vs. persistent

- `oneShot: true` (what `playOneShot` sets) — the instance **self-releases** when its timeline
  finishes, and is killed by a `maxLifetime` backstop (default 60s) as a safety net.
- `oneShot: false` (default for `playEvent`) — the instance **persists** until you stop it. Use this
  for looping music/ambience (whose root clip loops). A *non-looping*, non-one-shot instance plays
  once then sits idle (silent) until stopped — usually not what you want, so loop the clip or use
  `playOneShot`.

The ECS `AudioEmitterSystem` derives `oneShot` automatically from `event.rootClip.loops()`.

### EventInstance API

```js
inst.setGainDb(-6);              // immediate volume (dB), composes with 3D attenuation
inst.fadeToGainDb(0, 2);         // click-safe ramp to 0 dB over 2s (does not stop)
inst.fadeOutAndStop(1.5);        // fade to silence over 1.5s, then stop (audio-clock deadline)
inst.seek(10);                   // jump the playhead to 10s (keeps the resolved timeline + RNG)
inst.restart();                  // seek(0)
inst.stop();                     // stop now, tear down nodes, fire onEnded

inst.playhead;                   // current position in seconds
inst.gainDb;                     // current instance gain (dB)
inst.state;                      // EventInstanceState: Initial | Playing | Stopped
inst.currentGain;                // current post-attenuation linear gain
inst.onEnded.add(i => { ... });  // fires once when the instance fully stops
```

### Crossfade

```js
// fade `current` out and a fresh play of `calmTrack` in, over 3s
const next = engine.crossfade(current, calmTrack, { busId: "music", duration: 3 });
```

---

## Buses, effects, and sends

The mixer is a tree of `BusDefinition`s. Each bus is `input → effect[0] → … → effect[n] → gain`, and
its output routes into its parent's input (or the engine destination for a root bus, `parentId: ""`).

`setBuses` **replaces** the whole tree (and rebuilds the WebAudio graph). Effects and sends are wired
at build time; there is no live effect insertion.

```js
import { BusDefinition } from "./definition/BusDefinition.js";
import { EqEffect, EqFilterType } from "./definition/effect/EqEffect.js";
import { CompressorEffect } from "./definition/effect/CompressorEffect.js";
import { ReverbEffect } from "./definition/effect/ReverbEffect.js";

engine.setBuses([
    BusDefinition.from("master", { gainDb: 0 }),                          // root (parentId "")
    BusDefinition.from("music",  { parentId: "master", gainDb: -6,
        effects: [CompressorEffect.from({ threshold: -18, ratio: 4 })] }),
    BusDefinition.from("sfx",    { parentId: "master",
        effects: [EqEffect.from({ filterType: EqFilterType.Highshelf, frequency: 6000, gainDb: -3 })],
        sends:   [{ targetBusId: "reverb", levelDb: -9 }] }),            // post-fader aux send
    BusDefinition.from("reverb", { parentId: "master",                   // a "reverb bus": others send to it
        effects: [ReverbEffect.from({ decaySeconds: 2.0, decayPower: 2 })] })
]);
```

A **send** routes a post-fader copy of a bus into another bus at a given level — the standard way to
share one reverb across many sources. `ReverbEffect` generates its impulse response procedurally
(decaying noise), so there is no IR asset to load and `build` stays synchronous.

Live bus volume (linear gain):

```js
engine.getBusVolume("music");        // → linear gain
engine.setBusVolume("music", 0.5);
```

---

## Parameters (RTPC)

Named float values that drive selection (`Switch`/`Blend`) and automation. They live in the
`ParameterStore`; `Switch`/`Blend` read them **once at trigger time**.

```js
engine.defineParameter("surface", 0);   // declare with a default (no-op if already defined)
engine.setParameter("surface", 1);      // 1 → the next footstep Switch picks child 1 (stone)
engine.getParameter("surface");         // → 1

// Drive a bus volume from a parameter through a curve — updates live as the parameter changes.
engine.bindParameterToBusVolume("tension", "music", AnimationCurve.linear(0, 0.2, 1, 1));
engine.setParameter("tension", 0.8);    // music bus volume follows the curve immediately

// Or bind an arbitrary callback (runs on change, and once now if already set).
engine.bindParameter("tension", value => console.log("tension is", value));
```

Note the timing difference: bus-volume bindings update **live**; clip selection (`Switch`/`Blend`) is
sampled **when the event is triggered**, so changing a parameter affects the *next* play, not voices
already sounding.

---

## 3D / spatialization

Set the listener position, give 3D events a position, and call `update()` each frame to re-attenuate.

```js
engine.setListener(camera.position);     // a shared Vector3

const inst = engine.playOneShot(roar, { position: monster.position });
// `position` is held by reference — moving the Vector3 moves the source; update() re-evaluates.
```

Per instance, distance attenuation is a custom gain node driven by `attenuation.evaluate(distance)`;
the `PannerNode` does direction only (rolloff disabled). Beyond `distanceMax`, or below
`virtualThresholdDb`, the instance goes **virtual**: its voices stop while the playhead keeps
advancing, and revive at the correct child + buffer offset when it becomes audible again.

Virtualization bounds the *voice* (source-node) count among the **live** instances. The bigger lever
for huge worlds is one tier up, in the ECS layer: the `LiveEmitterSet` keeps all-but-a-budget of the
registered 3D emitters **dormant** (not even an `EventInstance`), so a scene can register ~100k
emitters and still tick only ~budget of them. See **ECS integration**.

---

## Voice limits and stealing

`VoiceManager` caps concurrent instances **per event** at `EventDescription.maxInstances` and steals
when full, per the event's `stealMode`:

- `None` — over the limit, the new play is dropped (`playEvent` returns `null`).
- `Oldest` — stop the earliest-started instance.
- `Quietest` — stop the instance with the lowest post-attenuation gain.

Defaults (`maxInstances: 32`, `stealMode: Oldest`) effectively never engage until content opts into a
low limit — e.g. a machine-gun: `maxInstances: 6`.

---

## Mixer snapshots and ducking

### Snapshots — shift the whole mix between game states

A `MixerSnapshot` is a named set of per-bus target gains (dB). Apply it instantly or as a click-safe
ramp.

```js
import { MixerSnapshot } from "./definition/MixerSnapshot.js";

const combat = MixerSnapshot.from("combat", [
    { busId: "music",   gainDb: 0 },
    { busId: "ambient", gainDb: -12 }
]);

engine.applySnapshot(combat, { duration: 1.5 });        // ramp into the combat mix over 1.5s
const saved = engine.captureSnapshot("pre-combat", ["music", "ambient"]); // grab the current mix to restore later
```

### Ducking — emulated sidechain

WebAudio has no native sidechain, so this is **play-state** ducking: while any instance is live on the
trigger bus, the target bus is attenuated by `duckDb` (smooth `setTargetAtTime` attack/release),
restored to its captured nominal when the trigger goes quiet.

```js
import { DuckingRule } from "./definition/DuckingRule.js";

// duck music by 8 dB whenever anything plays on the effects bus
engine.addDucker(DuckingRule.from({
    triggerBusId: "effects", targetBusId: "music",
    duckDb: -8, attack: 0.1, release: 0.4
}));

engine.clearDuckers();   // remove all rules (restoring any currently-ducked target)
```

The trigger matches by `instance.busId`. Duckers are evaluated every `update()`.

---

## Determinism

Random selection (`RandomContainer`, per-trigger pitch/gain randomisation) runs on a **seeded** RNG
held in a `SopraPlaybackContext`. Same seed → identical pick stream → networked clients stay in sync.

```js
// seed from a replicated source, e.g. entity id + fixed-step tick
engine.playEvent(footsteps, { seed: entityId * 31 + tick });

// or supply your own context (its history persists across re-triggers — reuse one per emitter)
import { SopraPlaybackContext } from "./runtime/SopraPlaybackContext.js";
const ctx = new SopraPlaybackContext(seed);
engine.playEvent(footsteps, { playbackContext: ctx });
```

If you supply neither, the engine's shared `defaultPlaybackContext` is used.

---

## The update loop

`SopraEngine` is driven by a single clock — the injected `AudioContext.currentTime`. **You must call
`engine.update()` once per frame.** It advances every instance (scheduling/virtualization, 3D
attenuation, fade-out deadlines, one-shot release) and evaluates ducking rules.

```js
engine.update();             // uses the live clock
engine.update(someTime);     // or pass an explicit context time (tests do this)
```

Nothing self-schedules with `setTimeout`; fades and stops are resolved against the audio clock inside
`update`, so a late frame never desynchronises audio.

---

## Serialization

Definitions are fully serializable, two ways:

**JSON** — every definition has `toJSON()` / `fromJSON()`. Polymorphic clip/effect trees go through
type-tagged dispatch in `serialization/sopraJSON.js`:

```js
const json = description.toJSON();
const restored = new EventDescription();
restored.fromJSON(json);
```

**Binary** — each class has a co-located `<Class>SerializationAdapter.js`. Register them all on a
`BinarySerializationRegistry` with `populateSopraSerializationRegistry`:

```js
import { BinarySerializationRegistry } from "../../ecs/storage/binary/BinarySerializationRegistry.js";
import { populateSopraSerializationRegistry } from "./serialization/populateSopraSerializationRegistry.js";

const registry = new BinarySerializationRegistry();
populateSopraSerializationRegistry(registry);
```

Numeric fields are stored as float32. `serialization/sopraSerializationHarness.js` provides
`makeSopraObjectAdapter()` + `binaryRoundTrip()` for round-trip tests.

---

## ECS integration

In-game you almost never touch `SopraEngine` directly — you add an `AudioEmitter` component.

```js
import { AudioEmitter } from "../ecs/audio/AudioEmitter.js";

const emitter = new AudioEmitter();
emitter.event = EventDescription.from(
    "torch.loop", SampleAudioClip.from("torch.ogg", { loop: true }),
    { busId: "ambient", is3D: true, distanceMax: 20 }
);
emitter.autoplay = true;            // this is a looping 3D event -> spatially managed (sounds when the
                                    // listener is in range + within budget); see the two play paths below
emitter.volume.set(0.8);            // live multiplier on top of the event gain (Vector1)

entity.add(emitter).add(new Transform(...));
```

`AudioEmitter` **owns a full `EventDescription`** — the whole clip graph, routing, 3D, voice config —
as first-class ECS data. There is no global "bank"; the `EntityComponentDataset` *is* the data store.

`AudioEmitterSystem(assetManager, soundEngine, liveEmitterSetOptions?)` drives all emitters. It shares
the one `SopraEngine` (via `SoundEngine.createSopra(bufferProvider)`, idempotent), feeds the
`SoundListener` pose, and ticks the engine each frame. *How* an emitter plays is decided **once at
link**, from `autoplay` + the event:

- **Spatially managed** — `autoplay && is3D && looping`. Registered with a `LiveEmitterSet` (a BVH
  broadphase, `SpatialAudioIndex`, + a live/dormant lifecycle) and left **dormant**: no instance, no
  nodes, no per-frame work — just one BVH leaf. Each frame the system culls by the listener position and
  promotes the nearest in-range emitters up to a global voice **budget** (default 64), demoting the rest
  (a hard cut when culled out of range, a click-safe fade on contention). This is what lets the engine
  carry **~100,000 registered emitters** while only the audible ~budget ever hold WebAudio nodes — the
  per-frame cost tracks the budget, not the registered count.
- **Direct** — any other `autoplay` event (2D sounds like music / ambience beds, or finite 3D
  one-shots): played immediately on link, stopped on unlink. Nothing to cull by distance, and these are
  few.
- **Inert** — `autoplay === false`: neither played nor registered.

The entity `Transform.position` is held by the instance (a 3D source tracks the moving entity);
`AudioEmitter.volume` is a live multiplier carried **through** promote/demote, so a managed emitter
keeps its volume across a cull. `system.instanceFor(entity)` returns the active instance (or `null`
while dormant).

> **Content rule for managed crowds:** the live `budget` and an event's `maxInstances` are independent
> caps. If many emitters share one *content-equal* `EventDescription` (e.g. 100k identical birds), give
> that event `maxInstances ≥ budget` — content-equal events share one polyphony bucket, so a lower cap
> would gate the budget and fewer than `budget` would actually sound.

In production the provider is an `AssetManagerBufferProvider` over the meep `AssetManager` (shared
decode cache + the alias system).

---

## Testing

The entire core is unit-testable without real audio: a `MockAudioContext` records the constructed
graph and scheduled events, and a `StubBufferProvider` hands over fake buffers. Tests assert the graph
shape, timeline math, voice lifecycle, deterministic selection, and serialization — never sound.

```js
import { SopraEngine } from "./SopraEngine.js";
import { MockAudioContext } from "./util/MockAudioContext.js";
import { StubBufferProvider } from "./asset/StubBufferProvider.js";

const ctx = new MockAudioContext();
const provider = new StubBufferProvider();
provider.set("x.ogg", { duration: 2 });        // a fake buffer is anything with a `duration`
// provider.failOn("y.ogg");                    // simulate a failed/missing asset

const engine = new SopraEngine(ctx, ctx.destination, provider);
const inst = engine.playOneShot(EventDescription.from("e", SampleAudioClip.from("x.ogg")));

ctx.currentTime = 2;
engine.update(2);
// inst.state === EventInstanceState.Stopped  (the one-shot self-released at the end)
```

Run the suite from the repo root:

```
npx jest --config jest.conf.json engine/sound
```

---

## Design rules (don't break these)

- **Definitions are immutable, shared, and serializable.** No WebAudio nodes, no playback state, no
  per-instance mutation on a definition. Runtime state lives on `EventInstance` / the
  `SopraPlaybackContext`.
- **Gain is composed exactly once.** Authored gains are dB and accumulate down the clip tree at plan
  time; bus/instance gains are linear on their own nodes. Don't double-apply.
- **One clock.** Everything reads `AudioContext.currentTime`; no `setTimeout`-driven audio.
- **The core never creates an `AudioContext`** and never imports ECS. It takes a context, a
  destination, and a `BufferProvider`.
- **Errors surface.** Expected failures (missing asset, unresolved alias, limit reached) return a
  sentinel (`null`) or reject; unexpected errors throw. Nothing is silently swallowed.
- **Determinism is sacred.** Random selection is seeded from a replicated source; never reach for
  `Math.random()` in the playback path.

---

## Glossary

Acronyms and shorthand used in this document (and the code).

| Term | Expansion | Meaning in Sopra |
|---|---|---|
| **2D / 3D** | two- / three-dimensional | A 2D event is non-positional (plays straight into its bus); a 3D event (`is3D: true`) is spatialised — panned and distance-attenuated relative to the listener. |
| **API** | Application Programming Interface | The public methods/classes you call. |
| **aux send** | auxiliary send | A post-fader copy of a bus routed into another bus at a level (`BusDefinition.sends`), e.g. feeding a shared reverb bus. |
| **cents** | — | Pitch unit; 100 cents = 1 semitone, 1200 = 1 octave. All pitch offsets/randomisation are in cents. |
| **dB** | decibel | Logarithmic gain unit; 0 dB = unity, −6 dB ≈ half power. **All authored gains are in dB**; node gains on the WebAudio graph are linear (the two are converted by `dB2Volume` / `volume2dB`). |
| **ECS** | Entity Component System | meep's architecture: entities are ids, data lives in components, behaviour in systems. Sopra's *core* is ECS-agnostic; the ECS binding is `AudioEmitter` + `AudioEmitterSystem`. |
| **EQ** | equaliser | A filter insert (`EqEffect`), backed by a WebAudio `BiquadFilterNode`. |
| **FMOD / Wwise** | (product names) | Industry audio middleware. Sopra borrows their concepts — events, a bus tree, RTPC, voice stealing, snapshots, ducking — but is built on meep's data model, not a bank file. |
| **HRTF** | Head-Related Transfer Function | The high-quality (per-source convolution) 3D panning model; one of the `SopraPanningModel` options (the other, `EqualPower`, is cheaper). |
| **Hz** | hertz | Frequency unit (cycles/second), e.g. an EQ cutoff/centre frequency. |
| **IR** | Impulse Response | The convolution kernel that defines a reverb's space. `ReverbEffect` **generates one procedurally** (decaying noise), so there is no IR asset to load. |
| **JSON** | JavaScript Object Notation | One of the two serialization formats (human-readable; via `toJSON`/`fromJSON` + the type-tag dispatch in `sopraJSON.js`). The other is binary. |
| **Q** | quality factor | A filter's bandwidth/resonance control (`EqEffect.Q`). |
| **RNG** | Random Number Generator | Sopra's RNG is **seeded** (per `SopraPlaybackContext`) so random selection is reproducible across networked clients. |
| **RTPC** | Real-Time Parameter Control | A named float "parameter" (FMOD's term). Drives `Switch`/`Blend` selection and bus-volume automation. Stored in the `ParameterStore`; see `defineParameter` / `setParameter`. |
| **WebAudio** | Web Audio API | The browser audio API Sopra renders through (`AudioContext`, `GainNode`, `PannerNode`, `BiquadFilterNode`, `ConvolverNode`, …). Sopra never creates the `AudioContext` — meep's `SoundEngine` does. |
