# Design — enterprise-react-audio `@suaveplan/react-audio` is the **presentation layer** of the audio family. It owns no DSP, no plugin instance state, no transport scheduler, no parameter automation logic, no audio context. Its job is to take the universal contracts (`AudioFxPlugin`, `PluginChain`, `ChainContext`, `AmbientState`, `Transport`, `TimelineEngine`, `BrowserTimelineAdapter`) and surface them to React components, ergonomically, type-safely, and without leaking engine types into application code. The cross-cutting architecture for the whole audio family lives in `openspec/archive/2026-05-14-enterprise-audio-core/design.md` and `openspec/archive/2026-05-19-split-audio-plugins-fx/specs/audio-plugins-fx/spec.md`. This document covers only the React-specific concerns: provider lifecycle, hook subscription model, component contracts, accessibility, and Storybook coverage. --- ## 1. Position in the audio family ``` application (Next.js / Vite / Remix consumer) │ ▼ @suaveplan/react-audio ← this package AudioProvider + hooks + components │ ┌──────────────┼──────────────┬───────────────┐ ▼ ▼ ▼ ▼ audio-engine- audio-fx-plugin-base audio- audio-controllers browser (PluginChain, timeline + (TimelineAdapter, AudioFxPlugin) transport BusRegistry) ``` The package is a **fan-in** in React's tree: the `AudioProvider` constructs one engine + one `TimelineEngine` + one `Transport` + one `BusRegistry` + one `PluginRegistry` for the React subtree below it. Every hook reads from that single provider via React Context; every component reads from hooks. There is no global singleton. Multiple `AudioProvider`s in the same React tree are supported (useful for sandboxed previews) — they don't share state. --- ## 2. `AudioProvider` lifecycle and strict-mode safety React 19 Strict Mode mounts → unmounts → re-mounts every effect in development. A naive `useEffect(() => { const adapter = new BrowserTimelineAdapter(); … }, [])` produces two engines (the second mounted on top of the first's leaked `AudioContext`). The provider avoids this with a **ref + version counter** pattern: ```ts function AudioProvider({ children, options }: AudioProviderProps) { const validated = useMemo(() => ProviderOptionsSchema.parse(options), [options]) const stateRef = useRef(null) const [version, setVersion] = useState(0) useEffect(() => { let cancelled = false let local: ProviderState | null = null void (async () => { const engine = new BrowserTimelineAdapter(validated.adapter) await engine.init({ signal: abortController.signal }) const timeline = new TimelineEngine({ adapter: engine }) const transport = new Transport(validated.transport ?? {}) const buses = new DefaultBusRegistry() const registry = new PluginRegistry() autoRegisterPlugins(registry, validated.plugins ?? 'all') if (cancelled) { engine.dispose(); return } local = { engine, timeline, transport, buses, registry, ambient: defaultAmbientState() } stateRef.current = local setVersion(v => v + 1) })() return () => { cancelled = true if (local) { local.engine.dispose(); local.transport.dispose() } stateRef.current = null } }, [validated, abortController]) const ctxValue = stateRef.current return {children} } ``` - The async construction lives outside React's render path so Strict Mode's double-invoke does not double-construct. - `version` triggers re-render once the engine has resolved; consumers see `state === null` during the brief async init window and may render a loading state. - On unmount, dispose is unconditional and synchronous. The provider is therefore safe in SSR (renders `null` state on the server), in Next.js client components, and under hot-reload. A `useEngineState()` hook reports the engine's `init` state (`pending | ready | error`) so consumers can render "Initializing audio…" without each component reaching into provider internals. --- ## 3. Hook subscription model Hooks subscribe to the engine, chain, transport, or plugin event surfaces. Updates are batched with `useSyncExternalStore` to avoid React 18+ tearing and to ensure SSR consistency: ```ts export function useParameter( plugin: TPlugin, paramId: string ): { value: number; set(value: number): void; curve(curve: ParameterCurve): void } { const value = useSyncExternalStore( useCallback(listener => plugin.onParameterChange(paramId, listener), [plugin, paramId]), useCallback(() => plugin.getParameter(paramId), [plugin, paramId]), useCallback(() => plugin.getParameter(paramId), [plugin, paramId]), // SSR snapshot ) return { value, set: useCallback(v => plugin.setParameter(paramId, v), [plugin, paramId]), curve: useCallback(c => plugin.setParameterCurve(paramId, c), [plugin, paramId]), } } ``` - `useSyncExternalStore` guarantees consistency under concurrent rendering and Suspense. - The subscribe callback uses the plugin's own event surface — no module-level state, no Zustand listener for engine-derived values. - SSR snapshot equals client snapshot, so the same parameter value renders on server and client. Hooks that depend on per-block updates (the most demanding — `useTransportTime`, `useAnalyser`, parameter values during automation) sample at a configurable rate (default 60 Hz via `requestAnimationFrame`) rather than every audio block (typically 689 Hz for 128-sample blocks at 88.2 kHz). React cannot re-render at 689 Hz; sampling at frame rate is correct. --- ## 4. Component contracts Every component: - accepts the **engine entity it represents** as a typed prop (e.g. ``, ``), not an id-by-string lookup; the parent hook resolves the entity once and passes it down, - emits **typed events** for user actions (e.g. ``), - uses **`parameterIndex` for parameter rendering** so the UI auto-derives from the plugin's declaration; specialised UIs opt in via a `renderer` prop, - is **accessible by default** — keyboard, screen reader, high-contrast, reduced-motion. The generic `` is the cornerstone. Given any `AudioFxPlugin` it renders a row with: name + version, bypass toggle, parameter knobs/sliders generated from `plugin.parameters` (one per parameter, type chosen from the `unit`/`scale` declaration), preset selector dropdown, and a "show advanced" expander that reveals less-used parameters. Specialised UIs (``, ``, ``) can be registered to a plugin id via the provider's `slotRenderers` option and supersede the generic renderer. `` is built on `` plus drag-to-reorder (HTML5 DnD + keyboard alternative), drop-to-insert from a plugin picker, and a chain-level "Add FX" menu that lists `PluginRegistry.list()` filtered by IO compatibility with the chain's current effective output (so you can't insert a 5.1 plugin into a stereo chain). --- ## 5. Accessibility Every parameter control implements: - **Keyboard**: `←`/`→` for fine step (1% of range), `↑`/`↓` for coarse step (10%), `PageUp`/`PageDown` for ×10 coarse, `Home`/`End` for min/max, `Enter` to type a value, `Shift` multiplier ×10, `Alt` divider ÷10. - **Screen reader**: `role="slider"`, `aria-valuemin` / `aria-valuemax` / `aria-valuenow`, `aria-valuetext` that formats the value with the parameter's unit (e.g. `"+3.5 dB"`, `"880 Hz"`, `"50 %"`). - **Reduced motion**: `prefers-reduced-motion` disables knob spring animations; values still update. - **High contrast**: every visual focus indicator passes WCAG AAA. - **Reduced transparency**: respects `prefers-reduced-transparency` for translucent panels. A `keyboard-bindings.ts` module owns the keymap; tests assert every parameter type honours every binding. --- ## 6. Zustand stores The package keeps Zustand for **UI-only** state — anything derivable from engine state is read via hooks, not duplicated in the store: - `useAudioStore` — empty by default; reserved for app-level overrides (e.g. a consumer wants to persist track names across sessions). The provider exposes a `useAudioStore` hook scoped to its subtree. - `useUiStore` — `panelOpen: Record`, `theme: 'light' | 'dark' | 'auto'`, `visualization: 'waveform' | 'spectrum' | 'meter' | 'off'`, `selectedTrackId: string | undefined`, `dragState`. Stores live behind the provider so different `AudioProvider`s have different stores (useful for sandboxed previews). --- ## 7. Testing strategy - **Unit**: every hook tested with `@testing-library/react` against a `BrowserTimelineAdapter` with an injected `OfflineAudioContext`. No mocking of the engine — the real engine runs, just into an offline context. - **Integration**: a full provider + chain + parameter automation + bus pipeline test that proves a side-chain compressor in chain B ducks under signal from chain A, end-to-end through React. - **E2E (Playwright)**: real-browser audio test for the cases offline-context can't cover (audio context resume on user gesture, MIDI device hot-plug). - **Storybook**: every component renders with at least default / interactive / edge-case (empty / loading / error / max-state) stories. Storybook stories double as visual regression tests via Chromatic-equivalent snapshots in CI. - **Bundle-size**: `tests/bundle-size/hooks-only.size.test.ts` asserts that importing only the hooks does not pull in the components or Storybook. `provider-only.size.test.ts` asserts the same for the provider. --- ## 8. Integration with sibling packages - **`@suaveplan/audio-engine-browser`** — provider constructs and disposes one `BrowserTimelineAdapter`. Hooks subscribe to its `stateChanged` and per-track chain events. - **`@suaveplan/audio-fx-plugin-base`** — provider constructs one `DefaultBusRegistry` and one `PluginRegistry`; hooks consume `PluginChain` instances per track. Components render any `AudioFxPlugin` generically. - **`@suaveplan/audio-timeline`** — provider constructs one `TimelineEngine`; hooks expose track CRUD, clip CRUD, undo/redo. - **`@suaveplan/audio-transport`** — provider constructs one `Transport`; hooks expose play/pause/stop/seek/tempo and `useTransportTime` sampled at frame rate. - **`@suaveplan/audio-controllers`** — provider exposes a `MidiControllerRegistry`; `` renders a learn-mode UI and calls `bindParameter` for each captured mapping. --- ## 9. Benchmarks - `hook-rerender.bench.ts` — measure re-renders per second on a chain UI with 8 plugins each with 6 automated parameters at 60 Hz sample rate; expect ≤ 60 React renders/sec (one per RAF tick). - `chain-ui.bench.ts` — measure time to render `` with 16 plugins; expect ≤ 16 ms (one frame). --- ## 10. Decisions and trade-offs - **One provider owns everything.** Multiple providers are allowed but each gets its own engine. No global singleton. - **`useSyncExternalStore`, not `useState` + manual subscriptions.** Concurrent-rendering safe; SSR consistent. - **Sample external events at frame rate, not block rate.** React cannot render at 689 Hz; 60 Hz is the right ceiling. - **Components take entities, not ids.** Hooks resolve once; props are typed; tree-shake clean. - **Generic `` is the default; specialised renderers opt in.** New plugins work without writing UI code. - **`` validates IO compatibility on drop.** Catches "can't insert a 5.1 plugin into a stereo chain" at the UI, before the engine. - **Accessibility is non-negotiable.** Keyboard + screen-reader + reduced-motion + high-contrast pass at component-test level, not as an afterthought. - **Storybook coverage is a gate, not a nice-to-have.** Every component ships stories; missing stories block the change. - **No Zustand-mirrored engine state.** Engine state lives on the engine; React reads via hooks. No two sources of truth.