first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
# `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.