Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
108
packages/ui/react/react-audio/src/hooks/use-preset/use-preset.ts
Normal file
108
packages/ui/react/react-audio/src/hooks/use-preset/use-preset.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user