Initial Commit
This commit is contained in:
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user