first commit
This commit is contained in:
120
docs/ui/react/react-audio/src/hooks/use-analyser/use-analyser.md
Normal file
120
docs/ui/react/react-audio/src/hooks/use-analyser/use-analyser.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# `hooks/use-analyser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and
|
||||
returns the latest byte-magnitude FFT frame plus the matching
|
||||
time-domain (waveform) frame. It is the data feed for the family's
|
||||
two stock visualisers — `<WaveformVisualizer>` and
|
||||
`<SpectrumAnalyzer>` — and for any consumer-built `<canvas>`
|
||||
visualiser that wants live spectrum data without dealing with
|
||||
`AudioContext` or `AnalyserNode` lifecycle.
|
||||
|
||||
The hook deliberately does **not** create or own an `AnalyserNode`.
|
||||
The audio engine builds one analyser per track inside its
|
||||
`TrackSubgraph`, and the player tier hosts the master analyser on
|
||||
the source chain; the consumer hands either node to this hook. Two
|
||||
consumers reading the same analyser independently each get their
|
||||
own buffer pair so they cannot tear each other's frame reads.
|
||||
|
||||
## Features
|
||||
|
||||
- Polls at a caller-tunable interval (default 16 ms ≈ 60 Hz).
|
||||
- Auto-configures `fftSize`, `smoothingTimeConstant`, `minDecibels`,
|
||||
`maxDecibels` on the analyser on every change.
|
||||
- Lazy `factory` fallback for when the analyser is not available at
|
||||
mount (typical: engine still initialising).
|
||||
- Identity-stable `Uint8Array` buffers — mutated in place; the hook
|
||||
never allocates per frame.
|
||||
- Cleanup on unmount cancels the interval.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAnalyser } from "@suaveplan/react-audio";
|
||||
|
||||
function Spectrum({ analyser }) {
|
||||
const { frequency } = useAnalyser({ analyser, fftSize: 1024 });
|
||||
return (
|
||||
<div role="img" aria-label="spectrum">
|
||||
{Array.from(frequency.slice(0, 32)).map((v, i) => (
|
||||
<span key={i} style={{ height: `${v}px` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAnalyser } from "@suaveplan/react-audio";
|
||||
|
||||
// Resolve the engine's per-track analyser via the graph snapshot.
|
||||
function trackAnalyserFactory(adapter, trackId) {
|
||||
return () => {
|
||||
const snap = adapter.snapshotGraph();
|
||||
const node = snap.nodes.find(
|
||||
(n) => n.id === `${trackId}:analyser`
|
||||
);
|
||||
return node?.ref ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
function TrackVisualizer({ adapter, trackId }) {
|
||||
const { frequency, waveform, analyser } = useAnalyser({
|
||||
factory: trackAnalyserFactory(adapter, trackId),
|
||||
intervalMs: 33, // ~30 Hz to save CPU
|
||||
fftSize: 2048,
|
||||
smoothingTimeConstant: 0.6,
|
||||
});
|
||||
if (!analyser) return <span>Connecting…</span>;
|
||||
return (
|
||||
<canvas
|
||||
ref={(c) => drawSpectrum(c, frequency, waveform)}
|
||||
width={512}
|
||||
height={128}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAnalyser(opts?: UseAnalyserOptions): UseAnalyserResult`
|
||||
- `UseAnalyserOptions`:
|
||||
- `analyser?: AnalyserLike | null` — direct ref; supersedes `factory`.
|
||||
- `factory?: () => AnalyserLike | null` — lazy resolver, retried per tick.
|
||||
- `intervalMs?: number` — poll interval (default 16).
|
||||
- `fftSize?: number` — power-of-2 in `[32, 32768]` (default 2048).
|
||||
- `smoothingTimeConstant?: number` — `[0, 1]` (default 0.8).
|
||||
- `minDecibels?: number` — default `-100`.
|
||||
- `maxDecibels?: number` — default `-30`.
|
||||
- `UseAnalyserResult`:
|
||||
- `analyser: AnalyserLike | null` — resolved instance or `null`.
|
||||
- `frequency: Uint8Array` — byte-magnitude FFT, identity-stable.
|
||||
- `waveform: Uint8Array` — byte time-domain, identity-stable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook owns three refs: the resolved analyser, the factory, and
|
||||
the buffer pair. Buffers are reallocated when `analyser` or
|
||||
`fftSize` changes (the new `frequencyBinCount` determines length).
|
||||
Polling uses `setInterval` rather than `requestAnimationFrame`
|
||||
because the former is deterministic under happy-dom — the same
|
||||
reason `useTransportTime` makes the same choice.
|
||||
|
||||
The factory fallback is invoked on every tick until it returns a
|
||||
non-`null` value, then never again. This lets the consumer mount
|
||||
the hook before the engine finishes async init without races: the
|
||||
visualiser simply renders zeros until the analyser appears, then
|
||||
continues seamlessly.
|
||||
|
||||
The hook is engine-agnostic by design — it accepts the structural
|
||||
`AnalyserLike` shape, so consumers can stub it in tests with three
|
||||
lines of code and there is no hidden `AudioContext` dependency
|
||||
poisoning tree-shake or SSR.
|
||||
@@ -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,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.
|
||||
69
docs/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal file
69
docs/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,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,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,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,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,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.
|
||||
135
docs/ui/react/react-audio/src/hooks/use-playlist/use-playlist.md
Normal file
135
docs/ui/react/react-audio/src/hooks/use-playlist/use-playlist.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# `hooks/use-playlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries
|
||||
and exposes the standard playlist verbs (`next`, `prev`, `skipTo`,
|
||||
`setIndex`) plus mode toggles (`repeat`, `shuffle`). The hook is
|
||||
deliberately engine-agnostic: it knows nothing about `AudioContext`,
|
||||
`AudioPlayerPlugin`, or transport. Consumers wire the returned
|
||||
`current.src` into whichever loader they use, typically
|
||||
`<AudioPlayer source={current.src}>` from this same package, or any
|
||||
of the higher-level players that build on it.
|
||||
|
||||
The hook is the single source of truth for queue traversal across
|
||||
the audio family. The companion `<Queue>` UI in
|
||||
`@suaveplan/react-audio-player` is a thin renderer over its return
|
||||
value; the karaoke surface in the same package advances lyrics
|
||||
independently from playlist position.
|
||||
|
||||
## Features
|
||||
|
||||
- `repeat: "off" | "all" | "one"` with correct wrap semantics on
|
||||
`next` and `prev`.
|
||||
- `shuffle: boolean` with a per-shuffle history stack so `prev`
|
||||
reverses the exact path `next` walked.
|
||||
- Avoids immediately repeating the current track when shuffling.
|
||||
- `skipTo(id)` jumps to an item by id; ids not in `items` are a
|
||||
silent no-op.
|
||||
- `setIndex(n)` clamps out-of-range values rather than throwing.
|
||||
- Injectable `rng` for deterministic testing.
|
||||
- Stable return-value identity for `current` across renders that
|
||||
do not change the active item.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePlaylist } from "@suaveplan/react-audio";
|
||||
|
||||
function MyPlayer({ items }) {
|
||||
const { current, next, prev, index, length } = usePlaylist(items);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={prev} disabled={index <= 0}>
|
||||
←
|
||||
</button>
|
||||
<span>{current?.title ?? "—"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= length - 1}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePlaylist } from "@suaveplan/react-audio";
|
||||
|
||||
function ShuffledPlayer({ items }) {
|
||||
// Inject a deterministic RNG for repeatable tests.
|
||||
const rng = () => 0.42;
|
||||
const pl = usePlaylist(items, {
|
||||
initialIndex: 2,
|
||||
initialRepeat: "all",
|
||||
initialShuffle: true,
|
||||
rng,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => pl.setShuffle(!pl.shuffle)}>
|
||||
{pl.shuffle ? "Shuffle on" : "Shuffle off"}
|
||||
</button>
|
||||
<button type="button" onClick={() => pl.setRepeat("one")}>
|
||||
Repeat one
|
||||
</button>
|
||||
<ol>
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.id}
|
||||
aria-current={
|
||||
pl.current?.id === it.id ? "true" : undefined
|
||||
}
|
||||
>
|
||||
<button type="button" onClick={() => pl.skipTo(it.id)}>
|
||||
{it.title ?? it.id}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePlaylist(items: readonly PlaylistItem[], opts?: UsePlaylistOptions): UsePlaylistResult`
|
||||
- `PlaylistItem` — `{ id, src, title?, artist?, durationSec?, meta? }`.
|
||||
- `PlaylistRepeat` — `"off" | "all" | "one"`.
|
||||
- `UsePlaylistOptions` — `initialIndex`, `initialRepeat`,
|
||||
`initialShuffle`, `rng`.
|
||||
- `UsePlaylistResult` — `current`, `index`, `length`, `repeat`,
|
||||
`shuffle`, `next()`, `prev()`, `skipTo(id)`, `setIndex(n)`,
|
||||
`setRepeat(mode)`, `setShuffle(enabled)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cursor is held in a single `useState<number>`; all verbs route
|
||||
through the same setter so React batches updates per event. The
|
||||
shuffle "history" lives in a `useRef<number[]>` because it is not
|
||||
rendered — pushing to it must not cause a commit. When `setShuffle`
|
||||
toggles off, the history is cleared so the next shuffle session
|
||||
starts clean.
|
||||
|
||||
The `rng` ref is rebound on every render to `opts.rng ?? Math.random`
|
||||
so the consumer can swap RNGs without losing state. The deferred
|
||||
update inside callbacks reads the ref, not the captured option, so a
|
||||
late-arriving RNG takes effect on the very next `next`.
|
||||
|
||||
`current` is memoised by `(items, index, length)`; since `length`
|
||||
mirrors `items.length`, the dep set is minimal but explicit so React
|
||||
strict-mode double-renders cannot manufacture stale snapshots.
|
||||
|
||||
`next` and `prev` are pure functions of `(repeat, shuffle, length,
|
||||
cur)` — no I/O, no thrown errors, no `console`. The hook is safe to
|
||||
mount under React's concurrent renderer without `useTransition`.
|
||||
@@ -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).
|
||||
81
docs/ui/react/react-audio/src/hooks/use-preset/use-preset.md
Normal file
81
docs/ui/react/react-audio/src/hooks/use-preset/use-preset.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# `hooks/use-preset`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePreset(plugin)` exposes plugin-level preset operations as
|
||||
React-friendly callbacks: `get` reads the current preset, `apply`
|
||||
writes one back, and `save` / `load` persist through the provider's
|
||||
optional preset store. The hook gates `save`/`load` on the presence
|
||||
of a store; if none is configured (or if the store throws), the hook
|
||||
raises `ReactAudioError(REACT_AUDIO.PRESET_STORE_FAILED)` carrying
|
||||
the offending id and the underlying cause.
|
||||
|
||||
## Features
|
||||
|
||||
- `get(): PresetSnapshot` — synchronous read.
|
||||
- `apply(preset): void` — synchronous write.
|
||||
- `save(id): Promise<void>` — async write through the preset store.
|
||||
- `load(id): Promise<void>` — async read + apply.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for missing store, save
|
||||
failure, load failure, and preset-not-found.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function PresetButtons({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { save, load } = usePreset(plugin);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save</button>
|
||||
<button type="button" onClick={() => load("default")}>Load</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportPreset({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { get } = usePreset(plugin);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const preset = get();
|
||||
navigator.clipboard.writeText(JSON.stringify(preset));
|
||||
}}
|
||||
>
|
||||
Copy JSON
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePreset(plugin): { get, apply, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook wraps each store interaction in a try/catch that converts
|
||||
underlying errors into `ReactAudioError(PRESET_STORE_FAILED)` with
|
||||
the original `Error` set as `cause`. The store is allowed to be
|
||||
undefined; consumers that don't need persistence omit it from
|
||||
provider options and use `get` / `apply` only. Promise rejections are
|
||||
deliberately wrapped (rather than re-thrown raw) so a single
|
||||
`error instanceof ReactAudioError` branch handles every failure path.
|
||||
The four failure modes — missing store, store-rejected save,
|
||||
store-rejected load, preset-not-found — share one code
|
||||
(`PRESET_STORE_FAILED`) but distinct `messageKey`s so an i18n layer
|
||||
can branch on the message resolver instead of the code. Non-Error
|
||||
values thrown by a store are narrowed to `undefined` causes so the
|
||||
wrapper Error remains type-safe.
|
||||
70
docs/ui/react/react-audio/src/hooks/use-provider-state.md
Normal file
70
docs/ui/react/react-audio/src/hooks/use-provider-state.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# `hooks/use-provider-state`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helpers shared across every public hook. They centralise the
|
||||
two structured-error throws every engine-dependent hook must perform:
|
||||
`REACT_AUDIO.NO_PROVIDER` when the consumer renders outside an
|
||||
`<AudioProvider>` ancestor, and `REACT_AUDIO.PROVIDER_PENDING` when
|
||||
the engine has not yet finished its async init. Keeping the throw
|
||||
logic in one file means there is exactly one place to audit the error
|
||||
contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `useProviderStateOrThrow()` — returns the resolved `ProviderState`
|
||||
or throws.
|
||||
- `useAudioContextOrThrow()` — returns the raw `AudioContextValue`
|
||||
(without the pending check) for hooks like `useEngineState` that
|
||||
need to *report* the lifecycle phase rather than throw on it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useProviderStateOrThrow } from "@suaveplan/react-audio";
|
||||
|
||||
function MyHook() {
|
||||
const state = useProviderStateOrThrow();
|
||||
return state.transport;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { useAudioContextOrThrow } from "@suaveplan/react-audio";
|
||||
|
||||
function useReadyOrLoading(): "ready" | "loading" {
|
||||
const ctx = useAudioContextOrThrow();
|
||||
return ctx.engineState === "ready" ? "ready" : "loading";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useProviderStateOrThrow(): ProviderState` — throws
|
||||
`REACT_AUDIO.NO_PROVIDER` outside provider; throws
|
||||
`REACT_AUDIO.PROVIDER_PENDING` while engine init is pending.
|
||||
- `useAudioContextOrThrow(): AudioContextValue` — throws
|
||||
`REACT_AUDIO.NO_PROVIDER` outside provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useContext` returns `null` as the "no provider" sentinel because the
|
||||
context's default value is `null`. The two helpers use a single
|
||||
discriminator check (`ctx === null`) for that path so the throw site
|
||||
is canonical. `useProviderStateOrThrow` additionally checks for
|
||||
`ctx.state === null` (the async init window) and throws
|
||||
`PROVIDER_PENDING` so engine-dependent hooks fail fast — consumers
|
||||
typically gate on `useEngineState() === 'ready'` before rendering
|
||||
those hooks' subtrees. The split into two helpers exists so
|
||||
`useEngineState` itself can read the phase without throwing on it —
|
||||
otherwise the consumer would have no way to discover "we're still
|
||||
initialising" without crashing the component tree. Both helpers
|
||||
register exactly one React hook (`useContext`) so any downstream hook
|
||||
can register additional `useCallback` / `useSyncExternalStore` calls
|
||||
unconditionally and stay compliant with the Rules of Hooks.
|
||||
@@ -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.
|
||||
74
docs/ui/react/react-audio/src/hooks/use-track/use-track.md
Normal file
74
docs/ui/react/react-audio/src/hooks/use-track/use-track.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `hooks/use-track`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTrack(trackId)` subscribes to a single track on the
|
||||
`TimelineEngine` by id and re-renders only when that specific track
|
||||
changes. It returns the live `Track` snapshot (or `undefined` if no
|
||||
track has that id) plus pre-bound `update` / `remove` callbacks. The
|
||||
hook is the canonical way to render per-track UI without re-rendering
|
||||
on every other track's state change.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-renders only on `trackChange` events for the same id.
|
||||
- Returns `undefined` for unknown ids — render-safe.
|
||||
- Stable `update` / `remove` identities across re-renders.
|
||||
- `useSyncExternalStore`-based subscription with SSR-consistent
|
||||
snapshots.
|
||||
- Listener is removed on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTrack } from "@suaveplan/react-audio";
|
||||
|
||||
function TrackName({ trackId }: { trackId: string }) {
|
||||
const { track } = useTrack(trackId);
|
||||
return <span>{track?.name ?? "(missing track)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTrack } from "@suaveplan/react-audio";
|
||||
|
||||
function MuteToggle({ trackId }: { trackId: string }) {
|
||||
const { track, update } = useTrack(trackId);
|
||||
if (track === undefined) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={track.muted}
|
||||
onClick={() => update({ muted: !track.muted })}
|
||||
>
|
||||
{track.muted ? "Unmute" : "Mute"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTrack(trackId): { track: Track | undefined, update, remove }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook caches the resolved Track in a `ref` so
|
||||
`useSyncExternalStore.getSnapshot` is identity-stable between change
|
||||
events. Without that cache, `timeline.getState().tracks.find(...)`
|
||||
would build a new wrapper on every snapshot read; React 18+ treats
|
||||
that as an unstable store and tears the consuming subtree down on
|
||||
the next commit. Refresh fires on every `trackChange` from the
|
||||
timeline AND from local `update` / `remove` mutators — the
|
||||
underlying engine emits `trackChange` only for tracks STILL present
|
||||
in the list, so removing the last track of an id needs the mutator
|
||||
to update the cache directly. After removal the hook continues to
|
||||
render but `track` becomes `undefined`. Components consuming
|
||||
`update` / `remove` should expect those callbacks to settle
|
||||
synchronously; the next React commit reflects the change.
|
||||
@@ -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,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.
|
||||
Reference in New Issue
Block a user