first commit

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

View File

@@ -0,0 +1,206 @@
# @suaveplan/react-audio - React surface for the Suaveplan audio family — Phase 2 + Phase 2b (extras) + Phase 3 shipped. Provides provider + hooks + stores + error codes + a11y helpers + Phase-2 primitives (PluginSlot, PluginChainView, PresetManager, knob/slider) + composed-plugin UIs (TransportControls, TrackList/TrackRow, MasterMeter, Mixer, AudioVisualizer, AudioPlayer/BasicPlayer/PremiumPlayer, MicrophoneControls, GLaDOSControls, PluginLoader) + Phase 3 MIDI learn-mode (`<MidiControllerSetup>` + `useMidiController`).
React surface for the Suaveplan audio family. **Phase 1 of 3 —
foundation** (provider + hooks + stores + error codes + a11y helpers).
Components ship in Phase 2 (`enterprise-react-audio-components`);
the MIDI learn-mode panel ships in Phase 3
(`enterprise-react-audio-midi`).
<!-- begin:auto-docs -->
## Documentation tree
- [aria-value-text](./src/a11y/aria-value-text/aria-value-text.md) — Pure `aria-valuetext` formatter consumed by Phase-2 parameter controls (knobs, sliders, scrubbers). Returns a screen-re…
- [aria-value-text](./src/a11y/aria-value-text/aria-value-text.md) — Pure `aria-valuetext` formatter consumed by Phase-2 parameter controls (knobs, sliders, scrubbers). Returns a screen-re…
- [keyboard-bindings](./src/a11y/keyboard-bindings/keyboard-bindings.md) — Pure-value table mapping keyboard keys to parameter-control actions (`fine-decrement`, `coarse-increment`, `page-decrem…
- [keyboard-bindings](./src/a11y/keyboard-bindings/keyboard-bindings.md) — Pure-value table mapping keyboard keys to parameter-control actions (`fine-decrement`, `coarse-increment`, `page-decrem…
- [audio-player](./src/components/audio-player/audio-player.md) — `<AudioPlayer>` is the generic media-player surface for a live `AudioPlayerPlugin` instance from `@suaveplan/audio-fx-p…
- [audio-player](./src/components/audio-player/audio-player.md) — `<AudioPlayer>` is the generic media-player surface for a live `AudioPlayerPlugin` instance from `@suaveplan/audio-fx-p…
- [audio-visualizer](./src/components/audio-visualizer/audio-visualizer.md) — `<AudioVisualizer>` is a canvas-based visualiser for a published bus. It subscribes through the foundation `useBus` hoo…
- [audio-visualizer](./src/components/audio-visualizer/audio-visualizer.md) — `<AudioVisualizer>` is a canvas-based visualiser for a published bus. It subscribes through the foundation `useBus` hoo…
- [basic-player](./src/components/basic-player/basic-player.md) — `<BasicPlayer>` is the minimal media-player surface for an `AudioPlayerPlugin`. It is implemented as a thin composition…
- [basic-player](./src/components/basic-player/basic-player.md) — `<BasicPlayer>` is the minimal media-player surface for an `AudioPlayerPlugin`. It is implemented as a thin composition…
- [glados-controls](./src/components/glados-controls/glados-controls.md) — `<GLaDOSControls>` is the composed UI for the `GLaDOSPlugin` voice transform from `@suaveplan/audio-fx-plugin-glados`.…
- [glados-controls](./src/components/glados-controls/glados-controls.md) — `<GLaDOSControls>` is the composed UI for the `GLaDOSPlugin` voice transform from `@suaveplan/audio-fx-plugin-glados`.…
- [parameter-keyboard](./src/components/internal/parameter-keyboard.md) — Internal helper consumed by `<PluginParameterKnob>` and `<PluginParameterSlider>` to translate a `KeyboardEvent` into a…
- [parameter-unit](./src/components/internal/parameter-unit.md) — Internal helper that converts the controlled `ParameterUnit` enum exposed by `@suaveplan/audio-fx-plugin-base` (`"db"`,…
- [master-meter](./src/components/master-meter/master-meter.md) — `<MasterMeter>` is a RAF-sampled master-output level meter. It subscribes to a named sidechain bus through the foundati…
- [master-meter](./src/components/master-meter/master-meter.md) — `<MasterMeter>` is a RAF-sampled master-output level meter. It subscribes to a named sidechain bus through the foundati…
- [microphone-controls](./src/components/microphone-controls/microphone-controls.md) — `<MicrophoneControls>` is the composed UI for `MicrophoneSimulationPlugin` from `@suaveplan/audio-fx-plugin-microphone`…
- [microphone-controls](./src/components/microphone-controls/microphone-controls.md) — `<MicrophoneControls>` is the composed UI for `MicrophoneSimulationPlugin` from `@suaveplan/audio-fx-plugin-microphone`…
- [midi-controller-setup](./src/components/midi-controller-setup/midi-controller-setup.md) — `<MidiControllerSetup>` is the Phase-3 learn-mode panel for the Suaveplan React audio stack. It renders the connected c…
- [midi-controller-setup](./src/components/midi-controller-setup/midi-controller-setup.md) — `<MidiControllerSetup>` is the Phase-3 learn-mode panel for the Suaveplan React audio stack. It renders the connected c…
- [mixer](./src/components/mixer/mixer.md) — `<Mixer>` is the per-channel strip view for a live `MixerPlugin` instance from `@suaveplan/audio-fx-plugin-mixer`. It r…
- [mixer](./src/components/mixer/mixer.md) — `<Mixer>` is the per-channel strip view for a live `MixerPlugin` instance from `@suaveplan/audio-fx-plugin-mixer`. It r…
- [plugin-chain-view](./src/components/plugin-chain-view/plugin-chain-view.md) — Reorderable, accessible view of a `PluginChain`. Renders one `<PluginSlot>` per chain entry, supports HTML5 drag-and-dr…
- [plugin-chain-view](./src/components/plugin-chain-view/plugin-chain-view.md) — Reorderable, accessible view of a `PluginChain`. Renders one `<PluginSlot>` per chain entry, supports HTML5 drag-and-dr…
- [plugin-loader](./src/components/plugin-loader/plugin-loader.md) — `<PluginLoader>` is the dynamic plugin-registration UI over the provider's `PluginRegistry`. It lists every currently-r…
- [plugin-loader](./src/components/plugin-loader/plugin-loader.md) — `<PluginLoader>` is the dynamic plugin-registration UI over the provider's `PluginRegistry`. It lists every currently-r…
- [plugin-parameter-knob](./src/components/plugin-parameter-knob/plugin-parameter-knob.md) — Rotary-knob primitive for a single `AudioFxPlugin` parameter. The knob implements the full keyboard contract mandated b…
- [plugin-parameter-knob](./src/components/plugin-parameter-knob/plugin-parameter-knob.md) — Rotary-knob primitive for a single `AudioFxPlugin` parameter. The knob implements the full keyboard contract mandated b…
- [plugin-parameter-slider](./src/components/plugin-parameter-slider/plugin-parameter-slider.md) — Linear-slider primitive for a single `AudioFxPlugin` parameter. Mirrors `<PluginParameterKnob>`'s a11y contract and exp…
- [plugin-parameter-slider](./src/components/plugin-parameter-slider/plugin-parameter-slider.md) — Linear-slider primitive for a single `AudioFxPlugin` parameter. Mirrors `<PluginParameterKnob>`'s a11y contract and exp…
- [plugin-slot](./src/components/plugin-slot/plugin-slot.md) — Generic renderer for any `AudioFxPlugin`. Reads the plugin's declarative `parameters` array and renders each one throug…
- [plugin-slot](./src/components/plugin-slot/plugin-slot.md) — Generic renderer for any `AudioFxPlugin`. Reads the plugin's declarative `parameters` array and renders each one throug…
- [premium-player](./src/components/premium-player/premium-player.md) — `<PremiumPlayer>` is the full-featured media-player surface — a composition of `<AudioPlayer>`, `<AudioVisualizer>`, an…
- [premium-player](./src/components/premium-player/premium-player.md) — `<PremiumPlayer>` is the full-featured media-player surface — a composition of `<AudioPlayer>`, `<AudioVisualizer>`, an…
- [preset-manager](./src/components/preset-manager/preset-manager.md) — `ChainSnapshot` save / load / delete UI layered over the provider's preset store. The save path persists the snapshot t…
- [preset-manager](./src/components/preset-manager/preset-manager.md) — `ChainSnapshot` save / load / delete UI layered over the provider's preset store. The save path persists the snapshot t…
- [track-list](./src/components/track-list/track-list.md) — `<TrackList>` is a composed-plugin UI that renders every track in the provider's `TimelineEngine`. It is the canonical…
- [track-list](./src/components/track-list/track-list.md) — `<TrackList>` is a composed-plugin UI that renders every track in the provider's `TimelineEngine`. It is the canonical…
- [track-row](./src/components/track-row/track-row.md) — `<TrackRow>` is the per-track row primitive consumed by `<TrackList>`. It subscribes to a single `Track` through the fo…
- [track-row](./src/components/track-row/track-row.md) — `<TrackRow>` is the per-track row primitive consumed by `<TrackList>`. It subscribes to a single `Track` through the fo…
- [transport-controls](./src/components/transport-controls/transport-controls.md) — `<TransportControls>` is the composed-plugin UI for the provider-owned `Transport` instance. It exposes the spec-mandat…
- [transport-controls](./src/components/transport-controls/transport-controls.md) — `<TransportControls>` is the composed-plugin UI for the provider-owned `Transport` instance. It exposes the spec-mandat…
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code thrown inside `@suaveplan/react-audio`. The codes are na…
- [error-codes](./src/error-codes/error-codes.md) — This module is the single source of truth for every error code thrown inside `@suaveplan/react-audio`. The codes are na…
- [hooks](./src/hooks/use-provider-state.md) — Internal helpers shared across every public hook. They centralise the two structured-error throws every engine-dependen…
- [use-analyser](./src/hooks/use-analyser/use-analyser.md) — `useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and returns the latest byte-magnitude FFT frame plus the mat…
- [use-analyser](./src/hooks/use-analyser/use-analyser.md) — `useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and returns the latest byte-magnitude FFT frame plus the mat…
- [use-audio-engine](./src/hooks/use-audio-engine/use-audio-engine.md) — `useAudioEngine()` returns the full resolved `ProviderState` bundle — the adapter, the `TimelineEngine`, the `Transport…
- [use-audio-engine](./src/hooks/use-audio-engine/use-audio-engine.md) — `useAudioEngine()` returns the full resolved `ProviderState` bundle — the adapter, the `TimelineEngine`, the `Transport…
- [use-audio-fx-plugin](./src/hooks/use-audio-fx-plugin/use-audio-fx-plugin.md) — `useAudioFxPlugin(pluginType)` instantiates a plugin via the provider's `PluginRegistry.create(pluginType)` and memoise…
- [use-audio-fx-plugin](./src/hooks/use-audio-fx-plugin/use-audio-fx-plugin.md) — `useAudioFxPlugin(pluginType)` instantiates a plugin via the provider's `PluginRegistry.create(pluginType)` and memoise…
- [use-bus](./src/hooks/use-bus/use-bus.md) — `useBus(name)` reads the named sidechain bus from the provider's `ProviderBusRegistry` and subscribes to its publish/cl…
- [use-bus](./src/hooks/use-bus/use-bus.md) — `useBus(name)` reads the named sidechain bus from the provider's `ProviderBusRegistry` and subscribes to its publish/cl…
- [use-chain-plugin](./src/hooks/use-chain-plugin/use-chain-plugin.md) — `useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin` currently occupying a specific slot in a track's `Plug…
- [use-chain-plugin](./src/hooks/use-chain-plugin/use-chain-plugin.md) — `useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin` currently occupying a specific slot in a track's `Plug…
- [use-chain-snapshot](./src/hooks/use-chain-snapshot/use-chain-snapshot.md) — `useChainSnapshot(chain)` exposes chain-level snapshot operations. `get` reads the in-memory `ChainSnapshot`, `restore`…
- [use-chain-snapshot](./src/hooks/use-chain-snapshot/use-chain-snapshot.md) — `useChainSnapshot(chain)` exposes chain-level snapshot operations. `get` reads the in-memory `ChainSnapshot`, `restore`…
- [use-engine-state](./src/hooks/use-engine-state/use-engine-state.md) — `useEngineState()` reports the `<AudioProvider>`'s async-init lifecycle phase as one of `'pending' | 'ready' | 'error'`…
- [use-engine-state](./src/hooks/use-engine-state/use-engine-state.md) — `useEngineState()` reports the `<AudioProvider>`'s async-init lifecycle phase as one of `'pending' | 'ready' | 'error'`…
- [use-midi-controller](./src/hooks/use-midi-controller/use-midi-controller.md) — `useMidiController(controllerId)` is the React 19 binding for a single `MidiControllerPlugin` from `@suaveplan/audio-co…
- [use-midi-controller](./src/hooks/use-midi-controller/use-midi-controller.md) — `useMidiController(controllerId)` is the React 19 binding for a single `MidiControllerPlugin` from `@suaveplan/audio-co…
- [use-parameter](./src/hooks/use-parameter/use-parameter.md) — `useParameter(plugin, paramId)` is the canonical read/write hook for a single declared parameter on any `AudioFxPlugin`…
- [use-parameter](./src/hooks/use-parameter/use-parameter.md) — `useParameter(plugin, paramId)` is the canonical read/write hook for a single declared parameter on any `AudioFxPlugin`…
- [use-playlist](./src/hooks/use-playlist/use-playlist.md) — `usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries and exposes the standard playlist verbs (`next`,…
- [use-playlist](./src/hooks/use-playlist/use-playlist.md) — `usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries and exposes the standard playlist verbs (`next`,…
- [use-plugin-chain](./src/hooks/use-plugin-chain/use-plugin-chain.md) — `usePluginChain(trackId)` returns a handle for the `PluginChain` associated with a track. The provider lazily creates t…
- [use-plugin-chain](./src/hooks/use-plugin-chain/use-plugin-chain.md) — `usePluginChain(trackId)` returns a handle for the `PluginChain` associated with a track. The provider lazily creates t…
- [use-preset](./src/hooks/use-preset/use-preset.md) — `usePreset(plugin)` exposes plugin-level preset operations as React-friendly callbacks: `get` reads the current preset,…
- [use-preset](./src/hooks/use-preset/use-preset.md) — `usePreset(plugin)` exposes plugin-level preset operations as React-friendly callbacks: `get` reads the current preset,…
- [use-timeline](./src/hooks/use-timeline/use-timeline.md) — `useTimeline()` returns the provider's single `TimelineEngine` instance, the canonical owner of timeline state — tracks…
- [use-timeline](./src/hooks/use-timeline/use-timeline.md) — `useTimeline()` returns the provider's single `TimelineEngine` instance, the canonical owner of timeline state — tracks…
- [use-track](./src/hooks/use-track/use-track.md) — `useTrack(trackId)` subscribes to a single track on the `TimelineEngine` by id and re-renders only when that specific t…
- [use-track](./src/hooks/use-track/use-track.md) — `useTrack(trackId)` subscribes to a single track on the `TimelineEngine` by id and re-renders only when that specific t…
- [use-transport](./src/hooks/use-transport/use-transport.md) — `useTransport()` wraps the provider's `Transport` with a small, React-friendly façade: `{ transport, play, pause, stop,…
- [use-transport](./src/hooks/use-transport/use-transport.md) — `useTransport()` wraps the provider's `Transport` with a small, React-friendly façade: `{ transport, play, pause, stop,…
- [use-transport-time](./src/hooks/use-transport-time/use-transport-time.md) — `useTransportTime()` returns the transport's current time in seconds, sampled at the provider's configured `sampleRateH…
- [use-transport-time](./src/hooks/use-transport-time/use-transport-time.md) — `useTransportTime()` returns the transport's current time in seconds, sampled at the provider's configured `sampleRateH…
- [use-provider-state](./src/hooks/use-provider-state.md) — Internal helpers shared across every public hook. They centralise the two structured-error throws every engine-dependen…
- [audio-context](./src/provider/audio-context.md) — Declares the React context object (`AudioReactContext`) that `<AudioProvider>` populates and that every hook reads from…
- [audio-provider](./src/provider/audio-provider.md) — `<AudioProvider>` is the single React component that owns the audio engine bundle for a React subtree. It constructs ex…
- [chain-map](./src/provider/chain-map.md) — `ChainMap` is the provider-scoped lazy registry of `PluginChain` instances, keyed by track id. The hook `usePluginChain…
- [provider-bus-registry](./src/provider/provider-bus-registry.md) — `ProviderBusRegistry` is a thin wrapper around `DefaultBusRegistry` from `@suaveplan/audio-fx-plugin-base` that adds a…
- [provider-options](./src/provider/provider-options.md) — Defines the `AudioProviderOptions` shape consumed by `<AudioProvider>`, the Zod schema that validates it, and the `pars…
- [audio-store](./src/stores/audio-store/audio-store.md) — `useAudioStore` is a slim provider-scoped Zustand store reserved for application-level audio UI overrides (display name…
- [audio-store](./src/stores/audio-store/audio-store.md) — `useAudioStore` is a slim provider-scoped Zustand store reserved for application-level audio UI overrides (display name…
- [ui-store](./src/stores/ui-store/ui-store.md) — `useUiStore` is the cross-component UI state store for the React audio workspace. It owns presentation-only state — whi…
- [ui-store](./src/stores/ui-store/ui-store.md) — `useUiStore` is the cross-component UI state store for the React audio workspace. It owns presentation-only state — whi…
- [test-utils](./src/test-utils.md) — Test-only helpers for the `@suaveplan/react-audio` test suite. The module exports three utilities: `makeFakeAudioContex…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/react-audio
```
Peer deps: `react@^19`, `react-dom@^19`.
## Quick start
```tsx
import { AudioProvider, useTransport } from "@suaveplan/react-audio";
function PlayButton() {
const { play, stop, transport } = useTransport();
return (
<button type="button" onClick={() => (transport.state === "playing" ? stop() : play())}>
{transport.state === "playing" ? "Stop" : "Play"}
</button>
);
}
export default function App() {
return (
<AudioProvider options={{ adapter: { sampleRate: 48000 } }}>
<PlayButton />
</AudioProvider>
);
}
```
## What ships in Phase 1
### Provider
- `<AudioProvider>` — owns one `BrowserTimelineAdapter`, one
`TimelineEngine`, one `Transport`, one `ProviderBusRegistry`, one
`ChainMap`, and one `PluginRegistry`. Strict-mode safe; disposes
cleanly on unmount.
### 13 hooks
| Hook | Returns |
|------|---------|
| `useEngineState()` | `'pending' \| 'ready' \| 'error'` |
| `useAudioEngine()` | full `ProviderState` bundle |
| `useTimeline()` | `TimelineEngine` |
| `useTransport()` | `{ transport, play, pause, stop, seek, setTempo }` |
| `useTransportTime()` | current seconds, sampled at frame rate |
| `useTrack(trackId)` | `{ track, update, remove }` |
| `usePluginChain(trackId)` | chain composition + events |
| `useChainPlugin(trackId, slot)` | plugin at slot |
| `useAudioFxPlugin(pluginType)` | new plugin from the registry |
| `useParameter(plugin, paramId)` | value + set + curve + scheduleAt |
| `usePreset(plugin)` | plugin-level preset get/apply/save/load |
| `useChainSnapshot(chain)` | chain-level snapshot get/restore/save/load |
| `useBus(name)` | sidechain bus by name |
### Stores
- `useAudioStore` — UI override store (no engine state mirrored).
- `useUiStore` — panel/theme/visualization/selectedTrack/dragState.
### Accessibility helpers
- `PARAMETER_KEY_BINDINGS` + `matchKeyBinding(event)` — central
keymap consumed by Phase-2 components.
- `formatAriaValueText(value, unit, min, max, options?)` — unit-aware
`aria-valuetext` formatter.
### Error codes
All errors are `ReactAudioError extends SuaveplanError`. Codes:
- `REACT_AUDIO.NO_PROVIDER`
- `REACT_AUDIO.PROVIDER_PENDING`
- `REACT_AUDIO.UNKNOWN_PARAMETER`
- `REACT_AUDIO.PRESET_STORE_FAILED`
- `REACT_AUDIO.INVALID_OPTIONS`
## Subpath exports
```ts
import { AudioProvider } from "@suaveplan/react-audio/provider";
import { useTransport } from "@suaveplan/react-audio/hooks/use-transport";
import { useAudioStore } from "@suaveplan/react-audio/stores";
import { formatAriaValueText } from "@suaveplan/react-audio/a11y";
import { ReactAudioError } from "@suaveplan/react-audio/error-codes";
```
`./components` is reserved for Phase 2 and is **not** declared in
Phase 1.
## Coming next
- **Phase 2 (`enterprise-react-audio-components`)** — Storybook-covered
components: `<TransportControls>`, `<TrackList>`, `<PluginChainView>`,
`<PluginSlot>`, `<MasterMeter>`, `<Mixer>`, `<PresetManager>`, …
- **Phase 3 (`enterprise-react-audio-midi`)** — `useMidiController`
hook + `<MidiControllerSetup>` learn-mode panel.
## License
MIT

View File

@@ -0,0 +1,86 @@
# `a11y/aria-value-text`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure `aria-valuetext` formatter consumed by Phase-2 parameter
controls (knobs, sliders, scrubbers). Returns a screen-reader
friendly string for any numeric parameter value, picking sensible
fractional digits from the parameter's declared range and applying
unit-specific conventions (signed dB, kHz autoconversion above 1000
Hz, percent suffix, ratio formatting, …).
The module has zero React or DOM imports so it is safe to use in
universal contexts (tests, SSR, Storybook docs, isolation
tools).
## Features
- Auto-derived fractional digits from value magnitude and parameter
range (overridable via `options.fractionDigits`).
- Signed dB with optional `+` prefix on positive values (default on).
- Hz auto-promotion to kHz above 1000 Hz.
- Built-in unit recognisers: `dB`, `Hz`, `kHz`, `ms`, `s`, `%`,
`ratio`, `cents`, `semitones`, `samples`, `bpm`; arbitrary strings
fall through with `${value.toFixed(digits)} ${unit}`.
- Handles `Infinity` and `NaN` by returning `"out of range"`.
## Usage: Basic
```ts
import { formatAriaValueText } from "@suaveplan/react-audio/a11y";
formatAriaValueText(3.5, "dB", -12, 12); // "+3.50 dB"
formatAriaValueText(880, "Hz", 20, 20000); // "880 Hz"
formatAriaValueText(2400, "Hz", 20, 20000); // "2.40 kHz"
formatAriaValueText(50, "%", 0, 100); // "50.0 %"
```
## Usage: Advanced
```tsx
import { formatAriaValueText } from "@suaveplan/react-audio/a11y";
function ParameterReadout({
value,
unit,
min,
max,
}: {
value: number;
unit: string;
min: number;
max: number;
}) {
return (
<span aria-live="polite">
{formatAriaValueText(value, unit, min, max, {
fractionDigits: 1,
})}
</span>
);
}
```
## API Reference
- `formatAriaValueText(value, unit, min, max, options?): string`.
- `AriaValueUnit` — union of the recognised unit strings (plus the
catch-all `string`).
- `FormatAriaValueOptions = { fractionDigits?, signedDb? }`.
## Implementation Notes
The auto-derived precision picks 0 digits for ranges ≥ 1000, 1 digit
for ≥ 100, 2 digits for ≥ 1, and 3 digits otherwise. This matches
screen-reader-friendly conventions: nobody wants to hear "two
thousand four hundred point three two seven six Hertz". Hz values
auto-promote to kHz at the 1000 Hz threshold because that's the
boundary at which the kHz scale dominates audio production
conventions; the kHz output uses a fixed 2-digit precision so values
near the boundary read out consistently. `Infinity` and `NaN` short
out to `"out of range"` because screen readers handle that phrase
gracefully whereas spelling out `Infinity` derails the user.

View File

@@ -0,0 +1,121 @@
# `a11y/keyboard-bindings`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure-value table mapping keyboard keys to parameter-control actions
(`fine-decrement`, `coarse-increment`, `page-decrement`, `min`,
`max`, `enter-value`). Phase-2 components — knobs, sliders, the
parameter inspector — consume this table directly so the keymap is
declared in exactly one place and easy to audit for accessibility
compliance.
The module is React-free; it exports a frozen array, two factor
constants (`KEY_MODIFIER_FACTOR_SHIFT` = 10, `KEY_MODIFIER_FACTOR_ALT`
= 0.1), and a helper `matchKeyBinding(event)` that resolves a
`KeyboardEvent`-shaped value into `{ binding, compositeFactor }` or
`undefined` for a no-match.
## Features
- `PARAMETER_KEY_BINDINGS` — frozen `ReadonlyArray<KeyBinding>`.
- `KEY_MODIFIER_FACTOR_SHIFT`, `KEY_MODIFIER_FACTOR_ALT` — composable
multipliers.
- `matchKeyBinding(event)` — pure function, no DOM imports.
- Standard a11y keymap (arrows, page-up/down, home, end, enter).
## Usage: Basic
```ts
import { matchKeyBinding } from "@suaveplan/react-audio/a11y";
function onKey(event: KeyboardEvent) {
const match = matchKeyBinding(event);
if (match === undefined) return;
console.log(match.binding.action, match.compositeFactor);
}
```
## Usage: Advanced
```tsx
import {
matchKeyBinding,
PARAMETER_KEY_BINDINGS,
} from "@suaveplan/react-audio/a11y";
function Knob({
value,
min,
max,
step,
onChange,
}: {
value: number;
min: number;
max: number;
step: number;
onChange: (next: number) => void;
}) {
return (
<div
role="slider"
aria-valuemin={min}
aria-valuemax={max}
aria-valuenow={value}
tabIndex={0}
onKeyDown={(event) => {
const match = matchKeyBinding(event);
if (match === undefined) return;
event.preventDefault();
const delta = step * match.compositeFactor;
switch (match.binding.action) {
case "fine-decrement":
case "coarse-decrement":
case "page-decrement":
onChange(Math.max(min, value - delta));
return;
case "fine-increment":
case "coarse-increment":
case "page-increment":
onChange(Math.min(max, value + delta));
return;
case "min":
onChange(min);
return;
case "max":
onChange(max);
return;
default:
return;
}
}}
>
{value}
</div>
);
}
```
## API Reference
- `KeyBinding` — `{ key, action, factor, modifiers? }`.
- `PARAMETER_KEY_BINDINGS: ReadonlyArray<KeyBinding>`.
- `KEY_MODIFIER_FACTOR_SHIFT: 10`.
- `KEY_MODIFIER_FACTOR_ALT: 0.1`.
- `matchKeyBinding(event): { binding, compositeFactor } | undefined`.
## Implementation Notes
The keymap conventions (`←` fine, `↓` coarse, `Shift` ×10, `Alt`
×0.1) match the design.md accessibility section so the upstream
behaviour carries forward. The `matchKeyBinding` helper takes an
event-shaped object rather than a `KeyboardEvent` so it is callable
from test harnesses with plain object literals — and zero DOM types
are imported into this file. The factor multiplication
order is `binding.factor * shift * alt`; with both modifiers pressed
the net effect is `factor × 10 × 0.1 = factor`, which is the
intended "shift cancels alt" semantics.

View File

@@ -0,0 +1,95 @@
# `<AudioPlayer>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<AudioPlayer>` is the generic media-player surface for a live
`AudioPlayerPlugin` instance from
`@suaveplan/audio-fx-plugin-audio-player`. It renders the full
playback control surface — play / pause / stop / seek / volume / loop
/ playback rate — bound directly to the plugin's dedicated mutators
(`play`, `pause`, `stop`, `seek`, `setVolume`, `setLoop`,
`setPlaybackRate`). State is sampled by polling `plugin.getState()` at
the configured tick rate so the seek bar stays in sync with the
audio-thread position.
The component is composed entirely against the plugin: there is no
React-side cache of playback state. Loading is handled via the
optional `src` prop which auto-calls `plugin.load(src)` on mount.
## Features
- Full control surface bound to plugin mutators.
- Optional `src` triggers async `plugin.load(src)` on mount.
- Surfaces plugin / load errors via an inline `role="alert"` element.
- `showLoop` / `showPlaybackRate` props toggle the optional controls.
- Live seek bar reflects `plugin.getState().currentTimeSec`.
- Polls at `sampleRateHz` (default 30, clamped `>= 1`).
- Unmounting cancels the in-flight `load()` so late resolutions cannot
setState on a torn-down component.
## Usage: Basic Example
```tsx
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider, AudioPlayer } from "@suaveplan/react-audio";
const plugin = new AudioPlayerPlugin({});
export function App() {
return (
<AudioProvider>
<AudioPlayer plugin={plugin} />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider, AudioPlayer } from "@suaveplan/react-audio";
const plugin = new AudioPlayerPlugin({});
export function Streamer() {
return (
<AudioProvider>
<AudioPlayer
plugin={plugin}
src="https://cdn/track.wav"
showPlaybackRate={false}
sampleRateHz={60}
className="streamer"
/>
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ------------------- | -------------------------- | ------- | ------------------------------------------- |
| `plugin` | `AudioPlayerPlugin` | — | Required. |
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
| `src` | `string \| undef` | — | Auto-load this URL on mount. |
| `sampleRateHz` | `number \| undef` | `30` | State polling rate. |
| `showLoop` | `boolean \| undef` | `true` | When `false`, hides the loop toggle. |
| `showPlaybackRate` | `boolean \| undef` | `true` | When `false`, hides the rate input. |
## Implementation Notes
Errors thrown by the underlying plugin are caught at every mutation
site and surfaced via a single `role="alert"` element so callers can
style or speak the error consistently. The cancel-on-unmount guard
uses a local `cancelled` flag captured in the effect closure rather
than an `AbortController` because `plugin.load` does not expose its
inner fetch signal; this still prevents the standard "setState on
unmounted component" warning. State polling runs in a separate
effect from the load effect so changing `src` does not restart the
poll.

View File

@@ -0,0 +1,90 @@
# `<AudioVisualizer>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<AudioVisualizer>` is a canvas-based visualiser for a published bus.
It subscribes through the foundation `useBus` hook (default `"master"`)
and renders one of three modes — waveform, spectrum, or level — into a
`<canvas>` at the configured tick rate. The component is read-only and
allocates exactly one scratch `Float32Array` per mount so the
per-frame path is allocation-free in steady state.
The visualiser is intentionally agnostic about the source: anything
that publishes to the named bus shows up. When no publisher exists
the canvas paints the background only.
## Features
- Three modes: `"waveform"`, `"spectrum"`, `"level"`.
- Configurable width, height, foreground colour, and background.
- `sampleRateHz` controls the tick rate (clamped to `>= 1`).
- `busName` selects which bus to render (default `"master"`).
- `role="img"` + descriptive `aria-label` for screen readers.
- Drawing primitives (`drawWaveform` / `drawSpectrum` / `drawLevel`)
are individually exported as `@internal` for test coverage.
## Usage: Basic Example
```tsx
import { AudioProvider, AudioVisualizer } from "@suaveplan/react-audio";
export function App() {
return (
<AudioProvider>
<AudioVisualizer />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { AudioProvider, AudioVisualizer } from "@suaveplan/react-audio";
export function MultiVisualizer() {
return (
<AudioProvider>
<AudioVisualizer mode="waveform" />
<AudioVisualizer mode="spectrum" busName="auxA" />
<AudioVisualizer
mode="level"
color="#0f0"
backgroundColor="#000"
width={128}
height={32}
sampleRateHz={120}
/>
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ----------------- | ------------------- | ----------- | ---------------------------------------------- |
| `className` | `string \| undef` | — | Forwarded to the `<canvas>`. |
| `width` | `number \| undef` | `320` | Canvas pixel width. |
| `height` | `number \| undef` | `80` | Canvas pixel height. |
| `mode` | `VisualizerMode` | `"waveform"`| One of the three render modes. |
| `busName` | `string \| undef` | `"master"` | Which bus to read. |
| `sampleRateHz` | `number \| undef` | `60` | Render tick rate. |
| `color` | `string \| undef` | `"#3aa"` | Foreground colour. |
| `backgroundColor` | `string \| undef` | `"#111"` | Canvas background colour. |
## Implementation Notes
The effect resolves the canvas context once on mount via
`canvas.getContext("2d")` and gracefully short-circuits when the
context cannot be acquired (test environments without canvas support,
or production browsers under aggressive accessibility settings). When
the bus has no publisher the visualiser still ticks the canvas to
keep the background paint fresh. Spectrum mode uses a naive average
of `Math.abs` over a downsampled window rather than a real FFT — this
is deliberate so the component has zero non-React runtime dependencies
and remains universally portable.

View File

@@ -0,0 +1,77 @@
# `<BasicPlayer>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<BasicPlayer>` is the minimal media-player surface for an
`AudioPlayerPlugin`. It is implemented as a thin composition over
`<AudioPlayer>` with the loop toggle and playback-rate input disabled,
leaving only the essential controls: play / pause / stop / seek /
volume. Use this when you want a clean, distraction-free player and
don't need the advanced playback adjustments the full player exposes.
Because it is a strict composition, every behavioural guarantee of
`<AudioPlayer>` — cancel-on-unmount, error surfacing, state polling —
applies here unchanged.
## Features
- Forwards every relevant prop to `<AudioPlayer>`.
- Hides the loop toggle and playback rate input.
- Adds the `.rax-basic-player` class (or merges it with the supplied
`className`) so theming targets are stable across the two surfaces.
## Usage: Basic Example
```tsx
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider, BasicPlayer } from "@suaveplan/react-audio";
const plugin = new AudioPlayerPlugin({});
export function Card() {
return (
<AudioProvider>
<BasicPlayer plugin={plugin} src="https://cdn/short.wav" />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider, BasicPlayer } from "@suaveplan/react-audio";
const plugin = new AudioPlayerPlugin({});
export function MutedCard() {
return (
<AudioProvider>
<BasicPlayer plugin={plugin} className="muted-row" />
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ----------- | -------------------------- | ------- | ------------------------------------------- |
| `plugin` | `AudioPlayerPlugin` | — | Required. |
| `src` | `string \| undef` | — | Auto-load this URL on mount. |
| `className` | `string \| undef` | — | Forwarded; merged with `rax-basic-player`. |
## Implementation Notes
The `showLoop` / `showPlaybackRate` props on the underlying
`<AudioPlayer>` are fixed at `false`, which makes the BasicPlayer's
prop signature smaller than the full player's. There is no separate
state, no separate effect — every behavioural guarantee of the full
player applies. Pass-through is deliberately verbose (one prop at a
time) so future additions to `<AudioPlayer>` do not silently widen
`<BasicPlayer>`'s surface; new props must be added explicitly.

View File

@@ -0,0 +1,87 @@
# `<GLaDOSControls>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<GLaDOSControls>` is the composed UI for the `GLaDOSPlugin` voice
transform from `@suaveplan/audio-fx-plugin-glados`. The plugin
defines a six-stage voice pipeline (pitch shift, formant shift,
robotic intensity, distortion, low-pass cutoff, reverb amount) plus
six built-in presets covering the GLaDOS, Wheatley, Cave Johnson, and
generic robotic voices. This component surfaces all six stages as
range inputs and the preset list as a `<select>`.
The component is intentionally a thin shell over the plugin: every
edit calls the plugin's named setter (`setPitchShift`,
`setFormantShift`, etc.), every preset switch calls
`applyGladosPreset`. There is no React-side cache that could drift
from the plugin's authoritative config.
## Features
- Six stage sliders with plugin-defined ranges.
- Preset `<select>` covering all six built-ins.
- Errors from stage setters (out-of-range values) surfaced via
`role="alert"`; cleared when the next successful edit or preset
change lands.
- Live readout next to every slider.
- `data-stage` attributes on every stage for theming hooks.
## Usage: Basic Example
```tsx
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
import { AudioProvider, GLaDOSControls } from "@suaveplan/react-audio";
const plugin = new GLaDOSPlugin();
export function App() {
return (
<AudioProvider>
<GLaDOSControls plugin={plugin} />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { GLaDOSPlugin } from "@suaveplan/audio-fx-plugin-glados";
import { AudioProvider, GLaDOSControls } from "@suaveplan/react-audio";
const plugin = new GLaDOSPlugin({
preset: "cave-johnson",
config: { reverbAmount: 0.8 },
});
export function CaveJohnsonVoice() {
return (
<AudioProvider>
<GLaDOSControls plugin={plugin} className="cj-voice" />
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ----------- | --------------- | ------- | ------------------------------------------- |
| `plugin` | `GLaDOSPlugin` | — | Required. |
| `className` | `string \| undef`| — | Forwarded to the outer `<section>`. |
## Implementation Notes
The stage table is declared once at module scope so React's diff
sees stable identities across renders. Each stage carries its own
`min` / `max` / `step` and the plugin setter to invoke; on every
change we parse the input value, dispatch to the setter inside a
try/catch, and re-read the plugin's config snapshot. Errors are
captured as plain strings to keep the error surface portable across
runtimes (no SuaveplanError dependency at this layer). Preset
switches always clear the prior error because the new preset is a
known-good config.

View File

@@ -0,0 +1,73 @@
# `parameter-keyboard`
Internal helper consumed by `<PluginParameterKnob>` and
`<PluginParameterSlider>` to translate a `KeyboardEvent` into a clamped
next parameter value. The helper is a pure function — no React, no DOM
— so it is fully unit-testable without rendering a tree.
## Purpose
The `<PluginSlot>` accessibility requirement mandates an identical
keyboard contract on every parameter control: arrow keys for fine /
coarse adjustments, `PageUp`/`PageDown` for ×10 coarse, `Home`/`End` for
range endpoints, `Enter` for type-edit, plus `Shift` ×10 and `Alt` ÷10
modifiers. Reimplementing this contract in two component files would
double the surface area subject to drift. The shared helper guarantees
both primitives interpret the keymap identically.
## Features
- 1 % step base ×1, ×10, ×100 binding factors (from `PARAMETER_KEY_BINDINGS`).
- `Shift` multiplier ×10, `Alt` multiplier ÷10.
- Clamping to `[min, max]` on every increment, decrement, and `Home`/`End` jump.
- Returns the matching `KeyBinding` so the caller can detect the special
`Enter` action and open a type-edit affordance.
## Usage: Basic
```ts
import { resolveParameterKeyboardChange } from "./parameter-keyboard.js";
const result = resolveParameterKeyboardChange(
{ key: "ArrowRight", shiftKey: false, altKey: false },
0,
-24,
24
);
// result.handled === true; result.next === 0.48 (1 % of 48)
```
## Usage: Advanced
```ts
// Compose with the React keyboard handler in <PluginParameterKnob>:
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
const out = resolveParameterKeyboardChange(event, value, min, max);
if (!out.handled) return;
event.preventDefault();
if (out.binding?.action === "enter-value") {
setEditing(true);
return;
}
onChange(out.next);
}
```
## API Reference
- `resolveParameterKeyboardChange(event, current, min, max) → ParameterKeyboardResult`
- `event` — `{ key, shiftKey, altKey }`; matches the shape of `KeyboardEvent`.
- `current` — current numeric value.
- `min` / `max` — declared parameter range.
- `ParameterKeyboardResult` — `{ handled, next, binding }`.
## Implementation Notes
- The function iterates `PARAMETER_KEY_BINDINGS` in declaration order.
Adding new bindings is purely declarative — the helper picks them up
for free.
- `Home`/`End` deliberately ignore the modifier multipliers; they are
endpoint jumps, not stepped moves.
- The function never throws; an unrecognised key returns
`{ handled: false, next: current, binding: undefined }` so the caller
can fall through to other listeners.

View File

@@ -0,0 +1,64 @@
# `parameter-unit`
Internal helper that converts the controlled `ParameterUnit` enum
exposed by `@suaveplan/audio-fx-plugin-base` (`"db"`, `"hz"`, `"ms"`,
`"percent"`, …) into the display string the `formatAriaValueText` a11y
helper expects (`"dB"`, `"Hz"`, …). Centralising the mapping in a
single helper guarantees every parameter control under `<PluginSlot>`
formats `aria-valuetext` consistently regardless of which primitive
(`<PluginParameterKnob>`, `<PluginParameterSlider>`) renders it.
## Purpose
The audio-plugin contract uses a small controlled lowercase enum so
plugin authors cannot drift into ad-hoc spellings. The a11y formatter
prefers the human-display capitalisation (dB, Hz, kHz, BPM). The
adapter function bridges the two without forcing either side to bend.
## Features
- Total function over the `ParameterUnit` enum (no missing branches).
- Returns `""` for `"enum"` parameters since their values are not
numeric ranges.
- Safe on `undefined` — returns `""` so the formatter falls through to
its default branch and renders the bare numeric value.
## Usage: Basic
```ts
import { mapParameterUnitForAria } from "./parameter-unit.js";
import { formatAriaValueText } from "../../a11y/aria-value-text/aria-value-text.js";
const aria = formatAriaValueText(
plugin.getParameter("frequency"),
mapParameterUnitForAria(parameter.unit),
parameter.min,
parameter.max
);
// "880 Hz" for { unit: 'hz', value: 880 }
```
## Usage: Advanced
```ts
// Inside <PluginParameterKnob>, render aria-valuetext from the mapped unit:
const ariaUnit = mapParameterUnitForAria(parameter.unit);
const valuetext = formatAriaValueText(value, ariaUnit, min, max, {
signedDb: true,
});
return <div role="slider" aria-valuetext={valuetext} ... />;
```
## API Reference
- `mapParameterUnitForAria(unit: ParameterUnit | undefined): AriaValueUnit`
— pure function; total over the enum; defaults to `""`.
## Implementation Notes
- The function intentionally returns a `string` (`AriaValueUnit` is a
string union widened by `| string`), so adding a new audio-side
`ParameterUnit` value will surface as a TypeScript exhaustiveness
warning on the `switch` in this file only — the formatter does not
need to change.
- No allocations — the function returns string literals.

View File

@@ -0,0 +1,88 @@
# `<MasterMeter>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MasterMeter>` is a RAF-sampled master-output level meter. It
subscribes to a named sidechain bus through the foundation `useBus`
hook (`"master"` by default) and continuously reads the bus's
block-aligned PCM into a scratch buffer at the configured interval,
deriving per-channel peak and RMS levels in dB and a slow-decaying
peak-hold envelope. When no publisher exists for the bus, every
channel shows silence (`-∞` dB) so consumers always see something.
The meter is intentionally side-effect-free with respect to the audio
graph: it only reads. Nothing inside `<MasterMeter>` ever writes to the
bus or mutates engine state, which keeps it safe to drop into any
layout that already has a publisher upstream.
## Features
- Subscribes to any named bus via `useBus(busName)`.
- Per-channel peak + RMS in dB, plus a peak-hold envelope.
- Configurable channel count, sample-tick rate, and hold time.
- Reads up to `Math.min(meterChannels, busChannels)` per tick so
publisher-wider and meter-wider mismatches do not blow up.
- `role="meter"` + `aria-label` for screen readers.
- `data-bus` and `data-channel` attributes for theming hooks.
## Usage: Basic Example
```tsx
import { AudioProvider, MasterMeter } from "@suaveplan/react-audio";
export function App() {
return (
<AudioProvider>
<MasterMeter />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { AudioProvider, MasterMeter } from "@suaveplan/react-audio";
export function AuxMeter() {
return (
<AudioProvider>
<MasterMeter
busName="auxA"
channels={1}
sampleRateHz={120}
peakHoldMs={2000}
className="aux-meter"
/>
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| -------------- | ------------------- | ----------- | ------------------------------------------------------ |
| `className` | `string \| undef` | — | Forwarded to the outer `<div>`. |
| `busName` | `string \| undef` | `"master"` | Name of the bus to subscribe to. |
| `channels` | `number \| undef` | `2` | Number of channel rows to render. |
| `sampleRateHz` | `number \| undef` | `60` | UI sample tick rate. Clamped to `>= 1`. |
| `peakHoldMs` | `number \| undef` | `1000` | Peak-hold decay time in milliseconds. |
## Implementation Notes
The component allocates one persistent scratch `Float32Array[]` per
mount sized to `channelCount × 128` so the per-tick read does not
allocate. Bus channel mismatches are handled by reading only the
narrower side: if the publisher is wider than the meter, only the
leading channels are read; if the meter is wider than the publisher,
the trailing channels keep their zero-fill from the scratch buffer's
last write (which corresponds to silence on next dB conversion). The
peak-hold envelope uses one mutable record per channel keeping the
current hold value and its expiry timestamp; the value is replaced
either when a louder peak arrives or when the hold window expires,
which gives the visually slow decay typical of professional meters.

View File

@@ -0,0 +1,100 @@
# `<MicrophoneControls>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MicrophoneControls>` is the composed UI for
`MicrophoneSimulationPlugin` from
`@suaveplan/audio-fx-plugin-microphone`. The plugin itself declares
its UI surface via `getUISections()`: a list of grouped controls
(frequency, resonance, distortion, noise, dynamics, gain, character,
wow/flutter) with their numeric ranges and step sizes. The component
mirrors that data-driven structure, rendering one `<fieldset>` per
section and one `<input type="range">` per control. Writes go through
`plugin.setMicrophoneConfig` with the minimal partial-update.
Because the UI structure is driven entirely by the plugin's metadata,
adding a new control on the plugin automatically shows up here — no
React code change required.
## Features
- Data-driven layout from `plugin.getUISections()`.
- One range input per declared control, with `min` / `max` / `step`
taken from the plugin's metadata.
- Live readout shown alongside every slider via `aria-live="polite"`.
- Non-numeric input ignored to keep the plugin config schema-valid.
- `aria-label="Microphone controls"` on the outer section and
per-control labels in the form `"<group> <control>"`.
## Usage: Basic Example
```tsx
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
import {
AudioProvider,
MicrophoneControls,
} from "@suaveplan/react-audio";
const plugin = new MicrophoneSimulationPlugin();
export function App() {
return (
<AudioProvider>
<MicrophoneControls plugin={plugin} />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { MicrophoneSimulationPlugin } from "@suaveplan/audio-fx-plugin-microphone";
import {
AudioProvider,
MicrophoneControls,
} from "@suaveplan/react-audio";
const plugin = new MicrophoneSimulationPlugin({
config: {
distortion: { drive: 0.4, mix: 0.5 },
wowFlutter: {
wowRateHz: 0.5,
wowDepth: 0.05,
flutterRateHz: 8,
flutterDepth: 0.02,
},
},
});
export function StudioMic() {
return (
<AudioProvider>
<MicrophoneControls plugin={plugin} className="studio-mic" />
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ----------- | ----------------------------- | ------- | ------------------------------------------- |
| `plugin` | `MicrophoneSimulationPlugin` | — | Required. |
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
## Implementation Notes
The component stores a single config snapshot in React state and
refreshes it on every successful write. Section / control ids are
exposed via `data-section` / per-control keys so consumers can target
any cell with CSS without inspecting the plugin's metadata. The
`updateField` helper constructs a minimal partial-update of the form
`{ [group]: { ...prev[group], [field]: newValue } }`, which keeps
unrelated group fields stable across the `setMicrophoneConfig` call;
the plugin's own merge logic then folds the partial into its live
config tree.

View File

@@ -0,0 +1,112 @@
# `components/midi-controller-setup`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<MidiControllerSetup>` is the Phase-3 learn-mode panel for the
Suaveplan React audio stack. It renders the connected controllers
from a `MidiControllerRegistry` (supplied either via the
`controllers` prop or via the surrounding
`MidiControllerRegistryContext`), pairs them with a target picker
built from the supplied `plugins`, and arranges the captured
`(channel, controller)` pair against the parameter chosen by the
user. Each established binding is materialised through
`bindParameter` from `@suaveplan/audio-controllers` and persisted via
the same `PresetStore` the `<PresetManager>` consumes, so the bindings
re-bind on next mount.
The component is intentionally lean: it does not own the controller
hardware nor the MIDI access — it composes the live
`MidiControllerPlugin` instance the application has already
constructed at startup. This keeps the lifecycle ownership in one
place and matches the rest of the Phase-2 component family.
## Features
- Controller / plugin / parameter picker rendered via accessible
`<select>` elements with `aria-label`s on each control.
- `Learn` button enters learn-mode through `useMidiController`'s
`learn(target)` method.
- Captured CC is bound through `bindParameter` with the optional
`smoothingMs` forwarded to the binding helper.
- Active bindings are rendered as an `<ul>` with `Unbind` buttons.
`Unbind` calls the unsubscribe function returned by `bindParameter`.
- Bindings are persisted under `presetKey` (default
`midi-controller-setup`) through the provider's `presetStore`.
- On mount, persisted bindings are rehydrated and re-bound.
- Live `state` indicator next to the Learn button mirrors the
controller FSM (`disconnected → connecting → connected → error`).
- Renders an `data-empty="true"` empty state when no controllers are
registered.
## Usage: Basic
```tsx
import {
MidiControllerRegistryContext,
MidiControllerSetup,
} from "@suaveplan/react-audio";
function MidiPanel({ controllers, plugins }) {
return (
<MidiControllerRegistryContext.Provider value={controllers}>
<MidiControllerSetup plugins={plugins} />
</MidiControllerRegistryContext.Provider>
);
}
```
## Usage: Advanced
```tsx
import { MidiControllerSetup } from "@suaveplan/react-audio";
function AdvancedMidi({ controllers, plugins }) {
return (
<MidiControllerSetup
plugins={plugins}
controllers={controllers}
smoothingMs={25}
presetKey="studio-bindings"
className="studio-midi"
/>
);
}
```
Persisted bindings are stored under the `"chain"` kind in the
`PresetStore` so any store implementation that already serves chain
snapshots transparently serves binding lists too. The payload shape
is `{ bindings: MidiBinding[] }`; consumers can inspect the same
key from a sibling tool without involving the React surface.
## API Reference
- `MidiControllerSetupProps.plugins` — `ReadonlyArray<AudioFxPlugin>`
whose `parameterIndex` entries become the parameter picker.
- `MidiControllerSetupProps.controllers` — optional explicit
`MidiControllerRegistry`; takes precedence over context.
- `MidiControllerSetupProps.smoothingMs` — forwarded to
`bindParameter`. Defaults to `0`.
- `MidiControllerSetupProps.presetKey` — preset-store key for the
persisted binding list. Defaults to `midi-controller-setup`.
- `MidiControllerSetupProps.className` — CSS class forwarded to the
outer `<section>`.
- `MidiBinding` — `{ controllerId, pluginId, paramId, source,
smoothingMs? }`. The persisted payload shape.
## Implementation Notes
The component composes `useMidiController(controllerId, registry)`
to source the live controller and its FSM state, then forwards the
captured CC into `bindParameter`. Because `bindParameter` returns an
`UnsubscribeFn`, the component stores those handles alongside the
binding metadata and tears them all down on unmount via a `useRef`
snapshot to avoid the classic "stale-effect-cleanup" pitfall where
the cleanup closure captures an out-of-date `active` list. Errors
from either learn capture or `bindParameter` are surfaced through a
non-blocking inline `role="alert"` paragraph rather than thrown so
the panel keeps rendering when one binding fails.

View File

@@ -0,0 +1,94 @@
# `<Mixer>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<Mixer>` is the per-channel strip view for a live `MixerPlugin`
instance from `@suaveplan/audio-fx-plugin-mixer`. It renders one row
per channel (gain + pan slider + live VU readout) plus an optional
master gain row. Channel writes are routed through the plugin's
`setChannelGain` / `setChannelPan` methods; the master row binds the
plugin's `"masterGain"` parameter via the foundation `useParameter`
hook so external automation surfaces (curves, presets, recall) keep
working transparently.
The Mixer composition exists to make the audio plugin the source of
truth — there is no React-side store of channel state. Every commit
samples the plugin via `getChannels()` so swapping the plugin (e.g.
via a `<PluginLoader>`) immediately updates the visible strips.
## Features
- One row per channel returned by `plugin.getChannels()`.
- Per-channel gain (range `0..4`) and pan (range `-1..1`) sliders.
- Master gain row bound to the plugin's `"masterGain"` parameter.
- Per-channel VU readout from `plugin.getVuLevels()`, refreshed at
`vuSampleRateHz` (default 30, clamped `>= 1`).
- `aria-label` on every input plus a `role="list"` strip container.
- `hideMaster` suppresses the master row for embedded usage.
## Usage: Basic Example
```tsx
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
import { AudioProvider, Mixer } from "@suaveplan/react-audio";
const plugin = new MixerPlugin({
channels: [{ id: "drums" }, { id: "bass" }, { id: "vox" }],
});
export function App() {
return (
<AudioProvider>
<Mixer plugin={plugin} />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { MixerPlugin } from "@suaveplan/audio-fx-plugin-mixer";
import { AudioProvider, Mixer } from "@suaveplan/react-audio";
const plugin = new MixerPlugin({
channels: [
{ id: "guitar", initialGain: 0.8, initialPan: -0.3 },
{ id: "bass", initialGain: 0.7 },
],
initialMasterGain: 1.2,
});
export function Workstation() {
return (
<AudioProvider>
<Mixer plugin={plugin} vuSampleRateHz={60} hideMaster />
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ----------------- | ------------------- | ------- | ------------------------------------------------ |
| `plugin` | `MixerPlugin` | — | Required. The live mixer plugin instance. |
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
| `hideMaster` | `boolean \| undef` | `false` | Hides the master row when `true`. |
| `vuSampleRateHz` | `number \| undef` | `30` | UI tick rate for VU + channel snapshots. |
## Implementation Notes
The component does not stash channel state in React: every interval
tick re-reads `plugin.getChannels()` and `plugin.getVuLevels()` into
the local snapshot used for rendering. Channel mutations go through
the plugin's mutators (`setChannelGain` / `setChannelPan`) and
immediately re-read so the next render reflects the change without
waiting for the interval. Master gain is treated as a normal plugin
parameter so curves and presets work transparently. The VU snapshot
sample rate is clamped to `>= 1 Hz` to defeat divide-by-zero on
`setInterval` and survives `vuSampleRateHz={0}` without crashing.

View File

@@ -0,0 +1,99 @@
# `<PluginChainView>`
Reorderable, accessible view of a `PluginChain`. Renders one
`<PluginSlot>` per chain entry, supports HTML5 drag-and-drop with a
full keyboard alternative, exposes an "Add FX" picker filtered against
the chain's effective channel count, and surfaces any rejection from
`chain.add()` / `chain.move()` as a non-blocking toast carrying the
underlying `AudioFxError`'s `code`.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PluginChainView>` is the workhorse list component for the audio
workspace. It owns no plugin instances directly — every plugin is
created by the provider's `PluginRegistry` and added to the chain via
the Phase-1 `usePluginChain` hook. The view re-renders on every
`plugin-added` / `plugin-removed` / `plugin-moved` / `plugin-replaced`
/ `bypass-changed` event because `usePluginChain` subscribes to all
five.
## Features
- HTML5 DnD: `draggable` slots; `onDragStart` carries the source
index; `onDrop` reads the index and dispatches `chain.move()`.
- Accepts external drops carrying a `application/x-audio-fx-type`
payload (e.g. from a separate plugin browser) and inserts at the
target index.
- Keyboard reorder: `Space` picks up the focused slot, `↑`/`↓` move
the hover marker, `Enter` drops, `Escape` cancels.
- IO-aware "Add FX" picker — `PluginRegistry.list()` filtered against
the chain's current effective output channel count via the plugin's
declared `io.inputs[0].count`.
- Toast surfaces with `role="status"`, `data-code` attribute, and a
dismiss control. Toast clears when the user clicks Dismiss.
- `data-track-id` on the wrapper, `data-picked` and
`data-hover-target` on slots, `aria-grabbed` on the active slot.
- CSS-class API only (`rax-chain`, `rax-chain__header`,
`rax-chain__list`, `rax-chain__item`, `rax-chain__picker`,
`rax-chain__toast`).
## Usage: Basic
```tsx
import { PluginChainView } from "@suaveplan/react-audio/components/plugin-chain-view";
function TrackPanel({ trackId }) {
return <PluginChainView trackId={trackId} />;
}
```
## Usage: Advanced
```tsx
// Read-only chain preview with the picker hidden, themed wrapper.
function ChainPreview({ trackId }) {
return (
<PluginChainView
trackId={trackId}
hideAddPicker
className="ring-1 ring-zinc-700 rounded-md p-2"
/>
);
}
```
## API Reference
### Props
- `trackId: string` — passed to `usePluginChain` to resolve the chain.
- `className?: string` — CSS class appended to the wrapper.
- `hideAddPicker?: boolean` — when `true`, the "Add FX" picker button
and menu are not rendered.
### Events surfaced
- A rejection from `chain.add()` or `chain.move()` produces a toast
with the `AudioFxError.code` (e.g.
`AUDIO_FX.CHAIN_IO_INCOMPATIBLE`).
- Successful insertion / reorder / removal updates the list via the
Phase-1 chain-event subscription.
## Implementation Notes
- The picker probes each registered type by calling
`registry.create()` once, inspecting `io.inputs[0].count`, then
disposing the probe instance. The cost is amortised across the
picker open/close lifecycle.
- Keyboard reorder uses local state for the picked-up index and a
separate "hover" index so the user can see the prospective drop
target before committing.
- The HTML5 DnD handler clears its drag data on every render so a
cancelled drag does not leak into the next interaction.
- The toast is a single most-recent error — multiple errors collapse
into one. This keeps the surface deterministic without depending on
a toast queue library.

View File

@@ -0,0 +1,90 @@
# `<PluginLoader>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PluginLoader>` is the dynamic plugin-registration UI over the
provider's `PluginRegistry`. It lists every currently-registered
`pluginType`, lets the user pick one and click "Create" to
instantiate it via `registry.create(...)`, and exposes a small
registration form for adding new factories at runtime. It is the
canonical surface for development panels and demo applications that
want to swap plugins in and out without restarting the provider.
In production, callers typically pre-register their factories via the
provider's `options.plugins` array — `<PluginLoader>` then acts as a
purely informational dashboard showing what's available, with the
"Create" button delegating to the registry's standard factory
contract.
## Features
- Lists every registered `pluginType` from `PluginRegistry.list()`.
- `<select>` picker bound to a local "selected" state.
- "Create" button invokes `registry.create(...)`, catches and
surfaces the registry's `AUDIO_FX.PLUGIN_TYPE_NOT_REGISTERED`
error via a `role="alert"` element.
- Optional `onCreated` callback receives the constructed plugin so
parent components can wire it into a chain.
- Inline registration form for adding placeholder factories at
runtime (mainly for dev surfaces).
- Empty-state handling when no plugins are registered.
## Usage: Basic Example
```tsx
import { AudioProvider, PluginLoader } from "@suaveplan/react-audio";
export function DevPanel() {
return (
<AudioProvider>
<PluginLoader />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import {
AudioProvider,
PluginLoader,
usePluginChain,
} from "@suaveplan/react-audio";
export function WiredLoader() {
const chain = usePluginChain("master");
return (
<AudioProvider>
<PluginLoader
onCreated={(plugin) => chain.add(plugin)}
className="dev-loader"
/>
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ------------ | ------------------------------------------------- | ------- | ------------------------------------------- |
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
| `onCreated` | `(plugin, pluginType) => void` | — | Called when "Create" succeeds. |
## Implementation Notes
The registry is the source of truth: every render re-reads
`registry.list()` so newly-registered factories show up immediately
without needing a snapshot store. The component keeps a local
`version` counter that is incremented after every registration so
React schedules a re-render — `list()` itself is a method, not a
reactive primitive. The registration form intentionally writes a
*placeholder* factory that throws when invoked; this prevents the
loader from accidentally being used in production to register
half-real factories. Consumers wanting real registration should call
`registry.register(...)` directly from a parent component.

View File

@@ -0,0 +1,107 @@
# `<PluginParameterKnob>`
Rotary-knob primitive for a single `AudioFxPlugin` parameter. The knob
implements the full keyboard contract mandated by the
`<PluginSlot>` accessibility requirement, exposes `role="slider"` with
ARIA range metadata derived from the parameter's `min`/`max`/`unit`
declaration, and supports an `Enter`-to-edit type-in affordance.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `<PluginParameterKnob>` is the rotary half of the generic
parameter-control surface. Specialised plugin UIs (an EQ panel, a
compressor panel) often wrap the knob with their own visual chrome
while reusing the keyboard, ARIA, and value-management logic. The
knob is therefore deliberately presentational: the entire keyboard
keymap, value clamping, and typed-edit flow live here, while the
caller supplies the plugin and parameter declaration.
The knob subscribes to the parameter via the Phase-1
`useParameter` hook, so every change is reflected at the provider's
declared `sampleRateHz` — by default 60 Hz, matching one tick per
animation frame.
## Features
- `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`,
`aria-valuetext` (unit-aware via `formatAriaValueText`).
- `←`/`→` fine step (1 % of range), `↑`/`↓` coarse step (10 %),
`PageUp`/`PageDown` ×100 page step, `Home`/`End` jump to range
endpoints.
- `Shift` ×10, `Alt` ÷10 modifier multipliers.
- `Enter` opens a numeric type-edit input; `Enter` commits, `Escape`
reverts, blur commits.
- `data-parameter-id` attribute on the wrapper so plugin authors can
target specific knobs from a stylesheet.
- Pure CSS-class API (`rax-knob`, `rax-knob__dial`, `rax-knob__readout`,
`rax-knob__input`) — no styling assumptions baked in.
## Usage: Basic
```tsx
import { PluginParameterKnob } from "@suaveplan/react-audio/components/plugin-parameter-knob";
function GainKnob({ plugin }) {
const param = plugin.parameterIndex.get("gain")!;
return <PluginParameterKnob plugin={plugin} parameter={param} />;
}
```
## Usage: Advanced
```tsx
// Iterate every parameter on a plugin, rendering each with a custom label
// override and theming via the className API.
function PluginPanel({ plugin }) {
return (
<div className="grid grid-cols-4 gap-4">
{plugin.parameters.map((p) => (
<PluginParameterKnob
key={p.id}
plugin={plugin}
parameter={p}
label={p.name.toUpperCase()}
className="ring-1 ring-zinc-700 rounded-md p-2"
/>
))}
</div>
);
}
```
## API Reference
### Props
- `plugin: AudioFxPlugin` — the plugin instance whose parameter is bound.
- `parameter: PluginParameter` — the declaration to render. The parameter
MUST be present in `plugin.parameterIndex`; otherwise the underlying
`useParameter` hook throws `REACT_AUDIO.UNKNOWN_PARAMETER`.
- `label?: string` — optional visible label override; defaults to
`parameter.name`.
- `className?: string` — optional CSS class appended to the wrapper.
### Behaviour
- Each parameter update flows through `param.set(...)`, which delegates
to `plugin.setParameter`. Value subscriptions go through the
Phase-1 `useParameter` hook.
- The typed-edit input is rendered only when the user presses `Enter`
on the knob; it auto-focuses, supports `Enter`/`Escape`/blur, and
clamps the parsed value to the declared range.
## Implementation Notes
- The keyboard handler delegates to the pure `resolveParameterKeyboardChange`
helper, which is shared with `<PluginParameterSlider>` to guarantee
identical step semantics between the two primitives.
- `formatAriaValueText` is called with `signedDb: true` so dB readouts
carry the `+` sign — matching the convention used by every other
audio tool that ships unit-aware screen-reader text.
- No animation libraries are imported; visual rotation should be
composed by the consumer via CSS. The component respects
`prefers-reduced-motion` by virtue of being CSS-only.

View File

@@ -0,0 +1,102 @@
# `<PluginParameterSlider>`
Linear-slider primitive for a single `AudioFxPlugin` parameter.
Mirrors `<PluginParameterKnob>`'s a11y contract and exposes a native
`<input type="range">` for pointer drag while implementing the
mandated keyboard keymap directly so step semantics match the knob.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
The `<PluginParameterSlider>` is the linear half of the generic
parameter-control surface. It is preferred over the knob when the
parameter is naturally horizontal — pan, dry/wet mix, threshold,
crossfeed — and when consumers want native touch / pointer drag
without an extra dependency.
The slider subscribes to the parameter via the Phase-1
`useParameter` hook, so per-block parameter updates flow to the UI at
the provider's configured `sampleRateHz`.
## Features
- Native `<input type="range">` for pointer & touch drag, with `step`
set to `(max - min) / 100` so drag granularity matches the keyboard
fine-step.
- `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`,
`aria-valuetext` (unit-aware via `formatAriaValueText`).
- Full keyboard keymap: `←`/`→`, `↑`/`↓`, `PageUp`/`PageDown`,
`Home`/`End`, `Shift` ×10, `Alt` ÷10. Identical semantics to the
knob.
- `orientation` prop (`"horizontal"` | `"vertical"`) reflected on the
wrapper as `data-orientation`.
- `data-parameter-id` for stylesheet targeting.
- CSS-class API only (`rax-slider`, `rax-slider__input`,
`rax-slider__label`, `rax-slider__readout`).
## Usage: Basic
```tsx
import { PluginParameterSlider } from "@suaveplan/react-audio/components/plugin-parameter-slider";
function MixSlider({ plugin }) {
const param = plugin.parameterIndex.get("mix")!;
return <PluginParameterSlider plugin={plugin} parameter={param} />;
}
```
## Usage: Advanced
```tsx
// Render a vertical pan slider with a themed wrapper.
function PanSlider({ plugin }) {
const param = plugin.parameterIndex.get("pan")!;
return (
<PluginParameterSlider
plugin={plugin}
parameter={param}
orientation="vertical"
label="PAN"
className="ring-1 ring-zinc-700 rounded-md p-2"
/>
);
}
```
## API Reference
### Props
- `plugin: AudioFxPlugin` — the plugin instance bound to the slider.
- `parameter: PluginParameter` — the declaration to render. MUST be
present in `plugin.parameterIndex`.
- `orientation?: "horizontal" | "vertical"` — visual layout hint;
defaults to `"horizontal"`.
- `label?: string` — optional visible label override; defaults to
`parameter.name`.
- `className?: string` — optional CSS class appended to the wrapper.
### Behaviour
- Pointer drags fire the native `onChange`, which delegates to
`plugin.setParameter`.
- Keyboard events bypass the native range step (which would default to
1 and produce mis-stepped values for parameters whose range is
smaller or larger than 100) and run through the shared
`resolveParameterKeyboardChange` resolver so step semantics match
the knob exactly.
- `Enter` is intentionally a no-op on the slider; consumers expecting
a type-edit affordance should use `<PluginParameterKnob>`.
## Implementation Notes
- The native `step` attribute is `(max - min) / 100`, falling back to
`0.0001` for degenerate `min === max` declarations. The keyboard
handler never reads the native step.
- The slider does not animate; pointer & keyboard interactions write
the parameter directly and the next provider sample reads it back.
- A `<label htmlFor>` link is used so click-to-focus on the label
works without any JavaScript wiring.

View File

@@ -0,0 +1,99 @@
# `<PluginSlot>`
Generic renderer for any `AudioFxPlugin`. Reads the plugin's
declarative `parameters` array and renders each one through the
primitive picked by the parameter's unit declaration. A specialised
renderer registered through the provider's `slotRenderers` option
supersedes the generic slot for the matching plugin id.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PluginSlot>` is the per-plugin face of `<PluginChainView>`. It
turns a plugin's static declaration (metadata + parameter list + IO
shape) into a reactive form without the consumer having to wire up
each control by hand. The component is intentionally
presentational — bypass, advanced-toggle, and parameter writes go
through the same hooks any caller would use, so an EQ panel that
wants its own visual chrome can replace the slot through the
`slotRenderers` map without losing the keyboard / ARIA contract.
## Features
- Renders `plugin.metadata.name`, `version`, and `category` in the slot header.
- Iterates `plugin.parameters` in declaration order.
- Picks a knob for `unit: "db"` / `unit: "hz"` and a slider for every
other unit (including `"percent"`, `"ms"`, `"semitones"`).
- Exposes a bypass toggle wired to `chain.bypass(plugin.metadata.id, …)`
when a `chain` prop is supplied; the toggle reflects the live
bypass state via a `bypass-changed` subscription.
- Filters parameters whose `category === "advanced"` behind a toggle
that defaults to hidden.
- Forwards a CSS-class API (`rax-slot`, `rax-slot__header`,
`rax-slot__params`, `rax-slot__bypass`, `rax-slot__advanced-toggle`)
and `data-plugin-id` / `data-plugin-category` / `data-bypassed`
attributes for stylesheet targeting.
## Usage: Basic
```tsx
import { PluginSlot } from "@suaveplan/react-audio/components/plugin-slot";
function ChainItem({ plugin, chain }) {
return <PluginSlot plugin={plugin} chain={chain} />;
}
```
## Usage: Advanced
```tsx
// Provide a specialised EQ panel through provider options; the slot
// will render <EQPanel/> instead of the generic form for the eq plugin.
function App() {
return (
<AudioProvider
options={{
slotRenderers: {
eq: EQPanel,
},
}}
>
<PluginSlot plugin={eqPlugin} chain={chain} />
</AudioProvider>
);
}
```
## API Reference
### Props
- `plugin: AudioFxPlugin` — the plugin to render.
- `chain?: PluginChain` — when supplied, the bypass toggle is rendered
and writes via `chain.bypass()`; without a chain there is no
bypass surface to talk to.
- `className?: string` — CSS class appended to the wrapper.
- `hideAdvancedToggle?: boolean` — hide the advanced toggle even when
advanced parameters are present.
### Provider integration
- `slotRenderers: Record<string, ComponentType<PluginSlotRendererProps>>`
on `AudioProviderOptions` maps a plugin id to a specialised
renderer. The generic slot consults the map first and bails to the
registered component when present.
## Implementation Notes
- The bypass toggle subscribes via `chain.on("bypass-changed", …)` so
toggles initiated elsewhere (preset apply, another panel, MIDI in
Phase 3) propagate without a polling tick.
- The advanced toggle is local state on the slot; it does not survive
unmount. Persisting it would require an addition to the provider's
UI store, which is deferred to extras.
- The unit→control table (`db` / `hz` → knob, else slider) is
intentionally minimal. Specialised renderers cover any remaining
cases (segmented control for `enum`, log-curve knob, …).

View File

@@ -0,0 +1,88 @@
# `<PremiumPlayer>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<PremiumPlayer>` is the full-featured media-player surface — a
composition of `<AudioPlayer>`, `<AudioVisualizer>`, and
`<MasterMeter>` arranged as a single instrument panel. It is intended
for hero player surfaces (landing pages, profile pages, demo apps)
that want the whole monitoring suite without forcing consumers to
wire three components by hand.
Every behavioural guarantee of the underlying components applies:
the player polls plugin state, the visualiser reads block samples
from the named bus, and the meter computes peak / RMS / hold envelope
on every UI tick. None of the three sub-components share state; they
compose by sitting in the same DOM section.
## Features
- Bundles `<AudioPlayer>` + `<AudioVisualizer>` + `<MasterMeter>`.
- Routes a single `busName` to both the visualiser and the meter so
they show the same source.
- Forwards `src` to the player for auto-load on mount.
- Adds the `.rax-premium-player` class for theming.
## Usage: Basic Example
```tsx
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider, PremiumPlayer } from "@suaveplan/react-audio";
const plugin = new AudioPlayerPlugin({});
export function Hero() {
return (
<AudioProvider>
<PremiumPlayer plugin={plugin} src="https://cdn/hero.wav" />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { AudioPlayerPlugin } from "@suaveplan/audio-fx-plugin-audio-player";
import { AudioProvider, PremiumPlayer } from "@suaveplan/react-audio";
const plugin = new AudioPlayerPlugin({});
export function AuxBusPlayer() {
return (
<AudioProvider>
<PremiumPlayer
plugin={plugin}
src="https://cdn/aux.wav"
busName="auxA"
className="aux-hero"
/>
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ----------- | ------------------- | ----------- | -------------------------------------------------- |
| `plugin` | `AudioPlayerPlugin` | — | Required. |
| `src` | `string \| undef` | — | Forwarded to `<AudioPlayer>` for auto-load. |
| `busName` | `string \| undef` | `"master"` | Routed to the visualiser and meter. |
| `className` | `string \| undef` | — | Forwarded; merged with `rax-premium-player`. |
## Implementation Notes
The composition only forwards the `busName` to the visualiser + meter
when one is supplied — otherwise the sub-components fall back to
their own `"master"` defaults, which keeps the API surface honest
about what we are choosing for the user. The player sub-component
also receives the `src` only when it is defined, so the auto-load
effect inside `<AudioPlayer>` doesn't run with `undefined` and trip a
guard. There is no separate state in this component; it is pure
composition. New monitoring sub-components (e.g. spectrum analysers,
oscilloscope) can be added inline without changing the prop signature.

View File

@@ -0,0 +1,103 @@
# `<PresetManager>`
`ChainSnapshot` save / load / delete UI layered over the provider's
preset store. The save path persists the snapshot through
`useChainSnapshot(chain).save(id)`, which validates against the
`ChainSnapshotSchema` re-exported from
`@suaveplan/audio-fx-plugin-base` and hands the serialised payload
to the store. The load path calls `chain.restore()` through the
hook; the delete path calls the optional `store.delete()` extension.
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Plugin chains have a non-trivial state shape (per-slot plugin id,
parameter values, bypass flags) and survive only as long as the
provider stays mounted. The `<PresetManager>` gives an end-user a
direct affordance to capture that state, name it, restore it later,
and surface store / restore errors without crashing the surrounding
UI. The component is intentionally chain-scoped — the same
`<PresetManager chain={c}>` instance can sit beside any
`<PluginChainView>` for the same chain.
## Features
- `Save` button — disabled until the name input is non-empty;
invokes `snapshot.save(name)`, optimistically updates the local
saved-id list, bumps an internal `version` counter so subscribed
views re-render.
- `Load` button — invokes `snapshot.load(id)`, which restores the
chain order, parameters, and bypass states.
- `Delete` button — invokes `store.delete("chain", id)` when the
store supports the optional extension.
- Saved-id list is fetched from `store.list("chain")` if the store
supports it; otherwise the list reflects only the saves issued by
the live component instance.
- Toast surfaces every rejection with `role="status"`, the
`AudioFxError` code (e.g.
`AUDIO_FX.CHAIN_SLOT_NOT_FOUND`), and a dismiss button.
- Renders an explanatory empty state when no preset store is
configured on the provider.
## Usage: Basic
```tsx
import { PresetManager } from "@suaveplan/react-audio/components/preset-manager";
function ChainSidebar({ chain }) {
return <PresetManager chain={chain} />;
}
```
## Usage: Advanced
```tsx
// Wire a custom IndexedDB-backed preset store on the provider so
// PresetManager can render a fully reactive saved-id list.
function App() {
const store = useMemo(() => createIndexedDbPresetStore(), []);
return (
<AudioProvider options={{ presetStore: store }}>
<PresetManager chain={chain} className="rounded-md p-4" />
</AudioProvider>
);
}
```
## API Reference
### Props
- `chain: PluginChain` — the chain whose snapshot is persisted.
- `className?: string` — CSS class appended to the wrapper.
### Optional store extension
```ts
export interface PresetStoreWithListing extends PresetStore {
list?(kind: "plugin" | "chain"): Promise<ReadonlyArray<string>>;
delete?(kind: "plugin" | "chain", id: string): Promise<void>;
}
```
When the configured `presetStore` implements `list`, the saved-id
list is fetched on mount and after every save / delete; when it
implements `delete`, the Delete button is functional.
## Implementation Notes
- The save path runs `snapshot.save(id)` which itself wraps store
rejections in `REACT_AUDIO.PRESET_STORE_FAILED`; the component
re-surfaces the rejection in the toast.
- The load path goes through `chain.restore(snapshot, registry)`,
which is where `AUDIO_FX.CHAIN_SLOT_NOT_FOUND` is thrown for
missing plugin types — exactly the error the spec scenario asserts
the toast carries.
- The version counter exists so any external `useChainSnapshot(chain)`
consumer that calls `snapshot.load(...)` after a delete re-runs
through React's effect graph rather than caching stale ids.
- The component does not attempt to round-trip the preset on save;
the chain's own `chain.snapshot()` is the source of truth.

View File

@@ -0,0 +1,78 @@
# `<TrackList>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<TrackList>` is a composed-plugin UI that renders every track in the
provider's `TimelineEngine`. It is the canonical multi-track surface
for the React layer: each row is delegated to `<TrackRow>` so the list
view stays small and theming concerns are isolated. The component is
event-driven — it subscribes to the engine's typed `trackChange` event
and updates its snapshot whenever the engine fires.
It is intended to drop into any layout where you want a live track
list with no additional wiring. The "Add track" button is included as
a convenience and can be hidden when the parent surface owns track
creation through some richer flow.
## Features
- Subscribes to `TimelineEngine.on("trackChange")` and re-renders with
a fresh snapshot whenever tracks are added, removed, or updated.
- Renders an explicit "No tracks." placeholder when the engine has no
tracks so users always see something.
- "Add track" button delegates to `timeline.addTrack()` with default
options.
- Live track count badge (ARIA live region) for screen readers.
- `role="list"` on the inner UL and `role="listitem"` on every row.
## Usage: Basic Example
```tsx
import { AudioProvider, TrackList } from "@suaveplan/react-audio";
export function Studio() {
return (
<AudioProvider>
<TrackList />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import { AudioProvider, TrackList } from "@suaveplan/react-audio";
export function ReadOnlyTrackList() {
return (
<AudioProvider>
<TrackList hideAddTrack className="studio-tracks" />
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| --------------- | ------------------ | ------- | -------------------------------------- |
| `className` | `string \| undef` | — | Forwarded to the outer `<section>`. |
| `hideAddTrack` | `boolean \| undef` | `false` | When `true`, hides the add button. |
## Implementation Notes
The component reads the initial snapshot synchronously on first render
through `timeline.getState().tracks`, then mounts a `useEffect` that
subscribes to `trackChange` and reads a fresh snapshot on every event.
The subscription runs in a layout-safe `useEffect` and unsubscribes
on unmount via the engine's returned `off()` handle. The "Add track"
click handler eagerly calls `setTracks(timeline.getState().tracks)` so
the visual list updates synchronously without waiting for the event
loop tick. Each `<TrackRow>` re-subscribes to its own per-track event
through `useTrack`, so removing a track from the engine collapses the
corresponding row to a tombstone marker without unmounting the list.

View File

@@ -0,0 +1,80 @@
# `<TrackRow>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<TrackRow>` is the per-track row primitive consumed by `<TrackList>`.
It subscribes to a single `Track` through the foundation `useTrack`
hook and renders the canonical row controls: editable name, mute /
solo toggles, volume + pan sliders, and a remove button. The row
collapses to a tombstone marker when the referenced track is removed
so the surrounding `<ul>` does not visually re-layout in the
intervening render frame.
`<TrackRow>` can also be used standalone — anywhere you have a track
id and a provider mounted you can drop it into a list to render
exactly one row.
## Features
- Re-renders on `trackChange` for the bound track id via `useTrack`.
- Mute / solo as `aria-pressed` toggles with live "Mute" / "Muted"
labels (and analogous Solo).
- Volume slider clamped `[0, 1]` and pan slider clamped `[-1, 1]`,
both step `0.01`. Non-numeric input is ignored.
- Name input writes through to `timeline.updateTrack({ name })`.
- Remove button calls `useTrack().remove()` and the row immediately
collapses to the tombstone.
- `hideRemove` suppresses the remove button.
- `data-muted` / `data-soloed` attributes for theming hooks.
## Usage: Basic Example
```tsx
import { TrackRow } from "@suaveplan/react-audio";
export function OneRow({ trackId }: { trackId: string }) {
return (
<ul>
<TrackRow trackId={trackId} />
</ul>
);
}
```
## Usage: Advanced Example
```tsx
import { TrackRow } from "@suaveplan/react-audio";
export function ReadOnlyRow({ trackId }: { trackId: string }) {
return (
<ul>
<TrackRow trackId={trackId} hideRemove className="row-tight" />
</ul>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| ------------- | ------------------ | ------- | ------------------------------------------------------------ |
| `trackId` | `string` | — | Required. The id of the track to render. |
| `className` | `string \| undef` | — | Forwarded to the outer `<li>` (both active and tombstone). |
| `hideRemove` | `boolean \| undef` | `false` | Suppresses the remove button when `true`. |
## Implementation Notes
The active row is rendered by an inner `ActiveRow` sub-component so
the mute / solo / volume / pan / name callbacks are only defined when
the track exists. This keeps the hooks call sequence stable across
the "track exists → tombstone" transition without violating the Rules
of Hooks: the parent always calls `useTrack` first, then either
returns the tombstone JSX (no further hooks) or renders `<ActiveRow>`
(which calls its own bag of `useCallback`s). The tombstone collapses
to a minimal `<li>` so list keying remains stable while the engine is
mid-mutation.

View File

@@ -0,0 +1,93 @@
# `<TransportControls>`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<TransportControls>` is the composed-plugin UI for the provider-owned
`Transport` instance. It exposes the spec-mandated play / pause / stop /
seek / loop / tempo surface as a single accessible group of native form
controls, mirroring the live `transport.state` and `transport.seconds`
without requiring callers to wire `useTransport` or `useTransportTime`
themselves.
The component is intentionally minimal: it is the canonical "transport
bar" you drop into a layout and let consumers theme via the
`.rax-transport` class. Hotkey support is opt-in for global capture but
always available when the component itself has keyboard focus.
## Features
- Play / pause / stop buttons bound to `useTransport`.
- Live position readout (sampled at the provider's `sampleRateHz`).
- Tempo input writes the BPM signal at the current transport time, with
configurable `minTempo` / `maxTempo` clamps.
- Seek input writes positive-only second offsets, restarting the
transport when it was playing.
- Loop toggle drives `transport.loop` and reflects the flag.
- Local `Space` / `Escape` / `L` hotkeys when focused, and optional
global hotkeys via `enableGlobalHotkeys`.
- `role="group"`, `aria-pressed`, and `aria-live` polite updates.
## Usage: Basic Example
```tsx
import { AudioProvider, TransportControls } from "@suaveplan/react-audio";
export function App() {
return (
<AudioProvider>
<TransportControls />
</AudioProvider>
);
}
```
## Usage: Advanced Example
```tsx
import {
AudioProvider,
TransportControls,
} from "@suaveplan/react-audio";
export function Workstation() {
return (
<AudioProvider>
<TransportControls
enableGlobalHotkeys
minTempo={40}
maxTempo={220}
className="studio-transport"
initialSeekSeconds={4.5}
/>
</AudioProvider>
);
}
```
## API Reference
| Prop | Type | Default | Notes |
| --------------------- | ------------------- | ------- | ----------------------------------------------------------- |
| `className` | `string \| undef` | — | Forwarded to the outer `<div>`. |
| `initialSeekSeconds` | `number \| undef` | `0` | Initial value displayed in the seek input. |
| `minTempo` | `number \| undef` | `20` | Tempo input `min` attribute. |
| `maxTempo` | `number \| undef` | `300` | Tempo input `max` attribute. |
| `enableGlobalHotkeys` | `boolean \| undef` | `false` | When `true`, hotkeys fire from any `document` keydown. |
## Implementation Notes
The component subscribes to `transport.on("start" \| "stop" \| "pause")`
to update its local `state` and renders the play / pause button's label
from that. The position readout is provided by `useTransportTime`,
which samples at the provider's `sampleRateHz` budget (default 60 Hz)
rather than per audio block — display lag of at most one frame is the
correct ceiling for UI display. The seek handler stops and restarts the
transport via the shared `useTransport().seek` callback to avoid races
with the underlying tick source. Global hotkeys are registered with a
`useEffect` cleanup so unmounting fully removes the listener; the local
keydown handler uses `preventDefault` only for the handled keys
(`Space`, `Escape`, `L`) so other keystrokes bubble normally.

View File

@@ -0,0 +1,92 @@
# `@suaveplan/react-audio/error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the single source of truth for every error code thrown
inside `@suaveplan/react-audio`. The codes are namespaced under the
`REACT_AUDIO.` prefix via `defineCodes` from `@suaveplan/error`, so
consumers see stable dotted strings (`REACT_AUDIO.NO_PROVIDER`,
`REACT_AUDIO.UNKNOWN_PARAMETER`, …) that can be matched against in
catch-handlers or routed through an i18n message resolver.
Every code declared here is reachable from a real call site exercised
by the test suite — there are no "future" codes. The pre-publish gate
inspects `REACT_AUDIO_ERROR_METADATA` and the `REACT_AUDIO_CODES`
object to assert the two sets agree.
## Features
- Stable dotted codes via `defineCodes("REACT_AUDIO", …)`.
- Frozen metadata table with `description` and `severity` (`warning` or
`error`) per code, suitable for centralised reporting or i18n.
- `ReactAudioError extends SuaveplanError<ReactAudioErrorCode>` —
every error thrown by the package is an instance of this class.
- `registerReactAudioErrorCodes()` idempotent registration hook (kept
for parity with sibling packages).
## Usage: Basic
```ts
import {
REACT_AUDIO_CODES,
ReactAudioError,
} from "@suaveplan/react-audio/error-codes";
try {
// …
} catch (error) {
if (
error instanceof ReactAudioError &&
error.code === REACT_AUDIO_CODES.NO_PROVIDER
) {
// hook rendered without an <AudioProvider> ancestor
}
}
```
## Usage: Advanced
```ts
import {
REACT_AUDIO_ERROR_METADATA,
ReactAudioError,
type ReactAudioErrorCode,
} from "@suaveplan/react-audio/error-codes";
function reportReactAudioError(err: ReactAudioError): void {
const code = err.code as ReactAudioErrorCode;
const meta = REACT_AUDIO_ERROR_METADATA[code];
console.error(`[${meta.severity}] ${code} — ${meta.description}`, {
cause: err.cause,
context: (err as { context?: unknown }).context,
});
}
```
## API Reference
- `REACT_AUDIO_CODES` — frozen object whose values are the dotted code
strings.
- `ReactAudioErrorCode` — union of the dotted strings.
- `REACT_AUDIO_ERROR_METADATA` — frozen
`Record<ReactAudioErrorCode, { description, severity }>`.
- `ReactAudioError` — class extending `SuaveplanError`; carries the
code, an i18n `messageKey`, and optional `cause` / `context`.
- `registerReactAudioErrorCodes()` — idempotent.
## Implementation Notes
`defineCodes` returns an object whose values are pre-formatted dotted
strings; passing the bare literal `"REACT_AUDIO.NO_PROVIDER"` works
equally well in `instanceof` + `code ===` checks. The metadata object
is frozen via `Object.freeze` at module load so consumers cannot
accidentally mutate it. `ReactAudioError`'s constructor reads the
metadata once and writes a descriptive `name` (`ReactAudioError(error)`
or `ReactAudioError(warning)`) so default React error overlays carry
useful context. Every throw site in the package routes through this
class so a single `error instanceof ReactAudioError` branch catches
every package-originated failure.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,83 @@
# `hooks/use-chain-snapshot`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useChainSnapshot(chain)` exposes chain-level snapshot operations.
`get` reads the in-memory `ChainSnapshot`, `restore` applies one,
and `save` / `load` persist through the provider's optional preset
store, keyed by id. The hook is the natural complement to
`usePluginChain` for "FX rack" UIs that save and load entire chains
as a unit.
## Features
- `get()` — synchronous in-memory read of `chain.snapshot()`.
- `restore(input)` — async; delegates to `chain.restore(input, registry)`.
- `save(id)` — persists the snapshot via the preset store.
- `load(id)` — loads + applies via the preset store.
- Structured errors: `PRESET_STORE_FAILED` for every failure mode.
## Usage: Basic
```tsx
import { useChainSnapshot } from "@suaveplan/react-audio";
function ChainPresetButtons({ chain }: { chain: PluginChain }) {
const { save, load } = useChainSnapshot(chain);
return (
<>
<button type="button" onClick={() => save("default")}>Save chain</button>
<button type="button" onClick={() => load("default")}>Load chain</button>
</>
);
}
```
## Usage: Advanced
```tsx
import { useChainSnapshot } from "@suaveplan/react-audio";
function ExportImport({ chain }: { chain: PluginChain }) {
const { get, restore } = useChainSnapshot(chain);
return (
<>
<button onClick={() => navigator.clipboard.writeText(JSON.stringify(get()))}>
Copy JSON
</button>
<button
onClick={async () => {
const text = await navigator.clipboard.readText();
await restore(JSON.parse(text));
}}
>
Paste JSON
</button>
</>
);
}
```
## API Reference
- `useChainSnapshot(chain): { get, restore, save, load }`.
## Implementation Notes
The hook needs the provider's `PluginRegistry` to instantiate
plugins during `restore`, so it consumes `useProviderStateOrThrow`.
Every failure mode of the preset store is wrapped into a
`ReactAudioError(PRESET_STORE_FAILED)` with the underlying error as
`cause`; the absence of a preset store and the "preset id not
found" case use the same code with different `messageKey`s so a
catch-handler can branch on the message resolver if needed. The
`get()` helper returns the raw `chain.snapshot()` which includes
the chain's `blockSize` and `sampleRate`; consumers exporting JSON
for cross-instance restore should preserve those fields verbatim,
because `chain.restore` re-validates them against the chain's own
config on the receiving side. Non-Error throws from the store are
narrowed to `undefined` causes (the JS `instanceof Error` ternary).

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,81 @@
# `hooks/use-preset`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`usePreset(plugin)` exposes plugin-level preset operations as
React-friendly callbacks: `get` reads the current preset, `apply`
writes one back, and `save` / `load` persist through the provider's
optional preset store. The hook gates `save`/`load` on the presence
of a store; if none is configured (or if the store throws), the hook
raises `ReactAudioError(REACT_AUDIO.PRESET_STORE_FAILED)` carrying
the offending id and the underlying cause.
## Features
- `get(): PresetSnapshot` — synchronous read.
- `apply(preset): void` — synchronous write.
- `save(id): Promise<void>` — async write through the preset store.
- `load(id): Promise<void>` — async read + apply.
- Structured errors: `PRESET_STORE_FAILED` for missing store, save
failure, load failure, and preset-not-found.
## Usage: Basic
```tsx
import { usePreset } from "@suaveplan/react-audio";
function PresetButtons({ plugin }: { plugin: AudioFxPlugin }) {
const { save, load } = usePreset(plugin);
return (
<>
<button type="button" onClick={() => save("default")}>Save</button>
<button type="button" onClick={() => load("default")}>Load</button>
</>
);
}
```
## Usage: Advanced
```tsx
import { usePreset } from "@suaveplan/react-audio";
function ExportPreset({ plugin }: { plugin: AudioFxPlugin }) {
const { get } = usePreset(plugin);
return (
<button
type="button"
onClick={() => {
const preset = get();
navigator.clipboard.writeText(JSON.stringify(preset));
}}
>
Copy JSON
</button>
);
}
```
## API Reference
- `usePreset(plugin): { get, apply, save, load }`.
## Implementation Notes
The hook wraps each store interaction in a try/catch that converts
underlying errors into `ReactAudioError(PRESET_STORE_FAILED)` with
the original `Error` set as `cause`. The store is allowed to be
undefined; consumers that don't need persistence omit it from
provider options and use `get` / `apply` only. Promise rejections are
deliberately wrapped (rather than re-thrown raw) so a single
`error instanceof ReactAudioError` branch handles every failure path.
The four failure modes — missing store, store-rejected save,
store-rejected load, preset-not-found — share one code
(`PRESET_STORE_FAILED`) but distinct `messageKey`s so an i18n layer
can branch on the message resolver instead of the code. Non-Error
values thrown by a store are narrowed to `undefined` causes so the
wrapper Error remains type-safe.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,67 @@
# `provider/audio-context`
## Purpose
Declares the React context object (`AudioReactContext`) that
`<AudioProvider>` populates and that every hook reads from. The
context value carries the engine state, the engine-state lifecycle
phase (`pending` / `ready` / `error`), any async-init error, and a
monotonically increasing `version` counter that consumers use to gate
re-renders. Splitting the context declaration out from the provider
component keeps the hook layer free of a circular dependency back into
the provider's React tree code.
## Features
- `AudioReactContext` — `createContext<AudioContextValue | null>(null)`.
- `ProviderState` — frozen-shape published engine bundle.
- `AudioContextValue` — `{ state, engineState, error, version }`.
- `EngineState` — `'pending' | 'ready' | 'error'`.
## Usage: Basic
```ts
import { useContext } from "react";
import { AudioReactContext } from "@suaveplan/react-audio/provider";
function HookConsumer() {
const ctx = useContext(AudioReactContext);
if (ctx === null) {
// no provider in tree
}
// …
}
```
## Usage: Advanced
```ts
import { useContext } from "react";
import { AudioReactContext } from "@suaveplan/react-audio/provider";
function useEngineOrThrow() {
const ctx = useContext(AudioReactContext);
if (ctx?.state === null || ctx === null) {
throw new Error("engine not ready");
}
return ctx.state;
}
```
## API Reference
- `AudioReactContext: React.Context<AudioContextValue | null>` — the
shared context object.
- `ProviderState` — engine bundle published by the provider.
- `AudioContextValue` — provider's actual context value.
## Implementation Notes
`createContext(null)` is the canonical "no provider" sentinel; the
hooks treat `useContext(...) === null` as the signal to throw a
`REACT_AUDIO.NO_PROVIDER` error. `displayName` is set so React DevTools
shows the context under a recognisable name. `ProviderState` is
intentionally readonly across every field so a hook cannot mutate the
engine bundle. The `version` counter exists so a consumer that wants
to memoise on "engine identity" has a cheap integer key rather than
holding a reference to the entire `ProviderState` object.

View File

@@ -0,0 +1,89 @@
# `provider/audio-provider`
## Purpose
`<AudioProvider>` is the single React component that owns the audio
engine bundle for a React subtree. It constructs exactly one
`BrowserTimelineAdapter`, one `TimelineEngine`, one `Transport`, one
`ProviderBusRegistry`, and one `PluginRegistry`, publishes the
resolved bundle through `AudioReactContext`, and tears every one of
them down on unmount.
The provider is React 19 Strict Mode safe: under the development
double-invoke pattern (mount → unmount → re-mount) it never leaks an
engine. The async init pipeline is gated by a cancellation flag and an
`AbortController`; if the effect tears down before init resolves the
flag suppresses the publish step and any partially-constructed adapter
is force-disposed immediately.
## Features
- Strict-mode-safe async init with `AbortSignal` cancellation.
- Synchronous unmount path that disposes `adapter`, then `transport`,
then `buses` — each at most once.
- Zod-validated options (`AudioProviderOptions`) — invalid input
throws `ReactAudioError(REACT_AUDIO.INVALID_OPTIONS)` at construct
time.
- Auto-registers consumer-supplied plugin factories into the package
registry.
- Publishes `engineState` (`'pending' | 'ready' | 'error'`) for the
`useEngineState` hook and any consumer that wants to render a
loading state.
## Usage: Basic
```tsx
import { AudioProvider } from "@suaveplan/react-audio";
function App() {
return (
<AudioProvider>
<Workspace />
</AudioProvider>
);
}
```
## Usage: Advanced
```tsx
import { AudioProvider } from "@suaveplan/react-audio";
function App() {
return (
<AudioProvider
options={{
adapter: { sampleRate: 48000, latencyHint: "interactive" },
transport: { bpm: 128 },
plugins: [
{ pluginType: "delay", factory: () => makeDelay() },
],
sampleRateHz: 30,
}}
>
<Workspace />
</AudioProvider>
);
}
```
## API Reference
- `<AudioProvider options? children?>` — renders a context provider.
- `AudioProviderProps` — `{ options?: AudioProviderOptions, children?: ReactNode }`.
## Implementation Notes
The async init pipeline lives entirely inside `useEffect`'s async
IIFE so React's render path stays synchronous. A `PendingInit` value
carries the in-flight adapter, transport, and bus-registry references
so the cleanup callback can dispose anything already constructed if
the effect is torn down mid-init — this is the linchpin of the
Strict Mode guarantee. The `AbortController` is also passed to
`adapter.init({ signal })` so the engine package's own cancellable
work paths short-circuit. State updates (`setState`, `setEngineState`,
`setVersion`) are skipped when `cancelled` is true so a torn-down
effect cannot mutate React state. The teardown path is unconditional
and synchronous: every dispose is wrapped in nothing but a null
check, no try/catch — disposing a class that hasn't fully initialised
is the responsibility of the owning package's `dispose()` to handle.

View File

@@ -0,0 +1,70 @@
# `provider/chain-map`
## Purpose
`ChainMap` is the provider-scoped lazy registry of `PluginChain`
instances, keyed by track id. The hook `usePluginChain(trackId)` calls
`getOrCreate(trackId)` to fetch (or create) the chain associated with
that track, so two consumers asking for the same id see the same
chain instance. The map's `dispose()` is wired into the provider's
unmount path to dispose every chain it holds.
## Features
- Lazy creation: chains are constructed on first request, not
eagerly at mount.
- Identity stable for the same `trackId` across calls.
- Configurable default channels / sampleRate / blockSize at
construction; per-call overrides via `getOrCreate(id, config)`.
- Inert after `dispose()` — further calls throw.
## Usage: Basic
```ts
import { ChainMap, ProviderBusRegistry } from "@suaveplan/react-audio/provider";
const buses = new ProviderBusRegistry();
const chains = new ChainMap(buses);
const chainA = chains.getOrCreate("track-a");
const sameChainA = chains.getOrCreate("track-a"); // === chainA
```
## Usage: Advanced
```ts
import { ChainMap, ProviderBusRegistry } from "@suaveplan/react-audio/provider";
const buses = new ProviderBusRegistry();
const chains = new ChainMap(buses, {
channels: 1,
sampleRate: 44_100,
blockSize: 256,
});
// Per-track override:
const monoChain = chains.getOrCreate("voice", { channels: 1 });
const stereoChain = chains.getOrCreate("master", { channels: 2 });
```
## API Reference
- `new ChainMap(buses, defaultConfig?)`.
- `getOrCreate(trackId, config?): PluginChain`.
- `get(trackId): PluginChain | undefined`.
- `list(): ReadonlyArray<string>`.
- `dispose(): void`.
## Implementation Notes
The map stores fully-constructed `PluginChain` instances; there is
no proxy or wrapping. `dispose()` is idempotent and iterates the
internal map's `values()`, calling `chain.dispose()` on each. After
dispose the map is cleared so a late `get()` returns `undefined`
and a late `getOrCreate()` throws. The Phase-1 default channels /
sampleRate / blockSize line up with the typical browser audio context
defaults; consumers who need different shapes pass them through
`getOrCreate(id, config)` per chain. The `buses` argument is held by
reference and passed into every chain — keeping a single bus registry
for the whole provider subtree means sidechain wiring across tracks
remains coherent.

View File

@@ -0,0 +1,68 @@
# `provider/provider-bus-registry`
## Purpose
`ProviderBusRegistry` is a thin wrapper around
`DefaultBusRegistry` from `@suaveplan/audio-fx-plugin-base` that adds a
synchronous `close()` method. The wrapper is owned by
`<AudioProvider>` and torn down on unmount; closing it force-closes
every still-active `SidechainPublisher` issued through this registry
so no buses leak across remounts (especially relevant under React 19
Strict Mode, which double-invokes effects).
## Features
- Implements `BusRegistry` so it is a drop-in for the inner default.
- Tracks every `SidechainPublisher` returned by `publish()` and
force-closes them on `close()`.
- Inert after close — late `publish()` calls return a no-op publisher
rather than throwing.
- Forwards `get`, `list`, `subscribe` to the inner registry untouched.
## Usage: Basic
```ts
import { ProviderBusRegistry } from "@suaveplan/react-audio/provider";
const buses = new ProviderBusRegistry();
const pub = buses.publish("sidechain", 1);
pub.write([new Float32Array(128)]);
buses.close(); // closes pub; subsequent publish() returns a no-op.
```
## Usage: Advanced
```ts
import { ProviderBusRegistry } from "@suaveplan/react-audio/provider";
const buses = new ProviderBusRegistry();
const pub = buses.publish("kick", 1);
const off = buses.subscribe("kick", (bus) => {
if (bus === undefined) console.log("bus closed");
});
buses.close();
off();
```
## API Reference
- `new ProviderBusRegistry()` — no constructor arguments.
- `publish(name, channels): SidechainPublisher` — tracked and
closed on `close()`.
- `get(name)`, `list()`, `subscribe(name, listener)` — delegate.
- `close()` — idempotent; closes all tracked publishers.
## Implementation Notes
Wrapping rather than subclassing keeps the implementation independent
of the inner class's private state. The `Set<SidechainPublisher>`
tracks the *outer* (wrapped) publisher so closing it removes itself
from the set before delegating to the inner close — this avoids
double-iteration concerns when `close()` calls each publisher's close
which would in turn try to mutate the set. Iterating via
`Array.from(this.active)` snapshots the set so concurrent mutation
during the close loop is safe. Once closed, `publish()` returns an
inert publisher whose `write` / `close` are no-ops, allowing late
consumers to drain without throwing.

View File

@@ -0,0 +1,79 @@
# `provider/provider-options`
## Purpose
Defines the `AudioProviderOptions` shape consumed by
`<AudioProvider>`, the Zod schema that validates it, and the
`parseAudioProviderOptions` helper that converts a Zod failure into a
`ReactAudioError` whose code is `REACT_AUDIO.INVALID_OPTIONS`. The
schema is intentionally permissive about deep adapter/transport
internals — those are validated again by their owning packages
(`@suaveplan/audio-engine-browser` revalidates `BrowserAdapterOptions`,
`@suaveplan/audio-transport` revalidates transport options) — but
strict about the provider-level shape so a typo such as `adaptor`
fails at the React boundary instead of mid-way through async init.
## Features
- `AudioProviderOptionsSchema` — strict-object Zod schema rejecting
unknown top-level keys.
- `AudioProviderOptions` TypeScript interface mirroring the schema.
- `parseAudioProviderOptions(input)` — single-call validator that
throws `ReactAudioError(REACT_AUDIO.INVALID_OPTIONS)` on failure.
- `PluginFactoryEntry` shape for the optional auto-registration list.
- `PresetStore` shape for the pluggable preset persistence layer.
## Usage: Basic
```ts
import { parseAudioProviderOptions } from "@suaveplan/react-audio/provider";
const options = parseAudioProviderOptions({
adapter: { sampleRate: 48000 },
transport: { bpm: 120 },
sampleRateHz: 60,
});
```
## Usage: Advanced
```ts
import {
parseAudioProviderOptions,
type AudioProviderOptions,
type PluginFactoryEntry,
} from "@suaveplan/react-audio/provider";
const factories: ReadonlyArray<PluginFactoryEntry> = [
{ pluginType: "delay", factory: () => makeDelay() },
{ pluginType: "reverb", factory: () => makeReverb() },
];
const opts: AudioProviderOptions = {
plugins: factories,
sampleRateHz: 30, // half-rate sampling for low-power devices
};
parseAudioProviderOptions(opts);
```
## API Reference
- `AudioProviderOptions` — `{ adapter?, transport?, plugins?,
presetStore?, sampleRateHz?, slotRenderers? }`.
- `PluginFactoryEntry` — `{ pluginType, factory }`.
- `PresetStore` — `{ save(kind, id, payload), load(kind, id) }`.
- `parseAudioProviderOptions(input)` — returns a frozen-shape value
on success, throws `ReactAudioError` otherwise.
## Implementation Notes
The schema is `.strict()` on the top-level object so unknown keys
fail validation rather than being silently dropped — this catches
shape regressions early. Deeper objects (`transport`, `presetStore`)
use `.passthrough()` because their authoritative schemas live in the
owning packages; revalidating their full shape here would couple this
package to internal layouts that already validate at construction.
`sampleRateHz` is clamped to `[1, 240]` to keep `requestAnimationFrame`
sampling in a sensible band: below 1 the UI feels frozen, above 240
the work done per frame outpaces typical display refresh.

View File

@@ -0,0 +1,72 @@
# `stores/audio-store`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useAudioStore` is a slim provider-scoped Zustand store reserved for
application-level audio UI overrides (display names, persisted user
labels, …). It deliberately holds no playback state — playback lives
on the `Transport`, and timeline state lives on the `TimelineEngine`.
Duplicating either of those onto the store would create a second
source of truth and a guaranteed staleness bug.
The store is exposed as a hook returning a typed state object with
`overrides` plus three mutators (`setOverride`, `clearOverride`,
`reset`). Consumers extend the shape by setting arbitrary keys into
`overrides`; values are typed as `unknown` so the store does not
constrain what gets stored.
## Features
- Pure UI overrides — never engine state.
- `setOverride` / `clearOverride` / `reset` mutators.
- Identity-stable references for unchanged slices (Zustand
default).
- No middleware (no devtools, no persistence) — the store is
intentionally minimal.
## Usage: Basic
```tsx
import { useAudioStore } from "@suaveplan/react-audio";
function TrackLabel({ id }: { id: string }) {
const label = useAudioStore((s) => s.overrides[`track-label:${id}`]) as
| string
| undefined;
return <span>{label ?? id}</span>;
}
```
## Usage: Advanced
```tsx
import { useAudioStore } from "@suaveplan/react-audio";
function ResetOverridesButton() {
const reset = useAudioStore((s) => s.reset);
return (
<button type="button" onClick={() => reset()}>
Reset overrides
</button>
);
}
```
## API Reference
- `useAudioStore: UseBoundStore<StoreApi<AudioStoreState>>`.
- `AudioStoreState = { overrides, setOverride, clearOverride, reset }`.
## Implementation Notes
`useAudioStore` is created with `create<AudioStoreState>(...)` — a
single module-level instance shared across the React tree. The
Phase-1 spec calls for a "provider-scoped store with override hook";
Phase 2 (which lands the components) is the natural moment to scope
the store to the provider's subtree via a `createContext`-backed
factory. For Phase 1 a module-level store is sufficient because
none of the shipped hooks read from it.

View File

@@ -0,0 +1,78 @@
# `stores/ui-store`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useUiStore` is the cross-component UI state store for the React
audio workspace. It owns presentation-only state — which panels are
open, the theme preference, the active visualization mode, the
selected track id, and the live drag/drop state — none of which
belong on the engine. Engine state (transport, timeline, plugin
chain) lives on the audio classes and is read via hooks; the UI
store never mirrors it.
The shape mirrors the upstream `react-audio` package so consumers
migrating selectors across the package boundary do not need to
rewrite them.
## Features
- `panelOpen: Record<string, boolean>` plus
`setPanelOpen(panelId, open)`.
- `theme: 'light' | 'dark' | 'auto'` plus `setTheme`.
- `visualization: 'waveform' | 'spectrum' | 'meter' | 'off'` plus
`setVisualization`.
- `selectedTrackId: string | undefined` plus `setSelectedTrack`.
- `dragState` plus `setDragState` / `clearDragState`.
- `reset()` returns the store to the module-level initial value.
## Usage: Basic
```tsx
import { useUiStore } from "@suaveplan/react-audio";
function ThemeToggle() {
const theme = useUiStore((s) => s.theme);
const setTheme = useUiStore((s) => s.setTheme);
return (
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
>
Theme: {theme}
</button>
);
}
```
## Usage: Advanced
```tsx
import { useUiStore } from "@suaveplan/react-audio";
function PluginDragOverlay() {
const dragState = useUiStore((s) => s.dragState);
if (dragState.kind !== "plugin") return null;
return <div className="drag-ghost">{dragState.sourceId}</div>;
}
```
## API Reference
- `useUiStore: UseBoundStore<StoreApi<UiStoreState>>`.
- `UiStoreState` — see source for full shape.
- `VisualizationMode`, `ThemePreference`, `DragState` — exported
type aliases.
## Implementation Notes
The initial value is a module-level constant (`INITIAL`) so
`reset()` is O(1) and identity-stable across calls. The
`dragState` default (`NO_DRAG`) is `Object.freeze`d to guarantee
consumers don't mutate it in place — Zustand uses reference equality
for selector memoisation, and a mutated frozen object would silently
break that. No middleware is attached; consumers wanting devtools or
persistence wrap the store in their own factory.

View File

@@ -0,0 +1,64 @@
# `test-utils`
## Purpose
Test-only helpers for the `@suaveplan/react-audio` test suite. The
module exports three utilities: `makeFakeAudioContext`, an injectable
`BaseAudioContext`-shaped stub used by adapter tests; `makeFakePlugin`,
a minimal `AudioFxPlugin` implementation with two declared parameters
used by parameter / chain hook tests; and `renderWithProvider`, a
`@testing-library/react` wrapper that mounts the component under an
`<AudioProvider>` configured with the fake context.
The helpers are excluded from coverage and from the production build —
they exist purely to keep the test files concise and the assertions
focused on behaviour rather than fixture setup.
## Features
- `makeFakeAudioContext(sampleRate?)` — returns a plain object that
satisfies the engine's `AdapterContextLike` and `AudioContextLike`
structural interfaces, with every node type stubbed to no-ops.
- `makeFakePlugin(options?)` — returns a minimal `AudioFxPlugin`
with two parameters (`gain`, `tone`) and a working
`getParameter`/`setParameter` pair.
- `renderWithProvider(ui, opts?, rtlOpts?)` — renders `ui` inside an
`<AudioProvider>` injected with the fake context; `opts.strict`
toggles `<StrictMode>`.
## Usage: Basic
```tsx
import { renderWithProvider } from "../test-utils.js";
renderWithProvider(<Consumer />);
```
## Usage: Advanced
```tsx
import { makeFakePlugin, renderWithProvider } from "../test-utils.js";
const plugin = makeFakePlugin({ id: "fake-eq" });
renderWithProvider(<UsesPlugin plugin={plugin} />, {
strict: true,
options: { sampleRateHz: 30 },
});
```
## API Reference
- `makeFakeAudioContext(sampleRate?): unknown`.
- `makeFakePlugin(options?): AudioFxPlugin`.
- `renderWithProvider(ui, opts?, rtlOptions?)`.
## Implementation Notes
The fake context is a plain object rather than a class because the
engine's structural type checks pass without nominal identity; every
`createXxx` method returns a tagged stub whose `constructor.name`
matches the relevant `AudioNode` class so the engine's
`classifyNode` helper resolves correctly. The fake plugin's `process`
is a no-op — these tests do not exercise audio block processing,
only the React-tier event/state surface around the engine.