11 KiB
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-audiorollout. 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, orBuffer
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
BrowserTimelineAdapteris ever fully initialised at any time, the cancelled construction's adapter is disposed, and noAudioContextis leaked
Scenario: unmount tears down the engine
- WHEN the provider unmounts
- THEN
adapter.dispose(),transport.dispose(), andbusRegistry.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 anAudioProviderancestor - THEN the render throws
ReactAudioErrorwhosecode === "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 onstate !== nullre-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
useTransportTimere-renders at most 7 times (60 Hz × 100 ms + 1)
Scenario: SSR snapshot equals first client render
- WHEN a component using
useParameteris 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.5and the hook'svaluere-renders to0.5on the next RAF sample tick
Scenario: unknown parameter throws REACT_AUDIO.UNKNOWN_PARAMETER
- WHEN
useParameter(plugin, 'nonexistent')renders - THEN the render throws
ReactAudioErrorwhosecode === "REACT_AUDIO.UNKNOWN_PARAMETER"with the offending id incontext
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
usePluginChaincallsadd(eqPlugin)and the chain emitsplugin-added - THEN the component re-renders once with
plugins.lengthincreased by one andplugins[plugins.length - 1] === eqPlugin
Scenario: unmount removes every listener
- WHEN the component using
usePluginChainunmounts - 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_METADATAis 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-transportonly - THEN the tree-shaken bundle excludes the other hooks' modules (the
use-transportchunk does not pulluse-parameter,use-plugin-chain, …)
Scenario: every Phase-1 surface has a subpath entry
- WHEN the test suite inspects
package.jsonexports - 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"