12 KiB
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 AudioProviders 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:
function AudioProvider({ children, options }: AudioProviderProps) {
const validated = useMemo(() => ProviderOptionsSchema.parse(options), [options])
const stateRef = useRef<ProviderState | null>(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 <AudioContext.Provider value={{ state: ctxValue, version }}>{children}</AudioContext.Provider>
}
- The async construction lives outside React's render path so Strict Mode's double-invoke does not double-construct.
versiontriggers re-render once the engine has resolved; consumers seestate === nullduring 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
nullstate 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:
export function useParameter<TPlugin extends AudioFxPlugin>(
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]),
}
}
useSyncExternalStoreguarantees 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.
<PluginSlot plugin={...} />,<PluginChainView chain={...} />), 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.
<PluginSlot onParameterChange={...} />), - uses
parameterIndexfor parameter rendering so the UI auto-derives from the plugin's declaration; specialised UIs opt in via arendererprop, - is accessible by default — keyboard, screen reader, high-contrast, reduced-motion.
The generic <PluginSlot> 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 (<EQPanel>, <ReverbPanel>, <CompressorPanel>) can be registered to a plugin id via the provider's slotRenderers option and supersede the generic renderer.
<PluginChainView> is built on <PluginSlot> 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/PageDownfor ×10 coarse,Home/Endfor min/max,Enterto type a value,Shiftmultiplier ×10,Altdivider ÷10. - Screen reader:
role="slider",aria-valuemin/aria-valuemax/aria-valuenow,aria-valuetextthat formats the value with the parameter's unit (e.g."+3.5 dB","880 Hz","50 %"). - Reduced motion:
prefers-reduced-motiondisables knob spring animations; values still update. - High contrast: every visual focus indicator passes WCAG AAA.
- Reduced transparency: respects
prefers-reduced-transparencyfor 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 auseAudioStorehook scoped to its subtree.useUiStore—panelOpen: Record<PanelId, boolean>,theme: 'light' | 'dark' | 'auto',visualization: 'waveform' | 'spectrum' | 'meter' | 'off',selectedTrackId: string | undefined,dragState.
Stores live behind the provider so different AudioProviders have different stores (useful for sandboxed previews).
7. Testing strategy
- Unit: every hook tested with
@testing-library/reactagainst aBrowserTimelineAdapterwith an injectedOfflineAudioContext. 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.tsasserts that importing only the hooks does not pull in the components or Storybook.provider-only.size.test.tsasserts the same for the provider.
8. Integration with sibling packages
@suaveplan/audio-engine-browser— provider constructs and disposes oneBrowserTimelineAdapter. Hooks subscribe to itsstateChangedand per-track chain events.@suaveplan/audio-fx-plugin-base— provider constructs oneDefaultBusRegistryand onePluginRegistry; hooks consumePluginChaininstances per track. Components render anyAudioFxPlugingenerically.@suaveplan/audio-timeline— provider constructs oneTimelineEngine; hooks expose track CRUD, clip CRUD, undo/redo.@suaveplan/audio-transport— provider constructs oneTransport; hooks expose play/pause/stop/seek/tempo anduseTransportTimesampled at frame rate.@suaveplan/audio-controllers— provider exposes aMidiControllerRegistry;<MidiControllerSetup>renders a learn-mode UI and callsbindParameterfor 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<PluginChainView>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, notuseState+ 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
<PluginSlot>is the default; specialised renderers opt in. New plugins work without writing UI code. <PluginChainView>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.