Files
suaveplan/openspec/archive/2026-05-19-enterprise-react-audio-foundation/design.md
2026-05-27 23:29:53 +01:00

184 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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.
- `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<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]),
}
}
```
- `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. `<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 **`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 `<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`/`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<PanelId, boolean>`, `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`; `<MidiControllerSetup>` 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 `<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`, 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 `<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.