Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# `hooks/use-chain-snapshot`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useChainSnapshot(chain)` exposes chain-level snapshot operations.
`get` reads the in-memory `ChainSnapshot`, `restore` applies one,
and `save` / `load` persist through the provider's optional preset
store, keyed by id. The hook is the natural complement to
`usePluginChain` for "FX rack" UIs that save and load entire chains
as a unit.
## Features
- `get()` — synchronous in-memory read of `chain.snapshot()`.
- `restore(input)` — async; delegates to `chain.restore(input, registry)`.
- `save(id)` — persists the snapshot via the preset store.
- `load(id)` — loads + applies via the preset store.
- Structured errors: `PRESET_STORE_FAILED` for every failure mode.
## Usage: Basic
```tsx
import { useChainSnapshot } from "@suaveplan/react-audio";
function ChainPresetButtons({ chain }: { chain: PluginChain }) {
const { save, load } = useChainSnapshot(chain);
return (
<>
<button type="button" onClick={() => save("default")}>Save chain</button>
<button type="button" onClick={() => load("default")}>Load chain</button>
</>
);
}
```
## Usage: Advanced
```tsx
import { useChainSnapshot } from "@suaveplan/react-audio";
function ExportImport({ chain }: { chain: PluginChain }) {
const { get, restore } = useChainSnapshot(chain);
return (
<>
<button onClick={() => navigator.clipboard.writeText(JSON.stringify(get()))}>
Copy JSON
</button>
<button
onClick={async () => {
const text = await navigator.clipboard.readText();
await restore(JSON.parse(text));
}}
>
Paste JSON
</button>
</>
);
}
```
## API Reference
- `useChainSnapshot(chain): { get, restore, save, load }`.
## Implementation Notes
The hook needs the provider's `PluginRegistry` to instantiate
plugins during `restore`, so it consumes `useProviderStateOrThrow`.
Every failure mode of the preset store is wrapped into a
`ReactAudioError(PRESET_STORE_FAILED)` with the underlying error as
`cause`; the absence of a preset store and the "preset id not
found" case use the same code with different `messageKey`s so a
catch-handler can branch on the message resolver if needed. The
`get()` helper returns the raw `chain.snapshot()` which includes
the chain's `blockSize` and `sampleRate`; consumers exporting JSON
for cross-instance restore should preserve those fields verbatim,
because `chain.restore` re-validates them against the chain's own
config on the receiving side. Non-Error throws from the store are
narrowed to `undefined` causes (the JS `instanceof Error` ternary).

View File

@@ -0,0 +1,211 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { waitFor } from "@testing-library/react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import type { PresetStore } from "../../provider/provider-options.js";
import { makeFakePlugin, renderHookWithProvider } from "../../test-utils.js";
import { usePluginChain } from "../use-plugin-chain/use-plugin-chain.js";
import { useChainSnapshot } from "./use-chain-snapshot.js";
describe("useChainSnapshot", () => {
it("get reads the chain's current snapshot", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => {
const chain = usePluginChain("t1");
return { chain, snap: useChainSnapshot(chain.chain) };
});
await waitFor(() => expect(handle.current).toBeDefined());
const snapshot = handle.current!.snap.get();
expect(snapshot.id).toBe("t1");
});
it("save/load round-trip via store", { timeout: 30000 }, async () => {
const memory = new Map<string, unknown>();
const store: PresetStore = {
async save(kind, id, payload) {
memory.set(`${kind}:${id}`, payload);
},
async load(kind, id) {
return memory.get(`${kind}:${id}`) as never;
},
};
const handle = renderHookWithProvider(
() => {
const chain = usePluginChain("t2");
return { chain, snap: useChainSnapshot(chain.chain) };
},
{
options: {
presetStore: store,
plugins: [
{
pluginType: "fake",
factory: () => makeFakePlugin({ id: "p" }),
},
],
},
}
);
await waitFor(() => expect(handle.current).toBeDefined());
await handle.current!.snap.save("v1");
expect(memory.has("chain:v1")).toBe(true);
});
it("save/load throw PRESET_STORE_FAILED without store", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => {
const chain = usePluginChain("t3");
return { chain, snap: useChainSnapshot(chain.chain) };
});
await waitFor(() => expect(handle.current).toBeDefined());
let saveErr: unknown;
try {
await handle.current!.snap.save("x");
} catch (e) {
saveErr = e;
}
expect((saveErr as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PRESET_STORE_FAILED
);
let loadErr: unknown;
try {
await handle.current!.snap.load("x");
} catch (e) {
loadErr = e;
}
expect((loadErr as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PRESET_STORE_FAILED
);
});
it("store throws on save/load wrap as PRESET_STORE_FAILED; missing snapshot too", {
timeout: 30000,
}, async () => {
const throwingStore: PresetStore = {
async save() {
throw new Error("save bad");
},
async load() {
throw new Error("load bad");
},
};
const handle = renderHookWithProvider(
() => {
const chain = usePluginChain("t4");
return { chain, snap: useChainSnapshot(chain.chain) };
},
{ options: { presetStore: throwingStore } }
);
await waitFor(() => expect(handle.current).toBeDefined());
await expect(handle.current!.snap.save("x")).rejects.toBeInstanceOf(
ReactAudioError
);
await expect(handle.current!.snap.load("x")).rejects.toBeInstanceOf(
ReactAudioError
);
const missingStore: PresetStore = {
async save() {},
async load() {
return undefined;
},
};
const handle2 = renderHookWithProvider(
() => {
const chain = usePluginChain("t5");
return { chain, snap: useChainSnapshot(chain.chain) };
},
{ options: { presetStore: missingStore } }
);
await waitFor(() => expect(handle2.current).toBeDefined());
await expect(
handle2.current!.snap.load("missing")
).rejects.toBeInstanceOf(ReactAudioError);
});
it("save/load wrap non-Error causes as undefined", {
timeout: 30000,
}, async () => {
const weirdStore: PresetStore = {
save() {
throw "string-thrown";
},
load() {
throw 42;
},
};
const handle = renderHookWithProvider(
() => {
const chain = usePluginChain("weird-t");
return { chain, snap: useChainSnapshot(chain.chain) };
},
{ options: { presetStore: weirdStore } }
);
await waitFor(() => expect(handle.current).toBeDefined());
await expect(handle.current!.snap.save("x")).rejects.toBeInstanceOf(
ReactAudioError
);
await expect(handle.current!.snap.load("x")).rejects.toBeInstanceOf(
ReactAudioError
);
});
it("load restores from store via chain.restore", {
timeout: 30000,
}, async () => {
const valid = {
id: "load-t",
name: "load-t",
channels: 2,
sampleRate: 48_000,
blockSize: 128,
plugins: [],
};
const store: PresetStore = {
async save() {},
async load() {
return valid as unknown as never;
},
};
const handle = renderHookWithProvider(
() => {
const chain = usePluginChain("load-t");
return { chain, snap: useChainSnapshot(chain.chain) };
},
{ options: { presetStore: store } }
);
await waitFor(() => expect(handle.current).toBeDefined());
await handle.current!.snap.load("v1");
expect(handle.current!.chain.plugins.length).toBe(0);
});
it("restore delegates to chain.restore via registry", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => {
const chain = usePluginChain("t-restore");
return { chain, snap: useChainSnapshot(chain.chain) };
});
await waitFor(() => expect(handle.current).toBeDefined());
// Construct a known-valid empty snapshot for the restore path.
// The chain's own `snapshot()` produces `blockSize: 0` when the
// chain has not been `setup()`'d, which fails Zod validation;
// composing the input directly lets us exercise restore.
const empty = {
id: "t-restore",
name: "t-restore",
channels: 2,
sampleRate: 48_000,
blockSize: 128,
plugins: [],
};
await handle.current!.snap.restore(empty);
expect(handle.current!.chain.plugins.length).toBe(0);
});
});

View File

@@ -0,0 +1,100 @@
import type {
ChainSnapshot,
PluginChain,
} from "@suaveplan/audio-fx-plugin-base";
import { useCallback } from "react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import { useProviderStateOrThrow } from "../use-provider-state.js";
export interface UseChainSnapshotResult {
/** Read the chain's current snapshot. */
get(): ChainSnapshot;
/** Restore the chain from a serialised snapshot. */
restore(input: unknown): Promise<void>;
/** Persist the current snapshot through the provider preset store. */
save(id: string): Promise<void>;
/** Load a snapshot by id and restore the chain. */
load(id: string): Promise<void>;
}
/**
* Chain-level snapshot save/load through the provider's preset
* store. The chain's own `snapshot()` / `restore()` methods are
* exposed for in-memory operations.
*/
export function useChainSnapshot(chain: PluginChain): UseChainSnapshotResult {
const { presetStore, registry } = useProviderStateOrThrow();
const get = useCallback(() => chain.snapshot(), [chain]);
const restore = useCallback(
async (input: unknown) => {
await chain.restore(input, registry);
},
[chain, registry]
);
const save = useCallback(
async (id: string) => {
if (presetStore === undefined) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.preset-store-missing",
{ context: { id, kind: "chain" } }
);
}
try {
await presetStore.save("chain", id, chain.snapshot());
} catch (cause) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.chain-snapshot-save-failed",
{
context: { id, kind: "chain" },
cause: cause instanceof Error ? cause : undefined,
}
);
}
},
[chain, presetStore]
);
const load = useCallback(
async (id: string) => {
if (presetStore === undefined) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.preset-store-missing",
{ context: { id, kind: "chain" } }
);
}
let snapshot: unknown;
try {
snapshot = await presetStore.load("chain", id);
} catch (cause) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.chain-snapshot-load-failed",
{
context: { id, kind: "chain" },
cause: cause instanceof Error ? cause : undefined,
}
);
}
if (snapshot === undefined) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.chain-snapshot-not-found",
{ context: { id, kind: "chain" } }
);
}
await chain.restore(snapshot, registry);
},
[chain, presetStore, registry]
);
return { get, restore, save, load };
}