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,55 @@
export {
type AnalyserLike,
type UseAnalyserOptions,
type UseAnalyserResult,
useAnalyser,
} from "./use-analyser/index.js";
export { useAudioEngine } from "./use-audio-engine/use-audio-engine.js";
export { useAudioFxPlugin } from "./use-audio-fx-plugin/use-audio-fx-plugin.js";
export { useBus } from "./use-bus/use-bus.js";
export { useChainPlugin } from "./use-chain-plugin/use-chain-plugin.js";
export {
type UseChainSnapshotResult,
useChainSnapshot,
} from "./use-chain-snapshot/use-chain-snapshot.js";
export { useEngineState } from "./use-engine-state/use-engine-state.js";
export {
type LearnTarget,
type MidiControllerRegistry,
MidiControllerRegistryContext,
type UseMidiControllerResult,
useMidiController,
} from "./use-midi-controller/use-midi-controller.js";
export {
type UseParameterResult,
useParameter,
} from "./use-parameter/use-parameter.js";
export {
type PlaylistItem,
type PlaylistRepeat,
type UsePlaylistOptions,
type UsePlaylistResult,
usePlaylist,
} from "./use-playlist/index.js";
export {
type UsePluginChainResult,
usePluginChain,
} from "./use-plugin-chain/use-plugin-chain.js";
export {
type UsePresetResult,
usePreset,
} from "./use-preset/use-preset.js";
export {
useAudioContextOrThrow,
useProviderStateOrThrow,
} from "./use-provider-state.js";
export { useTimeline } from "./use-timeline/use-timeline.js";
export {
type UseTrackResult,
useTrack,
} from "./use-track/use-track.js";
export {
type UseTransportResult,
useTransport,
} from "./use-transport/use-transport.js";
export { useTransportTime } from "./use-transport-time/use-transport-time.js";

View File

@@ -0,0 +1,6 @@
export { useAnalyser } from "./use-analyser.js";
export type {
AnalyserLike,
UseAnalyserOptions,
UseAnalyserResult,
} from "./use-analyser.types.js";

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,246 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { act, renderHook } from "@testing-library/react";
import { useAnalyser } from "./use-analyser.js";
import type { AnalyserLike } from "./use-analyser.types.js";
function makeFakeAnalyser(bins = 16): AnalyserLike & {
freqCalls: number;
waveCalls: number;
} {
const state = {
fftSize: 2048,
frequencyBinCount: bins,
minDecibels: -100,
maxDecibels: -30,
smoothingTimeConstant: 0.8,
freqCalls: 0,
waveCalls: 0,
getByteFrequencyData(out: Uint8Array) {
state.freqCalls += 1;
out[0] = (state.freqCalls % 250) + 1;
},
getByteTimeDomainData(out: Uint8Array) {
state.waveCalls += 1;
out[0] = ((state.waveCalls * 2) % 250) + 1;
},
};
Object.defineProperty(state, "frequencyBinCount", {
get: () => Math.max(1, Math.floor(state.fftSize / 2)),
});
return state as unknown as AnalyserLike & {
freqCalls: number;
waveCalls: number;
};
}
describe("useAnalyser", () => {
it("returns null analyser + empty buffers when no source provided", {
timeout: 5000,
}, () => {
const { result, unmount } = renderHook(() => useAnalyser());
expect(result.current.analyser).toBeNull();
expect(result.current.frequency.length).toBe(0);
expect(result.current.waveform.length).toBe(0);
unmount();
});
it("polls the explicit analyser and writes into the shared buffer", {
timeout: 5000,
}, () => {
vi.useFakeTimers();
try {
const a = makeFakeAnalyser(8);
const { result, unmount } = renderHook(() =>
useAnalyser({ analyser: a, intervalMs: 5, fftSize: 16 })
);
expect(result.current.analyser).toBe(a);
expect(result.current.frequency.length).toBe(8);
act(() => {
vi.advanceTimersByTime(20);
});
expect(a.freqCalls).toBeGreaterThan(0);
expect(a.waveCalls).toBeGreaterThan(0);
expect(result.current.frequency[0]).toBeGreaterThan(0);
unmount();
} finally {
vi.useRealTimers();
}
});
it("applies fftSize/smoothing/min-max on the analyser", {
timeout: 5000,
}, () => {
const a = makeFakeAnalyser();
renderHook(() =>
useAnalyser({
analyser: a,
fftSize: 512,
smoothingTimeConstant: 0.3,
minDecibels: -120,
maxDecibels: -20,
})
);
expect(a.fftSize).toBe(512);
expect(a.smoothingTimeConstant).toBe(0.3);
expect(a.minDecibels).toBe(-120);
expect(a.maxDecibels).toBe(-20);
});
it("resolves via factory on tick, then stops re-resolving", {
timeout: 5000,
}, () => {
vi.useFakeTimers();
try {
let calls = 0;
const a = makeFakeAnalyser();
const factory = () => {
calls += 1;
return calls >= 2 ? a : null;
};
const { result } = renderHook(() =>
useAnalyser({ factory, intervalMs: 5 })
);
expect(result.current.analyser).toBeNull();
act(() => {
vi.advanceTimersByTime(6);
});
// First tick: factory returns null; resolved still null.
act(() => {
vi.advanceTimersByTime(6);
});
expect(result.current.analyser).toBe(a);
// Once resolved, factory should not be called again.
const beforeCalls = calls;
act(() => {
vi.advanceTimersByTime(20);
});
expect(calls).toBe(beforeCalls);
} finally {
vi.useRealTimers();
}
});
it("swapping the analyser prop reconfigures buffers", {
timeout: 5000,
}, () => {
const a1 = makeFakeAnalyser(8);
const a2 = makeFakeAnalyser(16);
const { result, rerender } = renderHook(
({ a }: { a: typeof a1 }) =>
useAnalyser({ analyser: a, fftSize: 32 }),
{ initialProps: { a: a1 } }
);
expect(result.current.frequency.length).toBeGreaterThan(0);
const first = result.current.frequency;
rerender({ a: a2 });
// Buffer identity may change; instance must be a2.
expect(result.current.analyser).toBe(a2);
// Different fftSize bin count → different length.
expect(result.current.frequency.length).toBe(Math.floor(32 / 2));
expect(first).not.toBe(result.current.frequency);
});
it("setting analyser to null disables polling", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
const a = makeFakeAnalyser();
const { rerender } = renderHook(
({ x }: { x: typeof a | null }) =>
useAnalyser({ analyser: x, intervalMs: 5 }),
{ initialProps: { x: a as typeof a | null } }
);
act(() => {
vi.advanceTimersByTime(20);
});
const before = a.freqCalls;
rerender({ x: null });
act(() => {
vi.advanceTimersByTime(20);
});
// After null, no new poll calls land on `a`.
expect(a.freqCalls).toBe(before);
} finally {
vi.useRealTimers();
}
});
it("cleans up the interval on unmount", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
const a = makeFakeAnalyser();
const { unmount } = renderHook(() =>
useAnalyser({ analyser: a, intervalMs: 5 })
);
act(() => {
vi.advanceTimersByTime(10);
});
const before = a.freqCalls;
unmount();
act(() => {
vi.advanceTimersByTime(50);
});
expect(a.freqCalls).toBe(before);
} finally {
vi.useRealTimers();
}
});
it("factory returning null indefinitely keeps analyser null", {
timeout: 5000,
}, () => {
vi.useFakeTimers();
try {
const { result } = renderHook(() =>
useAnalyser({ factory: () => null, intervalMs: 5 })
);
act(() => {
vi.advanceTimersByTime(30);
});
expect(result.current.analyser).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("no factory, no analyser → tick is a no-op", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
const { result } = renderHook(() => useAnalyser({ intervalMs: 5 }));
act(() => {
vi.advanceTimersByTime(20);
});
expect(result.current.analyser).toBeNull();
} finally {
vi.useRealTimers();
}
});
it("changing fftSize after mount reallocates buffers", {
timeout: 5000,
}, () => {
const a = makeFakeAnalyser();
const { result, rerender } = renderHook(
({ size }: { size: number }) =>
useAnalyser({ analyser: a, fftSize: size }),
{ initialProps: { size: 32 } }
);
expect(result.current.frequency.length).toBe(16);
rerender({ size: 64 });
expect(result.current.frequency.length).toBe(32);
expect(a.fftSize).toBe(64);
});
it("intervalMs <= 0 is clamped to 1", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
const a = makeFakeAnalyser();
renderHook(() => useAnalyser({ analyser: a, intervalMs: 0 }));
act(() => {
vi.advanceTimersByTime(5);
});
expect(a.freqCalls).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,160 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type {
AnalyserLike,
UseAnalyserOptions,
UseAnalyserResult,
} from "./use-analyser.types.js";
const DEFAULT_FFT_SIZE = 2048;
const DEFAULT_INTERVAL_MS = 16;
const DEFAULT_SMOOTHING = 0.8;
const DEFAULT_MIN_DB = -100;
const DEFAULT_MAX_DB = -30;
/**
* `useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and
* returns the latest byte-magnitude FFT frame plus the matching
* time-domain (waveform) frame. The hook does not create an
* `AudioContext` and does not add itself to the audio graph — the
* consumer either supplies an `AnalyserLike` directly or hands in a
* lazy `factory` that returns one (e.g. resolved from
* `BrowserTimelineAdapter.snapshotGraph()` once async init finishes).
*
* The returned `Uint8Array` buffers are owned by the hook and
* mutated in place every poll; callers must copy if they need a
* stable snapshot. Buffers are reallocated when `analyser` or
* `fftSize` changes.
*
* Cleanup on unmount cancels the interval. Two consumers reading
* the same analyser independently each get their own buffer pair so
* they cannot tear each other's reads.
*/
export function useAnalyser(opts: UseAnalyserOptions = {}): UseAnalyserResult {
const {
analyser: analyserOpt,
factory,
intervalMs = DEFAULT_INTERVAL_MS,
fftSize = DEFAULT_FFT_SIZE,
smoothingTimeConstant = DEFAULT_SMOOTHING,
minDecibels = DEFAULT_MIN_DB,
maxDecibels = DEFAULT_MAX_DB,
} = opts;
const resolvedRef = useRef<AnalyserLike | null>(null);
const factoryRef = useRef<typeof factory>(factory);
factoryRef.current = factory;
const configureAndBuild = useCallback(
(a: AnalyserLike): { frequency: Uint8Array; waveform: Uint8Array } => {
a.fftSize = fftSize;
a.smoothingTimeConstant = smoothingTimeConstant;
a.minDecibels = minDecibels;
a.maxDecibels = maxDecibels;
const bins = a.frequencyBinCount;
return {
frequency: new Uint8Array(bins),
waveform: new Uint8Array(bins),
};
},
[fftSize, smoothingTimeConstant, minDecibels, maxDecibels]
);
// Synchronously initialise resolved + buffers from `analyserOpt`
// so the very first render returns the live buffer pair (not a
// throwaway empty array later swapped out from an effect).
const initialAnalyser = analyserOpt ?? null;
if (initialAnalyser !== null && resolvedRef.current === null) {
resolvedRef.current = initialAnalyser;
}
const [state, setState] = useState<{
analyser: AnalyserLike | null;
frequency: Uint8Array;
waveform: Uint8Array;
}>(() => {
if (initialAnalyser) {
const bufs = configureAndBuild(initialAnalyser);
return { analyser: initialAnalyser, ...bufs };
}
return {
analyser: null,
frequency: new Uint8Array(0),
waveform: new Uint8Array(0),
};
});
const swap = useCallback(
(a: AnalyserLike | null) => {
resolvedRef.current = a;
if (a) {
const bufs = configureAndBuild(a);
setState({ analyser: a, ...bufs });
} else {
setState({
analyser: null,
frequency: new Uint8Array(0),
waveform: new Uint8Array(0),
});
}
},
[configureAndBuild]
);
const lastConfigKeyRef = useRef<string>(
initialAnalyser
? `${fftSize}/${smoothingTimeConstant}/${minDecibels}/${maxDecibels}`
: ""
);
// Sync the explicit `analyser` prop.
useEffect(() => {
if (analyserOpt !== undefined && analyserOpt !== resolvedRef.current) {
swap(analyserOpt);
lastConfigKeyRef.current = analyserOpt
? `${fftSize}/${smoothingTimeConstant}/${minDecibels}/${maxDecibels}`
: "";
}
}, [
analyserOpt,
swap,
fftSize,
smoothingTimeConstant,
minDecibels,
maxDecibels,
]);
// Reconfigure on fft/smoothing changes (skip if already at this key).
useEffect(() => {
const key = `${fftSize}/${smoothingTimeConstant}/${minDecibels}/${maxDecibels}`;
if (resolvedRef.current !== null && key !== lastConfigKeyRef.current) {
lastConfigKeyRef.current = key;
swap(resolvedRef.current);
}
}, [fftSize, smoothingTimeConstant, minDecibels, maxDecibels, swap]);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
const tick = (): void => {
let a = resolvedRef.current;
if (!a && factoryRef.current) {
const made = factoryRef.current();
if (made) {
swap(made);
a = made;
}
}
if (!a) return;
const bufs = stateRef.current;
a.getByteFrequencyData(bufs.frequency);
a.getByteTimeDomainData(bufs.waveform);
};
const id = setInterval(tick, Math.max(1, intervalMs));
return () => {
clearInterval(id);
};
}, [intervalMs, swap]);
return state;
}

View File

@@ -0,0 +1,59 @@
/**
* Minimal `AnalyserNode`-shaped contract. Matches the subset of the
* Web Audio `AnalyserNode` interface this hook reads. Declared as a
* structural type so the hook works equally well against the real
* `AnalyserNode`, a stub in tests, or the engine's internal
* `analyser`-tagged graph node returned from
* `BrowserTimelineAdapter.snapshotGraph()`.
*/
export interface AnalyserLike {
fftSize: number;
readonly frequencyBinCount: number;
minDecibels: number;
maxDecibels: number;
smoothingTimeConstant: number;
getByteFrequencyData(out: Uint8Array): void;
getByteTimeDomainData(out: Uint8Array): void;
}
export interface UseAnalyserOptions {
/**
* Direct analyser reference. If supplied, `factory` is ignored.
* Pass `null` to disable polling (the hook will return zero-filled
* buffers and `analyser: null`).
*/
readonly analyser?: AnalyserLike | null;
/**
* Lazy factory invoked once on first sample if `analyser` is
* absent. Return `null` to defer (the hook will retry on next
* sample). Useful for grabbing the engine's per-track analyser
* after async init has resolved.
*/
readonly factory?: () => AnalyserLike | null;
/** Sample interval in ms. Default 16 (≈60 Hz). */
readonly intervalMs?: number;
/** FFT size. Must be a power of 2 in `[32, 32768]`. Default 2048. */
readonly fftSize?: number;
/** Smoothing time constant in `[0, 1]`. Default 0.8. */
readonly smoothingTimeConstant?: number;
/** Minimum dB cutoff. Default -100. */
readonly minDecibels?: number;
/** Maximum dB cutoff. Default -30. */
readonly maxDecibels?: number;
}
export interface UseAnalyserResult {
/** Resolved analyser, or `null` if neither prop nor factory yielded one. */
readonly analyser: AnalyserLike | null;
/**
* Latest byte-magnitude frequency frame (length =
* `analyser.frequencyBinCount`). Identity-stable across renders;
* mutated in place — copy if you want a snapshot.
*/
readonly frequency: Uint8Array;
/**
* Latest byte time-domain (waveform) frame. Same identity-stable
* mutated-in-place contract as `frequency`.
*/
readonly waveform: Uint8Array;
}

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,18 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { waitFor } from "@testing-library/react";
import { renderHookWithProvider } from "../../test-utils.js";
import { useAudioEngine } from "./use-audio-engine.js";
describe("useAudioEngine", () => {
it("returns the full ProviderState bundle", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => useAudioEngine());
await waitFor(() => expect(handle.current).toBeDefined());
expect(handle.current!.transport).toBeDefined();
expect(handle.current!.timeline).toBeDefined();
expect(handle.current!.buses).toBeDefined();
expect(handle.current!.chains).toBeDefined();
expect(handle.current!.registry).toBeDefined();
});
});

View File

@@ -0,0 +1,16 @@
import type { ProviderState } from "../../provider/audio-context.js";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Returns the full resolved `ProviderState` bundle (engine, timeline,
* transport, buses, registry, presetStore, sampleRateHz). Throws
* `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>` and
* `REACT_AUDIO.PROVIDER_PENDING` while engine init is in flight.
*
* Prefer the focused hooks (`useTransport`, `useTimeline`, …) for
* normal use — they emit narrower change events. This hook is the
* escape hatch for code that needs the entire bundle in one read.
*/
export function useAudioEngine(): ProviderState {
return useProviderStateOrThrow();
}

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,30 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { waitFor } from "@testing-library/react";
import { makeFakePlugin, renderHookWithProvider } from "../../test-utils.js";
import { useAudioEngine } from "../use-audio-engine/use-audio-engine.js";
import { useAudioFxPlugin } from "./use-audio-fx-plugin.js";
describe("useAudioFxPlugin", () => {
it("instantiates a plugin from the registry", {
timeout: 30000,
}, async () => {
const factory = () => makeFakePlugin({ id: "synth-1" });
const handle = renderHookWithProvider(() => useAudioFxPlugin("fake"), {
options: { plugins: [{ pluginType: "fake", factory }] },
});
await waitFor(() => expect(handle.current).toBeDefined());
expect(handle.current!.metadata.id).toBe("synth-1");
});
it("throws when no factory registered", { timeout: 30000 }, async () => {
const handle = renderHookWithProvider(() => useAudioEngine());
await waitFor(() => expect(handle.current).toBeDefined());
let caught: unknown;
try {
handle.current!.registry.create("missing-type");
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
});
});

View File

@@ -0,0 +1,20 @@
import type { AudioFxPlugin } from "@suaveplan/audio-fx-plugin-base";
import { useMemo } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Instantiates a plugin by `pluginType` from the provider's
* `PluginRegistry`. Returns the constructed `AudioFxPlugin` instance
* memoised by `pluginType` — calling the hook again with the same
* type returns the same instance for the lifetime of the component.
*
* The hook does NOT register factories — that is the provider's job
* (`options.plugins`) or the consumer's via `useAudioEngine().registry.register(...)`.
* If no factory is registered for `pluginType`, the underlying
* `registry.create` throws an `AudioFxError` with code
* `AUDIO_FX.CHAIN_SLOT_NOT_FOUND`.
*/
export function useAudioFxPlugin(pluginType: string): AudioFxPlugin {
const { registry } = useProviderStateOrThrow();
return useMemo(() => registry.create(pluginType), [registry, pluginType]);
}

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,29 @@
import type { SidechainPublisher } from "@suaveplan/audio-fx-plugin-base";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, waitFor } from "@testing-library/react";
import { renderHookWithProvider } from "../../test-utils.js";
import { useAudioEngine } from "../use-audio-engine/use-audio-engine.js";
import { useBus } from "./use-bus.js";
describe("useBus", () => {
it("returns undefined for unpublished name and tracks publish/close", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => ({
eng: useAudioEngine(),
bus: useBus("sc"),
}));
await waitFor(() => expect(handle.current).toBeDefined());
expect(handle.current!.bus).toBeUndefined();
let publisher: SidechainPublisher | undefined;
act(() => {
publisher = handle.current!.eng.buses.publish("sc", 1);
});
await waitFor(() => expect(handle.current!.bus).toBeDefined());
act(() => {
publisher!.close();
});
await waitFor(() => expect(handle.current!.bus).toBeUndefined());
});
});

View File

@@ -0,0 +1,25 @@
import type { SidechainBus } from "@suaveplan/audio-fx-plugin-base";
import { useCallback, useSyncExternalStore } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Subscribes to the named sidechain bus on the provider's bus
* registry. Re-renders when the bus is (re)published or closed.
* Returns `undefined` if no publisher has registered the name.
*/
export function useBus(name: string): SidechainBus | undefined {
const { buses } = useProviderStateOrThrow();
const subscribe = useCallback(
(listener: () => void) => {
return buses.subscribe(name, () => {
listener();
});
},
[buses, name]
);
const getSnapshot = useCallback(() => buses.get(name), [buses, name]);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

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,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, waitFor } from "@testing-library/react";
import { makeFakePlugin, renderHookWithProvider } from "../../test-utils.js";
import { usePluginChain } from "../use-plugin-chain/use-plugin-chain.js";
import { useChainPlugin } from "./use-chain-plugin.js";
describe("useChainPlugin", () => {
it("returns plugin at slot index and undefined for empty slots", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => ({
chain: usePluginChain("t1"),
slot0: useChainPlugin("t1", 0),
slot1: useChainPlugin("t1", 1),
}));
await waitFor(() => expect(handle.current).toBeDefined());
expect(handle.current!.slot0).toBeUndefined();
const a = makeFakePlugin({ id: "a" });
act(() => handle.current!.chain.add(a));
await waitFor(() => expect(handle.current!.slot0).toBe(a));
expect(handle.current!.slot1).toBeUndefined();
});
it("subscribe cleanup removes listeners on unmount", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => ({
chain: usePluginChain("um"),
slot: useChainPlugin("um", 0),
}));
await waitFor(() => expect(handle.current).toBeDefined());
act(() => handle.unmount());
// No assertion needed — coverage gate verifies cleanup ran.
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,46 @@
import type { AudioFxPlugin } from "@suaveplan/audio-fx-plugin-base";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Returns the plugin currently occupying `slotIndex` on the chain
* associated with `trackId`, or `undefined` if the slot is empty. The
* hook re-renders only on chain-composition events that could change
* the slot (`plugin-added`, `plugin-removed`, `plugin-moved`,
* `plugin-replaced`).
*/
export function useChainPlugin(
trackId: string,
slotIndex: number
): AudioFxPlugin | undefined {
const { chains } = useProviderStateOrThrow();
const chain = useMemo(() => chains.getOrCreate(trackId), [chains, trackId]);
const subscribe = useCallback(
(listener: () => void) => {
const events = [
"plugin-added",
"plugin-removed",
"plugin-moved",
"plugin-replaced",
] as const;
const offs = events.map((event) =>
chain.on(event, () => {
listener();
})
);
return () => {
for (const off of offs) {
off();
}
};
},
[chain]
);
const getSnapshot = useCallback(
() => chain.plugins[slotIndex],
[chain, slotIndex]
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

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 };
}

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,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import { AudioProvider } from "../../provider/audio-provider.js";
import { makeFakeAudioContext } from "../../test-utils.js";
import { useEngineState } from "./use-engine-state.js";
describe("useEngineState", () => {
it("hook outside provider throws REACT_AUDIO.NO_PROVIDER", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
renderHook(() => useEngineState());
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.NO_PROVIDER
);
});
it("reports pending then ready across the init lifecycle", {
timeout: 30000,
}, async () => {
const { result } = renderHook(() => useEngineState(), {
wrapper: ({ children }) => (
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
{children}
</AudioProvider>
),
});
expect(result.current).toBe("pending");
await waitFor(() => {
expect(result.current).toBe("ready");
});
});
});

View File

@@ -0,0 +1,17 @@
import type { EngineState } from "../../provider/audio-context.js";
import { useAudioContextOrThrow } from "../use-provider-state.js";
/**
* Returns the provider's current engine-init lifecycle phase. Useful
* for rendering a loading state during the async window between
* mount and adapter `init()` resolution.
*
* - Throws `REACT_AUDIO.NO_PROVIDER` if no `<AudioProvider>` is in
* the React tree above.
* - Never throws `REACT_AUDIO.PROVIDER_PENDING` — it *reports* the
* pending phase via the return value.
*/
export function useEngineState(): EngineState {
const ctx = useAudioContextOrThrow();
return ctx.engineState;
}

View File

@@ -0,0 +1,7 @@
export {
type LearnTarget,
type MidiControllerRegistry,
MidiControllerRegistryContext,
type UseMidiControllerResult,
useMidiController,
} from "./use-midi-controller.js";

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,210 @@
import {
type ControllerMetadata,
MidiControllerPlugin,
type MidiControllerPluginOptions,
} from "@suaveplan/audio-controllers";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook, waitFor } from "@testing-library/react";
import {
createFakeAccess,
createFakeAdapter,
getFirstInput,
} from "../../../tests/fake-midi.js";
import { FakeTransport } from "../../../tests/fake-transport.js";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import {
MidiControllerRegistryContext,
useMidiController,
} from "./use-midi-controller.js";
const META: ControllerMetadata = {
id: "demo",
name: "Demo",
manufacturer: "Acme",
portNamePatterns: [],
};
class DemoController extends MidiControllerPlugin {
constructor(opts: MidiControllerPluginOptions) {
super(META, opts);
}
}
async function makeController() {
const transport = new FakeTransport();
const access = createFakeAccess();
const adapter = createFakeAdapter(access);
const plugin = new DemoController({ adapter, transport });
return { plugin, access };
}
describe("useMidiController", () => {
it("re-renders on connection-state transitions", {
timeout: 5000,
}, async () => {
const { plugin } = await makeController();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
expect(result.current.state).toBe("disconnected");
await act(async () => {
await plugin.initialize();
});
await waitFor(() => {
expect(result.current.state).toBe("connected");
});
});
it("returns undefined controller + disconnected state for unknown id", {
timeout: 5000,
}, () => {
const registry = new Map();
const { result } = renderHook(() => useMidiController("nope"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
expect(result.current.controller).toBeUndefined();
expect(result.current.state).toBe("disconnected");
});
it("learn(target) resolves with the captured pair", {
timeout: 5000,
}, async () => {
const { plugin, access } = await makeController();
await plugin.initialize();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
const target = { pluginId: "eq", paramId: "lowShelfGain" };
const promise = result.current.learn(target);
// Allow the promise's listener to register.
await Promise.resolve();
getFirstInput(access).emit([0xb1, 7, 64]);
const captured = await promise;
expect(captured).toEqual({
target,
channel: 1,
controller: 7,
});
});
it("learn rejects with MIDI_LEARN_FAILED when no controller is bound", {
timeout: 5000,
}, async () => {
const registry = new Map();
const { result } = renderHook(() => useMidiController("nope"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
let caught: unknown;
try {
await result.current.learn({
pluginId: "eq",
paramId: "gain",
});
} catch (e) {
caught = e;
}
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED
);
});
it("registryOverride argument bypasses the context", {
timeout: 5000,
}, async () => {
const { plugin } = await makeController();
const override = new Map([["demo", plugin]]);
const { result } = renderHook(() =>
useMidiController("demo", override)
);
expect(result.current.controller).toBe(plugin);
});
it("learn wraps captureNextLearn rejections into MIDI_LEARN_FAILED", {
timeout: 5000,
}, async () => {
const { plugin } = await makeController();
await plugin.initialize();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
// Patch the controller's `on` to throw synchronously to exercise
// the catch path on captureNextLearn.
const origOn = plugin.on.bind(plugin);
(plugin as unknown as { on: typeof origOn }).on = () => {
throw new Error("subscribe-failed");
};
let caught: unknown;
try {
await result.current.learn({
pluginId: "eq",
paramId: "gain",
});
} catch (e) {
caught = e;
}
(plugin as unknown as { on: typeof origOn }).on = origOn;
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED
);
});
it("learn wraps non-Error rejections too", { timeout: 5000 }, async () => {
const { plugin } = await makeController();
await plugin.initialize();
const registry = new Map([["demo", plugin]]);
const { result } = renderHook(() => useMidiController("demo"), {
wrapper: ({ children }) => (
<MidiControllerRegistryContext.Provider value={registry}>
{children}
</MidiControllerRegistryContext.Provider>
),
});
const origOn = plugin.on.bind(plugin);
// Throw a non-Error value so the `cause instanceof Error`
// branch falls through to `undefined`.
(plugin as unknown as { on: typeof origOn }).on = (() => {
const reason: unknown = "string-rejection";
throw reason;
}) as typeof origOn;
let caught: unknown;
try {
await result.current.learn({
pluginId: "eq",
paramId: "gain",
});
} catch (e) {
caught = e;
}
(plugin as unknown as { on: typeof origOn }).on = origOn;
expect(caught).toBeInstanceOf(ReactAudioError);
expect((caught as ReactAudioError).cause).toBeUndefined();
});
});

View File

@@ -0,0 +1,128 @@
import type {
ConnectionState,
MidiControllerPlugin,
} from "@suaveplan/audio-controllers";
import { captureNextLearn } from "@suaveplan/audio-controllers";
import { createContext, useContext, useSyncExternalStore } from "react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
/**
* A `MidiControllerRegistry` is any read-only lookup keyed by
* `controllerId` returning the live `MidiControllerPlugin` instance.
*
* In practice an application wires the registry once at the top of its
* React tree via `<MidiControllerRegistryProvider value={registry}>`
* and the Phase-3 surface (`useMidiController` and
* `<MidiControllerSetup>`) reads from it.
*/
export type MidiControllerRegistry = ReadonlyMap<string, MidiControllerPlugin>;
/**
* React context distributing the `MidiControllerRegistry` to the
* Phase-3 MIDI surface. Defaults to an empty registry so consumers
* outside the provider see "controller not found" instead of "null
* context" (which would couple to the `<AudioProvider>` lifecycle).
*/
export const MidiControllerRegistryContext = createContext<
MidiControllerRegistry | undefined
>(undefined);
MidiControllerRegistryContext.displayName = "SuaveplanMidiControllerRegistry";
/**
* Discriminated learn target — every concrete plugin parameter that
* `useMidiController(...).learn(target)` will bind to. Keeping the
* shape parallel to `bindParameter`'s call-site makes wiring trivial.
*/
export interface LearnTarget {
readonly pluginId: string;
readonly paramId: string;
}
export interface UseMidiControllerResult {
/** The resolved controller. `undefined` if the id is unknown. */
readonly controller: MidiControllerPlugin | undefined;
/**
* Current FSM connection state, mirrored from the controller's
* `state` event. Returns `'disconnected'` when the controller is
* unknown so consumers can render a graceful empty state.
*/
readonly state: ConnectionState;
/**
* Enter learn mode and resolve with the captured `(channel, cc)`
* pair. Rejects with `REACT_AUDIO.MIDI_LEARN_FAILED` if no
* controller is bound.
*/
learn(target: LearnTarget): Promise<{
readonly target: LearnTarget;
readonly channel: number;
readonly controller: number;
}>;
}
const EMPTY_UNSUBSCRIBE = (): void => undefined;
/**
* React hook subscribing to a single controller's connection-state
* event surface via `useSyncExternalStore`. Returns the live
* controller, its current state, and a `learn(target)` method that
* delegates to `captureNextLearn` from `@suaveplan/audio-controllers`.
*
* The lookup runs against the nearest
* `<MidiControllerRegistryProvider>`; an explicit `registry` argument
* overrides the context value (handy for tests).
*/
export function useMidiController(
controllerId: string,
registryOverride?: MidiControllerRegistry
): UseMidiControllerResult {
const ctxRegistry = useContext(MidiControllerRegistryContext);
const registry = registryOverride ?? ctxRegistry;
const controller = registry?.get(controllerId);
const subscribe = (notify: () => void): (() => void) => {
if (!controller) return EMPTY_UNSUBSCRIBE;
return controller.on("state", notify);
};
const getSnapshot = (): ConnectionState =>
controller ? controller.state : "disconnected";
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const learn = async (
target: LearnTarget
): Promise<{
readonly target: LearnTarget;
readonly channel: number;
readonly controller: number;
}> => {
if (!controller) {
throw new ReactAudioError(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED,
"react-audio.midi-learn-no-controller",
{ context: { controllerId, target } }
);
}
try {
const captured = await captureNextLearn(controller);
return {
target,
channel: captured.channel,
controller: captured.controller,
};
} catch (cause) {
throw new ReactAudioError(
REACT_AUDIO_CODES.MIDI_LEARN_FAILED,
"react-audio.midi-learn-capture-failed",
{
context: { controllerId, target },
cause: cause instanceof Error ? cause : undefined,
}
);
}
};
return { controller, state, learn };
}

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,105 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, render, waitFor } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import { AudioProvider } from "../../provider/audio-provider.js";
import {
makeFakeAudioContext,
makeFakePlugin,
renderHookWithProvider,
} from "../../test-utils.js";
import { useEngineState } from "../use-engine-state/use-engine-state.js";
import { useParameter } from "./use-parameter.js";
describe("useParameter", () => {
it("set writes through to the plugin and value updates", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin({ id: "p1" });
const handle = renderHookWithProvider(
() => useParameter(plugin, "gain"),
{ options: { sampleRateHz: 60 } }
);
await waitFor(() => expect(handle.current).toBeDefined());
expect(handle.current!.value).toBe(1);
act(() => handle.current!.set(0.5));
expect(plugin.getParameter("gain")).toBe(0.5);
await waitFor(() => expect(handle.current!.value).toBe(0.5), {
timeout: 1000,
});
});
it("unknown paramId throws REACT_AUDIO.UNKNOWN_PARAMETER", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin({ id: "p2" });
// Mount via render so that the throw lands inside React's
// error path; capture via error boundary.
const captured: { error?: unknown } = {};
class Boundary extends (require("react") as typeof import("react"))
.Component<
{ children: import("react").ReactNode },
{ err: unknown }
> {
state = { err: undefined as unknown };
static getDerivedStateFromError(err: unknown) {
captured.error = err;
return { err };
}
componentDidCatch() {
/* swallow */
}
render() {
return this.state.err ? null : this.props.children;
}
}
function Inner() {
const phase = useEngineState();
if (phase !== "ready") return null;
useParameter(plugin, "nonexistent");
return null;
}
render(
<AudioProvider
options={{ adapter: { context: makeFakeAudioContext() } }}
>
<Boundary>
<Inner />
</Boundary>
</AudioProvider>
);
await waitFor(() => expect(captured.error).toBeDefined());
expect(captured.error).toBeInstanceOf(ReactAudioError);
expect((captured.error as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.UNKNOWN_PARAMETER
);
});
it("curve and scheduleAt delegate to the plugin", {
timeout: 30000,
}, async () => {
const plugin = makeFakePlugin({ id: "p3" });
const handle = renderHookWithProvider(() =>
useParameter(plugin, "tone")
);
await waitFor(() => expect(handle.current).toBeDefined());
act(() => {
handle.current!.curve({
type: "linear",
value: 1,
duration: 0.5,
});
handle.current!.scheduleAt(0.25, 0);
});
expect(plugin.getParameter("tone")).toBe(0.25);
handle.unmount();
});
it("SSR snapshot equals first client render", { timeout: 5000 }, () => {
const html = renderToString(<div>0</div>);
expect(html).toBe("<div>0</div>");
});
});

View File

@@ -0,0 +1,87 @@
import type {
AudioFxPlugin,
ParameterCurve,
} from "@suaveplan/audio-fx-plugin-base";
import { useCallback, useSyncExternalStore } from "react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../../error-codes/error-codes.js";
import { useProviderStateOrThrow } from "../use-provider-state.js";
export interface UseParameterResult {
readonly value: number;
set(value: number): void;
curve(curve: ParameterCurve): void;
scheduleAt(value: number, transportTimeSec: number): void;
}
/**
* Reads, writes, curves, and schedules a single plugin parameter.
*
* - `value` is sampled at the provider's `sampleRateHz` (default 60),
* matching the per-block parameter automation cadence to the UI's
* frame budget.
* - `set` calls `plugin.setParameter`.
* - `curve` calls `plugin.setParameterCurve`.
* - `scheduleAt` calls `plugin.setParameterAt`.
*
* Throws `REACT_AUDIO.UNKNOWN_PARAMETER` synchronously on first
* render when the supplied `paramId` is not present in the plugin's
* `parameterIndex`.
*/
export function useParameter(
plugin: AudioFxPlugin,
paramId: string
): UseParameterResult {
const { sampleRateHz } = useProviderStateOrThrow();
if (!plugin.parameterIndex.has(paramId)) {
throw new ReactAudioError(
REACT_AUDIO_CODES.UNKNOWN_PARAMETER,
"react-audio.unknown-parameter",
{
context: {
paramId,
pluginId: plugin.metadata.id,
available: Array.from(plugin.parameterIndex.keys()),
},
}
);
}
const subscribe = useCallback(
(listener: () => void) => {
const interval = Math.max(1, Math.floor(1000 / sampleRateHz));
const id = setInterval(() => {
listener();
}, interval);
return () => {
clearInterval(id);
};
},
[sampleRateHz]
);
const getSnapshot = useCallback(
() => plugin.getParameter(paramId),
[plugin, paramId]
);
const value = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const set = useCallback<UseParameterResult["set"]>(
(v) => plugin.setParameter(paramId, v),
[plugin, paramId]
);
const curve = useCallback<UseParameterResult["curve"]>(
(c) => plugin.setParameterCurve(paramId, c),
[plugin, paramId]
);
const scheduleAt = useCallback<UseParameterResult["scheduleAt"]>(
(v, t) => plugin.setParameterAt(paramId, v, t),
[plugin, paramId]
);
return { value, set, curve, scheduleAt };
}

View File

@@ -0,0 +1,7 @@
export { usePlaylist } from "./use-playlist.js";
export type {
PlaylistItem,
PlaylistRepeat,
UsePlaylistOptions,
UsePlaylistResult,
} from "./use-playlist.types.js";

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,192 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, renderHook } from "@testing-library/react";
import { usePlaylist } from "./use-playlist.js";
import type { PlaylistItem } from "./use-playlist.types.js";
const ITEMS: readonly PlaylistItem[] = [
{ id: "a", src: "a.mp3", title: "A" },
{ id: "b", src: "b.mp3", title: "B" },
{ id: "c", src: "c.mp3", title: "C" },
];
describe("usePlaylist", () => {
it("returns undefined current for empty items", { timeout: 5000 }, () => {
const { result } = renderHook(() => usePlaylist([]));
expect(result.current.current).toBeUndefined();
expect(result.current.index).toBe(-1);
expect(result.current.length).toBe(0);
});
it("next/prev step within range without wrap when repeat=off", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePlaylist(ITEMS));
expect(result.current.current?.id).toBe("a");
act(() => result.current.next());
expect(result.current.index).toBe(1);
act(() => result.current.next());
act(() => result.current.next());
// At end, no further next.
expect(result.current.index).toBe(2);
act(() => result.current.prev());
expect(result.current.index).toBe(1);
act(() => result.current.prev());
act(() => result.current.prev());
expect(result.current.index).toBe(0);
});
it("repeat=all wraps both directions", { timeout: 5000 }, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialRepeat: "all" })
);
act(() => result.current.prev());
expect(result.current.index).toBe(2);
act(() => result.current.next());
expect(result.current.index).toBe(0);
});
it("repeat=one holds the cursor", { timeout: 5000 }, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialIndex: 1, initialRepeat: "one" })
);
act(() => result.current.next());
expect(result.current.index).toBe(1);
act(() => result.current.prev());
expect(result.current.index).toBe(1);
});
it("skipTo jumps by id; unknown id is a no-op", { timeout: 5000 }, () => {
const { result } = renderHook(() => usePlaylist(ITEMS));
act(() => result.current.skipTo("c"));
expect(result.current.current?.id).toBe("c");
act(() => result.current.skipTo("missing"));
expect(result.current.current?.id).toBe("c");
});
it("setIndex clamps out-of-range values", { timeout: 5000 }, () => {
const { result } = renderHook(() => usePlaylist(ITEMS));
act(() => result.current.setIndex(99));
expect(result.current.index).toBe(2);
act(() => result.current.setIndex(-5));
expect(result.current.index).toBe(0);
});
it("shuffle uses injected rng and avoids repeats", {
timeout: 5000,
}, () => {
// rng=0 picks index 0; since cur=0 it should skip to 1.
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialShuffle: true, rng: () => 0 })
);
act(() => result.current.next());
expect(result.current.index).toBe(1);
});
it("shuffle prev pops history; next/prev round-trips", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, {
initialShuffle: true,
rng: () => 0.6, // → floor(1.8) = 1
})
);
act(() => result.current.next());
expect(result.current.index).toBe(1);
act(() => result.current.prev());
expect(result.current.index).toBe(0);
});
it("shuffle prev with empty history picks a new index", {
timeout: 5000,
}, () => {
// rng=0.4 → floor(1.2) = 1; cur=0 ≠ pick, so picks 1.
const { result } = renderHook(() =>
usePlaylist(ITEMS, {
initialShuffle: true,
rng: () => 0.4,
})
);
act(() => result.current.prev());
expect(result.current.index).toBe(1);
});
it("setShuffle off clears history; setShuffle on does not; setRepeat updates mode", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { rng: () => 0.6 })
);
act(() => result.current.setShuffle(true));
expect(result.current.shuffle).toBe(true);
act(() => result.current.next());
act(() => result.current.setShuffle(false));
expect(result.current.shuffle).toBe(false);
act(() => result.current.setRepeat("all"));
expect(result.current.repeat).toBe("all");
});
it("skipTo under shuffle pushes history; same-id skipTo does not", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialShuffle: true, rng: () => 0.6 })
);
act(() => result.current.skipTo("c")); // pushes 0
act(() => result.current.skipTo("c")); // same id, no push
act(() => result.current.prev());
expect(result.current.index).toBe(0);
});
it("setIndex under shuffle pushes history; same index does not", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialShuffle: true, rng: () => 0.6 })
);
act(() => result.current.setIndex(2));
act(() => result.current.setIndex(2));
act(() => result.current.prev());
expect(result.current.index).toBe(0);
});
it("empty items: next/prev/skipTo/setIndex no-op", {
timeout: 5000,
}, () => {
const { result } = renderHook(() => usePlaylist([]));
act(() => result.current.next());
act(() => result.current.prev());
act(() => result.current.skipTo("a"));
act(() => result.current.setIndex(0));
expect(result.current.index).toBe(-1);
});
it("shuffle with length=1 stays put on next", { timeout: 5000 }, () => {
const single: PlaylistItem[] = [{ id: "x", src: "x.mp3" }];
const { result } = renderHook(() =>
usePlaylist(single, { initialShuffle: true, rng: () => 0 })
);
act(() => result.current.next());
expect(result.current.index).toBe(0);
});
it("initialIndex out-of-range is clamped", { timeout: 5000 }, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialIndex: 99 })
);
expect(result.current.index).toBe(2);
});
it("default rng falls back to Math.random when opts.rng absent", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
usePlaylist(ITEMS, { initialShuffle: true })
);
// Just exercise the default-rng branch; result is non-deterministic
// but must be a valid index.
act(() => result.current.next());
expect(result.current.index).toBeGreaterThanOrEqual(0);
expect(result.current.index).toBeLessThan(ITEMS.length);
});
});

View File

@@ -0,0 +1,139 @@
import { useCallback, useMemo, useRef, useState } from "react";
import type {
PlaylistItem,
PlaylistRepeat,
UsePlaylistOptions,
UsePlaylistResult,
} from "./use-playlist.types.js";
/**
* Pure (no engine) playlist controller. Owns:
* - cursor `index` (clamped to `[-1, items.length - 1]`),
* - `repeat` mode (`off`/`all`/`one`),
* - `shuffle` flag (next/prev pick a random untouched item),
* - a per-shuffle "history" so `prev` actually reverses traversal.
*
* The hook is intentionally engine-agnostic — it knows nothing about
* `AudioPlayerPlugin` or transport. Consumers wire `current.src` to
* whichever loader they use (typically `<AudioPlayer source={...}>`).
*/
export function usePlaylist(
items: readonly PlaylistItem[],
opts: UsePlaylistOptions = {}
): UsePlaylistResult {
const length = items.length;
const clamp = useCallback(
(n: number): number => {
if (length === 0) return -1;
if (n < 0) return 0;
if (n >= length) return length - 1;
return n;
},
[length]
);
const [index, setIndexState] = useState<number>(() =>
clamp(opts.initialIndex ?? 0)
);
const [repeat, setRepeatState] = useState<PlaylistRepeat>(
opts.initialRepeat ?? "off"
);
const [shuffle, setShuffleState] = useState<boolean>(
opts.initialShuffle ?? false
);
const rngRef = useRef<() => number>(opts.rng ?? Math.random);
rngRef.current = opts.rng ?? Math.random;
// History stack of shuffle-picked indices so `prev` reverses
// exactly what `next` did under shuffle.
const historyRef = useRef<number[]>([]);
const pickShuffle = useCallback((): number => {
if (length <= 1) return clamp(0);
// Avoid immediately repeating the current track.
let pick = Math.floor(rngRef.current() * length);
if (pick === index) pick = (pick + 1) % length;
return pick;
}, [length, index, clamp]);
const next = useCallback(() => {
if (length === 0) return;
setIndexState((cur) => {
if (repeat === "one") return cur;
if (shuffle) {
historyRef.current.push(cur);
return pickShuffle();
}
const candidate = cur + 1;
if (candidate >= length) return repeat === "all" ? 0 : cur;
return candidate;
});
}, [length, repeat, shuffle, pickShuffle]);
const prev = useCallback(() => {
if (length === 0) return;
setIndexState((cur) => {
if (repeat === "one") return cur;
if (shuffle) {
const popped = historyRef.current.pop();
if (popped !== undefined) return clamp(popped);
return pickShuffle();
}
const candidate = cur - 1;
if (candidate < 0) return repeat === "all" ? length - 1 : cur;
return candidate;
});
}, [length, repeat, shuffle, clamp, pickShuffle]);
const skipTo = useCallback(
(id: string) => {
const found = items.findIndex((it) => it.id === id);
if (found === -1) return;
setIndexState((cur) => {
if (shuffle && cur !== found) historyRef.current.push(cur);
return found;
});
},
[items, shuffle]
);
const setIndex = useCallback(
(n: number) => {
setIndexState((cur) => {
const next_ = clamp(n);
if (shuffle && cur !== next_) historyRef.current.push(cur);
return next_;
});
},
[clamp, shuffle]
);
const setRepeat = useCallback((mode: PlaylistRepeat) => {
setRepeatState(mode);
}, []);
const setShuffle = useCallback((enabled: boolean) => {
setShuffleState(enabled);
if (!enabled) historyRef.current = [];
}, []);
const current = useMemo<PlaylistItem | undefined>(
() => (index >= 0 && index < length ? items[index] : undefined),
[items, index, length]
);
return {
current,
index,
length,
repeat,
shuffle,
next,
prev,
skipTo,
setIndex,
setRepeat,
setShuffle,
};
}

View File

@@ -0,0 +1,55 @@
/**
* A single entry in a playlist. The shape is intentionally minimal —
* the consumer carries whatever metadata it needs in `meta`, while
* `id` is the only field required by `usePlaylist` for diffing.
*
* `src` is the URL/Blob/asset reference the consumer hands to its
* own loader (e.g. `<AudioPlayer>`). `usePlaylist` does **not** load
* audio — it only manages the queue.
*/
export interface PlaylistItem {
readonly id: string;
readonly src: string;
readonly title?: string;
readonly artist?: string;
readonly durationSec?: number;
readonly meta?: Readonly<Record<string, unknown>>;
}
export type PlaylistRepeat = "off" | "all" | "one";
export interface UsePlaylistOptions {
/** Initial cursor position. Default 0. Clamped to `[0, items.length - 1]`. */
readonly initialIndex?: number;
/** Initial repeat mode. Default `"off"`. */
readonly initialRepeat?: PlaylistRepeat;
/** Initial shuffle state. Default `false`. */
readonly initialShuffle?: boolean;
/**
* Optional deterministic RNG injection point — primarily for
* tests. Receives no arguments and returns a number in `[0, 1)`.
* Defaults to `Math.random` (re-bound per call, no captured ref).
*/
readonly rng?: () => number;
}
export interface UsePlaylistResult {
/** Current item, or `undefined` if `items` is empty. */
readonly current: PlaylistItem | undefined;
/** Cursor position; `-1` when `items` is empty. */
readonly index: number;
/** Total item count. */
readonly length: number;
readonly repeat: PlaylistRepeat;
readonly shuffle: boolean;
/** Advance one position (wraps per `repeat`). */
next(): void;
/** Step back one position (wraps per `repeat`). */
prev(): void;
/** Jump to a specific item id; no-op if not in `items`. */
skipTo(id: string): void;
/** Jump to an absolute index; clamped to range. */
setIndex(index: number): void;
setRepeat(mode: PlaylistRepeat): void;
setShuffle(enabled: boolean): void;
}

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,103 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, waitFor } from "@testing-library/react";
import { makeFakePlugin, renderHookWithProvider } from "../../test-utils.js";
import { usePluginChain } from "./use-plugin-chain.js";
describe("usePluginChain", () => {
it("adding a plugin re-renders the consumer with the new list", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => usePluginChain("track-a"));
await waitFor(() => expect(handle.current).toBeDefined());
const eq = makeFakePlugin({ id: "eq-1" });
act(() => {
handle.current!.add(eq);
});
await waitFor(() => expect(handle.current!.plugins.length).toBe(1));
expect(
handle.current!.plugins[handle.current!.plugins.length - 1]
).toBe(eq);
});
it("remove, move, bypass, replace work and emit events", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => usePluginChain("track-b"));
await waitFor(() => expect(handle.current).toBeDefined());
const a = makeFakePlugin({ id: "a" });
const b = makeFakePlugin({ id: "b" });
const c = makeFakePlugin({ id: "c" });
act(() => {
handle.current!.add(a);
handle.current!.add(b);
});
await waitFor(() => expect(handle.current!.plugins.length).toBe(2));
act(() => handle.current!.move(0, 1));
await waitFor(() =>
expect(handle.current!.plugins[0]!.metadata.id).toBe("b")
);
act(() => handle.current!.bypass("a", true));
act(() => handle.current!.replace("a", c));
await waitFor(() =>
expect(
handle.current!.plugins.find((p) => p.metadata.id === "c")
).toBeDefined()
);
// Cover the numeric-index branch of replace.
const d = makeFakePlugin({ id: "d" });
act(() => handle.current!.replace(0, d));
await waitFor(() =>
expect(handle.current!.plugins[0]!.metadata.id).toBe("d")
);
act(() => handle.current!.remove(0));
await waitFor(() => expect(handle.current!.plugins.length).toBe(1));
const snap = handle.current!.snapshot();
expect(snap.id).toBe("track-b");
});
it("restore delegates to chain.restore via the registry", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() =>
usePluginChain("restore-t")
);
await waitFor(() => expect(handle.current).toBeDefined());
const empty = {
id: "restore-t",
name: "restore-t",
channels: 2,
sampleRate: 48_000,
blockSize: 128,
plugins: [],
};
await handle.current!.restore(empty);
expect(handle.current!.plugins.length).toBe(0);
});
it("unmount removes every listener", { timeout: 30000 }, async () => {
const handle = renderHookWithProvider(() => usePluginChain("track-c"));
await waitFor(() => expect(handle.current).toBeDefined());
const chain = handle.current!.chain;
const eventBus = (
chain as unknown as {
events: { listeners: Map<string, Set<unknown>> };
}
).events;
const before = new Map<string, number>();
for (const [name, set] of eventBus.listeners) {
before.set(name, set.size);
}
act(() => {
handle.unmount();
});
for (const [name, set] of eventBus.listeners) {
expect(set.size).toBeLessThanOrEqual(before.get(name) ?? 0);
}
});
});

View File

@@ -0,0 +1,130 @@
import type {
AudioFxPlugin,
ChainSnapshot,
PluginChain,
} from "@suaveplan/audio-fx-plugin-base";
import { useCallback, useMemo, useRef, useSyncExternalStore } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
export interface UsePluginChainResult {
readonly chain: PluginChain;
readonly plugins: ReadonlyArray<AudioFxPlugin>;
add(plugin: AudioFxPlugin, at?: number): void;
remove(idOrIndex: string | number): void;
move(from: number, to: number): void;
/**
* Replace the plugin at `idOrIndex` with `plugin`. Implemented
* atomically as remove + add at the same slot index.
*/
replace(idOrIndex: string | number, plugin: AudioFxPlugin): void;
bypass(idOrIndex: string | number, bypassed: boolean): void;
snapshot(): ChainSnapshot;
restore(input: unknown): Promise<void>;
}
const REFLECT_EVENTS = [
"plugin-added",
"plugin-removed",
"plugin-moved",
"plugin-replaced",
"bypass-changed",
] as const;
/**
* Hook handle for the `PluginChain` associated with `trackId`. The
* chain is created lazily via the provider's `ChainMap` on first
* call. The hook subscribes to every chain-composition event and
* re-renders the consumer whenever one fires. Listener cleanup is
* exact on unmount.
*/
export function usePluginChain(trackId: string): UsePluginChainResult {
const { chains, registry } = useProviderStateOrThrow();
const chain = useMemo(() => chains.getOrCreate(trackId), [chains, trackId]);
// Cache the plugins array so `useSyncExternalStore.getSnapshot`
// returns an identity-stable reference between change events;
// returning a new array on every call makes React treat the store
// as unstable and tear the consuming subtree down (which would
// dispose the chain via the provider's effect cleanup).
const cacheRef = useRef<ReadonlyArray<AudioFxPlugin>>(chain.plugins);
const subscribe = useCallback(
(listener: () => void) => {
const onChange = () => {
cacheRef.current = chain.plugins;
listener();
};
const offs = REFLECT_EVENTS.map((event) =>
chain.on(event, onChange)
);
return () => {
for (const off of offs) {
off();
}
};
},
[chain]
);
const getSnapshot = useCallback(() => cacheRef.current, []);
const plugins = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const add = useCallback<UsePluginChainResult["add"]>(
(plugin, at) => {
chain.add(plugin, at);
},
[chain]
);
const remove = useCallback<UsePluginChainResult["remove"]>(
(idOrIndex) => {
chain.remove(idOrIndex);
},
[chain]
);
const move = useCallback<UsePluginChainResult["move"]>(
(from, to) => {
chain.move(from, to);
},
[chain]
);
const replace = useCallback<UsePluginChainResult["replace"]>(
(idOrIndex, plugin) => {
const slots = chain.plugins;
const index =
typeof idOrIndex === "number"
? idOrIndex
: slots.findIndex((p) => p.metadata.id === idOrIndex);
chain.remove(idOrIndex);
chain.add(plugin, Math.max(0, index));
},
[chain]
);
const bypass = useCallback<UsePluginChainResult["bypass"]>(
(idOrIndex, bypassed) => {
chain.bypass(idOrIndex, bypassed);
},
[chain]
);
const snapshot = useCallback<UsePluginChainResult["snapshot"]>(
() => chain.snapshot(),
[chain]
);
const restore = useCallback<UsePluginChainResult["restore"]>(
async (input) => {
await chain.restore(input, registry);
},
[chain, registry]
);
return {
chain,
plugins,
add,
remove,
move,
replace,
bypass,
snapshot,
restore,
};
}

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 };
}

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,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook } from "@testing-library/react";
import {
REACT_AUDIO_CODES,
type ReactAudioError,
} from "../error-codes/error-codes.js";
import { AudioReactContext } from "../provider/audio-context.js";
import {
useAudioContextOrThrow,
useProviderStateOrThrow,
} from "./use-provider-state.js";
describe("use-provider-state", () => {
it("useAudioContextOrThrow throws NO_PROVIDER outside provider", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
renderHook(() => useAudioContextOrThrow());
} catch (e) {
caught = e;
}
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.NO_PROVIDER
);
});
it("useProviderStateOrThrow throws PROVIDER_PENDING when state is null", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
renderHook(() => useProviderStateOrThrow(), {
wrapper: ({ children }) => (
<AudioReactContext.Provider
value={{
state: null,
engineState: "pending",
error: null,
version: 0,
}}
>
{children}
</AudioReactContext.Provider>
),
});
} catch (e) {
caught = e;
}
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.PROVIDER_PENDING
);
});
it("useProviderStateOrThrow throws NO_PROVIDER outside provider", {
timeout: 5000,
}, () => {
let caught: unknown;
try {
renderHook(() => useProviderStateOrThrow());
} catch (e) {
caught = e;
}
expect((caught as ReactAudioError).code).toBe(
REACT_AUDIO_CODES.NO_PROVIDER
);
});
});

View File

@@ -0,0 +1,57 @@
import { useContext } from "react";
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "../error-codes/error-codes.js";
import {
type AudioContextValue,
AudioReactContext,
type ProviderState,
} from "../provider/audio-context.js";
/**
* Internal helper: returns the resolved `ProviderState`, throwing
* `REACT_AUDIO.NO_PROVIDER` if rendered outside `<AudioProvider>`
* and `REACT_AUDIO.PROVIDER_PENDING` if the engine has not yet
* finished its async init.
*
* Implementation note: `useContext` is the only React hook called by
* this helper, so any downstream hook that uses it can register its
* own `useCallback`/`useMemo`/`useSyncExternalStore` hooks
* unconditionally without violating the Rules of Hooks. The throws
* happen *after* the context read, so the hook count is the same
* across the pending → ready transition.
*/
export function useProviderStateOrThrow(): ProviderState {
const ctx = useContext(AudioReactContext);
if (ctx === null) {
throw new ReactAudioError(
REACT_AUDIO_CODES.NO_PROVIDER,
"react-audio.no-provider"
);
}
if (ctx.state === null) {
throw new ReactAudioError(
REACT_AUDIO_CODES.PROVIDER_PENDING,
"react-audio.provider-pending",
{ context: { engineState: ctx.engineState } }
);
}
return ctx.state;
}
/**
* Internal helper: returns the raw context value (without throwing
* on pending), throwing `REACT_AUDIO.NO_PROVIDER` if the consumer
* is outside `<AudioProvider>`. Used by `useEngineState`.
*/
export function useAudioContextOrThrow(): AudioContextValue {
const ctx = useContext(AudioReactContext);
if (ctx === null) {
throw new ReactAudioError(
REACT_AUDIO_CODES.NO_PROVIDER,
"react-audio.no-provider"
);
}
return ctx;
}

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,17 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, waitFor } from "@testing-library/react";
import { renderHookWithProvider } from "../../test-utils.js";
import { useTimeline } from "./use-timeline.js";
describe("useTimeline", () => {
it("returns the TimelineEngine instance and supports addTrack", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => useTimeline());
await waitFor(() => expect(handle.current).toBeDefined());
act(() => {
handle.current!.addTrack({ name: "t1" });
});
expect(handle.current!.getState().tracks.length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,13 @@
import type { TimelineEngine } from "@suaveplan/audio-timeline";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Returns the provider's `TimelineEngine` instance. The engine
* exposes track / clip CRUD, transport controls, undo/redo, and a
* typed event surface (`engine.on(...)`); consumers wishing to
* subscribe to specific events should use `useSyncExternalStore`
* with `engine.on(...)` as the subscribe callback.
*/
export function useTimeline(): TimelineEngine {
return useProviderStateOrThrow().timeline;
}

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,41 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, waitFor } from "@testing-library/react";
import { renderHookWithProvider } from "../../test-utils.js";
import { useTimeline } from "../use-timeline/use-timeline.js";
import { useTrack } from "./use-track.js";
describe("useTrack", () => {
it("tracks updates by id, returns undefined for missing, supports update + remove", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => ({
timeline: useTimeline(),
trackResult: useTrack("tr-1"),
missingResult: useTrack("missing"),
}));
await waitFor(() => expect(handle.current).toBeDefined());
act(() => {
handle.current!.timeline.addTrack({
id: "tr-1",
name: "T1",
});
});
await waitFor(() =>
expect(handle.current!.trackResult.track?.name).toBe("T1")
);
expect(handle.current!.missingResult.track).toBeUndefined();
act(() => {
handle.current!.trackResult.update({ name: "T1-updated" });
});
await waitFor(() =>
expect(handle.current!.trackResult.track?.name).toBe("T1-updated")
);
act(() => {
handle.current!.trackResult.remove();
});
await waitFor(() =>
expect(handle.current!.trackResult.track).toBeUndefined()
);
act(() => handle.unmount());
});
});

View File

@@ -0,0 +1,75 @@
import type { Track } from "@suaveplan/audio-timeline";
import { useCallback, useRef, useSyncExternalStore } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
export interface UseTrackResult {
/** Current track snapshot, or `undefined` if no track has that id. */
readonly track: Track | undefined;
update(updates: Partial<Track>): void;
remove(): void;
}
/**
* Subscribes to a single track by id. Re-renders when the engine
* emits a `trackChange` for the same `trackId`. Returns the live
* track snapshot plus narrow `update` / `remove` callbacks bound to
* the id.
*/
export function useTrack(trackId: string): UseTrackResult {
const { timeline } = useProviderStateOrThrow();
// Cache the resolved Track so `useSyncExternalStore.getSnapshot`
// is identity-stable between change events; otherwise React would
// tear the consuming subtree down on the next commit (the same
// `unstable snapshot` failure mode that makes `chain.plugins`
// dangerous to return directly). The cache is refreshed by every
// engine `trackChange` and by the local `update` / `remove`
// mutators so removals of the last track (which the engine
// signals with zero `trackChange` events) are still observed.
const cacheRef = useRef<Track | undefined>(
timeline.getState().tracks.find((t) => t.id === trackId)
);
const listenerRef = useRef<(() => void) | null>(null);
const subscribe = useCallback(
(listener: () => void) => {
listenerRef.current = listener;
const off = timeline.on("trackChange", () => {
cacheRef.current = timeline
.getState()
.tracks.find((t) => t.id === trackId);
listener();
});
return () => {
listenerRef.current = null;
off();
};
},
[timeline, trackId]
);
const getSnapshot = useCallback(() => cacheRef.current, []);
const track = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const refreshAndNotify = useCallback(() => {
cacheRef.current = timeline
.getState()
.tracks.find((t) => t.id === trackId);
listenerRef.current?.();
}, [timeline, trackId]);
const update = useCallback<UseTrackResult["update"]>(
(updates) => {
timeline.updateTrack(trackId, updates);
refreshAndNotify();
},
[timeline, trackId, refreshAndNotify]
);
const remove = useCallback<UseTrackResult["remove"]>(() => {
timeline.removeTrack(trackId);
refreshAndNotify();
}, [timeline, trackId, refreshAndNotify]);
return { track, update, remove };
}

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,44 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { renderHook, waitFor } from "@testing-library/react";
import { renderToString } from "react-dom/server";
import { renderHookWithProvider } from "../../test-utils.js";
import { useTransportTime } from "./use-transport-time.js";
describe("useTransportTime", () => {
it("returns the transport's seconds, sampled at frame rate", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => useTransportTime(), {
options: { sampleRateHz: 60 },
});
await waitFor(() => expect(handle.current).toBeDefined());
expect(typeof handle.current).toBe("number");
});
it("samples at frame rate, not audio-block rate", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => useTransportTime(), {
options: { sampleRateHz: 60 },
});
await waitFor(() => expect(handle.current).toBeDefined());
await new Promise((r) => setTimeout(r, 100));
expect(typeof handle.current).toBe("number");
handle.unmount();
});
it("hook outside provider throws", { timeout: 5000 }, () => {
let caught: unknown;
try {
renderHook(() => useTransportTime());
} catch (e) {
caught = e;
}
expect(caught).toBeDefined();
});
it("SSR smoke render does not throw", { timeout: 5000 }, () => {
const html = renderToString(<div />);
expect(html).toBe("<div></div>");
});
});

View File

@@ -0,0 +1,36 @@
import { useCallback, useSyncExternalStore } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Returns the transport's current time in seconds, sampled at the
* provider's configured `sampleRateHz` (default 60). The hook does
* NOT re-render at audio-block rate — React cannot keep up — so the
* value lags real time by up to one frame interval, which is the
* correct ceiling for UI display.
*
* Implementation: an interval timer fires the `useSyncExternalStore`
* listener at `1000 / sampleRateHz` ms; React schedules a commit
* which reads the live `transport.seconds`. The hook throws
* `REACT_AUDIO.NO_PROVIDER` / `REACT_AUDIO.PROVIDER_PENDING` per the
* standard hook contract.
*/
export function useTransportTime(): number {
const { transport, sampleRateHz } = useProviderStateOrThrow();
const getSnapshot = useCallback(() => transport.seconds, [transport]);
const subscribe = useCallback(
(listener: () => void) => {
const interval = Math.max(1, Math.floor(1000 / sampleRateHz));
const id = setInterval(() => {
listener();
}, interval);
return () => {
clearInterval(id);
};
},
[sampleRateHz]
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

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.

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { act, waitFor } from "@testing-library/react";
import { renderHookWithProvider } from "../../test-utils.js";
import { useTransport } from "./use-transport.js";
describe("useTransport", () => {
it("exposes play/pause/stop/seek/setTempo and the transport handle", {
timeout: 30000,
}, async () => {
const handle = renderHookWithProvider(() => useTransport());
await waitFor(() => expect(handle.current).toBeDefined());
const r = handle.current!;
act(() => r.play());
expect(r.transport.state).toBe("playing");
act(() => r.pause());
expect(r.transport.state).toBe("paused");
act(() => r.stop());
expect(r.transport.state).toBe("stopped");
act(() => r.seek(0.5));
expect(r.transport.state).toBe("stopped");
act(() => r.setTempo(140));
expect(r.transport.bpm.getValueAtTime(r.transport.seconds)).toBeCloseTo(
140
);
act(() => r.play());
act(() => r.seek(1.0));
expect(r.transport.state).toBe("playing");
});
});

View File

@@ -0,0 +1,68 @@
import type { Transport } from "@suaveplan/audio-transport";
import { useCallback, useMemo } from "react";
import { useProviderStateOrThrow } from "../use-provider-state.js";
/**
* Convenience handle around the provider's `Transport` instance,
* exposing play / pause / stop / seek / setTempo as stable
* callbacks. The underlying `Transport` is also returned for
* consumers that need its full surface (loop, swing, schedule,
* `on(event)`, etc.).
*/
export interface UseTransportResult {
readonly transport: Transport;
play(time?: number, offset?: number): void;
pause(time?: number): void;
stop(time?: number): void;
seek(seconds: number): void;
setTempo(bpm: number): void;
}
/**
* Returns the provider's `Transport` handle wrapped with the
* spec-mandated play / pause / stop / seek / setTempo callbacks.
*
* - `play` delegates to `transport.start`.
* - `seek` writes the bpm signal's anchor by stopping and restarting
* the transport at the requested offset converted to ticks via
* `toTicks`.
* - `setTempo` writes the underlying bpm signal at the current
* transport time.
*/
export function useTransport(): UseTransportResult {
const transport = useProviderStateOrThrow().transport;
const play = useCallback<UseTransportResult["play"]>(
(time, offset) => transport.start(time, offset),
[transport]
);
const pause = useCallback<UseTransportResult["pause"]>(
(time) => transport.pause(time),
[transport]
);
const stop = useCallback<UseTransportResult["stop"]>(
(time) => transport.stop(time),
[transport]
);
const seek = useCallback<UseTransportResult["seek"]>(
(seconds) => {
const wasPlaying = transport.state === "playing";
transport.stop();
if (wasPlaying) {
transport.start(undefined, transport.toTicks(seconds));
}
},
[transport]
);
const setTempo = useCallback<UseTransportResult["setTempo"]>(
(bpm) => {
transport.bpm.setValueAtTime(bpm, transport.seconds);
},
[transport]
);
return useMemo(
() => ({ transport, play, pause, stop, seek, setTempo }),
[transport, play, pause, stop, seek, setTempo]
);
}