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

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. Validates id, name, channels, sampleRate, blockSize, and the per-slot plugins array. Nests PresetSnapshotSchema so plugin presets are validated alongside the slot envelope.
  • parseChainSnapshot(input) — throws AUDIO_FX.INVALID_PRESET with 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. Calls registry.create(pluginType) (which throws AUDIO_FX.CHAIN_SLOT_NOT_FOUND on a missing factory), then applyPreset(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's getPreset() envelope. The chain's id/name and configured channels/sampleRate/blockSize (or construction-time defaults if setup has 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 walks plugins[] adding each instantiated plugin and applying its bypass state. The result reproduces order, parameters, bypass, and getEffectiveLatencySamples() (the last because plugin latency comes from capabilities.latencySamples at 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 to safeParse directly.
  • parseChainSnapshot(input: unknown): ChainSnapshot — throws AUDIO_FX.INVALID_PRESET with Zod issues in context.issues on failure.
  • instantiateChainSlot(entry, registry): { plugin: AudioFxPlugin; bypassed: boolean } — restore step for one slot; throws AUDIO_FX.CHAIN_SLOT_NOT_FOUND (via registry.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.