# `ambient-state` ## Purpose This module owns the **engine-pushed ambient state** every `PluginChain` carries and forwards to every plugin via `ChainContext.ambient`. The three top-level slots — `track`, `transport`, optional `listener` — plus the open-ended `extension` map are the universal handshake between the project (timeline track, musical clock, 3D scene) and the FX layer. Plugins read these slots from their `ChainContext` every block without coupling to engine types. The module exposes a `defaultAmbientState()` constructor (sensible neutral baseline for unwired chains), a pure `applyAmbientPatch(previous, patch)` reducer (shallow-merges per slot, replaces or removes listener wholesale, shallow-merges the extension map), and `ambientStateEquals(a, b)` for short-circuiting no-op patches in `PluginChain.updateAmbient`. ## Features - One pure reducer, no global state — every state transition produces a new immutable record. Plugins can safely capture the `ChainContext.ambient` reference and dereference its slots without worrying about mutation under their feet. - Per-slot patching: an engine pushing a transport-state change does not perturb the track slot's reference, so consumers that key effects on `prev.track !== next.track` (React `useMemo`, frame-by-frame raycasters) only recompute when their slot actually changes. - Explicit listener removal via `patch.listener: null` so a chain can detach from a scene without erasing other ambient data. - Equality check skips spurious events when nothing actually changed. ## Usage: Basic Example ```ts import { applyAmbientPatch, defaultAmbientState } from "@suaveplan/audio-fx-plugin-base/chain"; let ambient = defaultAmbientState(); ambient = applyAmbientPatch(ambient, { track: { id: "drums", pan: -0.2, gainDb: -3 }, }); ambient = applyAmbientPatch(ambient, { transport: { tempo: 140, state: "playing" }, }); ``` ## Usage: Advanced Example ```ts import { ambientStateEquals, applyAmbientPatch, type AmbientStatePatch, } from "@suaveplan/audio-fx-plugin-base/chain"; class PluginChain { private ambient = defaultAmbientState(); private readonly events = new EventEmitter<{ "ambient-changed": [AmbientState, AmbientState] }>(); public updateAmbient(patch: AmbientStatePatch): void { const next = applyAmbientPatch(this.ambient, patch); if (ambientStateEquals(this.ambient, next)) { return; // no-op patch; do not perturb subscribers } const prev = this.ambient; this.ambient = next; this.events.emit("ambient-changed", prev, next); } } // Detach scene listener on r3f unmount: chain.updateAmbient({ listener: null }); // Push game-state vector for music-layer-mixer: chain.updateAmbient({ extension: { gameState: { tension: 0.8, combat: 1 } }, }); ``` ## API Reference - `defaultAmbientState(): AmbientState` — neutral baseline for unwired chains. - `AmbientStatePatch` — partial-per-slot patch shape: `{ track?, transport?, listener?: AmbientListenerState | null, extension? }`. - `applyAmbientPatch(previous, patch): AmbientState` — pure reducer; returns a new record with shallow-merged slots. `listener: null` clears; absent leaves intact; defined value replaces. - `ambientStateEquals(a, b): boolean` — structural equality (per-slot shallow plus listener-field comparison). ## Implementation Notes The reducer returns the **same slot reference** for unpatched slots (e.g. patching `track` returns `next.transport === previous.transport`). This makes React reconciliation and other identity-based subscription systems work without explicit memoisation: a consumer that only depends on `ambient.transport` only re-renders when the transport actually changes. The cost is one extra spread when a slot is patched; the win is no spurious renders on the other slots. `extension` shallow-merge over deep-merge is deliberate. Deep merge breaks the consumer's mental model around what `prev` vs `next` looks like (does updating `gameState.tension` preserve `gameState.combat`? Only via convention). Shallow merge plus the documented "extension is a flat key map" rule keeps update semantics predictable: to update a nested sub-tree, callers patch the whole sub-tree at once. The four game-audio building blocks document `gameState` as a flat record of axis names to 0..1 values, which fits. Listener equality compares the three `Vec3 / Quat` field bags shallowly. R3F's per-frame writes pass freshly-allocated `{x,y,z}` records, so reference equality would always be false; the shallow comparison correctly identifies no-op frames where the player hasn't moved. The savings are real — `r3f-audio` writes listener pose at 60 Hz, and most frames don't change anything, so most go through the equality early-return without an `ambient-changed` event. The reducer is intentionally simple-typed; the engine can mass-push partial slots without piecing together the unchanged ones. This keeps the chain hot path cheap (one allocation per non-no-op patch) and the test surface tiny.