first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,120 @@
# `hooks/use-analyser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and
returns the latest byte-magnitude FFT frame plus the matching
time-domain (waveform) frame. It is the data feed for the family's
two stock visualisers — `<WaveformVisualizer>` and
`<SpectrumAnalyzer>` — and for any consumer-built `<canvas>`
visualiser that wants live spectrum data without dealing with
`AudioContext` or `AnalyserNode` lifecycle.
The hook deliberately does **not** create or own an `AnalyserNode`.
The audio engine builds one analyser per track inside its
`TrackSubgraph`, and the player tier hosts the master analyser on
the source chain; the consumer hands either node to this hook. Two
consumers reading the same analyser independently each get their
own buffer pair so they cannot tear each other's frame reads.
## Features
- Polls at a caller-tunable interval (default 16 ms ≈ 60 Hz).
- Auto-configures `fftSize`, `smoothingTimeConstant`, `minDecibels`,
`maxDecibels` on the analyser on every change.
- Lazy `factory` fallback for when the analyser is not available at
mount (typical: engine still initialising).
- Identity-stable `Uint8Array` buffers — mutated in place; the hook
never allocates per frame.
- Cleanup on unmount cancels the interval.
## Usage: Basic
```tsx
import { useAnalyser } from "@suaveplan/react-audio";
function Spectrum({ analyser }) {
const { frequency } = useAnalyser({ analyser, fftSize: 1024 });
return (
<div role="img" aria-label="spectrum">
{Array.from(frequency.slice(0, 32)).map((v, i) => (
<span key={i} style={{ height: `${v}px` }} />
))}
</div>
);
}
```
## Usage: Advanced
```tsx
import { useAnalyser } from "@suaveplan/react-audio";
// Resolve the engine's per-track analyser via the graph snapshot.
function trackAnalyserFactory(adapter, trackId) {
return () => {
const snap = adapter.snapshotGraph();
const node = snap.nodes.find(
(n) => n.id === `${trackId}:analyser`
);
return node?.ref ?? null;
};
}
function TrackVisualizer({ adapter, trackId }) {
const { frequency, waveform, analyser } = useAnalyser({
factory: trackAnalyserFactory(adapter, trackId),
intervalMs: 33, // ~30 Hz to save CPU
fftSize: 2048,
smoothingTimeConstant: 0.6,
});
if (!analyser) return <span>Connecting…</span>;
return (
<canvas
ref={(c) => drawSpectrum(c, frequency, waveform)}
width={512}
height={128}
/>
);
}
```
## API Reference
- `useAnalyser(opts?: UseAnalyserOptions): UseAnalyserResult`
- `UseAnalyserOptions`:
- `analyser?: AnalyserLike | null` — direct ref; supersedes `factory`.
- `factory?: () => AnalyserLike | null` — lazy resolver, retried per tick.
- `intervalMs?: number` — poll interval (default 16).
- `fftSize?: number` — power-of-2 in `[32, 32768]` (default 2048).
- `smoothingTimeConstant?: number` — `[0, 1]` (default 0.8).
- `minDecibels?: number` — default `-100`.
- `maxDecibels?: number` — default `-30`.
- `UseAnalyserResult`:
- `analyser: AnalyserLike | null` — resolved instance or `null`.
- `frequency: Uint8Array` — byte-magnitude FFT, identity-stable.
- `waveform: Uint8Array` — byte time-domain, identity-stable.
## Implementation Notes
The hook owns three refs: the resolved analyser, the factory, and
the buffer pair. Buffers are reallocated when `analyser` or
`fftSize` changes (the new `frequencyBinCount` determines length).
Polling uses `setInterval` rather than `requestAnimationFrame`
because the former is deterministic under happy-dom — the same
reason `useTransportTime` makes the same choice.
The factory fallback is invoked on every tick until it returns a
non-`null` value, then never again. This lets the consumer mount
the hook before the engine finishes async init without races: the
visualiser simply renders zeros until the analyser appears, then
continues seamlessly.
The hook is engine-agnostic by design — it accepts the structural
`AnalyserLike` shape, so consumers can stub it in tests with three
lines of code and there is no hidden `AudioContext` dependency
poisoning tree-shake or SSR.

View File

@@ -0,0 +1,73 @@
# `hooks/use-audio-engine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioEngine()` returns the full resolved `ProviderState` bundle —
the adapter, the `TimelineEngine`, the `Transport`, the
`ProviderBusRegistry`, the `PluginRegistry`, the consumer-supplied
preset store (or `undefined`), and the configured `sampleRateHz`.
It throws `REACT_AUDIO.NO_PROVIDER` outside the provider and
`REACT_AUDIO.PROVIDER_PENDING` during the async init window. It is
the escape hatch for code that needs the entire bundle at once;
prefer the narrower hooks (`useTransport`, `useTimeline`, …) when
you only need one entity, as they minimise re-render scope.
## Features
- Returns `ProviderState` — a readonly bundle.
- Throws `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>`.
- Throws `REACT_AUDIO.PROVIDER_PENDING` while engine init is in
flight.
- Stable reference across re-renders within a single ready phase.
## Usage: Basic
```tsx
import { useAudioEngine } from "@suaveplan/react-audio";
function Diagnostics() {
const { engine, transport, sampleRateHz } = useAudioEngine();
return (
<pre>
engine.lookAhead = {engine.lookAhead}; transport.state ={" "}
{transport.state}; ui-sampleRate = {sampleRateHz} Hz
</pre>
);
}
```
## Usage: Advanced
```tsx
import { useAudioEngine } from "@suaveplan/react-audio";
function useRegisterPlugin(): (type: string, factory: () => AudioFxPlugin) => void {
const { registry } = useAudioEngine();
return (type, factory) => registry.register(type, factory);
}
```
## API Reference
- `useAudioEngine(): ProviderState`.
## Implementation Notes
The hook delegates to `useProviderStateOrThrow`, which performs the
two structured-error checks. The returned `ProviderState` is the
frozen object the provider published — consumers can stash it in a
ref without cloning. Identity is stable across renders within a
single ready phase; if the provider remounts (Strict Mode or genuine
remount) the bundle's identity changes, which is the correct cue for
downstream `useEffect`s keyed on `engine`/`transport` to re-bind.
Prefer the focused hooks (`useTransport`, `useTimeline`, …) for the
common case of "I only need one entity"; the broader bundle returned
here triggers re-renders only when the provider itself re-publishes
state (the focused hooks emit narrower subscriptions). Consumers
that want to forward the engine bundle into child components without
re-render bloat should destructure here, then memoise the
sub-references they pass downstream.

View File

@@ -0,0 +1,78 @@
# `hooks/use-audio-fx-plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioFxPlugin(pluginType)` instantiates a plugin via the
provider's `PluginRegistry.create(pluginType)` and memoises the
result. The hook is the canonical way to obtain a one-off plugin
instance (e.g. for a side-panel preview that is not in any chain).
Consumers who want to insert the plugin into a chain pass the
returned value to `usePluginChain(...).add(plugin)`.
The hook does not own or register factories. The provider can
auto-register factories via `options.plugins`; alternatively
`useAudioEngine().registry.register(...)` lets consumers add factories
dynamically.
## Features
- Instance memoised by `pluginType` for component lifetime.
- Throws `REACT_AUDIO.NO_PROVIDER` / `REACT_AUDIO.PROVIDER_PENDING`
outside / before-init.
- Throws `AudioFxError(AUDIO_FX.CHAIN_SLOT_NOT_FOUND)` if no factory
is registered for `pluginType`.
## Usage: Basic
```tsx
import { useAudioFxPlugin } from "@suaveplan/react-audio";
function PreviewEQ() {
const eq = useAudioFxPlugin("equaliser");
return <PluginPanel plugin={eq} />;
}
```
## Usage: Advanced
```tsx
import { useAudioEngine, useAudioFxPlugin, usePluginChain } from "@suaveplan/react-audio";
import { useEffect } from "react";
function EnsureRegistered() {
const { registry } = useAudioEngine();
useEffect(() => {
registry.register("delay", () => makeDelay());
}, [registry]);
return null;
}
function ChainAdder({ trackId }: { trackId: string }) {
const delay = useAudioFxPlugin("delay");
const { add } = usePluginChain(trackId);
return <button type="button" onClick={() => add(delay)}>Add delay</button>;
}
```
## API Reference
- `useAudioFxPlugin(pluginType: string): AudioFxPlugin`.
## Implementation Notes
The hook memoises on `[registry, pluginType]` so a remount of the
provider (new registry identity) yields a fresh instance, but a
re-render with the same provider and `pluginType` returns the same
instance. The hook does not call `dispose()` on unmount because the
plugin is typically inserted into a chain whose lifecycle owns the
disposal. Consumers using the hook for a transient preview should
call `plugin.dispose()` in their own `useEffect` cleanup. Plugins
constructed by this hook are never auto-inserted into any chain —
the caller is responsible for placement (e.g. via
`usePluginChain(trackId).add(plugin)`). This separation lets the
same hook back per-track inserts, side-panel previews, and one-off
analyser taps.

View File

@@ -0,0 +1,69 @@
# `hooks/use-bus`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useBus(name)` reads the named sidechain bus from the provider's
`ProviderBusRegistry` and subscribes to its publish/close lifecycle.
Returns `undefined` if no publisher has registered the name. The
hook re-renders whenever the bus is (re)published with a new
channel layout or closed.
## Features
- `useSyncExternalStore`-based subscription to the registry's per-name
listener set.
- Returns `undefined` when the bus is closed or never published —
render-safe.
- SSR-consistent snapshot (both `getSnapshot` and
`getServerSnapshot` read `buses.get(name)`).
## Usage: Basic
```tsx
import { useBus } from "@suaveplan/react-audio";
function SidechainStatus({ name }: { name: string }) {
const bus = useBus(name);
return <span>{bus ? `${bus.channels} ch` : "(no bus)"}</span>;
}
```
## Usage: Advanced
```tsx
import { useAudioEngine, useBus } from "@suaveplan/react-audio";
import { useEffect } from "react";
function PublishKick() {
const { buses } = useAudioEngine();
useEffect(() => {
const pub = buses.publish("kick", 1);
return () => pub.close();
}, [buses]);
const bus = useBus("kick");
return <span>bus is {bus ? "live" : "closed"}</span>;
}
```
## API Reference
- `useBus(name: string): SidechainBus | undefined`.
## Implementation Notes
The subscribe callback returns the registry's existing
`subscribe(name, listener)` teardown so cleanup is exact. Reading
through `buses.get(name)` is O(1) on the registry's internal map.
The `subscribe` listener is invoked from within `publish()` (when a
bus appears) and from within `close()` (when one disappears), so a
React commit happens for both transitions. The hook returns the
`SidechainBus` itself rather than a wrapper — consumers can call
`bus.read(into)` to pull a buffer from the bus on any tick. Phase-2
meter components and any future scope/analyser views are expected
to consume buses through this hook, so its surface is intentionally
minimal: one accessor, undefined when absent, narrow subscription.

View File

@@ -0,0 +1,66 @@
# `hooks/use-chain-plugin`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin`
currently occupying a specific slot in a track's `PluginChain`, or
`undefined` if the slot is empty. It is the per-slot view of
`usePluginChain` — useful for rendering a single
`<PluginSlot plugin={...} />` without re-rendering on every other
slot's parameter change. The hook re-renders only when chain
composition changes affect the targeted slot (`plugin-added`,
`plugin-removed`, `plugin-moved`, `plugin-replaced`).
## Features
- `useSyncExternalStore`-based subscription to four chain events.
- Returns `undefined` for out-of-range or empty slots — render-safe.
- Stable behaviour across React 18+ concurrent rendering.
- Listener cleanup on unmount.
## Usage: Basic
```tsx
import { useChainPlugin } from "@suaveplan/react-audio";
function SlotName({ trackId, slot }: { trackId: string; slot: number }) {
const plugin = useChainPlugin(trackId, slot);
return <span>{plugin?.metadata.name ?? "(empty)"}</span>;
}
```
## Usage: Advanced
```tsx
import { useChainPlugin, useParameter } from "@suaveplan/react-audio";
function SlotGain({ trackId, slot }: { trackId: string; slot: number }) {
const plugin = useChainPlugin(trackId, slot);
if (plugin === undefined) return null;
return <ParameterKnob plugin={plugin} paramId="gain" />;
}
```
## API Reference
- `useChainPlugin(trackId, slotIndex): AudioFxPlugin | undefined`.
## Implementation Notes
The hook subscribes to four chain events but reads only
`chain.plugins[slotIndex]` in its snapshot, so React re-renders the
consumer when (and only when) one of those events fires. Reading by
index is O(1) on the chain's internal array. `useSyncExternalStore`
returns the same plugin reference for the same slot across re-renders
when nothing about that slot changed, so downstream `useMemo` /
`useEffect` keyed on the plugin behave correctly. The hook ignores
`parameter-changed` and `bypass-changed` events because they don't
move the slot's plugin — for per-parameter subscriptions a child
should use `useParameter`. The list of subscribed events therefore
captures the smallest set of changes that could affect the value
returned, keeping per-slot re-renders proportional to actual chain
edits.

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,73 @@
# `hooks/use-engine-state`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useEngineState()` reports the `<AudioProvider>`'s async-init
lifecycle phase as one of `'pending' | 'ready' | 'error'`. It is the
only engine-aware hook that does **not** throw on the pending phase —
its purpose is to *report* that phase so consumers can render a
loading state or gate child trees on engine readiness.
The hook still throws `ReactAudioError(REACT_AUDIO.NO_PROVIDER)` when
rendered outside an `<AudioProvider>` ancestor, mirroring the
contract every public hook honours.
## Features
- Returns `'pending'`, `'ready'`, or `'error'`.
- Throws `REACT_AUDIO.NO_PROVIDER` outside the provider.
- Triggers re-render when the engine resolves (via the provider's
internal `setEngineState`).
- Stable identity across re-renders for the same lifecycle phase.
## Usage: Basic
```tsx
import { useEngineState } from "@suaveplan/react-audio";
function App() {
const engineState = useEngineState();
if (engineState === "pending") return <p>Loading audio…</p>;
if (engineState === "error") return <p>Audio failed to initialise.</p>;
return <Workspace />;
}
```
## Usage: Advanced
```tsx
import { useEngineState } from "@suaveplan/react-audio";
function useIsReady(): boolean {
return useEngineState() === "ready";
}
function Gate({ children }: { children: React.ReactNode }) {
const ready = useIsReady();
return ready ? <>{children}</> : <Spinner />;
}
```
## API Reference
- `useEngineState(): 'pending' | 'ready' | 'error'`.
## Implementation Notes
`useEngineState` reads the provider's React context directly rather
than going through `useSyncExternalStore`. React already batches state
updates inside the provider, so reading the context value is
sufficient and avoids the indirection of a snapshot+subscribe pair.
The hook returns the same `engineState` string instance across
renders within the same phase, so a `useMemo` keyed on it is a
constant-time identity check. The `'error'` state is reachable when
the engine's async init rejects (e.g. the browser refuses to start
an `AudioContext`). The provider's error object is exposed on the
context value too — consumers needing the underlying failure can
combine `useEngineState() === 'error'` with `useAudioEngine` or a
direct context read. The hook is the ONE hook in this package that
deliberately does NOT throw on the pending phase.

View File

@@ -0,0 +1,112 @@
# `hooks/use-midi-controller`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useMidiController(controllerId)` is the React 19 binding for a single
`MidiControllerPlugin` from `@suaveplan/audio-controllers`. It looks
the controller up by `id` in the surrounding
`MidiControllerRegistryContext`, subscribes to the controller's
`state` event via `useSyncExternalStore`, and exposes a `learn(target)`
method that wraps `captureNextLearn` for the learn-mode UX.
The hook is intentionally narrow: it does **not** own the MIDI
hardware, nor does it instantiate the controller. The application
constructs the controller plugin(s) at startup, places them in a
read-only registry (a plain `Map` is sufficient), and passes that
registry into the React tree via
`<MidiControllerRegistryContext.Provider>`. Every Phase-3 surface
(`useMidiController` and `<MidiControllerSetup>`) reads through the
same context, so swapping registry implementations during tests is
trivial.
## Features
- `useSyncExternalStore` subscription against the controller's
`state` event — re-renders once per FSM transition
(`disconnected → connecting → connected → error`).
- `learn(target)` returns a promise resolving to the captured
`(channel, controller)` pair; rejects with
`REACT_AUDIO.MIDI_LEARN_FAILED` when no controller is bound or
`captureNextLearn` rejects.
- `controller` is `undefined` and `state` is `"disconnected"` when
the registry does not contain the requested id — no throw, no
exception during render.
- Optional `registryOverride` second argument bypasses the context
for tests / isolated stories.
## Usage: Basic
```tsx
import {
MidiControllerRegistryContext,
useMidiController,
} from "@suaveplan/react-audio";
function ControllerBadge({ id }: { id: string }) {
const { state } = useMidiController(id);
return <span data-state={state}>{state}</span>;
}
function App({ controllers }: { controllers: Map<string, MidiControllerPlugin> }) {
return (
<MidiControllerRegistryContext.Provider value={controllers}>
<ControllerBadge id="kontrol-s88" />
</MidiControllerRegistryContext.Provider>
);
}
```
## Usage: Advanced
```tsx
import { useMidiController } from "@suaveplan/react-audio";
function LearnButton({ id, pluginId, paramId }: {
id: string; pluginId: string; paramId: string;
}) {
const { controller, state, learn } = useMidiController(id);
if (state !== "connected" || !controller) return <p>Connect controller…</p>;
return (
<button
type="button"
onClick={async () => {
const captured = await learn({ pluginId, paramId });
console.log("captured", captured);
}}
>
Learn {paramId}
</button>
);
}
```
## API Reference
- `MidiControllerRegistry` — type alias for
`ReadonlyMap<string, MidiControllerPlugin>`.
- `MidiControllerRegistryContext` — React context whose `.Provider`
distributes the registry to descendants.
- `LearnTarget` — `{ pluginId, paramId }` pair.
- `UseMidiControllerResult.controller` — resolved plugin or `undefined`.
- `UseMidiControllerResult.state` — `ConnectionState` mirrored from
the controller FSM.
- `UseMidiControllerResult.learn(target)` — returns
`Promise<{ target, channel, controller }>`; rejects with
`REACT_AUDIO.MIDI_LEARN_FAILED`.
## Implementation Notes
`useSyncExternalStore` requires that `getSnapshot` be referentially
stable for unchanged state. Because `ConnectionState` is a string
union, the snapshot value itself is the comparison key, so the
default `Object.is` equality check fires re-renders only on actual
transitions. The `subscribe` callback returns the controller's own
`off`-style unsubscribe handle, so the hook scales to any number of
mounted consumers without leaking listeners. Errors during `learn`
are wrapped in `ReactAudioError(MIDI_LEARN_FAILED)` so consumers can
handle a single, stable code rather than poking at the underlying
`AudioControllersError` surface.

View File

@@ -0,0 +1,98 @@
# `hooks/use-parameter`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useParameter(plugin, paramId)` is the canonical read/write hook for
a single declared parameter on any `AudioFxPlugin`. It returns the
parameter's current value (sampled at frame rate) and three
write-paths: an immediate `set`, a `curve` for ramps/automation, and
a `scheduleAt` for transport-anchored value changes.
The hook throws `ReactAudioError(REACT_AUDIO.UNKNOWN_PARAMETER)`
synchronously on first render if the `paramId` is not declared in
the plugin's `parameterIndex` — so a typo fails fast in the render
tree rather than silently dropping writes.
## Features
- Frame-rate sampled `value` (60 Hz default, configurable via
provider `sampleRateHz`).
- `set(v)` → `plugin.setParameter(paramId, v)`.
- `curve(c)` → `plugin.setParameterCurve(paramId, c)`.
- `scheduleAt(v, t)` → `plugin.setParameterAt(paramId, v, t)`.
- Throws `REACT_AUDIO.UNKNOWN_PARAMETER` on first render for an
unknown `paramId`, carrying the available ids in `context`.
- SSR-consistent snapshots (both `getSnapshot` and
`getServerSnapshot` read `plugin.getParameter(paramId)`).
## Usage: Basic
```tsx
import { useParameter } from "@suaveplan/react-audio";
function GainKnob({ plugin }: { plugin: AudioFxPlugin }) {
const { value, set } = useParameter(plugin, "gain");
return (
<input
type="range"
min={0}
max={1}
step={0.01}
value={value}
onChange={(e) => set(Number(e.currentTarget.value))}
/>
);
}
```
## Usage: Advanced
```tsx
import { useParameter } from "@suaveplan/react-audio";
function Sweep({ plugin }: { plugin: AudioFxPlugin }) {
const { curve, scheduleAt } = useParameter(plugin, "cutoff");
return (
<>
<button
type="button"
onClick={() =>
curve({
kind: "linear",
startValue: 200,
endValue: 8000,
duration: 4,
})
}
>
Linear sweep
</button>
<button type="button" onClick={() => scheduleAt(440, 2)}>
Set to 440 at t=2s
</button>
</>
);
}
```
## API Reference
- `useParameter(plugin, paramId): { value, set, curve, scheduleAt }`.
## Implementation Notes
Parameter automation can change values per audio block (~689 Hz at
128/88.2k); the React commit cycle cannot run at that rate. The hook
samples the live value at `sampleRateHz` (60 Hz default) via a
`useSyncExternalStore` subscription whose subscribe path schedules a
`setInterval` and getSnapshot path reads `plugin.getParameter(paramId)`.
`setInterval` is used rather than `requestAnimationFrame` so the
sampling behaves deterministically in tests (happy-dom RAF is
synchronous-per-microtask which makes the per-frame-cap test
impossible). The unknown-param check runs on every render —
parameter indices are immutable on a typical plugin so the lookup is
O(1) and the throw site is the same.

View File

@@ -0,0 +1,135 @@
# `hooks/use-playlist`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries
and exposes the standard playlist verbs (`next`, `prev`, `skipTo`,
`setIndex`) plus mode toggles (`repeat`, `shuffle`). The hook is
deliberately engine-agnostic: it knows nothing about `AudioContext`,
`AudioPlayerPlugin`, or transport. Consumers wire the returned
`current.src` into whichever loader they use, typically
`<AudioPlayer source={current.src}>` from this same package, or any
of the higher-level players that build on it.
The hook is the single source of truth for queue traversal across
the audio family. The companion `<Queue>` UI in
`@suaveplan/react-audio-player` is a thin renderer over its return
value; the karaoke surface in the same package advances lyrics
independently from playlist position.
## Features
- `repeat: "off" | "all" | "one"` with correct wrap semantics on
`next` and `prev`.
- `shuffle: boolean` with a per-shuffle history stack so `prev`
reverses the exact path `next` walked.
- Avoids immediately repeating the current track when shuffling.
- `skipTo(id)` jumps to an item by id; ids not in `items` are a
silent no-op.
- `setIndex(n)` clamps out-of-range values rather than throwing.
- Injectable `rng` for deterministic testing.
- Stable return-value identity for `current` across renders that
do not change the active item.
## Usage: Basic
```tsx
import { usePlaylist } from "@suaveplan/react-audio";
function MyPlayer({ items }) {
const { current, next, prev, index, length } = usePlaylist(items);
return (
<div>
<button type="button" onClick={prev} disabled={index <= 0}>
←
</button>
<span>{current?.title ?? "—"}</span>
<button
type="button"
onClick={next}
disabled={index >= length - 1}
>
→
</button>
</div>
);
}
```
## Usage: Advanced
```tsx
import { usePlaylist } from "@suaveplan/react-audio";
function ShuffledPlayer({ items }) {
// Inject a deterministic RNG for repeatable tests.
const rng = () => 0.42;
const pl = usePlaylist(items, {
initialIndex: 2,
initialRepeat: "all",
initialShuffle: true,
rng,
});
return (
<div>
<button type="button" onClick={() => pl.setShuffle(!pl.shuffle)}>
{pl.shuffle ? "Shuffle on" : "Shuffle off"}
</button>
<button type="button" onClick={() => pl.setRepeat("one")}>
Repeat one
</button>
<ol>
{items.map((it) => (
<li
key={it.id}
aria-current={
pl.current?.id === it.id ? "true" : undefined
}
>
<button type="button" onClick={() => pl.skipTo(it.id)}>
{it.title ?? it.id}
</button>
</li>
))}
</ol>
</div>
);
}
```
## API Reference
- `usePlaylist(items: readonly PlaylistItem[], opts?: UsePlaylistOptions): UsePlaylistResult`
- `PlaylistItem` — `{ id, src, title?, artist?, durationSec?, meta? }`.
- `PlaylistRepeat` — `"off" | "all" | "one"`.
- `UsePlaylistOptions` — `initialIndex`, `initialRepeat`,
`initialShuffle`, `rng`.
- `UsePlaylistResult` — `current`, `index`, `length`, `repeat`,
`shuffle`, `next()`, `prev()`, `skipTo(id)`, `setIndex(n)`,
`setRepeat(mode)`, `setShuffle(enabled)`.
## Implementation Notes
The cursor is held in a single `useState<number>`; all verbs route
through the same setter so React batches updates per event. The
shuffle "history" lives in a `useRef<number[]>` because it is not
rendered — pushing to it must not cause a commit. When `setShuffle`
toggles off, the history is cleared so the next shuffle session
starts clean.
The `rng` ref is rebound on every render to `opts.rng ?? Math.random`
so the consumer can swap RNGs without losing state. The deferred
update inside callbacks reads the ref, not the captured option, so a
late-arriving RNG takes effect on the very next `next`.
`current` is memoised by `(items, index, length)`; since `length`
mirrors `items.length`, the dep set is minimal but explicit so React
strict-mode double-renders cannot manufacture stale snapshots.
`next` and `prev` are pure functions of `(repeat, shuffle, length,
cur)` — no I/O, no thrown errors, no `console`. The hook is safe to
mount under React's concurrent renderer without `useTransition`.

View File

@@ -0,0 +1,82 @@
# `hooks/use-plugin-chain`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePluginChain(trackId)` returns a handle for the `PluginChain`
associated with a track. The provider lazily creates the chain
through its `ChainMap` on first call, so every consumer asking for
the same track sees the same chain. The hook subscribes to the
chain's composition events — `plugin-added`, `plugin-removed`,
`plugin-moved`, `plugin-replaced`, `bypass-changed` — and re-renders
the consumer when any of them fire. Listener cleanup on unmount is
exact: the chain's listener count returns to the pre-mount baseline.
## Features
- Lazy per-track chain creation via the provider's `ChainMap`.
- Re-renders on chain-composition events (5 events subscribed).
- Stable `add` / `remove` / `move` / `replace` / `bypass` /
`snapshot` / `restore` callback identities across re-renders.
- `restore(input)` delegates to `chain.restore(input, registry)`
using the provider's `PluginRegistry`.
- Exact listener cleanup on unmount.
## Usage: Basic
```tsx
import { usePluginChain } from "@suaveplan/react-audio";
function ChainBar({ trackId, eq }: { trackId: string; eq: AudioFxPlugin }) {
const { plugins, add } = usePluginChain(trackId);
return (
<div>
<button type="button" onClick={() => add(eq)}>Add EQ</button>
<span>{plugins.length} plugin(s)</span>
</div>
);
}
```
## Usage: Advanced
```tsx
import { usePluginChain } from "@suaveplan/react-audio";
function Reorderable({ trackId }: { trackId: string }) {
const { plugins, move, bypass, replace } = usePluginChain(trackId);
return (
<ul>
{plugins.map((plugin, index) => (
<li key={plugin.metadata.id}>
<button onClick={() => move(index, Math.max(0, index - 1))}>↑</button>
<button onClick={() => bypass(index, true)}>Bypass</button>
<span>{plugin.metadata.id}</span>
</li>
))}
</ul>
);
}
```
## API Reference
- `usePluginChain(trackId): UsePluginChainResult`.
- `UsePluginChainResult = { chain, plugins, add, remove, move,
replace, bypass, snapshot, restore }`.
## Implementation Notes
The subscribe path registers a listener on each of the five
composition events and returns a teardown that calls every
`UnsubscribeFn`. Because the chain's `event-bus` increments and
decrements listener counts symmetrically, the post-unmount listener
count is exactly the pre-mount count. `replace` is implemented as
`remove + add` at the same index because `PluginChain` does not
currently expose a single-call `replace`; the chain emits a
`plugin-removed` followed by a `plugin-added`, both of which the
hook is subscribed to, so the consumer sees one composite re-render
(React batches state updates within the same task).

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,70 @@
# `hooks/use-provider-state`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Internal helpers shared across every public hook. They centralise the
two structured-error throws every engine-dependent hook must perform:
`REACT_AUDIO.NO_PROVIDER` when the consumer renders outside an
`<AudioProvider>` ancestor, and `REACT_AUDIO.PROVIDER_PENDING` when
the engine has not yet finished its async init. Keeping the throw
logic in one file means there is exactly one place to audit the error
contract.
## Features
- `useProviderStateOrThrow()` — returns the resolved `ProviderState`
or throws.
- `useAudioContextOrThrow()` — returns the raw `AudioContextValue`
(without the pending check) for hooks like `useEngineState` that
need to *report* the lifecycle phase rather than throw on it.
## Usage: Basic
```ts
import { useProviderStateOrThrow } from "@suaveplan/react-audio";
function MyHook() {
const state = useProviderStateOrThrow();
return state.transport;
}
```
## Usage: Advanced
```ts
import { useAudioContextOrThrow } from "@suaveplan/react-audio";
function useReadyOrLoading(): "ready" | "loading" {
const ctx = useAudioContextOrThrow();
return ctx.engineState === "ready" ? "ready" : "loading";
}
```
## API Reference
- `useProviderStateOrThrow(): ProviderState` — throws
`REACT_AUDIO.NO_PROVIDER` outside provider; throws
`REACT_AUDIO.PROVIDER_PENDING` while engine init is pending.
- `useAudioContextOrThrow(): AudioContextValue` — throws
`REACT_AUDIO.NO_PROVIDER` outside provider.
## Implementation Notes
`useContext` returns `null` as the "no provider" sentinel because the
context's default value is `null`. The two helpers use a single
discriminator check (`ctx === null`) for that path so the throw site
is canonical. `useProviderStateOrThrow` additionally checks for
`ctx.state === null` (the async init window) and throws
`PROVIDER_PENDING` so engine-dependent hooks fail fast — consumers
typically gate on `useEngineState() === 'ready'` before rendering
those hooks' subtrees. The split into two helpers exists so
`useEngineState` itself can read the phase without throwing on it —
otherwise the consumer would have no way to discover "we're still
initialising" without crashing the component tree. Both helpers
register exactly one React hook (`useContext`) so any downstream hook
can register additional `useCallback` / `useSyncExternalStore` calls
unconditionally and stay compliant with the Rules of Hooks.

View File

@@ -0,0 +1,74 @@
# `hooks/use-timeline`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTimeline()` returns the provider's single `TimelineEngine`
instance, the canonical owner of timeline state — tracks, clips,
selection, loop region, master volume, undo/redo. The engine exposes
its own typed event surface (`engine.on(event, listener)`); consumers
who want to *subscribe* to engine events should pair this hook with
`useSyncExternalStore` and pass `engine.on(...)` as the subscribe
callback. The hook itself returns the engine handle and does not
trigger re-renders on engine state changes.
## Features
- Returns the `TimelineEngine` instance owned by the provider.
- Stable identity across re-renders within a ready phase.
- Throws `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>`.
- Throws `REACT_AUDIO.PROVIDER_PENDING` during async init.
## Usage: Basic
```tsx
import { useTimeline } from "@suaveplan/react-audio";
function AddTrackButton() {
const timeline = useTimeline();
return (
<button type="button" onClick={() => timeline.addTrack({ name: "New Track" })}>
Add track
</button>
);
}
```
## Usage: Advanced
```tsx
import { useSyncExternalStore } from "react";
import { useTimeline } from "@suaveplan/react-audio";
function useTrackCount(): number {
const timeline = useTimeline();
return useSyncExternalStore(
(listener) => timeline.on("trackChange", listener as never),
() => timeline.getState().tracks.length,
() => timeline.getState().tracks.length
);
}
```
## API Reference
- `useTimeline(): TimelineEngine` — see
`@suaveplan/audio-timeline` for the full engine surface.
## Implementation Notes
The hook deliberately stops short of re-rendering on every engine
event. The `TimelineEngine` has a rich change surface and re-rendering
on every state delta would be expensive at chain scale. Consumers
choose which events they care about and pair the engine handle with
`useSyncExternalStore` for narrow subscriptions, paying only for the
re-renders they want. Mutator methods (`addTrack`, `updateTrack`,
`removeTrack`, `setTempo`, `setMasterVolume`, …) are exposed directly
on the engine handle — callers wrap them in their own callbacks when
they need referential stability across renders. For per-track state
prefer `useTrack(id)`, which already wraps `useSyncExternalStore`
against the timeline's `trackChange` event so its subscription is
narrow by construction.

View File

@@ -0,0 +1,74 @@
# `hooks/use-track`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTrack(trackId)` subscribes to a single track on the
`TimelineEngine` by id and re-renders only when that specific track
changes. It returns the live `Track` snapshot (or `undefined` if no
track has that id) plus pre-bound `update` / `remove` callbacks. The
hook is the canonical way to render per-track UI without re-rendering
on every other track's state change.
## Features
- Re-renders only on `trackChange` events for the same id.
- Returns `undefined` for unknown ids — render-safe.
- Stable `update` / `remove` identities across re-renders.
- `useSyncExternalStore`-based subscription with SSR-consistent
snapshots.
- Listener is removed on unmount.
## Usage: Basic
```tsx
import { useTrack } from "@suaveplan/react-audio";
function TrackName({ trackId }: { trackId: string }) {
const { track } = useTrack(trackId);
return <span>{track?.name ?? "(missing track)"}</span>;
}
```
## Usage: Advanced
```tsx
import { useTrack } from "@suaveplan/react-audio";
function MuteToggle({ trackId }: { trackId: string }) {
const { track, update } = useTrack(trackId);
if (track === undefined) return null;
return (
<button
type="button"
aria-pressed={track.muted}
onClick={() => update({ muted: !track.muted })}
>
{track.muted ? "Unmute" : "Mute"}
</button>
);
}
```
## API Reference
- `useTrack(trackId): { track: Track | undefined, update, remove }`.
## Implementation Notes
The hook caches the resolved Track in a `ref` so
`useSyncExternalStore.getSnapshot` is identity-stable between change
events. Without that cache, `timeline.getState().tracks.find(...)`
would build a new wrapper on every snapshot read; React 18+ treats
that as an unstable store and tears the consuming subtree down on
the next commit. Refresh fires on every `trackChange` from the
timeline AND from local `update` / `remove` mutators — the
underlying engine emits `trackChange` only for tracks STILL present
in the list, so removing the last track of an id needs the mutator
to update the cache directly. After removal the hook continues to
render but `track` becomes `undefined`. Components consuming
`update` / `remove` should expect those callbacks to settle
synchronously; the next React commit reflects the change.

View File

@@ -0,0 +1,63 @@
# `hooks/use-transport-time`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTransportTime()` returns the transport's current time in seconds,
sampled at the provider's configured `sampleRateHz` (default 60).
The audio engine advances at audio-block rate (typically ~689 Hz for
128-sample blocks at 88.2 kHz); React cannot render at that rate, so
the hook deliberately samples at frame rate. The returned value lags
real time by at most one frame interval (~16.7 ms at 60 Hz) — the
correct ceiling for human-readable transport displays.
## Features
- `useSyncExternalStore`-based subscription for concurrent-render
safety and SSR consistency.
- Sample rate configurable via `<AudioProvider options={{ sampleRateHz }}>`.
- Cleanup on unmount cancels the timer — no leaked intervals.
- SSR snapshot equals first client render (both read `transport.seconds`).
## Usage: Basic
```tsx
import { useTransportTime } from "@suaveplan/react-audio";
function TimeDisplay() {
const seconds = useTransportTime();
return <span>{seconds.toFixed(3)} s</span>;
}
```
## Usage: Advanced
```tsx
import { useTransportTime } from "@suaveplan/react-audio";
function BeatLight() {
const seconds = useTransportTime();
const beat = Math.floor(seconds * 2) % 4; // 120 bpm assumed
return <div data-beat={beat} className="beat-light" />;
}
```
## API Reference
- `useTransportTime(): number` — current transport time in seconds,
sampled at `sampleRateHz`.
## Implementation Notes
The hook uses `setInterval` rather than `requestAnimationFrame`
because the former is deterministic under happy-dom and Vitest (RAF
in JSDOM-equivalents is synchronous and fires once per microtask,
making the per-frame-cap test impossible to write). The interval is
clamped to a minimum of 1 ms so a runaway `sampleRateHz` cannot lock
up the UI. On unmount the interval is cleared via the
`useSyncExternalStore` subscribe contract. The `getServerSnapshot`
and `getSnapshot` both read `transport.seconds`, so SSR-rendered
HTML byte-matches the first client commit.

View File

@@ -0,0 +1,78 @@
# `hooks/use-transport`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useTransport()` wraps the provider's `Transport` with a small,
React-friendly façade: `{ transport, play, pause, stop, seek,
setTempo }`. The wrapped callbacks are stable across re-renders so
consumers can pass them to memoised children without losing equality.
The underlying `Transport` is included in the result for consumers
that need its full surface (loops, swing, schedule, event
subscriptions via `transport.on(...)`).
## Features
- `play(time?, offset?)` — delegates to `transport.start`.
- `pause(time?)` — delegates to `transport.pause`.
- `stop(time?)` — delegates to `transport.stop`.
- `seek(seconds)` — restarts the transport at the requested offset
(converted to ticks via `transport.toTicks`).
- `setTempo(bpm)` — writes the bpm signal at the current clock.
- `transport` — escape hatch for advanced surface.
## Usage: Basic
```tsx
import { useTransport } from "@suaveplan/react-audio";
function TransportButtons() {
const { play, pause, stop } = useTransport();
return (
<>
<button type="button" onClick={() => play()}>Play</button>
<button type="button" onClick={() => pause()}>Pause</button>
<button type="button" onClick={() => stop()}>Stop</button>
</>
);
}
```
## Usage: Advanced
```tsx
import { useTransport } from "@suaveplan/react-audio";
function TempoSlider() {
const { setTempo, transport } = useTransport();
return (
<input
type="range"
min={40}
max={200}
defaultValue={transport.bpm.getValueAtTime(transport.seconds)}
onChange={(e) => setTempo(Number(e.currentTarget.value))}
/>
);
}
```
## API Reference
- `useTransport(): UseTransportResult`.
- `UseTransportResult = { transport, play, pause, stop, seek, setTempo }`.
## Implementation Notes
`seek` works by stopping the transport and (if it was playing)
restarting it at the requested tick offset; this matches the
underlying `Transport` API, which does not expose a direct seek
primitive. `setTempo` writes the bpm `Signal` at the current
clock — the signal owns its own ramp/automation logic, so consumers
should use the `Signal` API for ramped tempo changes instead of
calling `setTempo` rapidly. The callbacks are wrapped in
`useCallback` keyed on `transport` so callers can memoise downstream
work that depends on transport-callback identity.