Initial Commit
This commit is contained in:
335
openspec/specs/react-audio/spec.md
Normal file
335
openspec/specs/react-audio/spec.md
Normal file
@@ -0,0 +1,335 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user