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

336 lines
26 KiB
Markdown
Raw 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: 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 -->