Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
# 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.