Initial Commit
This commit is contained in:
55
packages/ui/react/react-audio/src/hooks/index.ts
Normal file
55
packages/ui/react/react-audio/src/hooks/index.ts
Normal 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";
|
||||
@@ -0,0 +1,6 @@
|
||||
export { useAnalyser } from "./use-analyser.js";
|
||||
export type {
|
||||
AnalyserLike,
|
||||
UseAnalyserOptions,
|
||||
UseAnalyserResult,
|
||||
} from "./use-analyser.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
69
packages/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal file
69
packages/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal 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.
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
25
packages/ui/react/react-audio/src/hooks/use-bus/use-bus.ts
Normal file
25
packages/ui/react/react-audio/src/hooks/use-bus/use-bus.ts
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
# `hooks/use-chain-snapshot`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useChainSnapshot(chain)` exposes chain-level snapshot operations.
|
||||
`get` reads the in-memory `ChainSnapshot`, `restore` applies one,
|
||||
and `save` / `load` persist through the provider's optional preset
|
||||
store, keyed by id. The hook is the natural complement to
|
||||
`usePluginChain` for "FX rack" UIs that save and load entire chains
|
||||
as a unit.
|
||||
|
||||
## Features
|
||||
|
||||
- `get()` — synchronous in-memory read of `chain.snapshot()`.
|
||||
- `restore(input)` — async; delegates to `chain.restore(input, registry)`.
|
||||
- `save(id)` — persists the snapshot via the preset store.
|
||||
- `load(id)` — loads + applies via the preset store.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for every failure mode.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useChainSnapshot } from "@suaveplan/react-audio";
|
||||
|
||||
function ChainPresetButtons({ chain }: { chain: PluginChain }) {
|
||||
const { save, load } = useChainSnapshot(chain);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save chain</button>
|
||||
<button type="button" onClick={() => load("default")}>Load chain</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useChainSnapshot } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportImport({ chain }: { chain: PluginChain }) {
|
||||
const { get, restore } = useChainSnapshot(chain);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => navigator.clipboard.writeText(JSON.stringify(get()))}>
|
||||
Copy JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const text = await navigator.clipboard.readText();
|
||||
await restore(JSON.parse(text));
|
||||
}}
|
||||
>
|
||||
Paste JSON
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useChainSnapshot(chain): { get, restore, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook needs the provider's `PluginRegistry` to instantiate
|
||||
plugins during `restore`, so it consumes `useProviderStateOrThrow`.
|
||||
Every failure mode of the preset store is wrapped into a
|
||||
`ReactAudioError(PRESET_STORE_FAILED)` with the underlying error as
|
||||
`cause`; the absence of a preset store and the "preset id not
|
||||
found" case use the same code with different `messageKey`s so a
|
||||
catch-handler can branch on the message resolver if needed. The
|
||||
`get()` helper returns the raw `chain.snapshot()` which includes
|
||||
the chain's `blockSize` and `sampleRate`; consumers exporting JSON
|
||||
for cross-instance restore should preserve those fields verbatim,
|
||||
because `chain.restore` re-validates them against the chain's own
|
||||
config on the receiving side. Non-Error throws from the store are
|
||||
narrowed to `undefined` causes (the JS `instanceof Error` ternary).
|
||||
@@ -0,0 +1,211 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import {
|
||||
REACT_AUDIO_CODES,
|
||||
ReactAudioError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { PresetStore } from "../../provider/provider-options.js";
|
||||
import { makeFakePlugin, renderHookWithProvider } from "../../test-utils.js";
|
||||
import { usePluginChain } from "../use-plugin-chain/use-plugin-chain.js";
|
||||
import { useChainSnapshot } from "./use-chain-snapshot.js";
|
||||
|
||||
describe("useChainSnapshot", () => {
|
||||
it("get reads the chain's current snapshot", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const handle = renderHookWithProvider(() => {
|
||||
const chain = usePluginChain("t1");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
});
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
const snapshot = handle.current!.snap.get();
|
||||
expect(snapshot.id).toBe("t1");
|
||||
});
|
||||
|
||||
it("save/load round-trip via store", { timeout: 30000 }, async () => {
|
||||
const memory = new Map<string, unknown>();
|
||||
const store: PresetStore = {
|
||||
async save(kind, id, payload) {
|
||||
memory.set(`${kind}:${id}`, payload);
|
||||
},
|
||||
async load(kind, id) {
|
||||
return memory.get(`${kind}:${id}`) as never;
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(
|
||||
() => {
|
||||
const chain = usePluginChain("t2");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
},
|
||||
{
|
||||
options: {
|
||||
presetStore: store,
|
||||
plugins: [
|
||||
{
|
||||
pluginType: "fake",
|
||||
factory: () => makeFakePlugin({ id: "p" }),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
);
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
await handle.current!.snap.save("v1");
|
||||
expect(memory.has("chain:v1")).toBe(true);
|
||||
});
|
||||
|
||||
it("save/load throw PRESET_STORE_FAILED without store", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const handle = renderHookWithProvider(() => {
|
||||
const chain = usePluginChain("t3");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
});
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
|
||||
let saveErr: unknown;
|
||||
try {
|
||||
await handle.current!.snap.save("x");
|
||||
} catch (e) {
|
||||
saveErr = e;
|
||||
}
|
||||
expect((saveErr as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
|
||||
let loadErr: unknown;
|
||||
try {
|
||||
await handle.current!.snap.load("x");
|
||||
} catch (e) {
|
||||
loadErr = e;
|
||||
}
|
||||
expect((loadErr as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("store throws on save/load wrap as PRESET_STORE_FAILED; missing snapshot too", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const throwingStore: PresetStore = {
|
||||
async save() {
|
||||
throw new Error("save bad");
|
||||
},
|
||||
async load() {
|
||||
throw new Error("load bad");
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(
|
||||
() => {
|
||||
const chain = usePluginChain("t4");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
},
|
||||
{ options: { presetStore: throwingStore } }
|
||||
);
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
await expect(handle.current!.snap.save("x")).rejects.toBeInstanceOf(
|
||||
ReactAudioError
|
||||
);
|
||||
await expect(handle.current!.snap.load("x")).rejects.toBeInstanceOf(
|
||||
ReactAudioError
|
||||
);
|
||||
|
||||
const missingStore: PresetStore = {
|
||||
async save() {},
|
||||
async load() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const handle2 = renderHookWithProvider(
|
||||
() => {
|
||||
const chain = usePluginChain("t5");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
},
|
||||
{ options: { presetStore: missingStore } }
|
||||
);
|
||||
await waitFor(() => expect(handle2.current).toBeDefined());
|
||||
await expect(
|
||||
handle2.current!.snap.load("missing")
|
||||
).rejects.toBeInstanceOf(ReactAudioError);
|
||||
});
|
||||
|
||||
it("save/load wrap non-Error causes as undefined", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const weirdStore: PresetStore = {
|
||||
save() {
|
||||
throw "string-thrown";
|
||||
},
|
||||
load() {
|
||||
throw 42;
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(
|
||||
() => {
|
||||
const chain = usePluginChain("weird-t");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
},
|
||||
{ options: { presetStore: weirdStore } }
|
||||
);
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
await expect(handle.current!.snap.save("x")).rejects.toBeInstanceOf(
|
||||
ReactAudioError
|
||||
);
|
||||
await expect(handle.current!.snap.load("x")).rejects.toBeInstanceOf(
|
||||
ReactAudioError
|
||||
);
|
||||
});
|
||||
|
||||
it("load restores from store via chain.restore", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const valid = {
|
||||
id: "load-t",
|
||||
name: "load-t",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 128,
|
||||
plugins: [],
|
||||
};
|
||||
const store: PresetStore = {
|
||||
async save() {},
|
||||
async load() {
|
||||
return valid as unknown as never;
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(
|
||||
() => {
|
||||
const chain = usePluginChain("load-t");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
},
|
||||
{ options: { presetStore: store } }
|
||||
);
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
await handle.current!.snap.load("v1");
|
||||
expect(handle.current!.chain.plugins.length).toBe(0);
|
||||
});
|
||||
|
||||
it("restore delegates to chain.restore via registry", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const handle = renderHookWithProvider(() => {
|
||||
const chain = usePluginChain("t-restore");
|
||||
return { chain, snap: useChainSnapshot(chain.chain) };
|
||||
});
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
// Construct a known-valid empty snapshot for the restore path.
|
||||
// The chain's own `snapshot()` produces `blockSize: 0` when the
|
||||
// chain has not been `setup()`'d, which fails Zod validation;
|
||||
// composing the input directly lets us exercise restore.
|
||||
const empty = {
|
||||
id: "t-restore",
|
||||
name: "t-restore",
|
||||
channels: 2,
|
||||
sampleRate: 48_000,
|
||||
blockSize: 128,
|
||||
plugins: [],
|
||||
};
|
||||
await handle.current!.snap.restore(empty);
|
||||
expect(handle.current!.chain.plugins.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type {
|
||||
ChainSnapshot,
|
||||
PluginChain,
|
||||
} from "@suaveplan/audio-fx-plugin-base";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
REACT_AUDIO_CODES,
|
||||
ReactAudioError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { useProviderStateOrThrow } from "../use-provider-state.js";
|
||||
|
||||
export interface UseChainSnapshotResult {
|
||||
/** Read the chain's current snapshot. */
|
||||
get(): ChainSnapshot;
|
||||
/** Restore the chain from a serialised snapshot. */
|
||||
restore(input: unknown): Promise<void>;
|
||||
/** Persist the current snapshot through the provider preset store. */
|
||||
save(id: string): Promise<void>;
|
||||
/** Load a snapshot by id and restore the chain. */
|
||||
load(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chain-level snapshot save/load through the provider's preset
|
||||
* store. The chain's own `snapshot()` / `restore()` methods are
|
||||
* exposed for in-memory operations.
|
||||
*/
|
||||
export function useChainSnapshot(chain: PluginChain): UseChainSnapshotResult {
|
||||
const { presetStore, registry } = useProviderStateOrThrow();
|
||||
|
||||
const get = useCallback(() => chain.snapshot(), [chain]);
|
||||
|
||||
const restore = useCallback(
|
||||
async (input: unknown) => {
|
||||
await chain.restore(input, registry);
|
||||
},
|
||||
[chain, registry]
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (id: string) => {
|
||||
if (presetStore === undefined) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-store-missing",
|
||||
{ context: { id, kind: "chain" } }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await presetStore.save("chain", id, chain.snapshot());
|
||||
} catch (cause) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.chain-snapshot-save-failed",
|
||||
{
|
||||
context: { id, kind: "chain" },
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[chain, presetStore]
|
||||
);
|
||||
|
||||
const load = useCallback(
|
||||
async (id: string) => {
|
||||
if (presetStore === undefined) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-store-missing",
|
||||
{ context: { id, kind: "chain" } }
|
||||
);
|
||||
}
|
||||
let snapshot: unknown;
|
||||
try {
|
||||
snapshot = await presetStore.load("chain", id);
|
||||
} catch (cause) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.chain-snapshot-load-failed",
|
||||
{
|
||||
context: { id, kind: "chain" },
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (snapshot === undefined) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.chain-snapshot-not-found",
|
||||
{ context: { id, kind: "chain" } }
|
||||
);
|
||||
}
|
||||
await chain.restore(snapshot, registry);
|
||||
},
|
||||
[chain, presetStore, registry]
|
||||
);
|
||||
|
||||
return { get, restore, save, load };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type LearnTarget,
|
||||
type MidiControllerRegistry,
|
||||
MidiControllerRegistryContext,
|
||||
type UseMidiControllerResult,
|
||||
useMidiController,
|
||||
} from "./use-midi-controller.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { usePlaylist } from "./use-playlist.js";
|
||||
export type {
|
||||
PlaylistItem,
|
||||
PlaylistRepeat,
|
||||
UsePlaylistOptions,
|
||||
UsePlaylistResult,
|
||||
} from "./use-playlist.types.js";
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
# `hooks/use-preset`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePreset(plugin)` exposes plugin-level preset operations as
|
||||
React-friendly callbacks: `get` reads the current preset, `apply`
|
||||
writes one back, and `save` / `load` persist through the provider's
|
||||
optional preset store. The hook gates `save`/`load` on the presence
|
||||
of a store; if none is configured (or if the store throws), the hook
|
||||
raises `ReactAudioError(REACT_AUDIO.PRESET_STORE_FAILED)` carrying
|
||||
the offending id and the underlying cause.
|
||||
|
||||
## Features
|
||||
|
||||
- `get(): PresetSnapshot` — synchronous read.
|
||||
- `apply(preset): void` — synchronous write.
|
||||
- `save(id): Promise<void>` — async write through the preset store.
|
||||
- `load(id): Promise<void>` — async read + apply.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for missing store, save
|
||||
failure, load failure, and preset-not-found.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function PresetButtons({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { save, load } = usePreset(plugin);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save</button>
|
||||
<button type="button" onClick={() => load("default")}>Load</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportPreset({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { get } = usePreset(plugin);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const preset = get();
|
||||
navigator.clipboard.writeText(JSON.stringify(preset));
|
||||
}}
|
||||
>
|
||||
Copy JSON
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePreset(plugin): { get, apply, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook wraps each store interaction in a try/catch that converts
|
||||
underlying errors into `ReactAudioError(PRESET_STORE_FAILED)` with
|
||||
the original `Error` set as `cause`. The store is allowed to be
|
||||
undefined; consumers that don't need persistence omit it from
|
||||
provider options and use `get` / `apply` only. Promise rejections are
|
||||
deliberately wrapped (rather than re-thrown raw) so a single
|
||||
`error instanceof ReactAudioError` branch handles every failure path.
|
||||
The four failure modes — missing store, store-rejected save,
|
||||
store-rejected load, preset-not-found — share one code
|
||||
(`PRESET_STORE_FAILED`) but distinct `messageKey`s so an i18n layer
|
||||
can branch on the message resolver instead of the code. Non-Error
|
||||
values thrown by a store are narrowed to `undefined` causes so the
|
||||
wrapper Error remains type-safe.
|
||||
@@ -0,0 +1,169 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { waitFor } from "@testing-library/react";
|
||||
import {
|
||||
REACT_AUDIO_CODES,
|
||||
ReactAudioError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import type { PresetStore } from "../../provider/provider-options.js";
|
||||
import { makeFakePlugin, renderHookWithProvider } from "../../test-utils.js";
|
||||
import { usePreset } from "./use-preset.js";
|
||||
|
||||
describe("usePreset", () => {
|
||||
it("get/apply work without a preset store", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const plugin = makeFakePlugin();
|
||||
const handle = renderHookWithProvider(() => usePreset(plugin));
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
const snapshot = handle.current!.get();
|
||||
expect(snapshot.parameters.gain).toBe(1);
|
||||
handle.current!.apply({
|
||||
schemaVersion: 1,
|
||||
pluginType: plugin.metadata.id,
|
||||
parameters: { gain: 0.7, tone: 0.3 },
|
||||
});
|
||||
expect(plugin.getParameter("gain")).toBe(0.7);
|
||||
});
|
||||
|
||||
it("save/load round-trip via store", { timeout: 30000 }, async () => {
|
||||
const plugin = makeFakePlugin();
|
||||
const memory = new Map<string, unknown>();
|
||||
const store: PresetStore = {
|
||||
async save(kind, id, payload) {
|
||||
memory.set(`${kind}:${id}`, payload);
|
||||
},
|
||||
async load(kind, id) {
|
||||
return memory.get(`${kind}:${id}`) as never;
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(() => usePreset(plugin), {
|
||||
options: { presetStore: store },
|
||||
});
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
plugin.setParameter("gain", 0.42);
|
||||
await handle.current!.save("p1");
|
||||
plugin.setParameter("gain", 0.0);
|
||||
await handle.current!.load("p1");
|
||||
expect(plugin.getParameter("gain")).toBeCloseTo(0.42);
|
||||
});
|
||||
|
||||
it("save throws REACT_AUDIO.PRESET_STORE_FAILED when no store", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const plugin = makeFakePlugin();
|
||||
const handle = renderHookWithProvider(() => usePreset(plugin));
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.current!.save("x");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(ReactAudioError);
|
||||
expect((caught as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("load throws PRESET_STORE_FAILED for missing preset / save error / load error", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const plugin = makeFakePlugin();
|
||||
const store: PresetStore = {
|
||||
async save() {
|
||||
throw new Error("save broke");
|
||||
},
|
||||
async load() {
|
||||
return undefined;
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(() => usePreset(plugin), {
|
||||
options: { presetStore: store },
|
||||
});
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
|
||||
let saveErr: unknown;
|
||||
try {
|
||||
await handle.current!.save("x");
|
||||
} catch (e) {
|
||||
saveErr = e;
|
||||
}
|
||||
expect((saveErr as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
|
||||
let loadErr: unknown;
|
||||
try {
|
||||
await handle.current!.load("x");
|
||||
} catch (e) {
|
||||
loadErr = e;
|
||||
}
|
||||
expect((loadErr as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
|
||||
// load with throwing store
|
||||
const throwingStore: PresetStore = {
|
||||
async save() {},
|
||||
async load() {
|
||||
throw new Error("load broke");
|
||||
},
|
||||
};
|
||||
const handle2 = renderHookWithProvider(() => usePreset(plugin), {
|
||||
options: { presetStore: throwingStore },
|
||||
});
|
||||
await waitFor(() => expect(handle2.current).toBeDefined());
|
||||
let loadErr2: unknown;
|
||||
try {
|
||||
await handle2.current!.load("y");
|
||||
} catch (e) {
|
||||
loadErr2 = e;
|
||||
}
|
||||
expect((loadErr2 as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
});
|
||||
|
||||
it("save/load wrap non-Error causes as undefined", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const plugin = makeFakePlugin();
|
||||
const weirdStore: PresetStore = {
|
||||
// Throw non-Error values to exercise the
|
||||
// `cause instanceof Error ? cause : undefined` branch.
|
||||
save() {
|
||||
throw "string-thrown";
|
||||
},
|
||||
load() {
|
||||
throw 42;
|
||||
},
|
||||
};
|
||||
const handle = renderHookWithProvider(() => usePreset(plugin), {
|
||||
options: { presetStore: weirdStore },
|
||||
});
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
await expect(handle.current!.save("x")).rejects.toBeInstanceOf(
|
||||
ReactAudioError
|
||||
);
|
||||
await expect(handle.current!.load("x")).rejects.toBeInstanceOf(
|
||||
ReactAudioError
|
||||
);
|
||||
});
|
||||
|
||||
it("load without store throws PRESET_STORE_FAILED", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const plugin = makeFakePlugin();
|
||||
const handle = renderHookWithProvider(() => usePreset(plugin));
|
||||
await waitFor(() => expect(handle.current).toBeDefined());
|
||||
let caught: unknown;
|
||||
try {
|
||||
await handle.current!.load("x");
|
||||
} catch (e) {
|
||||
caught = e;
|
||||
}
|
||||
expect((caught as ReactAudioError).code).toBe(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED
|
||||
);
|
||||
});
|
||||
});
|
||||
108
packages/ui/react/react-audio/src/hooks/use-preset/use-preset.ts
Normal file
108
packages/ui/react/react-audio/src/hooks/use-preset/use-preset.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type {
|
||||
AudioFxPlugin,
|
||||
PresetSnapshot,
|
||||
} from "@suaveplan/audio-fx-plugin-base";
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
REACT_AUDIO_CODES,
|
||||
ReactAudioError,
|
||||
} from "../../error-codes/error-codes.js";
|
||||
import { useProviderStateOrThrow } from "../use-provider-state.js";
|
||||
|
||||
export interface UsePresetResult {
|
||||
/** Read the plugin's current `PresetSnapshot`. */
|
||||
get(): PresetSnapshot;
|
||||
/** Apply a `PresetSnapshot` to the plugin. */
|
||||
apply(preset: PresetSnapshot): void;
|
||||
/**
|
||||
* Persist the current preset through the provider's preset
|
||||
* store, keyed by `id`. Throws
|
||||
* `REACT_AUDIO.PRESET_STORE_FAILED` if no preset store is
|
||||
* configured or the store rejects the write.
|
||||
*/
|
||||
save(id: string): Promise<void>;
|
||||
/**
|
||||
* Load a preset by `id` from the provider's preset store and
|
||||
* apply it to the plugin. Throws
|
||||
* `REACT_AUDIO.PRESET_STORE_FAILED` if no preset store is
|
||||
* configured or the store rejects the read.
|
||||
*/
|
||||
load(id: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plugin-level preset save/load. Direct `get` / `apply` work
|
||||
* without a preset store; `save` / `load` require one to be wired
|
||||
* through provider options.
|
||||
*/
|
||||
export function usePreset(plugin: AudioFxPlugin): UsePresetResult {
|
||||
const { presetStore } = useProviderStateOrThrow();
|
||||
|
||||
const get = useCallback(() => plugin.getPreset(), [plugin]);
|
||||
const apply = useCallback(
|
||||
(preset: PresetSnapshot) => plugin.applyPreset(preset),
|
||||
[plugin]
|
||||
);
|
||||
|
||||
const save = useCallback(
|
||||
async (id: string) => {
|
||||
if (presetStore === undefined) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-store-missing",
|
||||
{ context: { id, kind: "plugin" } }
|
||||
);
|
||||
}
|
||||
try {
|
||||
await presetStore.save("plugin", id, plugin.getPreset());
|
||||
} catch (cause) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-store-save-failed",
|
||||
{
|
||||
context: { id, kind: "plugin" },
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
},
|
||||
[plugin, presetStore]
|
||||
);
|
||||
|
||||
const load = useCallback(
|
||||
async (id: string) => {
|
||||
if (presetStore === undefined) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-store-missing",
|
||||
{ context: { id, kind: "plugin" } }
|
||||
);
|
||||
}
|
||||
let preset: PresetSnapshot | undefined;
|
||||
try {
|
||||
const result = await presetStore.load("plugin", id);
|
||||
preset = result as PresetSnapshot | undefined;
|
||||
} catch (cause) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-store-load-failed",
|
||||
{
|
||||
context: { id, kind: "plugin" },
|
||||
cause: cause instanceof Error ? cause : undefined,
|
||||
}
|
||||
);
|
||||
}
|
||||
if (preset === undefined) {
|
||||
throw new ReactAudioError(
|
||||
REACT_AUDIO_CODES.PRESET_STORE_FAILED,
|
||||
"react-audio.preset-not-found",
|
||||
{ context: { id, kind: "plugin" } }
|
||||
);
|
||||
}
|
||||
plugin.applyPreset(preset);
|
||||
},
|
||||
[plugin, presetStore]
|
||||
);
|
||||
|
||||
return { get, apply, save, load };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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>");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user