# Capability Spec: react-audio **Package**: `@suaveplan/react-audio` **Status**: Phase 1 of 3 shipped (foundation: provider + hooks + stores + error codes + a11y helpers). > This is the canonical spec. Phase 2 (`enterprise-react-audio-components`) will append component requirements; Phase 3 (`enterprise-react-audio-midi`) will append the MIDI learn-mode panel requirements. Phase 1 requirements remain unchanged across those additions. ## Requirements ### Requirement: React-tier purity — no engine, no audio context `@suaveplan/react-audio` SHALL import zero browser-only audio APIs (`AudioContext`, `OfflineAudioContext`, `AudioNode`, `AudioWorklet`) and zero Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All audio surface is reached through `@suaveplan/audio-engine-browser`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, and `@suaveplan/audio-controllers`. The package's runtime `dependencies` SHALL be limited to those five workspace packages plus `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `zustand`, `zod`, and `tslib`. `react` SHALL be a `peerDependency` only. #### Scenario: built dist references no audio context or Node globals - **WHEN** the built `dist/` is grep'd - **THEN** it MUST NOT reference `AudioContext`, `OfflineAudioContext`, `AudioNode`, `node:fs`, `node:path`, `process`, or `Buffer` **Verified by:** `tests/purity.test.ts::"dist references no audio context or Node globals"` AND `tests/purity.test.ts::"runtime dependency set matches the allowlist"` --- ### Requirement: AudioProvider strict-mode safety The package SHALL export an `AudioProvider` React component constructed from `AudioProviderOptions` (validated by Zod at construction). On mount, it SHALL asynchronously construct exactly one `BrowserTimelineAdapter`, one `TimelineEngine`, one `Transport`, one `DefaultBusRegistry`, and one `PluginRegistry`, await the adapter's `init()` (forwarding an `AbortSignal` cancellable on unmount), and publish the resulting state to its React context. Under React 19 Strict Mode the mount → unmount → re-mount cycle SHALL NOT produce two engines: the provider SHALL track its async construction with a cancellation flag and dispose any in-flight adapter if the effect is torn down before init resolves. On unmount the provider SHALL synchronously call `adapter.dispose()`, `transport.dispose()`, and close every active `SidechainPublisher`. #### Scenario: strict-mode double-invoke produces exactly one engine - **WHEN** the provider is rendered under React Strict Mode and the first effect is invoked, torn down, and re-invoked - **THEN** exactly one `BrowserTimelineAdapter` is ever fully initialised at any time, the cancelled construction's adapter is disposed, and no `AudioContext` is leaked #### Scenario: unmount tears down the engine - **WHEN** the provider unmounts - **THEN** `adapter.dispose()`, `transport.dispose()`, and `busRegistry.close()` are each called exactly once, in that order **Verified by:** `src/provider/audio-provider.test.tsx::"strict-mode double-invoke produces exactly one engine fully initialised"` AND `src/provider/audio-provider.test.tsx::"unmount tears down adapter, transport, and bus registry exactly once"` --- ### Requirement: AudioProvider exposes engine state to consumers The provider's React context SHALL carry `{ state: ProviderState | null, engineState, error, version }` where `state === null` during the async init window and the engine readiness state is reported via a `useEngineState(): 'pending' | 'ready' | 'error'` hook. `ProviderState` SHALL expose readonly `engine: BrowserTimelineAdapter`, `timeline: TimelineEngine`, `transport: Transport`, `buses: ProviderBusRegistry`, `chains: ChainMap`, `registry: PluginRegistry`, `presetStore: PresetStore | undefined`, `sampleRateHz: number`. Hooks SHALL throw `ReactAudioError` with code `REACT_AUDIO.NO_PROVIDER` if called outside an `AudioProvider`, and throw `ReactAudioError` with code `REACT_AUDIO.PROVIDER_PENDING` if called against a provider whose engine is still initialising AND the hook is not the `useEngineState` reader. #### Scenario: hook outside provider throws REACT_AUDIO.NO_PROVIDER - **WHEN** `useTransport()` is rendered without an `AudioProvider` ancestor - **THEN** the render throws `ReactAudioError` whose `code === "REACT_AUDIO.NO_PROVIDER"` #### Scenario: useEngineState reports pending → ready - **WHEN** the provider mounts and async init resolves - **THEN** `useEngineState()` first returns `'pending'`, then transitions to `'ready'` on the next React commit; consumers that gated on `state !== null` re-render once **Verified by:** `src/hooks/use-engine-state/use-engine-state.test.tsx::"hook outside provider throws REACT_AUDIO.NO_PROVIDER"` AND `src/hooks/use-engine-state/use-engine-state.test.tsx::"reports pending then ready across the init lifecycle"` --- ### Requirement: Hook subscription model is concurrent-safe and SSR-consistent Every hook that reflects engine state (`useTransport`, `useTransportTime`, `useEngineState`, `useTrack`, `usePluginChain`, `useChainPlugin`, `useParameter`, `usePreset`, `useChainSnapshot`, `useBus`, `useAudioFxPlugin`) SHALL use `useSyncExternalStore` for its subscription where applicable. The subscribe callback SHALL register against the source entity's own event surface (no module-level event hub). The server snapshot SHALL equal the client snapshot for the same source. Hooks SHALL NOT re-render at audio-block rate; per-block values (parameter automation, transport time) SHALL be sampled at `requestAnimationFrame`/interval rate (configurable via the provider's `sampleRateHz` option, default 60). #### Scenario: useTransportTime samples at frame rate, not block rate - **WHEN** transport advances and the consumer renders for 100 ms with `sampleRateHz: 60` - **THEN** the React component using `useTransportTime` re-renders at most 7 times (60 Hz × 100 ms + 1) #### Scenario: SSR snapshot equals first client render - **WHEN** a component using `useParameter` is rendered to a string on the server and hydrated on the client - **THEN** the SSR HTML and the first client render's output are byte-identical (no hydration mismatch) **Verified by:** `src/hooks/use-transport-time/use-transport-time.test.tsx::"samples at frame rate, not audio-block rate"` AND `src/hooks/use-parameter/use-parameter.test.tsx::"SSR snapshot equals first client render"` --- ### Requirement: useParameter reads, writes, and curves through the plugin `useParameter(plugin, paramId)` SHALL return `{ value: number, set(value): void, curve(curve): void, scheduleAt(value, transportTimeSec): void }`. `value` SHALL reflect the current parameter value, sampled at the provider's `sampleRateHz`. `set` SHALL call `plugin.setParameter(paramId, value)`. `curve` SHALL call `plugin.setParameterCurve(paramId, curve)`. `scheduleAt` SHALL call `plugin.setParameterAt(paramId, value, transportTimeSec)`. A hook for an unknown `paramId` SHALL throw `ReactAudioError` with code `REACT_AUDIO.UNKNOWN_PARAMETER` on first render. #### Scenario: set writes through to the plugin and value updates on next sample tick - **WHEN** a component calls `setParameter(0.5)` for a parameter whose declared range is `[0, 1]` - **THEN** the plugin observes `getParameter(id) === 0.5` and the hook's `value` re-renders to `0.5` on the next RAF sample tick #### Scenario: unknown parameter throws REACT_AUDIO.UNKNOWN_PARAMETER - **WHEN** `useParameter(plugin, 'nonexistent')` renders - **THEN** the render throws `ReactAudioError` whose `code === "REACT_AUDIO.UNKNOWN_PARAMETER"` with the offending id in `context` **Verified by:** `src/hooks/use-parameter/use-parameter.test.tsx::"set writes through to the plugin and value updates"` AND `src/hooks/use-parameter/use-parameter.test.tsx::"unknown paramId throws REACT_AUDIO.UNKNOWN_PARAMETER"` --- ### Requirement: usePluginChain reflects chain composition and events `usePluginChain(trackId)` SHALL return `{ chain: PluginChain, plugins: ReadonlyArray, add(plugin, at?), remove(idOrIndex), move(from, to), replace(idOrIndex, plugin), bypass(idOrIndex, bypassed), snapshot(), restore(input) }`. The `plugins` array SHALL be subscribed to the chain's `plugin-added`, `plugin-removed`, `plugin-moved`, `plugin-replaced`, and `bypass-changed` events and re-render the consuming component when any fire. Subscription cleanup on unmount SHALL be exact (no leaked listeners). #### Scenario: adding a plugin re-renders the consumer with the new list - **WHEN** a component using `usePluginChain` calls `add(eqPlugin)` and the chain emits `plugin-added` - **THEN** the component re-renders once with `plugins.length` increased by one and `plugins[plugins.length - 1] === eqPlugin` #### Scenario: unmount removes every listener - **WHEN** the component using `usePluginChain` unmounts - **THEN** the chain's listener count for the subscribed events returns to its pre-mount baseline **Verified by:** `src/hooks/use-plugin-chain/use-plugin-chain.test.tsx::"adding a plugin re-renders the consumer with the new list"` AND `src/hooks/use-plugin-chain/use-plugin-chain.test.tsx::"unmount removes every listener"` --- ### Requirement: Structured error codes The package SHALL declare its codes via `defineCodes("REACT_AUDIO", [...])` from `@suaveplan/error`, producing the dotted codes `REACT_AUDIO.NO_PROVIDER`, `REACT_AUDIO.PROVIDER_PENDING`, `REACT_AUDIO.UNKNOWN_PARAMETER`, `REACT_AUDIO.PRESET_STORE_FAILED`, and `REACT_AUDIO.INVALID_OPTIONS`. The package SHALL export `ReactAudioErrorCode`, `REACT_AUDIO_ERROR_METADATA`, `registerReactAudioErrorCodes()`, and a `ReactAudioError` class extending `SuaveplanError` from `@suaveplan/error`. Every error thrown by the package SHALL be a `ReactAudioError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests. #### Scenario: every registered code is thrown by a call site - **WHEN** the test suite runs - **THEN** each code in `REACT_AUDIO_ERROR_METADATA` is asserted thrown by at least one test **Verified by:** `src/error-codes/error-codes.test.ts::"every REACT_AUDIO code is reachable from a call site"` --- ### Requirement: Foundation subpath exports `package.json` SHALL declare subpath exports for every Phase-1 surface: `.` (foundation barrel covering provider + hooks + stores + error codes + a11y helpers + Phase-2 components), `./provider`, `./hooks`, `./hooks/` per hook, `./stores`, `./a11y`, `./error-codes`. `sideEffects` SHALL be `false`. A consumer importing `@suaveplan/react-audio/hooks/use-transport` SHALL NOT pull in the other hooks' modules beyond shared internals. #### Scenario: every Phase-1 surface has a subpath entry - **WHEN** the test suite inspects `package.json` exports - **THEN** every Phase-1 surface (provider, every hook in the declared hook list, stores, a11y, error-codes) has its own `./` entry **Verified by:** `tests/exports.test.ts::"every Phase-1 surface has a subpath export entry"` --- ### Requirement: Generic PluginSlot renders any AudioFxPlugin The package SHALL export `` that renders any `AudioFxPlugin` by: 1. reading `plugin.metadata` for the slot header (name + version + category badge), 2. iterating `plugin.parameters` (declaration order) and rendering each via a renderer chosen by the parameter's `unit` / `scale` / `min`/`max` declaration (boolean → toggle, `unit: 'db'` → log slider, `unit: 'hz'` → log knob with octave snap, `unit: 'enum'` → segmented control, default → linear slider), 3. rendering a bypass toggle wired to the chain-level bypass, 4. rendering a preset selector populated from `plugin.getPreset()` snapshots stored via the provider's preset store, 5. exposing an `advanced` toggle that reveals parameters declared with `category === 'advanced'`. The provider's `slotRenderers` option SHALL accept `Record>`; a registered renderer SHALL supersede the generic slot for that plugin id. Every rendered parameter control SHALL be keyboard-driven per the "Accessibility" requirement below. #### Scenario: every parameter renders a control bound to its plugin - **WHEN** `` is rendered for an EQ with 5 parameters - **THEN** 5 control elements are rendered, each with `role` matching its parameter type and a label equal to the parameter's `name` #### Scenario: registered specialised renderer supersedes the generic slot - **WHEN** the provider's `slotRenderers` maps `eq` to `` and `` is rendered - **THEN** `` renders in place of the generic slot **Verified by:** `src/components/plugin-slot/plugin-slot.test.tsx::"renders one control per parameter with correct role and label"` AND `src/components/plugin-slot/plugin-slot.test.tsx::"registered specialised renderer supersedes the generic slot"` --- ### Requirement: PluginChainView drag/drop with IO validation `` SHALL render the chain's plugins as a list of ``s and SHALL support drag-to-reorder (HTML5 DnD with a keyboard alternative using `Space` to pick up / `Arrow` to move / `Enter` to drop) and drop-to-insert from a plugin picker. Insertions SHALL be validated against `chain.add()` and rejections SHALL surface in a non-blocking toast with the underlying `AudioFxError`'s `code`. A keyboard "Add FX" menu SHALL list `PluginRegistry.list()` filtered by IO compatibility with the chain's current effective output channel count. #### Scenario: drop with incompatible IO surfaces the error - **WHEN** a user drops a 5.1-channel plugin into a 2-channel chain - **THEN** insertion is rejected with `AudioFxError` whose `code === "AUDIO_FX.CHAIN_IO_INCOMPATIBLE"`, a toast appears with the error message, and the chain is unchanged #### Scenario: keyboard reorder moves a slot - **WHEN** a user focuses the third slot, presses `Space`, presses `ArrowUp`, presses `Enter` - **THEN** `chain.move(2, 1)` was called and the slot's new index is 1 **Verified by:** `src/components/plugin-chain-view/plugin-chain-view.test.tsx::"drop with incompatible IO surfaces AUDIO_FX.CHAIN_IO_INCOMPATIBLE"` AND `src/components/plugin-chain-view/plugin-chain-view.test.tsx::"keyboard reorder moves a slot"` --- ### Requirement: Accessibility — keyboard and screen-reader Every parameter control rendered by `` SHALL implement: `←`/`→` for fine step (1 % of declared range), `↑`/`↓` for coarse step (10 %), `PageUp`/`PageDown` for ×10 coarse, `Home`/`End` for `min`/`max`, `Enter` to type-edit a value, `Shift` multiplier ×10, `Alt` divider ÷10. Each SHALL carry `role="slider"`, `aria-valuemin`, `aria-valuemax`, `aria-valuenow`, and `aria-valuetext` formatted with the parameter's `unit` (e.g. `"+3.5 dB"`, `"880 Hz"`, `"50 %"`). Animations SHALL respect `prefers-reduced-motion`. Focus indicators SHALL pass WCAG AAA contrast. #### Scenario: arrow keys adjust a knob by the declared step - **WHEN** a user focuses a parameter knob whose range is `[-24, 24]` (dB) and presses `→` - **THEN** the parameter advances by 0.48 (1 % of 48); `aria-valuenow` and `aria-valuetext` reflect the new value #### Scenario: aria-valuetext includes the parameter unit - **WHEN** a user inspects the aria attributes of a frequency knob set to 880 - **THEN** `aria-valuetext === "880 Hz"` **Verified by:** `src/components/plugin-parameter-knob/plugin-parameter-knob.test.tsx::"arrow keys adjust the knob by the declared step"` AND `src/components/plugin-parameter-knob/plugin-parameter-knob.test.tsx::"aria-valuetext includes the parameter unit"` --- ### Requirement: ChainSnapshot save / restore through PresetManager `` SHALL render save / load / delete controls for the chain. `Save` SHALL call `chain.snapshot()`, validate the result against the `ChainSnapshot` Zod schema (re-exported from `@suaveplan/audio-fx-plugin-base`), and persist it to the provider's preset store (default: `localStorage`-backed; consumers MAY inject a different store). `Load` SHALL call `chain.restore(snapshot, provider.registry)`. A failure SHALL surface as a toast carrying the underlying `AudioFxError`'s `code`. Cross-component subscriptions to the preset list SHALL re-render on save / delete via `useChainSnapshot(chain)`. #### Scenario: save persists a snapshot that load round-trips - **WHEN** a user clicks `Save`, then clears the chain by removing every plugin, then clicks the saved preset - **THEN** the chain is restored to the saved order with the saved parameters and bypass states #### Scenario: load failure surfaces the error - **WHEN** a saved snapshot references a plugin type no longer in the registry - **THEN** `restore` rejects with `AudioFxError` whose `code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND"` and a toast displays the missing pluginType **Verified by:** `src/components/preset-manager/preset-manager.test.tsx::"save persists a snapshot that load round-trips"` AND `src/components/preset-manager/preset-manager.test.tsx::"load failure surfaces AUDIO_FX.CHAIN_SLOT_NOT_FOUND"` --- ### Requirement: Storybook coverage gate Every exported component (including the foundation's `` once Phase 2 lands) SHALL ship at least three Storybook stories: a default (representative props), an interactive (a control that demonstrates state transitions), and an edge-case (empty / loading / error / max-state). The CI gate SHALL refuse a change that introduces a component without all three story types. #### Scenario: every component has at least three story types - **WHEN** `tests/exports.test.ts::"every component has default, interactive, and edge-case Storybook stories"` runs - **THEN** for every exported component the corresponding `*.stories.tsx` file declares stories whose names match the regex `/^(Default|Interactive|Edge.*)$/` **Verified by:** `tests/exports.test.ts::"every component has default, interactive, and edge-case Storybook stories"` --- ### Requirement: Component subpath exports `package.json` SHALL declare `./components` (barrel of every component) AND `./components/` for every exported component. Foundation subpath exports from Phase 1 SHALL remain. Tree-shaking SHALL keep `./hooks/use-transport` independent of any component bundle. #### Scenario: every component subpath is declared - **WHEN** the test suite inspects `package.json` exports - **THEN** every shipped component has a `./components/` entry AND `./components` is declared **Verified by:** `tests/exports.test.ts::"every component has a subpath export entry"` --- ### Requirement: MidiControllerSetup learn-mode panel `` SHALL render the connected controllers from the provider's `MidiControllerRegistry` (sourced via `@suaveplan/audio-controllers`) and expose a learn-mode UI: the user selects a target parameter on a plugin, presses a control on the hardware, and the panel calls `bindParameter(plugin, paramId, capturedSource, { smoothingMs })` from `@suaveplan/audio-controllers`. Captured bindings SHALL be persisted via the provider's preset store (the same store used by ``) and re-bound on next mount. An `Unbind` button SHALL invoke the unsubscribe function returned by `bindParameter`. The package SHALL also export the `useMidiController(controllerId)` hook returning the controller instance + a `learn(target)` method. #### Scenario: learn captures the next CC and binds it - **WHEN** a user selects `eq.lowShelfGain` as the target and the fake-device fixture emits `CC 1/7 = 64` - **THEN** `bindParameter(eq, 'lowShelfGain', { type: 'cc', channel: 1, number: 7 })` is called and the panel shows the binding #### Scenario: unbind removes the binding - **WHEN** a user clicks `Unbind` on an active binding - **THEN** subsequent CC events for that source no longer call `setParameter` on the plugin #### Scenario: useMidiController hook subscribes to connection-state changes - **WHEN** the controller transitions `disconnected → connecting → connected` - **THEN** the component using `useMidiController(controllerId)` re-renders once per transition with the new state **Verified by:** `src/components/midi-controller-setup/midi-controller-setup.test.tsx::"learn captures the next CC and binds it"` AND `src/components/midi-controller-setup/midi-controller-setup.test.tsx::"unbind removes the binding"` AND `src/hooks/use-midi-controller/use-midi-controller.test.tsx::"re-renders on connection-state transitions"` --- ### Requirement: Composed-plugin UI components The package SHALL export ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, ``, and ``. Each component SHALL compose against the foundation hooks (Phase 1) and the appropriate concrete plugin package: `` against `@suaveplan/audio-fx-plugin-mixer`, `` family against `@suaveplan/audio-fx-plugin-audio-player`, `` against `@suaveplan/audio-fx-plugin-microphone`, `` against `@suaveplan/audio-fx-plugin-glados`. Each component SHALL satisfy the Storybook coverage gate (default + interactive + edge-case stories) and the accessibility requirement from Phase 2. Each SHALL have a `./components/` subpath export. #### Scenario: every composed-plugin component renders against a real instance - **WHEN** a test renders `` with a real `MixerPlugin` from `@suaveplan/audio-fx-plugin-mixer` registered on the provider - **THEN** the component renders strip controls bound to the real plugin's parameters; setting a strip's gain calls `mixerPlugin.setChannelGain` and the new value is reflected on the next sample tick #### Scenario: subpath export per component - **WHEN** a consumer imports `@suaveplan/react-audio/components/transport-controls` - **THEN** the import resolves and pulls in only that component's bundle (verified by `tests/exports.test.ts`) **Verified by:** `src/components/transport-controls/transport-controls.test.tsx::"renders against a real transport"` AND `src/components/track-list/track-list.test.tsx::"renders against a real TimelineEngine"` AND `src/components/mixer/mixer.test.tsx::"renders against a real MixerPlugin"` AND `src/components/audio-player/audio-player.test.tsx::"renders against a real AudioPlayerPlugin"` AND `src/components/microphone-controls/microphone-controls.test.tsx::"renders against a real MicrophonePlugin"` AND `src/components/glados-controls/glados-controls.test.tsx::"renders against a real GLaDOSPlugin"` AND `src/components/audio-visualizer/audio-visualizer.test.tsx::"renders a canvas bound to useAnalyser"` AND `src/components/master-meter/master-meter.test.tsx::"renders against a master analyser"` AND `src/components/plugin-loader/plugin-loader.test.tsx::"lists every registered plugin from PluginRegistry"` AND `tests/exports.test.ts::"every extras component has a subpath export entry"` --- ### Requirement: Main-branch parity restored After the supplementary extras change archives, every component on `/mnt/s/web/suaveplan/packages/react/react-audio/src/components/` SHALL have a genesis equivalent exported from `@suaveplan/react-audio`. The parity test SHALL fail if any main-branch component family is missing on genesis. #### Scenario: parity test enumerates every main-branch component family - **WHEN** `tests/parity.test.ts::"every main-branch component family has a genesis equivalent"` runs - **THEN** for every top-level directory under `/mnt/s/web/suaveplan/packages/react/react-audio/src/components/` (the test reads this list from a checked-in fixture so it stays runnable offline) there is a corresponding genesis component reachable via `@suaveplan/react-audio/components/` (or another approved subpath listed in `tests/parity-fixture.ts`) **Verified by:** `tests/parity.test.ts::"every main-branch component family has a genesis equivalent"` --- ### Requirement: Player-tier hooks — useAnalyser and usePlaylist Added 2026-05-23 alongside `@suaveplan/react-audio-player`. The package SHALL export two additional hooks needed by the player-tier surface but generic enough to keep on the foundation: - `useAnalyser(opts?: UseAnalyserOptions): UseAnalyserResult` — polls an `AnalyserNode`-shaped source (direct ref or lazy factory) and returns the latest byte-magnitude FFT frame plus the matching time-domain (waveform) frame. The hook does NOT create an `AudioContext` or add itself to the audio graph; consumers hand in an existing analyser (typically the engine's per-track analyser). - `usePlaylist(items: readonly PlaylistItem[], opts?: UsePlaylistOptions): UsePlaylistResult` — pure (no engine) queue controller with `repeat: "off" | "all" | "one"`, `shuffle`, `next` / `prev` / `skipTo` / `setIndex`, per-shuffle history so `prev` reverses, and injectable RNG for deterministic tests. Both hooks SHALL have `./hooks/use-analyser` and `./hooks/use-playlist` subpath exports. #### Scenario: useAnalyser polls a structural AnalyserLike - **WHEN** `useAnalyser({ analyser: fake, intervalMs: 5 })` is rendered with a structural AnalyserNode test double - **THEN** within the interval the hook calls `getByteFrequencyData` and `getByteTimeDomainData` and exposes the resulting buffers #### Scenario: usePlaylist advances and wraps per repeat mode - **WHEN** `usePlaylist(items, { initialRepeat: "all" })` is rendered with three items, `next()` is called at the last item - **THEN** the cursor wraps to index 0 **Verified by:** `src/hooks/use-analyser/use-analyser.test.ts::"polls the explicit analyser and writes into the shared buffer"` AND `src/hooks/use-playlist/use-playlist.test.ts::"repeat=all wraps both directions"`