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

26 KiB
Raw Permalink Blame History

Capability Spec: react-audio

Package: @suaveplan/react-audio Status: Phase 1 of 3 shipped (foundation: provider + hooks + stores + error codes + a11y helpers).

This is the canonical spec. Phase 2 (enterprise-react-audio-components) will append component requirements; Phase 3 (enterprise-react-audio-midi) will append the MIDI learn-mode panel requirements. Phase 1 requirements remain unchanged across those additions.

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 fully initialised" 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, engineState, error, version } 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: ProviderBusRegistry, chains: ChainMap, registry: PluginRegistry, presetStore: PresetStore | undefined, sampleRateHz: number. 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, useAudioFxPlugin) SHALL use useSyncExternalStore for its subscription where applicable. 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, transport time) SHALL be sampled at requestAnimationFrame/interval rate (configurable via the provider's sampleRateHz option, default 60).

Scenario: useTransportTime samples at frame rate, not block rate

  • WHEN transport advances and the consumer renders for 100 ms with sampleRateHz: 60
  • 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, sampled at the provider's sampleRateHz. 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), bypass(idOrIndex, bypassed), snapshot(), restore(input) }. 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 + Phase-2 components), ./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.

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"


Requirement: Generic PluginSlot renders any AudioFxPlugin

The package SHALL export <PluginSlot plugin /> that renders any AudioFxPlugin by:

  1. reading plugin.metadata for the slot header (name + version + category badge),
  2. iterating plugin.parameters (declaration order) and rendering each via a renderer chosen by the parameter's unit / scale / min/max declaration (boolean → toggle, unit: 'db' → log slider, unit: 'hz' → log knob with octave snap, unit: 'enum' → segmented control, default → linear slider),
  3. rendering a bypass toggle wired to the chain-level bypass,
  4. rendering a preset selector populated from plugin.getPreset() snapshots stored via the provider's preset store,
  5. exposing an advanced toggle that reveals parameters declared with category === 'advanced'.

The provider's slotRenderers option SHALL accept Record<string /* plugin.metadata.id */, ComponentType<PluginSlotRendererProps>>; a registered renderer SHALL supersede the generic slot for that plugin id. Every rendered parameter control SHALL be keyboard-driven per the "Accessibility" requirement below.

Scenario: every parameter renders a control bound to its plugin

  • WHEN <PluginSlot plugin={eq}> is rendered for an EQ with 5 parameters
  • THEN 5 control elements are rendered, each with role matching its parameter type and a label equal to the parameter's name

Scenario: registered specialised renderer supersedes the generic slot

  • WHEN the provider's slotRenderers maps eq to <EQPanel /> and <PluginSlot plugin={eq}> is rendered
  • THEN <EQPanel /> renders in place of the generic slot

Verified by: src/components/plugin-slot/plugin-slot.test.tsx::"renders one control per parameter with correct role and label" AND src/components/plugin-slot/plugin-slot.test.tsx::"registered specialised renderer supersedes the generic slot"


Requirement: PluginChainView drag/drop with IO validation

<PluginChainView chain /> SHALL render the chain's plugins as a list of <PluginSlot>s and SHALL support drag-to-reorder (HTML5 DnD with a keyboard alternative using Space to pick up / Arrow to move / Enter to drop) and drop-to-insert from a plugin picker. Insertions SHALL be validated against chain.add() and rejections SHALL surface in a non-blocking toast with the underlying AudioFxError's code. A keyboard "Add FX" menu SHALL list PluginRegistry.list() filtered by IO compatibility with the chain's current effective output channel count.

Scenario: drop with incompatible IO surfaces the error

  • WHEN a user drops a 5.1-channel plugin into a 2-channel chain
  • THEN insertion is rejected with AudioFxError whose code === "AUDIO_FX.CHAIN_IO_INCOMPATIBLE", a toast appears with the error message, and the chain is unchanged

Scenario: keyboard reorder moves a slot

  • WHEN a user focuses the third slot, presses Space, presses ArrowUp, presses Enter
  • THEN chain.move(2, 1) was called and the slot's new index is 1

Verified by: src/components/plugin-chain-view/plugin-chain-view.test.tsx::"drop with incompatible IO surfaces AUDIO_FX.CHAIN_IO_INCOMPATIBLE" AND src/components/plugin-chain-view/plugin-chain-view.test.tsx::"keyboard reorder moves a slot"


Requirement: Accessibility — keyboard and screen-reader

Every parameter control rendered by <PluginSlot> SHALL implement: ←/→ for fine step (1 % of declared range), ↑/↓ for coarse step (10 %), PageUp/PageDown for ×10 coarse, Home/End for min/max, Enter to type-edit a value, Shift multiplier ×10, Alt divider ÷10. Each SHALL carry role="slider", aria-valuemin, aria-valuemax, aria-valuenow, and aria-valuetext formatted with the parameter's unit (e.g. "+3.5 dB", "880 Hz", "50 %"). Animations SHALL respect prefers-reduced-motion. Focus indicators SHALL pass WCAG AAA contrast.

Scenario: arrow keys adjust a knob by the declared step

  • WHEN a user focuses a parameter knob whose range is [-24, 24] (dB) and presses →
  • THEN the parameter advances by 0.48 (1 % of 48); aria-valuenow and aria-valuetext reflect the new value

Scenario: aria-valuetext includes the parameter unit

  • WHEN a user inspects the aria attributes of a frequency knob set to 880
  • THEN aria-valuetext === "880 Hz"

Verified by: src/components/plugin-parameter-knob/plugin-parameter-knob.test.tsx::"arrow keys adjust the knob by the declared step" AND src/components/plugin-parameter-knob/plugin-parameter-knob.test.tsx::"aria-valuetext includes the parameter unit"


Requirement: ChainSnapshot save / restore through PresetManager

<PresetManager chain /> SHALL render save / load / delete controls for the chain. Save SHALL call chain.snapshot(), validate the result against the ChainSnapshot Zod schema (re-exported from @suaveplan/audio-fx-plugin-base), and persist it to the provider's preset store (default: localStorage-backed; consumers MAY inject a different store). Load SHALL call chain.restore(snapshot, provider.registry). A failure SHALL surface as a toast carrying the underlying AudioFxError's code. Cross-component subscriptions to the preset list SHALL re-render on save / delete via useChainSnapshot(chain).

Scenario: save persists a snapshot that load round-trips

  • WHEN a user clicks Save, then clears the chain by removing every plugin, then clicks the saved preset
  • THEN the chain is restored to the saved order with the saved parameters and bypass states

Scenario: load failure surfaces the error

  • WHEN a saved snapshot references a plugin type no longer in the registry
  • THEN restore rejects with AudioFxError whose code === "AUDIO_FX.CHAIN_SLOT_NOT_FOUND" and a toast displays the missing pluginType

Verified by: src/components/preset-manager/preset-manager.test.tsx::"save persists a snapshot that load round-trips" AND src/components/preset-manager/preset-manager.test.tsx::"load failure surfaces AUDIO_FX.CHAIN_SLOT_NOT_FOUND"


Requirement: Storybook coverage gate

Every exported component (including the foundation's <AudioProvider> once Phase 2 lands) SHALL ship at least three Storybook stories: a default (representative props), an interactive (a control that demonstrates state transitions), and an edge-case (empty / loading / error / max-state). The CI gate SHALL refuse a change that introduces a component without all three story types.

Scenario: every component has at least three story types

  • WHEN tests/exports.test.ts::"every component has default, interactive, and edge-case Storybook stories" runs
  • THEN for every exported component the corresponding *.stories.tsx file declares stories whose names match the regex /^(Default|Interactive|Edge.*)$/

Verified by: tests/exports.test.ts::"every component has default, interactive, and edge-case Storybook stories"


Requirement: Component subpath exports

package.json SHALL declare ./components (barrel of every component) AND ./components/<component-name> for every exported component. Foundation subpath exports from Phase 1 SHALL remain. Tree-shaking SHALL keep ./hooks/use-transport independent of any component bundle.

Scenario: every component subpath is declared

  • WHEN the test suite inspects package.json exports
  • THEN every shipped component has a ./components/<component-name> entry AND ./components is declared

Verified by: tests/exports.test.ts::"every component has a subpath export entry"


Requirement: MidiControllerSetup learn-mode panel

<MidiControllerSetup> SHALL render the connected controllers from the provider's MidiControllerRegistry (sourced via @suaveplan/audio-controllers) and expose a learn-mode UI: the user selects a target parameter on a plugin, presses a control on the hardware, and the panel calls bindParameter(plugin, paramId, capturedSource, { smoothingMs }) from @suaveplan/audio-controllers. Captured bindings SHALL be persisted via the provider's preset store (the same store used by <PresetManager>) and re-bound on next mount. An Unbind button SHALL invoke the unsubscribe function returned by bindParameter. The package SHALL also export the useMidiController(controllerId) hook returning the controller instance + a learn(target) method.

Scenario: learn captures the next CC and binds it

  • WHEN a user selects eq.lowShelfGain as the target and the fake-device fixture emits CC 1/7 = 64
  • THEN bindParameter(eq, 'lowShelfGain', { type: 'cc', channel: 1, number: 7 }) is called and the panel shows the binding

Scenario: unbind removes the binding

  • WHEN a user clicks Unbind on an active binding
  • THEN subsequent CC events for that source no longer call setParameter on the plugin

Scenario: useMidiController hook subscribes to connection-state changes

  • WHEN the controller transitions disconnected → connecting → connected
  • THEN the component using useMidiController(controllerId) re-renders once per transition with the new state

Verified by: src/components/midi-controller-setup/midi-controller-setup.test.tsx::"learn captures the next CC and binds it" AND src/components/midi-controller-setup/midi-controller-setup.test.tsx::"unbind removes the binding" AND src/hooks/use-midi-controller/use-midi-controller.test.tsx::"re-renders on connection-state transitions"


Requirement: Composed-plugin UI components

The package SHALL export <TransportControls>, <TrackList>, <TrackRow>, <MasterMeter>, <Mixer>, <AudioVisualizer>, <AudioPlayer>, <BasicPlayer>, <PremiumPlayer>, <MicrophoneControls>, <GLaDOSControls>, and <PluginLoader>. Each component SHALL compose against the foundation hooks (Phase 1) and the appropriate concrete plugin package: <Mixer> against @suaveplan/audio-fx-plugin-mixer, <AudioPlayer> family against @suaveplan/audio-fx-plugin-audio-player, <MicrophoneControls> against @suaveplan/audio-fx-plugin-microphone, <GLaDOSControls> against @suaveplan/audio-fx-plugin-glados. Each component SHALL satisfy the Storybook coverage gate (default + interactive + edge-case stories) and the accessibility requirement from Phase 2. Each SHALL have a ./components/<name> subpath export.

Scenario: every composed-plugin component renders against a real instance

  • WHEN a test renders <Mixer> with a real MixerPlugin from @suaveplan/audio-fx-plugin-mixer registered on the provider
  • THEN the component renders strip controls bound to the real plugin's parameters; setting a strip's gain calls mixerPlugin.setChannelGain and the new value is reflected on the next sample tick

Scenario: subpath export per component

  • WHEN a consumer imports @suaveplan/react-audio/components/transport-controls
  • THEN the import resolves and pulls in only that component's bundle (verified by tests/exports.test.ts)

Verified by: src/components/transport-controls/transport-controls.test.tsx::"renders against a real transport" AND src/components/track-list/track-list.test.tsx::"renders against a real TimelineEngine" AND src/components/mixer/mixer.test.tsx::"renders against a real MixerPlugin" AND src/components/audio-player/audio-player.test.tsx::"renders against a real AudioPlayerPlugin" AND src/components/microphone-controls/microphone-controls.test.tsx::"renders against a real MicrophonePlugin" AND src/components/glados-controls/glados-controls.test.tsx::"renders against a real GLaDOSPlugin" AND src/components/audio-visualizer/audio-visualizer.test.tsx::"renders a canvas bound to useAnalyser" AND src/components/master-meter/master-meter.test.tsx::"renders against a master analyser" AND src/components/plugin-loader/plugin-loader.test.tsx::"lists every registered plugin from PluginRegistry" AND tests/exports.test.ts::"every extras component has a subpath export entry"


Requirement: Main-branch parity restored

After the supplementary extras change archives, every component on /mnt/s/web/suaveplan/packages/react/react-audio/src/components/ SHALL have a genesis equivalent exported from @suaveplan/react-audio. The parity test SHALL fail if any main-branch component family is missing on genesis.

Scenario: parity test enumerates every main-branch component family

  • WHEN tests/parity.test.ts::"every main-branch component family has a genesis equivalent" runs
  • THEN for every top-level directory under /mnt/s/web/suaveplan/packages/react/react-audio/src/components/ (the test reads this list from a checked-in fixture so it stays runnable offline) there is a corresponding genesis component reachable via @suaveplan/react-audio/components/<name> (or another approved subpath listed in tests/parity-fixture.ts)

Verified by: tests/parity.test.ts::"every main-branch component family has a genesis equivalent"


Requirement: Player-tier hooks — useAnalyser and usePlaylist

Added 2026-05-23 alongside @suaveplan/react-audio-player. The package SHALL export two additional hooks needed by the player-tier surface but generic enough to keep on the foundation:

  • useAnalyser(opts?: UseAnalyserOptions): UseAnalyserResult — polls an AnalyserNode-shaped source (direct ref or lazy factory) and returns the latest byte-magnitude FFT frame plus the matching time-domain (waveform) frame. The hook does NOT create an AudioContext or add itself to the audio graph; consumers hand in an existing analyser (typically the engine's per-track analyser).
  • usePlaylist(items: readonly PlaylistItem[], opts?: UsePlaylistOptions): UsePlaylistResult — pure (no engine) queue controller with repeat: "off" | "all" | "one", shuffle, next / prev / skipTo / setIndex, per-shuffle history so prev reverses, and injectable RNG for deterministic tests.

Both hooks SHALL have ./hooks/use-analyser and ./hooks/use-playlist subpath exports.

Scenario: useAnalyser polls a structural AnalyserLike

  • WHEN useAnalyser({ analyser: fake, intervalMs: 5 }) is rendered with a structural AnalyserNode test double
  • THEN within the interval the hook calls getByteFrequencyData and getByteTimeDomainData and exposes the resulting buffers

Scenario: usePlaylist advances and wraps per repeat mode

  • WHEN usePlaylist(items, { initialRepeat: "all" }) is rendered with three items, next() is called at the last item
  • THEN the cursor wraps to index 0

Verified by: src/hooks/use-analyser/use-analyser.test.ts::"polls the explicit analyser and writes into the shared buffer" AND src/hooks/use-playlist/use-playlist.test.ts::"repeat=all wraps both directions"