7.4 KiB
snapshot
(no submodules)
Purpose
The chain-snapshot module owns the PluginChain serialisation envelope and the boundary helpers that round-trip a chain through plain JSON. A snapshot is the unit of persistence for "save your track", "duplicate this insert chain", "export this preset to another project", and "restore from cloud sync". The envelope nests the universal PresetSnapshot per plugin slot so a chain snapshot is, at its core, a Zod-validated list of presets wrapped in chain metadata — the same envelope a single plugin emits, just composed.
The module is the connective tissue between PluginChain (which knows its slots) and PluginRegistry (which knows how to build plugins from a pluginType string). Together with base/preset.ts, this is the only path that turns serialised bytes into live AudioFxPlugin instances, so every load goes through Zod validation before any state is mutated.
Features
ChainSnapshotSchema— Zod schema for the envelope. Validatesid,name,channels,sampleRate,blockSize, and the per-slotpluginsarray. NestsPresetSnapshotSchemaso plugin presets are validated alongside the slot envelope.parseChainSnapshot(input)— throwsAUDIO_FX.INVALID_PRESETwith the Zod issue list on malformed input. Reuses the same code as preset validation because a chain snapshot is a structural superset of a preset.instantiateChainSlot(entry, registry)— restore step for one slot. Callsregistry.create(pluginType)(which throwsAUDIO_FX.CHAIN_SLOT_NOT_FOUNDon a missing factory), thenapplyPreset(entry.preset)on the new instance, and returns{ plugin, bypassed }to the caller.PluginChain.snapshot(): ChainSnapshot— captures slot order, each slot's bypass state, and each plugin'sgetPreset()envelope. The chain'sid/nameand configuredchannels/sampleRate/blockSize(or construction-time defaults ifsetuphas not run) are stored so a snapshot is self-describing.PluginChain.restore(input, registry): Promise<void>— Zod-validates the envelope, drains any pre-existing slots so the method is idempotent on a re-used chain, then walksplugins[]adding each instantiated plugin and applying its bypass state. The result reproduces order, parameters, bypass, andgetEffectiveLatencySamples()(the last because plugin latency comes fromcapabilities.latencySamplesat construction and the registry's factories rebuild identical capabilities).
Usage: Basic Example
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
const source = new PluginChain({ id: "vox-bus", channels: 2, sampleRate: 48_000, blockSize: 512 }, new DefaultBusRegistry());
source.add(eqPlugin);
source.add(compPlugin);
source.bypass(0, true);
const json = JSON.stringify(source.snapshot());
// elsewhere, on a fresh process:
const registry = new PluginRegistry();
registry.register("eq", () => new EQPlugin());
registry.register("compressor", () => new CompressorPlugin());
const target = new PluginChain({ id: "vox-bus", channels: 2, sampleRate: 48_000, blockSize: 512 }, new DefaultBusRegistry());
await target.restore(JSON.parse(json), registry);
Usage: Advanced Example
import { PluginChain, DefaultBusRegistry } from "@suaveplan/audio-fx-plugin-base/chain";
import { PluginRegistry } from "@suaveplan/audio-fx-plugin-base/registry";
import { parseChainSnapshot, ChainSnapshotSchema } from "@suaveplan/audio-fx-plugin-base/chain";
// 1. Validate the envelope shape early, surface field-level issues to the UI.
const result = ChainSnapshotSchema.safeParse(payload);
if (!result.success) {
return showFieldErrors(result.error.issues);
}
// 2. Reject restores referencing plugins not in the host's allowlist.
const allowedTypes = new Set(registry.list());
const missing = result.data.plugins
.map((p) => p.pluginType)
.filter((t) => !allowedTypes.has(t));
if (missing.length > 0) {
return showMissingPlugins(missing);
}
// 3. Now restore. parseChainSnapshot will run the same Zod check, and
// each instantiateChainSlot call propagates AUDIO_FX.CHAIN_SLOT_NOT_FOUND
// if registration changes underfoot.
const chain = new PluginChain(
{ id: result.data.id, channels: result.data.channels, sampleRate: result.data.sampleRate, blockSize: result.data.blockSize },
new DefaultBusRegistry(),
);
await chain.restore(payload, registry);
await chain.setup({ sampleRate: result.data.sampleRate, channels: result.data.channels, blockSize: result.data.blockSize }, defaultAmbientState());
API Reference
ChainPluginSnapshot(type) —{ pluginType: string; instanceId: string; bypassed: boolean; preset: PresetSnapshot }.ChainSnapshot(type) —{ id: string; name: string; channels: number; sampleRate: number; blockSize: number; plugins: ReadonlyArray<ChainPluginSnapshot> }.ChainSnapshotSchema,ChainPluginSnapshotSchema— exported Zod schemas for callers that want tosafeParsedirectly.parseChainSnapshot(input: unknown): ChainSnapshot— throwsAUDIO_FX.INVALID_PRESETwith Zod issues incontext.issueson failure.instantiateChainSlot(entry, registry): { plugin: AudioFxPlugin; bypassed: boolean }— restore step for one slot; throwsAUDIO_FX.CHAIN_SLOT_NOT_FOUND(viaregistry.create) when the registry has no factory.PluginChain.snapshot(): ChainSnapshot— instance method on the chain.PluginChain.restore(input: unknown, registry: PluginRegistry): Promise<void>— instance method; async to leave room for future async resource preload during restore.
Implementation Notes
instanceId currently equals metadata.id because PluginChain enforces unique metadata.id per chain via AUDIO_FX.CHAIN_DUPLICATE_PLUGIN_ID. The field is preserved as a distinct envelope key so a future chain that drops the uniqueness rule (e.g. multiple instances of the same plugin type on different bus stems) can store a per-instance key without an envelope migration. Restore intentionally does not assert instanceId === metadata.id — the registry's factory determines the live plugin's id, and forcing them to match would couple snapshot authors to factory implementations.
restore() drains existing slots before rebuilding so the chain object can be re-used across multiple restores (e.g. an "undo history" UI that replays older snapshots into the same chain). The drain uses remove(0) rather than dispose() so listeners attached to the chain via on() survive — restoring an older state should not silently remove the user's event wiring.
AUDIO_FX.INVALID_PRESET is reused for envelope failures rather than introducing a dedicated INVALID_CHAIN_SNAPSHOT code because the chain snapshot is structurally a list of presets with envelope metadata; consumers that pattern-match on the code see one unified "your saved state is malformed" path. The context.issues Zod payload is identical in shape, so UI error rendering is shared.
restore() is async to leave room for future factories that need to await resource preloading (e.g. an offline-first sampler that hydrates from IndexedDB). Today every step is synchronous; the trailing await Promise.resolve() makes the async annotation honest without changing call-site semantics. Engine adapters are expected to call chain.setup(...) after restore — restore reproduces the structure; setup allocates the live DSP.