# `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` — 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 ```ts 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 ```ts 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 }`. - `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` — 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.