import type { AmbientListenerState, AmbientState, AmbientTrackState, AmbientTransportState, } from "../base/audio-fx-plugin.types.js"; /** * Sensible defaults for a freshly-constructed chain that has not yet * been wired to a track or transport. Engines push real values via * `chain.updateAmbient(patch)` once both are available. */ export function defaultAmbientState(): AmbientState { return { track: { id: "", gainDb: 0, pan: 0, mute: false, solo: false, layout: "stereo", }, transport: { tempo: 120, timeSignatureNumerator: 4, timeSignatureDenominator: 4, transportTimeSec: 0, state: "stopped", ppqn: 960, }, extension: {}, }; } /** * Deeply-merged patch type for `chain.updateAmbient(patch)`. Each top- * level slot is independently patchable; sub-fields within a slot are * shallow-merged onto the existing slot. */ export interface AmbientStatePatch { readonly track?: Partial; readonly transport?: Partial; readonly listener?: AmbientListenerState | null; readonly extension?: Readonly>; } /** * Returns a new `AmbientState` with the patch applied. Pure — never * mutates the input. Used by `PluginChain.updateAmbient(patch)` so the * chain can emit `ambient-changed` with the previous + new values. * * Patch semantics: * * - `track` and `transport` slots are shallow-merged (partial fields * replace just those keys on the slot). * - `listener` is replaced verbatim (or removed when explicitly `null`). * - `extension` is shallow-merged: the patch overrides matching keys * and adds new ones; unspecified keys are preserved. */ export function applyAmbientPatch( previous: AmbientState, patch: AmbientStatePatch ): AmbientState { const next: { track: AmbientTrackState; transport: AmbientTransportState; listener?: AmbientListenerState; extension: Readonly>; } = { track: patch.track ? { ...previous.track, ...patch.track } : previous.track, transport: patch.transport ? { ...previous.transport, ...patch.transport } : previous.transport, extension: patch.extension ? { ...previous.extension, ...patch.extension } : previous.extension, }; if (patch.listener === null) { // explicit removal — leave next.listener undefined } else if (patch.listener !== undefined) { next.listener = patch.listener; } else if (previous.listener !== undefined) { next.listener = previous.listener; } return next as AmbientState; } /** * Returns true when two ambient states are structurally equal. Used by * `PluginChain.updateAmbient` to short-circuit no-op patches and avoid * spurious `ambient-changed` events. */ export function ambientStateEquals(a: AmbientState, b: AmbientState): boolean { if (!shallowEqual(asRecord(a.track), asRecord(b.track))) { return false; } if (!shallowEqual(asRecord(a.transport), asRecord(b.transport))) { return false; } if (!listenerEqual(a.listener, b.listener)) { return false; } return shallowEqual(a.extension, b.extension); } function asRecord(value: T): Readonly> { return value as unknown as Readonly>; } function shallowEqual( a: Readonly>, b: Readonly> ): boolean { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) { return false; } for (const key of aKeys) { if (a[key] !== b[key]) { return false; } } return true; } function listenerEqual( a: AmbientListenerState | undefined, b: AmbientListenerState | undefined ): boolean { if (a === b) { return true; } if (a === undefined || b === undefined) { return false; } return ( shallowEqual(asRecord(a.position), asRecord(b.position)) && shallowEqual(asRecord(a.orientation), asRecord(b.orientation)) && shallowEqual(asRecord(a.velocity), asRecord(b.velocity)) ); }