first commit
This commit is contained in:
206
docs/ui/react/react-audio/README.md
Normal file
206
docs/ui/react/react-audio/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
94
docs/ui/react/react-audio/src/components/mixer/mixer.md
Normal file
94
docs/ui/react/react-audio/src/components/mixer/mixer.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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, …).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
92
docs/ui/react/react-audio/src/error-codes/error-codes.md
Normal file
92
docs/ui/react/react-audio/src/error-codes/error-codes.md
Normal 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.
|
||||
120
docs/ui/react/react-audio/src/hooks/use-analyser/use-analyser.md
Normal file
120
docs/ui/react/react-audio/src/hooks/use-analyser/use-analyser.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# `hooks/use-analyser`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAnalyser(opts?)` polls an `AnalyserNode`-shaped source and
|
||||
returns the latest byte-magnitude FFT frame plus the matching
|
||||
time-domain (waveform) frame. It is the data feed for the family's
|
||||
two stock visualisers — `<WaveformVisualizer>` and
|
||||
`<SpectrumAnalyzer>` — and for any consumer-built `<canvas>`
|
||||
visualiser that wants live spectrum data without dealing with
|
||||
`AudioContext` or `AnalyserNode` lifecycle.
|
||||
|
||||
The hook deliberately does **not** create or own an `AnalyserNode`.
|
||||
The audio engine builds one analyser per track inside its
|
||||
`TrackSubgraph`, and the player tier hosts the master analyser on
|
||||
the source chain; the consumer hands either node to this hook. Two
|
||||
consumers reading the same analyser independently each get their
|
||||
own buffer pair so they cannot tear each other's frame reads.
|
||||
|
||||
## Features
|
||||
|
||||
- Polls at a caller-tunable interval (default 16 ms ≈ 60 Hz).
|
||||
- Auto-configures `fftSize`, `smoothingTimeConstant`, `minDecibels`,
|
||||
`maxDecibels` on the analyser on every change.
|
||||
- Lazy `factory` fallback for when the analyser is not available at
|
||||
mount (typical: engine still initialising).
|
||||
- Identity-stable `Uint8Array` buffers — mutated in place; the hook
|
||||
never allocates per frame.
|
||||
- Cleanup on unmount cancels the interval.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAnalyser } from "@suaveplan/react-audio";
|
||||
|
||||
function Spectrum({ analyser }) {
|
||||
const { frequency } = useAnalyser({ analyser, fftSize: 1024 });
|
||||
return (
|
||||
<div role="img" aria-label="spectrum">
|
||||
{Array.from(frequency.slice(0, 32)).map((v, i) => (
|
||||
<span key={i} style={{ height: `${v}px` }} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAnalyser } from "@suaveplan/react-audio";
|
||||
|
||||
// Resolve the engine's per-track analyser via the graph snapshot.
|
||||
function trackAnalyserFactory(adapter, trackId) {
|
||||
return () => {
|
||||
const snap = adapter.snapshotGraph();
|
||||
const node = snap.nodes.find(
|
||||
(n) => n.id === `${trackId}:analyser`
|
||||
);
|
||||
return node?.ref ?? null;
|
||||
};
|
||||
}
|
||||
|
||||
function TrackVisualizer({ adapter, trackId }) {
|
||||
const { frequency, waveform, analyser } = useAnalyser({
|
||||
factory: trackAnalyserFactory(adapter, trackId),
|
||||
intervalMs: 33, // ~30 Hz to save CPU
|
||||
fftSize: 2048,
|
||||
smoothingTimeConstant: 0.6,
|
||||
});
|
||||
if (!analyser) return <span>Connecting…</span>;
|
||||
return (
|
||||
<canvas
|
||||
ref={(c) => drawSpectrum(c, frequency, waveform)}
|
||||
width={512}
|
||||
height={128}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAnalyser(opts?: UseAnalyserOptions): UseAnalyserResult`
|
||||
- `UseAnalyserOptions`:
|
||||
- `analyser?: AnalyserLike | null` — direct ref; supersedes `factory`.
|
||||
- `factory?: () => AnalyserLike | null` — lazy resolver, retried per tick.
|
||||
- `intervalMs?: number` — poll interval (default 16).
|
||||
- `fftSize?: number` — power-of-2 in `[32, 32768]` (default 2048).
|
||||
- `smoothingTimeConstant?: number` — `[0, 1]` (default 0.8).
|
||||
- `minDecibels?: number` — default `-100`.
|
||||
- `maxDecibels?: number` — default `-30`.
|
||||
- `UseAnalyserResult`:
|
||||
- `analyser: AnalyserLike | null` — resolved instance or `null`.
|
||||
- `frequency: Uint8Array` — byte-magnitude FFT, identity-stable.
|
||||
- `waveform: Uint8Array` — byte time-domain, identity-stable.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook owns three refs: the resolved analyser, the factory, and
|
||||
the buffer pair. Buffers are reallocated when `analyser` or
|
||||
`fftSize` changes (the new `frequencyBinCount` determines length).
|
||||
Polling uses `setInterval` rather than `requestAnimationFrame`
|
||||
because the former is deterministic under happy-dom — the same
|
||||
reason `useTransportTime` makes the same choice.
|
||||
|
||||
The factory fallback is invoked on every tick until it returns a
|
||||
non-`null` value, then never again. This lets the consumer mount
|
||||
the hook before the engine finishes async init without races: the
|
||||
visualiser simply renders zeros until the analyser appears, then
|
||||
continues seamlessly.
|
||||
|
||||
The hook is engine-agnostic by design — it accepts the structural
|
||||
`AnalyserLike` shape, so consumers can stub it in tests with three
|
||||
lines of code and there is no hidden `AudioContext` dependency
|
||||
poisoning tree-shake or SSR.
|
||||
@@ -0,0 +1,73 @@
|
||||
# `hooks/use-audio-engine`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAudioEngine()` returns the full resolved `ProviderState` bundle —
|
||||
the adapter, the `TimelineEngine`, the `Transport`, the
|
||||
`ProviderBusRegistry`, the `PluginRegistry`, the consumer-supplied
|
||||
preset store (or `undefined`), and the configured `sampleRateHz`.
|
||||
It throws `REACT_AUDIO.NO_PROVIDER` outside the provider and
|
||||
`REACT_AUDIO.PROVIDER_PENDING` during the async init window. It is
|
||||
the escape hatch for code that needs the entire bundle at once;
|
||||
prefer the narrower hooks (`useTransport`, `useTimeline`, …) when
|
||||
you only need one entity, as they minimise re-render scope.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `ProviderState` — a readonly bundle.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>`.
|
||||
- Throws `REACT_AUDIO.PROVIDER_PENDING` while engine init is in
|
||||
flight.
|
||||
- Stable reference across re-renders within a single ready phase.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine } from "@suaveplan/react-audio";
|
||||
|
||||
function Diagnostics() {
|
||||
const { engine, transport, sampleRateHz } = useAudioEngine();
|
||||
return (
|
||||
<pre>
|
||||
engine.lookAhead = {engine.lookAhead}; transport.state ={" "}
|
||||
{transport.state}; ui-sampleRate = {sampleRateHz} Hz
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine } from "@suaveplan/react-audio";
|
||||
|
||||
function useRegisterPlugin(): (type: string, factory: () => AudioFxPlugin) => void {
|
||||
const { registry } = useAudioEngine();
|
||||
return (type, factory) => registry.register(type, factory);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAudioEngine(): ProviderState`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook delegates to `useProviderStateOrThrow`, which performs the
|
||||
two structured-error checks. The returned `ProviderState` is the
|
||||
frozen object the provider published — consumers can stash it in a
|
||||
ref without cloning. Identity is stable across renders within a
|
||||
single ready phase; if the provider remounts (Strict Mode or genuine
|
||||
remount) the bundle's identity changes, which is the correct cue for
|
||||
downstream `useEffect`s keyed on `engine`/`transport` to re-bind.
|
||||
Prefer the focused hooks (`useTransport`, `useTimeline`, …) for the
|
||||
common case of "I only need one entity"; the broader bundle returned
|
||||
here triggers re-renders only when the provider itself re-publishes
|
||||
state (the focused hooks emit narrower subscriptions). Consumers
|
||||
that want to forward the engine bundle into child components without
|
||||
re-render bloat should destructure here, then memoise the
|
||||
sub-references they pass downstream.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `hooks/use-audio-fx-plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useAudioFxPlugin(pluginType)` instantiates a plugin via the
|
||||
provider's `PluginRegistry.create(pluginType)` and memoises the
|
||||
result. The hook is the canonical way to obtain a one-off plugin
|
||||
instance (e.g. for a side-panel preview that is not in any chain).
|
||||
Consumers who want to insert the plugin into a chain pass the
|
||||
returned value to `usePluginChain(...).add(plugin)`.
|
||||
|
||||
The hook does not own or register factories. The provider can
|
||||
auto-register factories via `options.plugins`; alternatively
|
||||
`useAudioEngine().registry.register(...)` lets consumers add factories
|
||||
dynamically.
|
||||
|
||||
## Features
|
||||
|
||||
- Instance memoised by `pluginType` for component lifetime.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` / `REACT_AUDIO.PROVIDER_PENDING`
|
||||
outside / before-init.
|
||||
- Throws `AudioFxError(AUDIO_FX.CHAIN_SLOT_NOT_FOUND)` if no factory
|
||||
is registered for `pluginType`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useAudioFxPlugin } from "@suaveplan/react-audio";
|
||||
|
||||
function PreviewEQ() {
|
||||
const eq = useAudioFxPlugin("equaliser");
|
||||
return <PluginPanel plugin={eq} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine, useAudioFxPlugin, usePluginChain } from "@suaveplan/react-audio";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function EnsureRegistered() {
|
||||
const { registry } = useAudioEngine();
|
||||
useEffect(() => {
|
||||
registry.register("delay", () => makeDelay());
|
||||
}, [registry]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function ChainAdder({ trackId }: { trackId: string }) {
|
||||
const delay = useAudioFxPlugin("delay");
|
||||
const { add } = usePluginChain(trackId);
|
||||
return <button type="button" onClick={() => add(delay)}>Add delay</button>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useAudioFxPlugin(pluginType: string): AudioFxPlugin`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook memoises on `[registry, pluginType]` so a remount of the
|
||||
provider (new registry identity) yields a fresh instance, but a
|
||||
re-render with the same provider and `pluginType` returns the same
|
||||
instance. The hook does not call `dispose()` on unmount because the
|
||||
plugin is typically inserted into a chain whose lifecycle owns the
|
||||
disposal. Consumers using the hook for a transient preview should
|
||||
call `plugin.dispose()` in their own `useEffect` cleanup. Plugins
|
||||
constructed by this hook are never auto-inserted into any chain —
|
||||
the caller is responsible for placement (e.g. via
|
||||
`usePluginChain(trackId).add(plugin)`). This separation lets the
|
||||
same hook back per-track inserts, side-panel previews, and one-off
|
||||
analyser taps.
|
||||
69
docs/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal file
69
docs/ui/react/react-audio/src/hooks/use-bus/use-bus.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# `hooks/use-bus`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useBus(name)` reads the named sidechain bus from the provider's
|
||||
`ProviderBusRegistry` and subscribes to its publish/close lifecycle.
|
||||
Returns `undefined` if no publisher has registered the name. The
|
||||
hook re-renders whenever the bus is (re)published with a new
|
||||
channel layout or closed.
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore`-based subscription to the registry's per-name
|
||||
listener set.
|
||||
- Returns `undefined` when the bus is closed or never published —
|
||||
render-safe.
|
||||
- SSR-consistent snapshot (both `getSnapshot` and
|
||||
`getServerSnapshot` read `buses.get(name)`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useBus } from "@suaveplan/react-audio";
|
||||
|
||||
function SidechainStatus({ name }: { name: string }) {
|
||||
const bus = useBus(name);
|
||||
return <span>{bus ? `${bus.channels} ch` : "(no bus)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useAudioEngine, useBus } from "@suaveplan/react-audio";
|
||||
import { useEffect } from "react";
|
||||
|
||||
function PublishKick() {
|
||||
const { buses } = useAudioEngine();
|
||||
useEffect(() => {
|
||||
const pub = buses.publish("kick", 1);
|
||||
return () => pub.close();
|
||||
}, [buses]);
|
||||
|
||||
const bus = useBus("kick");
|
||||
return <span>bus is {bus ? "live" : "closed"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useBus(name: string): SidechainBus | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The subscribe callback returns the registry's existing
|
||||
`subscribe(name, listener)` teardown so cleanup is exact. Reading
|
||||
through `buses.get(name)` is O(1) on the registry's internal map.
|
||||
The `subscribe` listener is invoked from within `publish()` (when a
|
||||
bus appears) and from within `close()` (when one disappears), so a
|
||||
React commit happens for both transitions. The hook returns the
|
||||
`SidechainBus` itself rather than a wrapper — consumers can call
|
||||
`bus.read(into)` to pull a buffer from the bus on any tick. Phase-2
|
||||
meter components and any future scope/analyser views are expected
|
||||
to consume buses through this hook, so its surface is intentionally
|
||||
minimal: one accessor, undefined when absent, narrow subscription.
|
||||
@@ -0,0 +1,66 @@
|
||||
# `hooks/use-chain-plugin`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useChainPlugin(trackId, slotIndex)` returns the `AudioFxPlugin`
|
||||
currently occupying a specific slot in a track's `PluginChain`, or
|
||||
`undefined` if the slot is empty. It is the per-slot view of
|
||||
`usePluginChain` — useful for rendering a single
|
||||
`<PluginSlot plugin={...} />` without re-rendering on every other
|
||||
slot's parameter change. The hook re-renders only when chain
|
||||
composition changes affect the targeted slot (`plugin-added`,
|
||||
`plugin-removed`, `plugin-moved`, `plugin-replaced`).
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore`-based subscription to four chain events.
|
||||
- Returns `undefined` for out-of-range or empty slots — render-safe.
|
||||
- Stable behaviour across React 18+ concurrent rendering.
|
||||
- Listener cleanup on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useChainPlugin } from "@suaveplan/react-audio";
|
||||
|
||||
function SlotName({ trackId, slot }: { trackId: string; slot: number }) {
|
||||
const plugin = useChainPlugin(trackId, slot);
|
||||
return <span>{plugin?.metadata.name ?? "(empty)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useChainPlugin, useParameter } from "@suaveplan/react-audio";
|
||||
|
||||
function SlotGain({ trackId, slot }: { trackId: string; slot: number }) {
|
||||
const plugin = useChainPlugin(trackId, slot);
|
||||
if (plugin === undefined) return null;
|
||||
return <ParameterKnob plugin={plugin} paramId="gain" />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useChainPlugin(trackId, slotIndex): AudioFxPlugin | undefined`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook subscribes to four chain events but reads only
|
||||
`chain.plugins[slotIndex]` in its snapshot, so React re-renders the
|
||||
consumer when (and only when) one of those events fires. Reading by
|
||||
index is O(1) on the chain's internal array. `useSyncExternalStore`
|
||||
returns the same plugin reference for the same slot across re-renders
|
||||
when nothing about that slot changed, so downstream `useMemo` /
|
||||
`useEffect` keyed on the plugin behave correctly. The hook ignores
|
||||
`parameter-changed` and `bypass-changed` events because they don't
|
||||
move the slot's plugin — for per-parameter subscriptions a child
|
||||
should use `useParameter`. The list of subscribed events therefore
|
||||
captures the smallest set of changes that could affect the value
|
||||
returned, keeping per-slot re-renders proportional to actual chain
|
||||
edits.
|
||||
@@ -0,0 +1,83 @@
|
||||
# `hooks/use-chain-snapshot`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useChainSnapshot(chain)` exposes chain-level snapshot operations.
|
||||
`get` reads the in-memory `ChainSnapshot`, `restore` applies one,
|
||||
and `save` / `load` persist through the provider's optional preset
|
||||
store, keyed by id. The hook is the natural complement to
|
||||
`usePluginChain` for "FX rack" UIs that save and load entire chains
|
||||
as a unit.
|
||||
|
||||
## Features
|
||||
|
||||
- `get()` — synchronous in-memory read of `chain.snapshot()`.
|
||||
- `restore(input)` — async; delegates to `chain.restore(input, registry)`.
|
||||
- `save(id)` — persists the snapshot via the preset store.
|
||||
- `load(id)` — loads + applies via the preset store.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for every failure mode.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useChainSnapshot } from "@suaveplan/react-audio";
|
||||
|
||||
function ChainPresetButtons({ chain }: { chain: PluginChain }) {
|
||||
const { save, load } = useChainSnapshot(chain);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save chain</button>
|
||||
<button type="button" onClick={() => load("default")}>Load chain</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useChainSnapshot } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportImport({ chain }: { chain: PluginChain }) {
|
||||
const { get, restore } = useChainSnapshot(chain);
|
||||
return (
|
||||
<>
|
||||
<button onClick={() => navigator.clipboard.writeText(JSON.stringify(get()))}>
|
||||
Copy JSON
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const text = await navigator.clipboard.readText();
|
||||
await restore(JSON.parse(text));
|
||||
}}
|
||||
>
|
||||
Paste JSON
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useChainSnapshot(chain): { get, restore, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook needs the provider's `PluginRegistry` to instantiate
|
||||
plugins during `restore`, so it consumes `useProviderStateOrThrow`.
|
||||
Every failure mode of the preset store is wrapped into a
|
||||
`ReactAudioError(PRESET_STORE_FAILED)` with the underlying error as
|
||||
`cause`; the absence of a preset store and the "preset id not
|
||||
found" case use the same code with different `messageKey`s so a
|
||||
catch-handler can branch on the message resolver if needed. The
|
||||
`get()` helper returns the raw `chain.snapshot()` which includes
|
||||
the chain's `blockSize` and `sampleRate`; consumers exporting JSON
|
||||
for cross-instance restore should preserve those fields verbatim,
|
||||
because `chain.restore` re-validates them against the chain's own
|
||||
config on the receiving side. Non-Error throws from the store are
|
||||
narrowed to `undefined` causes (the JS `instanceof Error` ternary).
|
||||
@@ -0,0 +1,73 @@
|
||||
# `hooks/use-engine-state`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useEngineState()` reports the `<AudioProvider>`'s async-init
|
||||
lifecycle phase as one of `'pending' | 'ready' | 'error'`. It is the
|
||||
only engine-aware hook that does **not** throw on the pending phase —
|
||||
its purpose is to *report* that phase so consumers can render a
|
||||
loading state or gate child trees on engine readiness.
|
||||
|
||||
The hook still throws `ReactAudioError(REACT_AUDIO.NO_PROVIDER)` when
|
||||
rendered outside an `<AudioProvider>` ancestor, mirroring the
|
||||
contract every public hook honours.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `'pending'`, `'ready'`, or `'error'`.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside the provider.
|
||||
- Triggers re-render when the engine resolves (via the provider's
|
||||
internal `setEngineState`).
|
||||
- Stable identity across re-renders for the same lifecycle phase.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useEngineState } from "@suaveplan/react-audio";
|
||||
|
||||
function App() {
|
||||
const engineState = useEngineState();
|
||||
if (engineState === "pending") return <p>Loading audio…</p>;
|
||||
if (engineState === "error") return <p>Audio failed to initialise.</p>;
|
||||
return <Workspace />;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useEngineState } from "@suaveplan/react-audio";
|
||||
|
||||
function useIsReady(): boolean {
|
||||
return useEngineState() === "ready";
|
||||
}
|
||||
|
||||
function Gate({ children }: { children: React.ReactNode }) {
|
||||
const ready = useIsReady();
|
||||
return ready ? <>{children}</> : <Spinner />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useEngineState(): 'pending' | 'ready' | 'error'`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useEngineState` reads the provider's React context directly rather
|
||||
than going through `useSyncExternalStore`. React already batches state
|
||||
updates inside the provider, so reading the context value is
|
||||
sufficient and avoids the indirection of a snapshot+subscribe pair.
|
||||
The hook returns the same `engineState` string instance across
|
||||
renders within the same phase, so a `useMemo` keyed on it is a
|
||||
constant-time identity check. The `'error'` state is reachable when
|
||||
the engine's async init rejects (e.g. the browser refuses to start
|
||||
an `AudioContext`). The provider's error object is exposed on the
|
||||
context value too — consumers needing the underlying failure can
|
||||
combine `useEngineState() === 'error'` with `useAudioEngine` or a
|
||||
direct context read. The hook is the ONE hook in this package that
|
||||
deliberately does NOT throw on the pending phase.
|
||||
@@ -0,0 +1,112 @@
|
||||
# `hooks/use-midi-controller`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useMidiController(controllerId)` is the React 19 binding for a single
|
||||
`MidiControllerPlugin` from `@suaveplan/audio-controllers`. It looks
|
||||
the controller up by `id` in the surrounding
|
||||
`MidiControllerRegistryContext`, subscribes to the controller's
|
||||
`state` event via `useSyncExternalStore`, and exposes a `learn(target)`
|
||||
method that wraps `captureNextLearn` for the learn-mode UX.
|
||||
|
||||
The hook is intentionally narrow: it does **not** own the MIDI
|
||||
hardware, nor does it instantiate the controller. The application
|
||||
constructs the controller plugin(s) at startup, places them in a
|
||||
read-only registry (a plain `Map` is sufficient), and passes that
|
||||
registry into the React tree via
|
||||
`<MidiControllerRegistryContext.Provider>`. Every Phase-3 surface
|
||||
(`useMidiController` and `<MidiControllerSetup>`) reads through the
|
||||
same context, so swapping registry implementations during tests is
|
||||
trivial.
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore` subscription against the controller's
|
||||
`state` event — re-renders once per FSM transition
|
||||
(`disconnected → connecting → connected → error`).
|
||||
- `learn(target)` returns a promise resolving to the captured
|
||||
`(channel, controller)` pair; rejects with
|
||||
`REACT_AUDIO.MIDI_LEARN_FAILED` when no controller is bound or
|
||||
`captureNextLearn` rejects.
|
||||
- `controller` is `undefined` and `state` is `"disconnected"` when
|
||||
the registry does not contain the requested id — no throw, no
|
||||
exception during render.
|
||||
- Optional `registryOverride` second argument bypasses the context
|
||||
for tests / isolated stories.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import {
|
||||
MidiControllerRegistryContext,
|
||||
useMidiController,
|
||||
} from "@suaveplan/react-audio";
|
||||
|
||||
function ControllerBadge({ id }: { id: string }) {
|
||||
const { state } = useMidiController(id);
|
||||
return <span data-state={state}>{state}</span>;
|
||||
}
|
||||
|
||||
function App({ controllers }: { controllers: Map<string, MidiControllerPlugin> }) {
|
||||
return (
|
||||
<MidiControllerRegistryContext.Provider value={controllers}>
|
||||
<ControllerBadge id="kontrol-s88" />
|
||||
</MidiControllerRegistryContext.Provider>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useMidiController } from "@suaveplan/react-audio";
|
||||
|
||||
function LearnButton({ id, pluginId, paramId }: {
|
||||
id: string; pluginId: string; paramId: string;
|
||||
}) {
|
||||
const { controller, state, learn } = useMidiController(id);
|
||||
if (state !== "connected" || !controller) return <p>Connect controller…</p>;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const captured = await learn({ pluginId, paramId });
|
||||
console.log("captured", captured);
|
||||
}}
|
||||
>
|
||||
Learn {paramId}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `MidiControllerRegistry` — type alias for
|
||||
`ReadonlyMap<string, MidiControllerPlugin>`.
|
||||
- `MidiControllerRegistryContext` — React context whose `.Provider`
|
||||
distributes the registry to descendants.
|
||||
- `LearnTarget` — `{ pluginId, paramId }` pair.
|
||||
- `UseMidiControllerResult.controller` — resolved plugin or `undefined`.
|
||||
- `UseMidiControllerResult.state` — `ConnectionState` mirrored from
|
||||
the controller FSM.
|
||||
- `UseMidiControllerResult.learn(target)` — returns
|
||||
`Promise<{ target, channel, controller }>`; rejects with
|
||||
`REACT_AUDIO.MIDI_LEARN_FAILED`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useSyncExternalStore` requires that `getSnapshot` be referentially
|
||||
stable for unchanged state. Because `ConnectionState` is a string
|
||||
union, the snapshot value itself is the comparison key, so the
|
||||
default `Object.is` equality check fires re-renders only on actual
|
||||
transitions. The `subscribe` callback returns the controller's own
|
||||
`off`-style unsubscribe handle, so the hook scales to any number of
|
||||
mounted consumers without leaking listeners. Errors during `learn`
|
||||
are wrapped in `ReactAudioError(MIDI_LEARN_FAILED)` so consumers can
|
||||
handle a single, stable code rather than poking at the underlying
|
||||
`AudioControllersError` surface.
|
||||
@@ -0,0 +1,98 @@
|
||||
# `hooks/use-parameter`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useParameter(plugin, paramId)` is the canonical read/write hook for
|
||||
a single declared parameter on any `AudioFxPlugin`. It returns the
|
||||
parameter's current value (sampled at frame rate) and three
|
||||
write-paths: an immediate `set`, a `curve` for ramps/automation, and
|
||||
a `scheduleAt` for transport-anchored value changes.
|
||||
|
||||
The hook throws `ReactAudioError(REACT_AUDIO.UNKNOWN_PARAMETER)`
|
||||
synchronously on first render if the `paramId` is not declared in
|
||||
the plugin's `parameterIndex` — so a typo fails fast in the render
|
||||
tree rather than silently dropping writes.
|
||||
|
||||
## Features
|
||||
|
||||
- Frame-rate sampled `value` (60 Hz default, configurable via
|
||||
provider `sampleRateHz`).
|
||||
- `set(v)` → `plugin.setParameter(paramId, v)`.
|
||||
- `curve(c)` → `plugin.setParameterCurve(paramId, c)`.
|
||||
- `scheduleAt(v, t)` → `plugin.setParameterAt(paramId, v, t)`.
|
||||
- Throws `REACT_AUDIO.UNKNOWN_PARAMETER` on first render for an
|
||||
unknown `paramId`, carrying the available ids in `context`.
|
||||
- SSR-consistent snapshots (both `getSnapshot` and
|
||||
`getServerSnapshot` read `plugin.getParameter(paramId)`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useParameter } from "@suaveplan/react-audio";
|
||||
|
||||
function GainKnob({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { value, set } = useParameter(plugin, "gain");
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={value}
|
||||
onChange={(e) => set(Number(e.currentTarget.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useParameter } from "@suaveplan/react-audio";
|
||||
|
||||
function Sweep({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { curve, scheduleAt } = useParameter(plugin, "cutoff");
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
curve({
|
||||
kind: "linear",
|
||||
startValue: 200,
|
||||
endValue: 8000,
|
||||
duration: 4,
|
||||
})
|
||||
}
|
||||
>
|
||||
Linear sweep
|
||||
</button>
|
||||
<button type="button" onClick={() => scheduleAt(440, 2)}>
|
||||
Set to 440 at t=2s
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useParameter(plugin, paramId): { value, set, curve, scheduleAt }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Parameter automation can change values per audio block (~689 Hz at
|
||||
128/88.2k); the React commit cycle cannot run at that rate. The hook
|
||||
samples the live value at `sampleRateHz` (60 Hz default) via a
|
||||
`useSyncExternalStore` subscription whose subscribe path schedules a
|
||||
`setInterval` and getSnapshot path reads `plugin.getParameter(paramId)`.
|
||||
`setInterval` is used rather than `requestAnimationFrame` so the
|
||||
sampling behaves deterministically in tests (happy-dom RAF is
|
||||
synchronous-per-microtask which makes the per-frame-cap test
|
||||
impossible). The unknown-param check runs on every render —
|
||||
parameter indices are immutable on a typical plugin so the lookup is
|
||||
O(1) and the throw site is the same.
|
||||
135
docs/ui/react/react-audio/src/hooks/use-playlist/use-playlist.md
Normal file
135
docs/ui/react/react-audio/src/hooks/use-playlist/use-playlist.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# `hooks/use-playlist`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePlaylist(items, opts?)` manages a queue of `PlaylistItem` entries
|
||||
and exposes the standard playlist verbs (`next`, `prev`, `skipTo`,
|
||||
`setIndex`) plus mode toggles (`repeat`, `shuffle`). The hook is
|
||||
deliberately engine-agnostic: it knows nothing about `AudioContext`,
|
||||
`AudioPlayerPlugin`, or transport. Consumers wire the returned
|
||||
`current.src` into whichever loader they use, typically
|
||||
`<AudioPlayer source={current.src}>` from this same package, or any
|
||||
of the higher-level players that build on it.
|
||||
|
||||
The hook is the single source of truth for queue traversal across
|
||||
the audio family. The companion `<Queue>` UI in
|
||||
`@suaveplan/react-audio-player` is a thin renderer over its return
|
||||
value; the karaoke surface in the same package advances lyrics
|
||||
independently from playlist position.
|
||||
|
||||
## Features
|
||||
|
||||
- `repeat: "off" | "all" | "one"` with correct wrap semantics on
|
||||
`next` and `prev`.
|
||||
- `shuffle: boolean` with a per-shuffle history stack so `prev`
|
||||
reverses the exact path `next` walked.
|
||||
- Avoids immediately repeating the current track when shuffling.
|
||||
- `skipTo(id)` jumps to an item by id; ids not in `items` are a
|
||||
silent no-op.
|
||||
- `setIndex(n)` clamps out-of-range values rather than throwing.
|
||||
- Injectable `rng` for deterministic testing.
|
||||
- Stable return-value identity for `current` across renders that
|
||||
do not change the active item.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePlaylist } from "@suaveplan/react-audio";
|
||||
|
||||
function MyPlayer({ items }) {
|
||||
const { current, next, prev, index, length } = usePlaylist(items);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={prev} disabled={index <= 0}>
|
||||
←
|
||||
</button>
|
||||
<span>{current?.title ?? "—"}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={next}
|
||||
disabled={index >= length - 1}
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePlaylist } from "@suaveplan/react-audio";
|
||||
|
||||
function ShuffledPlayer({ items }) {
|
||||
// Inject a deterministic RNG for repeatable tests.
|
||||
const rng = () => 0.42;
|
||||
const pl = usePlaylist(items, {
|
||||
initialIndex: 2,
|
||||
initialRepeat: "all",
|
||||
initialShuffle: true,
|
||||
rng,
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => pl.setShuffle(!pl.shuffle)}>
|
||||
{pl.shuffle ? "Shuffle on" : "Shuffle off"}
|
||||
</button>
|
||||
<button type="button" onClick={() => pl.setRepeat("one")}>
|
||||
Repeat one
|
||||
</button>
|
||||
<ol>
|
||||
{items.map((it) => (
|
||||
<li
|
||||
key={it.id}
|
||||
aria-current={
|
||||
pl.current?.id === it.id ? "true" : undefined
|
||||
}
|
||||
>
|
||||
<button type="button" onClick={() => pl.skipTo(it.id)}>
|
||||
{it.title ?? it.id}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePlaylist(items: readonly PlaylistItem[], opts?: UsePlaylistOptions): UsePlaylistResult`
|
||||
- `PlaylistItem` — `{ id, src, title?, artist?, durationSec?, meta? }`.
|
||||
- `PlaylistRepeat` — `"off" | "all" | "one"`.
|
||||
- `UsePlaylistOptions` — `initialIndex`, `initialRepeat`,
|
||||
`initialShuffle`, `rng`.
|
||||
- `UsePlaylistResult` — `current`, `index`, `length`, `repeat`,
|
||||
`shuffle`, `next()`, `prev()`, `skipTo(id)`, `setIndex(n)`,
|
||||
`setRepeat(mode)`, `setShuffle(enabled)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The cursor is held in a single `useState<number>`; all verbs route
|
||||
through the same setter so React batches updates per event. The
|
||||
shuffle "history" lives in a `useRef<number[]>` because it is not
|
||||
rendered — pushing to it must not cause a commit. When `setShuffle`
|
||||
toggles off, the history is cleared so the next shuffle session
|
||||
starts clean.
|
||||
|
||||
The `rng` ref is rebound on every render to `opts.rng ?? Math.random`
|
||||
so the consumer can swap RNGs without losing state. The deferred
|
||||
update inside callbacks reads the ref, not the captured option, so a
|
||||
late-arriving RNG takes effect on the very next `next`.
|
||||
|
||||
`current` is memoised by `(items, index, length)`; since `length`
|
||||
mirrors `items.length`, the dep set is minimal but explicit so React
|
||||
strict-mode double-renders cannot manufacture stale snapshots.
|
||||
|
||||
`next` and `prev` are pure functions of `(repeat, shuffle, length,
|
||||
cur)` — no I/O, no thrown errors, no `console`. The hook is safe to
|
||||
mount under React's concurrent renderer without `useTransition`.
|
||||
@@ -0,0 +1,82 @@
|
||||
# `hooks/use-plugin-chain`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePluginChain(trackId)` returns a handle for the `PluginChain`
|
||||
associated with a track. The provider lazily creates the chain
|
||||
through its `ChainMap` on first call, so every consumer asking for
|
||||
the same track sees the same chain. The hook subscribes to the
|
||||
chain's composition events — `plugin-added`, `plugin-removed`,
|
||||
`plugin-moved`, `plugin-replaced`, `bypass-changed` — and re-renders
|
||||
the consumer when any of them fire. Listener cleanup on unmount is
|
||||
exact: the chain's listener count returns to the pre-mount baseline.
|
||||
|
||||
## Features
|
||||
|
||||
- Lazy per-track chain creation via the provider's `ChainMap`.
|
||||
- Re-renders on chain-composition events (5 events subscribed).
|
||||
- Stable `add` / `remove` / `move` / `replace` / `bypass` /
|
||||
`snapshot` / `restore` callback identities across re-renders.
|
||||
- `restore(input)` delegates to `chain.restore(input, registry)`
|
||||
using the provider's `PluginRegistry`.
|
||||
- Exact listener cleanup on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePluginChain } from "@suaveplan/react-audio";
|
||||
|
||||
function ChainBar({ trackId, eq }: { trackId: string; eq: AudioFxPlugin }) {
|
||||
const { plugins, add } = usePluginChain(trackId);
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => add(eq)}>Add EQ</button>
|
||||
<span>{plugins.length} plugin(s)</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePluginChain } from "@suaveplan/react-audio";
|
||||
|
||||
function Reorderable({ trackId }: { trackId: string }) {
|
||||
const { plugins, move, bypass, replace } = usePluginChain(trackId);
|
||||
return (
|
||||
<ul>
|
||||
{plugins.map((plugin, index) => (
|
||||
<li key={plugin.metadata.id}>
|
||||
<button onClick={() => move(index, Math.max(0, index - 1))}>↑</button>
|
||||
<button onClick={() => bypass(index, true)}>Bypass</button>
|
||||
<span>{plugin.metadata.id}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePluginChain(trackId): UsePluginChainResult`.
|
||||
- `UsePluginChainResult = { chain, plugins, add, remove, move,
|
||||
replace, bypass, snapshot, restore }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The subscribe path registers a listener on each of the five
|
||||
composition events and returns a teardown that calls every
|
||||
`UnsubscribeFn`. Because the chain's `event-bus` increments and
|
||||
decrements listener counts symmetrically, the post-unmount listener
|
||||
count is exactly the pre-mount count. `replace` is implemented as
|
||||
`remove + add` at the same index because `PluginChain` does not
|
||||
currently expose a single-call `replace`; the chain emits a
|
||||
`plugin-removed` followed by a `plugin-added`, both of which the
|
||||
hook is subscribed to, so the consumer sees one composite re-render
|
||||
(React batches state updates within the same task).
|
||||
81
docs/ui/react/react-audio/src/hooks/use-preset/use-preset.md
Normal file
81
docs/ui/react/react-audio/src/hooks/use-preset/use-preset.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# `hooks/use-preset`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`usePreset(plugin)` exposes plugin-level preset operations as
|
||||
React-friendly callbacks: `get` reads the current preset, `apply`
|
||||
writes one back, and `save` / `load` persist through the provider's
|
||||
optional preset store. The hook gates `save`/`load` on the presence
|
||||
of a store; if none is configured (or if the store throws), the hook
|
||||
raises `ReactAudioError(REACT_AUDIO.PRESET_STORE_FAILED)` carrying
|
||||
the offending id and the underlying cause.
|
||||
|
||||
## Features
|
||||
|
||||
- `get(): PresetSnapshot` — synchronous read.
|
||||
- `apply(preset): void` — synchronous write.
|
||||
- `save(id): Promise<void>` — async write through the preset store.
|
||||
- `load(id): Promise<void>` — async read + apply.
|
||||
- Structured errors: `PRESET_STORE_FAILED` for missing store, save
|
||||
failure, load failure, and preset-not-found.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function PresetButtons({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { save, load } = usePreset(plugin);
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => save("default")}>Save</button>
|
||||
<button type="button" onClick={() => load("default")}>Load</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { usePreset } from "@suaveplan/react-audio";
|
||||
|
||||
function ExportPreset({ plugin }: { plugin: AudioFxPlugin }) {
|
||||
const { get } = usePreset(plugin);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const preset = get();
|
||||
navigator.clipboard.writeText(JSON.stringify(preset));
|
||||
}}
|
||||
>
|
||||
Copy JSON
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `usePreset(plugin): { get, apply, save, load }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook wraps each store interaction in a try/catch that converts
|
||||
underlying errors into `ReactAudioError(PRESET_STORE_FAILED)` with
|
||||
the original `Error` set as `cause`. The store is allowed to be
|
||||
undefined; consumers that don't need persistence omit it from
|
||||
provider options and use `get` / `apply` only. Promise rejections are
|
||||
deliberately wrapped (rather than re-thrown raw) so a single
|
||||
`error instanceof ReactAudioError` branch handles every failure path.
|
||||
The four failure modes — missing store, store-rejected save,
|
||||
store-rejected load, preset-not-found — share one code
|
||||
(`PRESET_STORE_FAILED`) but distinct `messageKey`s so an i18n layer
|
||||
can branch on the message resolver instead of the code. Non-Error
|
||||
values thrown by a store are narrowed to `undefined` causes so the
|
||||
wrapper Error remains type-safe.
|
||||
70
docs/ui/react/react-audio/src/hooks/use-provider-state.md
Normal file
70
docs/ui/react/react-audio/src/hooks/use-provider-state.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# `hooks/use-provider-state`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Internal helpers shared across every public hook. They centralise the
|
||||
two structured-error throws every engine-dependent hook must perform:
|
||||
`REACT_AUDIO.NO_PROVIDER` when the consumer renders outside an
|
||||
`<AudioProvider>` ancestor, and `REACT_AUDIO.PROVIDER_PENDING` when
|
||||
the engine has not yet finished its async init. Keeping the throw
|
||||
logic in one file means there is exactly one place to audit the error
|
||||
contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `useProviderStateOrThrow()` — returns the resolved `ProviderState`
|
||||
or throws.
|
||||
- `useAudioContextOrThrow()` — returns the raw `AudioContextValue`
|
||||
(without the pending check) for hooks like `useEngineState` that
|
||||
need to *report* the lifecycle phase rather than throw on it.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { useProviderStateOrThrow } from "@suaveplan/react-audio";
|
||||
|
||||
function MyHook() {
|
||||
const state = useProviderStateOrThrow();
|
||||
return state.transport;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { useAudioContextOrThrow } from "@suaveplan/react-audio";
|
||||
|
||||
function useReadyOrLoading(): "ready" | "loading" {
|
||||
const ctx = useAudioContextOrThrow();
|
||||
return ctx.engineState === "ready" ? "ready" : "loading";
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useProviderStateOrThrow(): ProviderState` — throws
|
||||
`REACT_AUDIO.NO_PROVIDER` outside provider; throws
|
||||
`REACT_AUDIO.PROVIDER_PENDING` while engine init is pending.
|
||||
- `useAudioContextOrThrow(): AudioContextValue` — throws
|
||||
`REACT_AUDIO.NO_PROVIDER` outside provider.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`useContext` returns `null` as the "no provider" sentinel because the
|
||||
context's default value is `null`. The two helpers use a single
|
||||
discriminator check (`ctx === null`) for that path so the throw site
|
||||
is canonical. `useProviderStateOrThrow` additionally checks for
|
||||
`ctx.state === null` (the async init window) and throws
|
||||
`PROVIDER_PENDING` so engine-dependent hooks fail fast — consumers
|
||||
typically gate on `useEngineState() === 'ready'` before rendering
|
||||
those hooks' subtrees. The split into two helpers exists so
|
||||
`useEngineState` itself can read the phase without throwing on it —
|
||||
otherwise the consumer would have no way to discover "we're still
|
||||
initialising" without crashing the component tree. Both helpers
|
||||
register exactly one React hook (`useContext`) so any downstream hook
|
||||
can register additional `useCallback` / `useSyncExternalStore` calls
|
||||
unconditionally and stay compliant with the Rules of Hooks.
|
||||
@@ -0,0 +1,74 @@
|
||||
# `hooks/use-timeline`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTimeline()` returns the provider's single `TimelineEngine`
|
||||
instance, the canonical owner of timeline state — tracks, clips,
|
||||
selection, loop region, master volume, undo/redo. The engine exposes
|
||||
its own typed event surface (`engine.on(event, listener)`); consumers
|
||||
who want to *subscribe* to engine events should pair this hook with
|
||||
`useSyncExternalStore` and pass `engine.on(...)` as the subscribe
|
||||
callback. The hook itself returns the engine handle and does not
|
||||
trigger re-renders on engine state changes.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns the `TimelineEngine` instance owned by the provider.
|
||||
- Stable identity across re-renders within a ready phase.
|
||||
- Throws `REACT_AUDIO.NO_PROVIDER` outside `<AudioProvider>`.
|
||||
- Throws `REACT_AUDIO.PROVIDER_PENDING` during async init.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTimeline } from "@suaveplan/react-audio";
|
||||
|
||||
function AddTrackButton() {
|
||||
const timeline = useTimeline();
|
||||
return (
|
||||
<button type="button" onClick={() => timeline.addTrack({ name: "New Track" })}>
|
||||
Add track
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTimeline } from "@suaveplan/react-audio";
|
||||
|
||||
function useTrackCount(): number {
|
||||
const timeline = useTimeline();
|
||||
return useSyncExternalStore(
|
||||
(listener) => timeline.on("trackChange", listener as never),
|
||||
() => timeline.getState().tracks.length,
|
||||
() => timeline.getState().tracks.length
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTimeline(): TimelineEngine` — see
|
||||
`@suaveplan/audio-timeline` for the full engine surface.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook deliberately stops short of re-rendering on every engine
|
||||
event. The `TimelineEngine` has a rich change surface and re-rendering
|
||||
on every state delta would be expensive at chain scale. Consumers
|
||||
choose which events they care about and pair the engine handle with
|
||||
`useSyncExternalStore` for narrow subscriptions, paying only for the
|
||||
re-renders they want. Mutator methods (`addTrack`, `updateTrack`,
|
||||
`removeTrack`, `setTempo`, `setMasterVolume`, …) are exposed directly
|
||||
on the engine handle — callers wrap them in their own callbacks when
|
||||
they need referential stability across renders. For per-track state
|
||||
prefer `useTrack(id)`, which already wraps `useSyncExternalStore`
|
||||
against the timeline's `trackChange` event so its subscription is
|
||||
narrow by construction.
|
||||
74
docs/ui/react/react-audio/src/hooks/use-track/use-track.md
Normal file
74
docs/ui/react/react-audio/src/hooks/use-track/use-track.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# `hooks/use-track`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTrack(trackId)` subscribes to a single track on the
|
||||
`TimelineEngine` by id and re-renders only when that specific track
|
||||
changes. It returns the live `Track` snapshot (or `undefined` if no
|
||||
track has that id) plus pre-bound `update` / `remove` callbacks. The
|
||||
hook is the canonical way to render per-track UI without re-rendering
|
||||
on every other track's state change.
|
||||
|
||||
## Features
|
||||
|
||||
- Re-renders only on `trackChange` events for the same id.
|
||||
- Returns `undefined` for unknown ids — render-safe.
|
||||
- Stable `update` / `remove` identities across re-renders.
|
||||
- `useSyncExternalStore`-based subscription with SSR-consistent
|
||||
snapshots.
|
||||
- Listener is removed on unmount.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTrack } from "@suaveplan/react-audio";
|
||||
|
||||
function TrackName({ trackId }: { trackId: string }) {
|
||||
const { track } = useTrack(trackId);
|
||||
return <span>{track?.name ?? "(missing track)"}</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTrack } from "@suaveplan/react-audio";
|
||||
|
||||
function MuteToggle({ trackId }: { trackId: string }) {
|
||||
const { track, update } = useTrack(trackId);
|
||||
if (track === undefined) return null;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={track.muted}
|
||||
onClick={() => update({ muted: !track.muted })}
|
||||
>
|
||||
{track.muted ? "Unmute" : "Mute"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTrack(trackId): { track: Track | undefined, update, remove }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook caches the resolved Track in a `ref` so
|
||||
`useSyncExternalStore.getSnapshot` is identity-stable between change
|
||||
events. Without that cache, `timeline.getState().tracks.find(...)`
|
||||
would build a new wrapper on every snapshot read; React 18+ treats
|
||||
that as an unstable store and tears the consuming subtree down on
|
||||
the next commit. Refresh fires on every `trackChange` from the
|
||||
timeline AND from local `update` / `remove` mutators — the
|
||||
underlying engine emits `trackChange` only for tracks STILL present
|
||||
in the list, so removing the last track of an id needs the mutator
|
||||
to update the cache directly. After removal the hook continues to
|
||||
render but `track` becomes `undefined`. Components consuming
|
||||
`update` / `remove` should expect those callbacks to settle
|
||||
synchronously; the next React commit reflects the change.
|
||||
@@ -0,0 +1,63 @@
|
||||
# `hooks/use-transport-time`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransportTime()` returns the transport's current time in seconds,
|
||||
sampled at the provider's configured `sampleRateHz` (default 60).
|
||||
The audio engine advances at audio-block rate (typically ~689 Hz for
|
||||
128-sample blocks at 88.2 kHz); React cannot render at that rate, so
|
||||
the hook deliberately samples at frame rate. The returned value lags
|
||||
real time by at most one frame interval (~16.7 ms at 60 Hz) — the
|
||||
correct ceiling for human-readable transport displays.
|
||||
|
||||
## Features
|
||||
|
||||
- `useSyncExternalStore`-based subscription for concurrent-render
|
||||
safety and SSR consistency.
|
||||
- Sample rate configurable via `<AudioProvider options={{ sampleRateHz }}>`.
|
||||
- Cleanup on unmount cancels the timer — no leaked intervals.
|
||||
- SSR snapshot equals first client render (both read `transport.seconds`).
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function TimeDisplay() {
|
||||
const seconds = useTransportTime();
|
||||
return <span>{seconds.toFixed(3)} s</span>;
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTransportTime } from "@suaveplan/react-audio";
|
||||
|
||||
function BeatLight() {
|
||||
const seconds = useTransportTime();
|
||||
const beat = Math.floor(seconds * 2) % 4; // 120 bpm assumed
|
||||
return <div data-beat={beat} className="beat-light" />;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransportTime(): number` — current transport time in seconds,
|
||||
sampled at `sampleRateHz`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The hook uses `setInterval` rather than `requestAnimationFrame`
|
||||
because the former is deterministic under happy-dom and Vitest (RAF
|
||||
in JSDOM-equivalents is synchronous and fires once per microtask,
|
||||
making the per-frame-cap test impossible to write). The interval is
|
||||
clamped to a minimum of 1 ms so a runaway `sampleRateHz` cannot lock
|
||||
up the UI. On unmount the interval is cleared via the
|
||||
`useSyncExternalStore` subscribe contract. The `getServerSnapshot`
|
||||
and `getSnapshot` both read `transport.seconds`, so SSR-rendered
|
||||
HTML byte-matches the first client commit.
|
||||
@@ -0,0 +1,78 @@
|
||||
# `hooks/use-transport`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`useTransport()` wraps the provider's `Transport` with a small,
|
||||
React-friendly façade: `{ transport, play, pause, stop, seek,
|
||||
setTempo }`. The wrapped callbacks are stable across re-renders so
|
||||
consumers can pass them to memoised children without losing equality.
|
||||
The underlying `Transport` is included in the result for consumers
|
||||
that need its full surface (loops, swing, schedule, event
|
||||
subscriptions via `transport.on(...)`).
|
||||
|
||||
## Features
|
||||
|
||||
- `play(time?, offset?)` — delegates to `transport.start`.
|
||||
- `pause(time?)` — delegates to `transport.pause`.
|
||||
- `stop(time?)` — delegates to `transport.stop`.
|
||||
- `seek(seconds)` — restarts the transport at the requested offset
|
||||
(converted to ticks via `transport.toTicks`).
|
||||
- `setTempo(bpm)` — writes the bpm signal at the current clock.
|
||||
- `transport` — escape hatch for advanced surface.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```tsx
|
||||
import { useTransport } from "@suaveplan/react-audio";
|
||||
|
||||
function TransportButtons() {
|
||||
const { play, pause, stop } = useTransport();
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => play()}>Play</button>
|
||||
<button type="button" onClick={() => pause()}>Pause</button>
|
||||
<button type="button" onClick={() => stop()}>Stop</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```tsx
|
||||
import { useTransport } from "@suaveplan/react-audio";
|
||||
|
||||
function TempoSlider() {
|
||||
const { setTempo, transport } = useTransport();
|
||||
return (
|
||||
<input
|
||||
type="range"
|
||||
min={40}
|
||||
max={200}
|
||||
defaultValue={transport.bpm.getValueAtTime(transport.seconds)}
|
||||
onChange={(e) => setTempo(Number(e.currentTarget.value))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `useTransport(): UseTransportResult`.
|
||||
- `UseTransportResult = { transport, play, pause, stop, seek, setTempo }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`seek` works by stopping the transport and (if it was playing)
|
||||
restarting it at the requested tick offset; this matches the
|
||||
underlying `Transport` API, which does not expose a direct seek
|
||||
primitive. `setTempo` writes the bpm `Signal` at the current
|
||||
clock — the signal owns its own ramp/automation logic, so consumers
|
||||
should use the `Signal` API for ramped tempo changes instead of
|
||||
calling `setTempo` rapidly. The callbacks are wrapped in
|
||||
`useCallback` keyed on `transport` so callers can memoise downstream
|
||||
work that depends on transport-callback identity.
|
||||
67
docs/ui/react/react-audio/src/provider/audio-context.md
Normal file
67
docs/ui/react/react-audio/src/provider/audio-context.md
Normal 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.
|
||||
89
docs/ui/react/react-audio/src/provider/audio-provider.md
Normal file
89
docs/ui/react/react-audio/src/provider/audio-provider.md
Normal 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.
|
||||
70
docs/ui/react/react-audio/src/provider/chain-map.md
Normal file
70
docs/ui/react/react-audio/src/provider/chain-map.md
Normal 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.
|
||||
@@ -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.
|
||||
79
docs/ui/react/react-audio/src/provider/provider-options.md
Normal file
79
docs/ui/react/react-audio/src/provider/provider-options.md
Normal 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.
|
||||
@@ -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.
|
||||
78
docs/ui/react/react-audio/src/stores/ui-store/ui-store.md
Normal file
78
docs/ui/react/react-audio/src/stores/ui-store/ui-store.md
Normal 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.
|
||||
64
docs/ui/react/react-audio/src/test-utils.md
Normal file
64
docs/ui/react/react-audio/src/test-utils.md
Normal 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.
|
||||
Reference in New Issue
Block a user