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.

View File

@@ -0,0 +1,200 @@
# OpenSpec Change: enterprise-react-audio
- **Change ID**: enterprise-react-audio
- **Package**: `@suaveplan/react-audio`
- **Category**: react
- **Target path**: `packages/react/react-audio`
- **Status**: PROPOSED
- **Date**: 2026-05-15
- **Design**: `./design.md`
- **Spec delta**: `./specs/react-audio/spec.md`
> Replaces the prior 2026-04-24 draft that was flagged `NEEDS-REWRITE` because it targeted the obsolete monolithic `@suaveplan/audio-engine` and predated the universal `audio-core` + `audio-timeline` + `audio-transport` split. See `../REACT_AUDIO_WAVE_REWRITE_COMPLETE.md`.
---
## Summary
Deliver `@suaveplan/react-audio` on genesis from scratch at enterprise quality. The package is the React surface for the unified audio architecture: it composes `@suaveplan/audio-engine-browser` (the `TimelineAdapter` rendering backend), `@suaveplan/audio-fx-plugin-base` (the `AudioFxPlugin` contract + `PluginChain` primitive + `AmbientState`), `@suaveplan/audio-timeline` (`TimelineEngine`), `@suaveplan/audio-transport` (musical clock), and `@suaveplan/audio-controllers` (MIDI binding) into:
1. **A single React context provider** (`AudioProvider`) that owns one engine adapter, one timeline engine, one transport, one project-level bus registry, and one plugin registry for the React subtree below it. Strict-mode safe (double-render does not produce double-engine). Disposable; unmounting the provider tears down the engine cleanly.
2. **A typed hook layer** that exposes the unified architecture without leaking engine types: `useAudioEngine`, `useTimeline`, `useTransport`, `useTrack(id)`, `usePluginChain(trackId)`, `useChainPlugin(trackId, slotIndex)`, `useParameter(plugin, paramId)` (subscribes to the parameter's per-block value with sample-bounded updates), `usePreset(plugin)`, `useBus(name)`, `useAnalyser(trackId | "master")`, `useTransportTime()`, `useEngineState()`, `useMidiController(controllerId)`. Hooks subscribe via the plugin / chain / transport event surfaces; updates are React-batched.
3. **A component library** that maps the contract to UI: `<TrackList>`, `<TrackRow>`, `<PluginChainView>` (drag-to-reorder, drop-to-insert), `<PluginSlot>` (bypass toggle, dry/wet for plugins that expose it, parameter knobs/sliders rendered from `parameterIndex`, preset selector), `<MasterMeter>`, `<TransportControls>` (play/pause/stop/seek/loop/tempo), `<Mixer>` (track strips with gain/pan/mute/solo/sends), `<MicrophoneControls>`, `<GLaDOSControls>`, `<MidiControllerSetup>` (learn-mode panel), `<PresetManager>` (chain snapshot save/restore via Zod-validated JSON).
4. **Zustand stores** for cross-component UI state that does not belong on the engine (panel open/closed, theme, visualization mode, drag state).
5. **First-class accessibility** — every parameter control is keyboard-driven (arrow keys for fine, page-up/down for coarse, shift for ×10, alt for ×0.1), every knob/slider has a labelled `aria-valuetext` with the unit, every chain UI is screen-reader navigable.
6. **Storybook stories** for every component with default + interactive + edge-case states (empty chain, bypassed plugin, mid-load reverb IR, sidechain bound).
7. **100% test coverage** with happy-dom + injected `OfflineAudioContext` per `audio-engine-browser`'s test seam.
The package contains **no DSP, no plugin instance state, no transport scheduler, no parameter automation logic** — every one of those lives below and is consumed by reference. React's job is presentation and event wiring.
---
## Current-state audit (genesis)
The package does not exist on genesis yet — this is a fresh build replacing the obsolete pre-tier 2026-04-24 draft. The upstream `react/media/react-audio/` is built on the monolithic `@suaveplan/audio-engine`; its public surface (12 hooks, 14 components, 2 stores) is captured here and re-derived against the unified architecture so prior consumer expectations carry over where they remain meaningful.
---
## Parity Checklist (vs upstream `../suaveplan/packages/react/media/react-audio/`)
| Capability | Upstream | Migrated | Notes |
|---|:---:|:---:|---|
| AudioProvider context | ✅ | ✅ | Now wraps `BrowserTimelineAdapter` + `PluginRegistry` + `BusRegistry` instead of the old monolithic engine. |
| useAudioEngine hook | ✅ | ⬆️ | Now exposes the `TimelineEngine` + `Transport` + chain APIs, not the obsolete `AudioEngine` class. |
| useAudioPlayer | ✅ | ⬆️ | Built on `AudioPlayerPlugin` from `@suaveplan/audio-fx-plugin-audio-player`; reads transport time from `useTransportTime`. |
| useAudioRecorder | ✅ | ✅ | Wraps `MediaRecorder` + the engine's tap-to-bus surface for in-app capture. |
| useAudioVisualizer | ✅ | ✅ | Reads `useAnalyser(trackId)` rather than reaching into the engine. |
| useDragDropQueue | ✅ | ✅ | Reuses the existing drag/drop utilities; unchanged surface. |
| useKeyboardShortcuts | ✅ | ✅ | Unchanged. |
| useMobileGestures | ✅ | ✅ | Unchanged. |
| usePlaylist | ✅ | ✅ | Built on `AudioPlayerPlugin`'s loaded-source queue. |
| usePlugin (single plugin) | ✅ | ✅ | Now over `AudioFxPlugin` from `@suaveplan/audio-fx-plugin-base`. |
| usePluginChain | ✅ | ⬆️ | Now reflects the universal `PluginChain` primitive (insert / remove / move / replace / bypass / snapshot / restore / events). |
| usePresetManager | ✅ | ⬆️ | Plugin-level via `usePreset(plugin)` AND chain-level via `useChainSnapshot(chain)` for FX-rack-style "track presets". |
| useQueue | ✅ | ✅ | Unchanged. |
| audio-store (Zustand) | ✅ | ⬆️ | Slimmer; no playback state (that lives on transport). UI state only. |
| ui-store (Zustand) | ✅ | ✅ | Unchanged. |
| audio-dashboard component | ✅ | ✅ | Re-enabled; tree-shaking proven via subpath export bundle test. |
| audio-player component | ✅ | ✅ | Re-enabled; consumes `AudioPlayerPlugin`. |
| audio-provider component | ✅ | ⬆️ | Now also owns `PluginRegistry` and `BusRegistry`. |
| audio-visualizer | ✅ | ✅ | Same surface; reads `useAnalyser`. |
| basic-player | ✅ | ✅ | Slim variant. |
| premium-player | ✅ | ⬆️ | Adds chain UI for inserting FX plugins from the registry. |
| plugin-chain UI | ✅ | ⬆️ | Drag-to-reorder, drop-to-insert, bypass toggle, parameter rendering from `parameterIndex`. |
| plugin-controls (EQ/reverb/delay/compressor) | ✅ | ✅ | Replaced with generic `<PluginSlot>` that renders any `AudioFxPlugin` from its `parameters` declaration. EQ/reverb/delay/compressor get optional specialised UIs that opt in. |
| plugin-loader UI | ✅ | ⬆️ | Now reads from `PluginRegistry.list()`; supports dynamic registration. |
| mixer | ✅ | ✅ | Built on `MixerPlugin` from `@suaveplan/audio-fx-plugin-mixer`. |
| preset-manager | ✅ | ✅ | Chain-snapshot save/restore. |
| microphone-controls | ✅ | ✅ | Built on `MicrophonePlugin`. |
| vocoder-controls (GLaDOS) | ✅ | ✅ | Built on `GLaDOSPlugin`. |
| MIDI controller setup (NEW) | – | ⬆️ | Genesis addition; learn-mode panel for `MidiControllerPlugin` from `audio-controllers`. |
| Storybook coverage | ⚠ partial | ✅ | Every component gets default / interactive / edge-case stories. |
| Coverage | ~70 % | ✅ | 100/100/100/100 per genesis policy. |
No 🚫 rows.
---
## Available workspace deps on genesis
| Package | Role |
|---|---|
| `@suaveplan/audio-engine-browser` (`workspace:^`) | `BrowserTimelineAdapter` + `DefaultBusRegistry` |
| `@suaveplan/audio-timeline` (`workspace:^`) | `TimelineEngine`, `Track`, `Clip` |
| `@suaveplan/audio-transport` (`workspace:^`) | `Transport` musical clock |
| `@suaveplan/audio-fx-plugin-base` (`workspace:^`) | `AudioFxPlugin`, `PluginChain`, `PluginRegistry`, every plugin |
| `@suaveplan/audio-controllers` (`workspace:^`) | `MidiControllerPlugin`, `bindParameter` |
| `@suaveplan/events` (`workspace:^`) | Typed event subscriptions for hook updates |
| `@suaveplan/error` (`workspace:^`) | `defineCodes` + `AudioError` |
| `@suaveplan/types` (`workspace:^`) | `LoggerLike` |
| `@suaveplan/opentelemetry` (`workspace:^`) | OTel spans on lifecycle events |
| `react` (`peerDependency`) | React 19 peer |
| `zustand` (`catalog:state`) | UI store |
| `framer-motion` (`catalog:animation`) | Knob / slider animation |
| `lucide-react` (`catalog:ui`) | Icons |
| `zod` (`catalog:forms`) | Provider-options validation |
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `happy-dom`, `@storybook/react`, `@testing-library/react`, `@testing-library/user-event`, `playwright` (all `catalog:testing`).
---
## File layout
```text
packages/react/react-audio/
package.json
tsconfig.json
vitest.config.ts
src/
index.ts
error-codes.ts (+ .test.ts .md)
provider/
audio-provider.tsx (+ .md .types.ts .test.tsx .stories.tsx)
provider-options.ts (+ .schemas.ts .md .test.ts)
index.ts
hooks/
use-audio-engine.ts (+ .md .test.tsx)
use-timeline.ts (+ .md .test.tsx)
use-transport.ts (+ .md .test.tsx)
use-transport-time.ts (+ .md .test.tsx)
use-engine-state.ts (+ .md .test.tsx)
use-track.ts (+ .md .test.tsx)
use-plugin-chain.ts (+ .md .test.tsx)
use-chain-plugin.ts (+ .md .test.tsx)
use-audio-fx-plugin.ts (+ .md .test.tsx)
use-parameter.ts (+ .md .test.tsx)
use-preset.ts (+ .md .test.tsx)
use-chain-snapshot.ts (+ .md .test.tsx)
use-bus.ts (+ .md .test.tsx)
use-analyser.ts (+ .md .test.tsx)
use-audio-recorder.ts (+ .md .test.tsx)
use-audio-visualizer.ts (+ .md .test.tsx)
use-drag-drop-queue.ts (+ .md .test.tsx)
use-keyboard-shortcuts.ts (+ .md .test.tsx)
use-mobile-gestures.ts (+ .md .test.tsx)
use-playlist.ts (+ .md .test.tsx)
use-queue.ts (+ .md .test.tsx)
use-midi-controller.ts (+ .md .test.tsx)
index.ts
stores/
audio-store.ts (+ .md .test.ts)
ui-store.ts (+ .md .test.ts)
index.ts
components/
transport-controls/ (.tsx .md .test.tsx .stories.tsx)
track-list/ ...
track-row/ ...
plugin-chain-view/ ...
plugin-slot/ ...
plugin-parameter-knob/ ...
plugin-parameter-slider/ ...
preset-selector/ ...
preset-manager/ ...
master-meter/ ...
mixer/ ...
audio-provider-component/ ...
audio-visualizer/ ...
audio-dashboard/ ...
basic-player/ ...
premium-player/ ...
audio-player/ ...
microphone-controls/ ...
vocoder-controls/ ...
plugin-loader/ ...
midi-controller-setup/ ...
a11y/
keyboard-bindings.ts (+ .md .test.ts)
aria-value-text.ts (+ .md .test.ts)
index.ts
__benchmarks__/
hook-rerender.bench.ts
chain-ui.bench.ts
tests/
integration.test.tsx
purity.test.ts
exports.test.ts
bundle-size/
hooks-only.size.test.ts
provider-only.size.test.ts
e2e/
audio.spec.ts
plugin-chain.spec.ts
midi-learn.spec.ts
```
---
## Impact
**Breaking changes**: none — fresh build.
**Prerequisites**: `audio-engine-browser`, `audio-fx-plugin-base` + every `audio-fx-plugin-*` package, `audio-controllers`, `audio-timeline`, `audio-transport`, `events`, `error`, `types`, `opentelemetry` shipped to Verdaccio.
### Affected dependents
- `@suaveplan/react-audio-player` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate change `enterprise-react-audio-player` consumes this package's hooks + provider; created against this spec, not retrofitted)
- `@suaveplan/react-audio-stream` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate change `enterprise-react-audio-stream` builds on this package's transport hooks; created against this spec)
- `@suaveplan/react-audio-fingerprint` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate change `enterprise-react-audio-fingerprint` consumes `useAnalyser`; created against this spec)
- `@suaveplan/r3f-audio` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: separate change `enterprise-gaming-r3f-audio` consumes `useTransport` + the game-audio building blocks from the `@suaveplan/audio-fx-plugin-*` family; created against this spec)
Once landed, any subsequent change to this package's public surface MUST run `bunx turbo test --filter=...@suaveplan/react-audio` per `openspec/AGENTS.md` §13 step 10.

View File

@@ -0,0 +1,146 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: react-audio (enterprise-react-audio-foundation)
**Package**: `@suaveplan/react-audio`
**Status**: PROPOSED — Phase 1 of 3 (foundation)
> Phase 1 of the decomposed `enterprise-react-audio` rollout. Ships the provider + hook layer + stores + error codes + a11y helpers + hooks-only subpath exports. Components are Phase 2 (`enterprise-react-audio-components`). MIDI learn-mode panel is Phase 3 (`enterprise-react-audio-midi`).
>
> Every requirement below carries a `**Verified by:**` line.
## ADDED 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"` 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, version: number }` 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: BusRegistry`, `registry: PluginRegistry`. 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`, `useAnalyser`, `useAudioFxPlugin`) SHALL use `useSyncExternalStore` for its subscription. 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, analyser output, transport time) SHALL be sampled at `requestAnimationFrame` rate (configurable via the provider's `sampleRateHz` option, default 60).
#### Scenario: useTransportTime samples at frame rate, not block rate
- **WHEN** transport advances for 100 ms with 128-sample blocks at 48 kHz (≈ 37 blocks)
- **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, subscribed to the plugin's parameter-change event. `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<AudioFxPlugin>, add(plugin, at?), remove(idOrIndex), move(from, to), replace(idOrIndex, plugin, opts?), bypass(idOrIndex, bypassed), snapshot(), restore(snapshot) }`. 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), `./provider`, `./hooks`, `./hooks/<hook-name>` 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. The component subpath (`./components`, `./components/<name>`) is reserved for Phase 2 — Phase 1 packages SHALL NOT publish a `./components` entry.
#### Scenario: importing one hook does not pull in the rest
- **WHEN** a consumer imports `@suaveplan/react-audio/hooks/use-transport` only
- **THEN** the tree-shaken bundle excludes the other hooks' modules (the `use-transport` chunk does not pull `use-parameter`, `use-plugin-chain`, …)
#### 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 `./<surface>` entry
**Verified by:** `tests/exports.test.ts::"every Phase-1 surface has a subpath export entry"` AND `tests/exports.test.ts::"./components is not declared in Phase 1"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,84 @@
# Tasks: enterprise-react-audio-foundation
**Change ID**: enterprise-react-audio-foundation
**Package**: `@suaveplan/react-audio`
**Target path**: `packages/react/react-audio`
**Phase**: 1 of 3 (foundation)
**Prerequisite packages on disk**: `@suaveplan/audio-engine-browser`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-controllers`, `@suaveplan/audio-timeline`, `@suaveplan/audio-transport`, `@suaveplan/events`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry` — all shipped at v0.1.0.
> Phase 1 ships provider + hooks + stores + error codes + a11y helpers + hooks-only subpath exports. Components are Phase 2 (`enterprise-react-audio-components`). MIDI learn-mode panel is Phase 3 (`enterprise-react-audio-midi`).
---
## 1. Scaffold
- [ ] 1.1 Create directory tree per `proposal.md` "File layout" — `src/{provider,hooks,stores,a11y,error-codes}` plus `tests/`.
- [ ] 1.2 Write `package.json` at `0.1.0`, `"type": "module"`, `"sideEffects": false`, foundation-scoped `exports` map (root + `./provider`, `./hooks`, `./hooks/<name>` per hook, `./stores`, `./a11y`, `./error-codes`). NO `./components` entry yet — reserved for Phase 2. React 19 peer dep.
- [ ] 1.3 Write `tsconfig.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts` (`happy-dom`). Scripts.
## 2. Error codes
- [ ] 2.1 `src/error-codes/error-codes.ts` — `defineCodes("REACT_AUDIO", [...])`, `ReactAudioError` extending `SuaveplanError` from `@suaveplan/error`. Export `ReactAudioErrorCode`, `REACT_AUDIO_ERROR_METADATA`, `registerReactAudioErrorCodes()`. Codes: `NO_PROVIDER`, `PROVIDER_PENDING`, `UNKNOWN_PARAMETER`, `PRESET_STORE_FAILED`, `INVALID_OPTIONS`.
- [ ] 2.2 `error-codes.test.ts` + `error-codes.md` — every code reachable from a real call site (asserted).
## 3. Provider
- [ ] 3.1 `src/provider/provider-options.ts` + `.schemas.ts` — Zod `AudioProviderOptionsSchema` covering adapter context, transport options, plugin auto-registration, preset store, sample rate, slot renderers (just typed — components consume them in Phase 2).
- [ ] 3.2 `src/provider/audio-provider.tsx` — strict-mode-safe lifecycle; async init with `AbortSignal`; double-invoke proof; dispose on unmount; React context exposes `{ state: ProviderState | null, version: number }`.
- [ ] 3.3 `audio-provider.test.tsx` — strict-mode double-invoke produces exactly one engine; unmount disposes adapter + transport + bus registry exactly once; hook-outside-provider throws `REACT_AUDIO.NO_PROVIDER`.
- [ ] 3.4 `audio-provider.md`.
## 4. Hooks (per `src/hooks/<hook-name>/<hook-name>.{ts,test.tsx,md}`)
For each hook: implement with `useSyncExternalStore`, write `.test.tsx` covering happy + error + SSR-snapshot equality, and `.md` (≥200 words).
- [ ] 4.1 `use-engine-state` — `'pending' | 'ready' | 'error'`.
- [ ] 4.2 `use-audio-engine` — returns `ProviderState`.
- [ ] 4.3 `use-timeline` — `TimelineEngine` accessor + track CRUD.
- [ ] 4.4 `use-transport` — `Transport` accessor + play/pause/stop/seek/setTempo.
- [ ] 4.5 `use-transport-time` — frame-rate-sampled current time.
- [ ] 4.6 `use-track(trackId)` — track state + per-track chain accessor.
- [ ] 4.7 `use-plugin-chain(trackId)` — chain composition + events.
- [ ] 4.8 `use-chain-plugin(trackId, slotIndex)` — slot accessor.
- [ ] 4.9 `use-audio-fx-plugin(pluginId)` — plugin by id from registry.
- [ ] 4.10 `use-parameter(plugin, paramId)` — value + set + curve + scheduleAt; throws `REACT_AUDIO.UNKNOWN_PARAMETER` for unknown ids.
- [ ] 4.11 `use-preset(plugin)` — plugin-level preset save/load.
- [ ] 4.12 `use-chain-snapshot(chain)` — chain-level snapshot save/load via preset store.
- [ ] 4.13 `use-bus(name)` — bus accessor.
## 5. Stores
- [ ] 5.1 `src/stores/audio-store/audio-store.ts` — provider-scoped Zustand store with override hook. Slim shape; no playback state (that's on transport).
- [ ] 5.2 `src/stores/ui-store/ui-store.ts` — panels, theme, visualization mode, selectedTrackId, dragState. Ports verbatim from main.
## 6. A11y helpers (non-component-level, foundation-only)
- [ ] 6.1 `src/a11y/keyboard-bindings/keyboard-bindings.ts` — central keymap export consumed by Phase-2 component knobs/sliders. Pure value table.
- [ ] 6.2 `src/a11y/aria-value-text/aria-value-text.ts` — unit-aware `formatAriaValueText(value, unit, min, max)` formatter. Pure function.
- [ ] 6.3 `src/a11y/index.ts` — explicit named re-exports.
## 7. Root barrel + tests
- [ ] 7.1 `src/index.ts` — explicit named re-exports of provider, every hook, both stores, a11y helpers, error-codes surface (rule 10).
- [ ] 7.2 `tests/purity.test.ts` — built `dist/` references no `AudioContext`/`OfflineAudioContext`/`AudioNode`/`node:*`/`process`/`Buffer`; runtime `dependencies` match the foundation allowlist.
- [ ] 7.3 `tests/exports.test.ts` — every Phase-1 surface has a subpath entry; `./components` is NOT declared.
## 8. Documentation
- [ ] 8.1 Sibling `.md` for every `.ts`/`.tsx` (except test/types/index — per CLAUDE.md), ≥200 words.
- [ ] 8.2 `README.md` at package root with quick-start + hook examples + Phase-2/Phase-3 forward-reference.
## 9. Pre-archive verification (5-gate triple)
- [ ] 9.1 `bun run check` — Biome 0/0/0/0 monorepo-wide.
- [ ] 9.2 `bunx turbo lint typecheck test --filter=@suaveplan/react-audio` — green.
- [ ] 9.3 `bunx turbo test:coverage --filter=@suaveplan/react-audio` — 100/100/100/100.
- [ ] 9.4 `bun scripts/openspec-conformance.ts @suaveplan/react-audio` — PASS, exit 0.
- [ ] 9.5 `bunx turbo build --filter=@suaveplan/react-audio` — green.
- [ ] 9.6 `bun scripts/docs-check.ts packages/react/react-audio` — 100%.
## 10. Archive
- [ ] 10.1 Fold the foundation spec delta into NEW `openspec/specs/react-audio/spec.md` (Phase 1 shipped requirements). The canonical spec will grow with Phase 2 and Phase 3 — leave room.
- [ ] 10.2 `git mv openspec/changes/enterprise-react-audio-foundation openspec/archive/2026-05-19-enterprise-react-audio-foundation`.
- [ ] 10.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`. `STATUS.md` row for `@suaveplan/react-audio` stays at `–` (Phase 1 alone is incomplete relative to the proposal's full scope) — flip to ✅ only when Phase 3 lands. Note Phase 1 progress in the description column ("Phase 1 of 3 shipped: hooks + provider").