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,81 @@
# `hooks/use-preset`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePreset(plugin)` exposes plugin-level preset operations as
React-friendly callbacks: `get` reads the current preset, `apply`
writes one back, and `save` / `load` persist through the provider's
optional preset store. The hook gates `save`/`load` on the presence
of a store; if none is configured (or if the store throws), the hook
raises `ReactAudioError(REACT_AUDIO.PRESET_STORE_FAILED)` carrying
the offending id and the underlying cause.
## Features
- `get(): PresetSnapshot` — synchronous read.
- `apply(preset): void` — synchronous write.
- `save(id): Promise<void>` — async write through the preset store.
- `load(id): Promise<void>` — async read + apply.
- Structured errors: `PRESET_STORE_FAILED` for missing store, save
failure, load failure, and preset-not-found.
## Usage: Basic
```tsx
import { usePreset } from "@suaveplan/react-audio";
function PresetButtons({ plugin }: { plugin: AudioFxPlugin }) {
const { save, load } = usePreset(plugin);
return (
<>
<button type="button" onClick={() => save("default")}>Save</button>
<button type="button" onClick={() => load("default")}>Load</button>
</>
);
}
```
## Usage: Advanced
```tsx
import { usePreset } from "@suaveplan/react-audio";
function ExportPreset({ plugin }: { plugin: AudioFxPlugin }) {
const { get } = usePreset(plugin);
return (
<button
type="button"
onClick={() => {
const preset = get();
navigator.clipboard.writeText(JSON.stringify(preset));
}}
>
Copy JSON
</button>
);
}
```
## API Reference
- `usePreset(plugin): { get, apply, save, load }`.
## Implementation Notes
The hook wraps each store interaction in a try/catch that converts
underlying errors into `ReactAudioError(PRESET_STORE_FAILED)` with
the original `Error` set as `cause`. The store is allowed to be
undefined; consumers that don't need persistence omit it from
provider options and use `get` / `apply` only. Promise rejections are
deliberately wrapped (rather than re-thrown raw) so a single
`error instanceof ReactAudioError` branch handles every failure path.
The four failure modes — missing store, store-rejected save,
store-rejected load, preset-not-found — share one code
(`PRESET_STORE_FAILED`) but distinct `messageKey`s so an i18n layer
can branch on the message resolver instead of the code. Non-Error
values thrown by a store are narrowed to `undefined` causes so the
wrapper Error remains type-safe.

View File

@@ -0,0 +1,169 @@
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 { usePreset } from "./use-preset.js";
describe("usePreset", () => {
it("get/apply work without a preset store", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin();
const handle = renderHookWithProvider(() => usePreset(plugin));
await waitFor(() => expect(handle.current).toBeDefined());
const snapshot = handle.current!.get();
expect(snapshot.parameters.gain).toBe(1);
handle.current!.apply({
schemaVersion: 1,
pluginType: plugin.metadata.id,
parameters: { gain: 0.7, tone: 0.3 },
});
expect(plugin.getParameter("gain")).toBe(0.7);
});
it("save/load round-trip via store", { timeout: 30000 }, async () => {
const plugin = makeFakePlugin();
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(() => usePreset(plugin), {
options: { presetStore: store },
});
await waitFor(() => expect(handle.current).toBeDefined());
plugin.setParameter("gain", 0.42);
await handle.current!.save("p1");
plugin.setParameter("gain", 0.0);
await handle.current!.load("p1");
expect(plugin.getParameter("gain")).toBeCloseTo(0.42);
});
it("save throws REACT_AUDIO.PRESET_STORE_FAILED when no store", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin();
const handle = renderHookWithProvider(() => usePreset(plugin));
await waitFor(() => expect(handle.current).toBeDefined());
let caught: unknown;
try {
await handle.current!.save("x");
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PRESET_STORE_FAILED
);
});
it("load throws PRESET_STORE_FAILED for missing preset / save error / load error", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin();
const store: PresetStore = {
async save() {
throw new Error("save broke");
},
async load() {
return undefined;
},
};
const handle = renderHookWithProvider(() => usePreset(plugin), {
options: { presetStore: store },
});
await waitFor(() => expect(handle.current).toBeDefined());
let saveErr: unknown;
try {
await handle.current!.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!.load("x");
} catch (e) {
loadErr = e;
}
expect((loadErr as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PRESET_STORE_FAILED
);
// load with throwing store
const throwingStore: PresetStore = {
async save() {},
async load() {
throw new Error("load broke");
},
};
const handle2 = renderHookWithProvider(() => usePreset(plugin), {
options: { presetStore: throwingStore },
});
await waitFor(() => expect(handle2.current).toBeDefined());
let loadErr2: unknown;
try {
await handle2.current!.load("y");
} catch (e) {
loadErr2 = e;
}
expect((loadErr2 as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PRESET_STORE_FAILED
);
});
it("save/load wrap non-Error causes as undefined", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin();
const weirdStore: PresetStore = {
// Throw non-Error values to exercise the
// `cause instanceof Error ? cause : undefined` branch.
save() {
throw "string-thrown";
},
load() {
throw 42;
},
};
const handle = renderHookWithProvider(() => usePreset(plugin), {
options: { presetStore: weirdStore },
});
await waitFor(() => expect(handle.current).toBeDefined());
await expect(handle.current!.save("x")).rejects.toBeInstanceOf(
ReactAudioError
);
await expect(handle.current!.load("x")).rejects.toBeInstanceOf(
ReactAudioError
);
});
it("load without store throws PRESET_STORE_FAILED", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin();
const handle = renderHookWithProvider(() => usePreset(plugin));
await waitFor(() => expect(handle.current).toBeDefined());
let caught: unknown;
try {
await handle.current!.load("x");
} catch (e) {
caught = e;
}
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PRESET_STORE_FAILED
);
});
});

View File

@@ -0,0 +1,108 @@
import type {
AudioFxPlugin,
PresetSnapshot,
} 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 UsePresetResult {
/** Read the plugin's current `PresetSnapshot`. */
get(): PresetSnapshot;
/** Apply a `PresetSnapshot` to the plugin. */
apply(preset: PresetSnapshot): void;
/**
* Persist the current preset through the provider's preset
* store, keyed by `id`. Throws
* `REACT_AUDIO.PRESET_STORE_FAILED` if no preset store is
* configured or the store rejects the write.
*/
save(id: string): Promise<void>;
/**
* Load a preset by `id` from the provider's preset store and
* apply it to the plugin. Throws
* `REACT_AUDIO.PRESET_STORE_FAILED` if no preset store is
* configured or the store rejects the read.
*/
load(id: string): Promise<void>;
}
/**
* Plugin-level preset save/load. Direct `get` / `apply` work
* without a preset store; `save` / `load` require one to be wired
* through provider options.
*/
export function usePreset(plugin: AudioFxPlugin): UsePresetResult {
const { presetStore } = useProviderStateOrThrow();
const get = useCallback(() => plugin.getPreset(), [plugin]);
const apply = useCallback(
(preset: PresetSnapshot) => plugin.applyPreset(preset),
[plugin]
);
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: "plugin" } }
);
}
try {
await presetStore.save("plugin", id, plugin.getPreset());
} catch (cause) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.preset-store-save-failed",
{
context: { id, kind: "plugin" },
cause: cause instanceof Error ? cause : undefined,
}
);
}
},
[plugin, 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: "plugin" } }
);
}
let preset: PresetSnapshot | undefined;
try {
const result = await presetStore.load("plugin", id);
preset = result as PresetSnapshot | undefined;
} catch (cause) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.preset-store-load-failed",
{
context: { id, kind: "plugin" },
cause: cause instanceof Error ? cause : undefined,
}
);
}
if (preset === undefined) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
"react-audio.preset-not-found",
{ context: { id, kind: "plugin" } }
);
}
plugin.applyPreset(preset);
},
[plugin, presetStore]
);
return { get, apply, save, load };
}