Initial Commit
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
# `snapshot`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## 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
|
||||
|
||||
```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<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.
|
||||
Reference in New Issue
Block a user