first commit
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user