Files
suaveplan/openspec/archive/2026-05-19-enterprise-react-audio-foundation/specs/react-audio/spec.md
2026-05-27 23:29:53 +01:00

11 KiB
Raw Blame History

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"