147 lines
11 KiB
Markdown
147 lines
11 KiB
Markdown
<!-- 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 -->
|