Files
suaveplan/packages/media/audio-fx/audio-fx-plugin-base/src/chain/ambient-state.ts
2026-05-27 23:47:53 +01:00

145 lines
4.3 KiB
TypeScript

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<AmbientTrackState>;
readonly transport?: Partial<AmbientTransportState>;
readonly listener?: AmbientListenerState | null;
readonly extension?: Readonly<Record<string, unknown>>;
}
/**
* 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<Record<string, unknown>>;
} = {
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<T>(value: T): Readonly<Record<string, unknown>> {
return value as unknown as Readonly<Record<string, unknown>>;
}
function shallowEqual(
a: Readonly<Record<string, unknown>>,
b: Readonly<Record<string, unknown>>
): 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))
);
}