Files
2026-05-27 23:29:53 +01:00

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