Files
skills/docs/media/audio-fx/audio-fx-plugin-base/src/chain/ambient-state.md
2026-05-29 08:20:56 +01:00

5.0 KiB

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

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

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.