# Capability Spec Delta: react-audio-player (enterprise-react-audio-player) **Package**: `@suaveplan/react-audio-player` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Player-tier purity — no DSP, no audio context `@suaveplan/react-audio-player` SHALL import zero browser audio APIs (`AudioContext`, `OfflineAudioContext`, `AudioNode`, `AudioWorklet`) and zero Node-only APIs. Every audio surface is reached through `@suaveplan/react-audio` hooks and `@suaveplan/audio-fx-plugin-base` plugins. Runtime `dependencies` SHALL be limited to `@suaveplan/react-audio`, `@suaveplan/audio-fx-plugin-base`, `@suaveplan/audio-transport`, `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/opentelemetry`, `@suaveplan/events`, `zustand`, `framer-motion`, `lucide-react`, `zod`, `tslib`. `react`, `@react-three/fiber`, `three`, and `@suaveplan/react-audio-stream` SHALL be peer dependencies; the latter three SHALL be required only by their respective optional subpath exports (`./screensavers/*`, `./sync/*`). #### Scenario: built dist references no audio context - **WHEN** the built `dist/` is grep'd - **THEN** it MUST NOT reference `AudioContext`, `OfflineAudioContext`, `AudioNode`, or `AudioWorklet` **Verified by:** `tests/purity.test.ts::"dist references no audio context"` AND `tests/purity.test.ts::"runtime dependency set matches the allowlist"` --- ### Requirement: AudioPlayerProvider nests AudioProvider and owns player stores The package SHALL export `` which wraps a `` from `@suaveplan/react-audio` and additionally constructs one `useAudioPlayerStore`, `usePlaylistStore`, and `useLyricsStore` (Zustand) scoped to its React subtree. On mount the provider SHALL construct one `AudioPlayerPlugin` instance via the inner provider's `PluginRegistry`, insert it as the source plugin of a `player-track` chain, and forward `'ended'` events to the playlist for auto-advance. On unmount it SHALL dispose the player track and stores. #### Scenario: provider constructs one source plugin per id - **WHEN** two `` instances mount with different ids - **THEN** two distinct `AudioPlayerPlugin` instances exist, each in its own `player-track` chain #### Scenario: 'ended' event advances the playlist - **WHEN** the source plugin emits `'ended'` and the playlist's `position` is not at the last item - **THEN** the next track's source is loaded, the player plays, and the playlist `position` advances by 1 **Verified by:** `src/provider/audio-player-provider.test.tsx::"constructs one source plugin per provider id"` AND `src/provider/audio-player-provider.test.tsx::"ended event advances the playlist"` --- ### Requirement: Three player variants share state through the provider The package SHALL export ``, ``, and ``. All three SHALL read from and write to the same provider stores so swapping between them while the provider remains mounted SHALL preserve playback position, volume, mute, playlist, repeat/shuffle, and EQ settings. `` SHALL render only ``, ``, and ``. `` SHALL additionally render ``, `` (3-band), and ``. `` SHALL additionally render ``, optional `` when `lrc` prop is supplied, and optional `` when `showChain` prop is true. #### Scenario: swapping variants preserves state - **WHEN** `` is playing a track at position 30 s with volume 0.7 and `` is then rendered under the same provider with no other changes - **THEN** `` shows the same track playing at ~30 s with volume 0.7 and the playback was not interrupted #### Scenario: BasicPlayer does not bundle Queue or visualiser code - **WHEN** a consumer imports `@suaveplan/react-audio-player/components/basic-player` only - **THEN** the tree-shaken bundle contains zero bytes from `` or `` **Verified by:** `src/components/audio-player/audio-player.test.tsx::"swapping variants preserves playback state"` AND `tests/bundle-size/basic-only.size.test.ts::"BasicPlayer subpath does not bundle Queue or visualiser code"` --- ### Requirement: LRC parser and LyricsDisplay The `./karaoke/lrc-parser` subpath SHALL export `parseLrc(source: string): Lyric[]` returning `{ timestampSec: number; text: string }[]` sorted ascending by timestamp. Tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]` SHALL be ignored (no metadata extraction in v1). Lines without a leading timestamp SHALL be skipped. `` SHALL read `useTransportTime()` and via a binary search re-bound every 100 ms (not every frame) compute the active lyric index; the active line SHALL be highlighted; an optional `announceLyrics` prop (default true) SHALL set `aria-live="polite"` on the active line for screen-reader announcement. #### Scenario: parser sorts by timestamp and skips invalid lines - **WHEN** `parseLrc("[ar:Foo]\n[00:30.00]Line A\nBroken line\n[00:10.50]Line B")` is called - **THEN** the result is `[{ timestampSec: 10.5, text: "Line B" }, { timestampSec: 30, text: "Line A" }]` #### Scenario: active lyric advances with transport time - **WHEN** `` is rendered and the transport time crosses a lyric's timestamp - **THEN** within 100 ms the active line is the one whose timestamp is the greatest not exceeding current time, and the previous line is no longer highlighted **Verified by:** `src/karaoke/lrc-parser/lrc-parser.test.ts::"parser sorts by timestamp and skips invalid lines"` AND `src/karaoke/lyrics-display/lyrics-display.test.tsx::"active lyric advances within 100 ms of transport time"` --- ### Requirement: Persistence is injectable; default is localStorage The provider SHALL accept a `persistence?: PlayerPersistenceStore` option; if absent, the package SHALL use a `localStorage`-backed default implementation keyed on the provider's `id` prop. The store SHALL persist `{ playlist: PlaylistState, eqPreset: PresetSnapshot, volume: number, muted: boolean }`. Writes SHALL be debounced 300 ms. Reads at provider mount SHALL initialise the player to the persisted state, but the playlist's `position` SHALL be clamped to a valid range against the loaded `tracks` length. #### Scenario: persisted state is restored on remount - **WHEN** the consumer mounts ``, sets volume to 0.42, advances the playlist by 2, unmounts, and remounts the same provider - **THEN** on remount `volume === 0.42` and `playlist.position === 2` #### Scenario: injected store supersedes localStorage - **WHEN** the consumer mounts the provider with an injected `persistence` store that returns a fixed state - **THEN** the player initialises from the injected store's state and `localStorage` is never read **Verified by:** `src/provider/audio-player-provider.test.tsx::"persisted state is restored on remount"` AND `src/provider/audio-player-provider.test.tsx::"injected persistence store supersedes localStorage"` --- ### Requirement: Visualisers share the chain's master analyser `` and `` SHALL each consume `useAnalyser(trackId | 'master')` from `@suaveplan/react-audio`. The package SHALL NOT construct its own `AnalyserNode`. Multiple visualisers mounted simultaneously SHALL share the same analyser instance and SHALL each render to their own `` independently without contending for the analyser's frequency-data buffer. #### Scenario: two visualisers share one analyser - **WHEN** `` and `` are both mounted in the same provider - **THEN** exactly one `AnalyserNode` is in use within the engine and each visualiser reads its frequency / waveform data independently per frame **Verified by:** `src/components/spectrum-analyzer/spectrum-analyzer.test.tsx::"two visualisers share one analyser instance"` --- ### Requirement: R3F screensavers are gated to optional subpaths `` and `` SHALL be exported only via the `./screensavers/glb-viewer` and `./screensavers/shader-background` subpaths. The root barrel (`./`) and any non-screensaver subpath SHALL NOT pull in `@react-three/fiber` or `three`. The screensavers SHALL be designed to render behind the player UI (CSS `z-index` configurable). #### Scenario: importing the root barrel does not bundle three.js - **WHEN** a consumer imports `@suaveplan/react-audio-player` (root) - **THEN** the tree-shaken bundle contains zero bytes from `three` or `@react-three/fiber` **Verified by:** `tests/bundle-size/root-only.size.test.ts::"root barrel does not bundle three.js"` --- ### Requirement: Multi-peer sync is gated to optional subpath `useMultiPeerSync` SHALL be exported only via the `./sync/use-multi-peer-sync` subpath and SHALL consume `@suaveplan/react-audio-stream` for WebRTC transport. Transport state synchronisation SHALL use the local `Transport` from the provider as source of truth; on peer disconnect the local transport SHALL continue uninterrupted. A peer reconnect SHALL resync the joining peer to the local transport's current position. #### Scenario: peer disconnect does not interrupt local playback - **WHEN** a peer disconnects mid-playback - **THEN** the local transport continues at the same position and the player UI remains responsive **Verified by:** `src/sync/use-multi-peer-sync/use-multi-peer-sync.test.tsx::"peer disconnect does not interrupt local playback"` --- ### Requirement: ProgressBar and Queue are keyboard-accessible `` SHALL be a `role="slider"` with arrow keys for 5 s fine seek, page-up/down for 30 s coarse seek, Home/End for start/end of track, `aria-valuetext` formatted as `MM:SS / MM:SS`. `` SHALL be `role="listbox"` with up/down navigation, `Enter` to play the focused track, `Delete` to remove from playlist, `Space` to toggle play/pause. Both SHALL respect `prefers-reduced-motion` for any seek-animation transitions. #### Scenario: arrow keys on ProgressBar seek by 5 s - **WHEN** a user focuses `` at time 30 s and presses `→` - **THEN** `transport.seek(35)` is called and `aria-valuenow` updates #### Scenario: Queue Delete removes the focused track - **WHEN** a user focuses the third Queue item and presses `Delete` - **THEN** the playlist length decreases by 1 and the third track is no longer present **Verified by:** `src/components/progress-bar/progress-bar.test.tsx::"arrow keys seek by 5 seconds"` AND `src/components/queue/queue.test.tsx::"Delete removes the focused track from the playlist"` --- ### Requirement: Structured error codes The package SHALL declare its codes via `defineCodes("REACT_AUDIO_PLAYER", [...])` producing `REACT_AUDIO_PLAYER.NO_PROVIDER`, `REACT_AUDIO_PLAYER.PLAYLIST_EMPTY`, `REACT_AUDIO_PLAYER.PERSISTENCE_FAILED`, `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED`, and `REACT_AUDIO_PLAYER.INVALID_OPTIONS`. Export `ReactAudioPlayerErrorCode`, `REACT_AUDIO_PLAYER_ERROR_METADATA`, `registerReactAudioPlayerErrorCodes()`, and a `ReactAudioPlayerError` class extending `AudioError`. Every registered code SHALL be reachable from a real call site exercised by tests. #### Scenario: every registered code is reachable - **WHEN** the test suite runs - **THEN** each code in `REACT_AUDIO_PLAYER_ERROR_METADATA` is asserted thrown by at least one test **Verified by:** `src/error-codes.test.ts::"every REACT_AUDIO_PLAYER code is reachable from a call site"` --- ### Requirement: Subpath exports per surface `package.json` SHALL declare one `exports` entry per surface: `.`, `./provider`, `./components`, `./components/`, `./hooks`, `./hooks/`, `./stores`, `./karaoke`, `./screensavers/glb-viewer`, `./screensavers/shader-background`, `./sync/use-multi-peer-sync`. `sideEffects` SHALL be `false`. #### Scenario: importing one component does not pull in others - **WHEN** a consumer imports `@suaveplan/react-audio-player/components/basic-player` - **THEN** the tree-shaken bundle contains zero bytes from `` or `` **Verified by:** `tests/exports.test.ts::"every surface has a subpath export entry"` AND `tests/bundle-size/basic-only.size.test.ts::"importing basic-player does not pull in other components"`