8.4 KiB
Capability Spec: react-audio-player
Package: @suaveplan/react-audio-player
Path: packages/ui/react/react-audio-player/
Status: SHIPPED (karaoke + façade); multi-peer + R3F screensavers deferred
Every requirement carries a
**Verified by:**line. Where a requirement is intentionally deferred, the> Deferred:marker records the reason; the requirement remains canonical and the follow-up change must satisfy the same scenarios when it lands.
Requirements
Requirement: Karaoke surface — parser, hook, display
The package SHALL export a parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]
function, an activeLyricIndex(lyrics, currentSec): number binary
search, a useLyrics(opts) React hook, and a <LyricsDisplay>
component. The parser SHALL sort entries ascending by
timestampSec, drop the LRC metadata tags [ar:] / [ti:] /
[al:] / [length:] / [by:] / [offset:] / [re:] / [ve:],
skip lines without a leading timestamp, and expand
multi-timestamp lines ([00:10][00:20]hello) when
expandMultiTimestamp is true (default). parseLrc SHALL throw
REACT_AUDIO_PLAYER.LRC_PARSE_FAILED for non-string input and for
input exceeding maxLength (default 1 MiB). <LyricsDisplay>
SHALL render one element per lyric with data-active="true|false"
and set aria-live="polite" on the active line when
announceLyrics is true (default). The active line SHALL update
within intervalMs (default 100) of the consumer-supplied
currentSec change.
Scenario: parser sorts 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 currentSec
- WHEN
<LyricsDisplay lyrics={…} currentSec={t}>is rendered andtcrosses a lyric'stimestampSec - THEN within 100 ms the active line is the greatest entry whose
timestampSecis≤ t, and the previous line is no longer highlighted
Verified by: src/karaoke/lrc-parser/lrc-parser.test.ts::"sorts by timestamp and skips invalid lines" AND src/karaoke/lyrics-display/lyrics-display.test.tsx::"updates active line on interval when currentSec rises"
Requirement: useLyrics is engine-agnostic
useLyrics(opts) SHALL accept either lyrics: readonly Lyric[]
or lrc: string, plus a required currentSec: number, and SHALL
NOT call useTransportTime or any other engine-bound hook itself.
Consumers wire the transport time on the call site. This
guarantees the hook is callable outside <AudioProvider> —
required for documentation pages, deterministic Playwright clocks,
and standalone preview surfaces.
Scenario: hook renders without a provider
- WHEN
renderHook(() => useLyrics({ lyrics, currentSec: 5 }))is called with no surrounding<AudioProvider> - THEN the hook returns
{ lyrics, activeIndex, activeLyric }without throwing
Verified by: src/karaoke/use-lyrics/use-lyrics.test.tsx::"derives initial active index from currentSec"
Requirement: Re-export façade for the existing player surface
The root barrel SHALL re-export the player UI from
@suaveplan/react-audio via two channels: an export * as ReactAudio from "@suaveplan/react-audio" namespace export plus
flat named-export aliases for the highest-traffic entry points
(AudioProvider, AudioPlayer, BasicPlayer, PremiumPlayer,
TransportControls, Mixer, PluginChainView, PresetManager,
TrackList, useTransport, useTransportTime, useAnalyser,
usePlaylist). The façade SHALL NOT redefine, wrap, or duplicate
the underlying components; consumers reach the same singletons via
ESM module identity. The root barrel SHALL NOT use bare
export * (CLAUDE.md rule 10).
Scenario: façade exposes player components
- WHEN a consumer imports
{ AudioPlayer, ReactAudio }from@suaveplan/react-audio-player - THEN
AudioPlayer === ReactAudio.AudioPlayer === (require("@suaveplan/react-audio")).AudioPlayer
Verified by: tests/facade.test.tsx::"exposes the namespaced ReactAudio re-export" AND tests/facade.test.tsx::"exposes the flat alias façade"
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 SuaveplanError. Every
registered code SHALL be reachable from a real call site.
Scenario: every registered code is reachable
- WHEN the test suite runs
- THEN each code in
REACT_AUDIO_PLAYER_ERROR_METADATAis asserted reachable
Verified by: src/error-codes/error-codes.test.ts::"every code is reachable from a thrown error"
Requirement: Subpath exports per surface
package.json SHALL declare subpath exports for ., ./karaoke,
./karaoke/lrc-parser, ./karaoke/use-lyrics,
./karaoke/lyrics-display, and ./error-codes. sideEffects
SHALL be false.
Scenario: every surface has a subpath export
- WHEN the package's
package.jsonis read - THEN every entry in the required-surfaces allowlist resolves to a defined export
Verified by: tests/exports.test.ts::"every public surface has a subpath entry"
Requirement: Runtime dependency allowlist
Runtime dependencies SHALL be limited to @suaveplan/error,
@suaveplan/react-audio, @suaveplan/types, and tslib. react
and react-dom SHALL be peer dependencies. The narrow surface is
deliberate: this package is a thin karaoke addition + re-export
façade and pulls in zero direct audio-engine, plugin, or DSP
runtime deps — those reach the consumer transitively through
@suaveplan/react-audio.
Scenario: runtime deps match allowlist
- WHEN the package's
dependenciesmap is enumerated - THEN the set equals
{ "@suaveplan/error", "@suaveplan/react-audio", "@suaveplan/types", "tslib" }
Verified by: tests/exports.test.ts::"runtime dependency set matches allowlist"
Requirement: Multi-peer playback sync
Deferred (2026-05-23): the upstream
@suaveplan/react-audio-streampackage does not yet exist on genesis. This requirement is retained verbatim; the follow-up change that lands the upstream package SHALL implement the./sync/use-multi-peer-syncsubpath here and satisfy the scenarios below without breaking the currently-shipped public surface.
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: (deferred — to be filled by the follow-up change)
Requirement: R3F screensavers
Deferred (2026-05-23): the optional
./screensavers/*subpaths are not in this initial slice. The pre-existing player components in@suaveplan/react-audiodo not include R3F screensavers either, so deferring here introduces no regression. A focused follow-up SHALL add<GLBViewer>and<ShaderBackground>behind their respective optional subpath exports and satisfy the scenario below.
<GLBViewer> and <ShaderBackground> 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.
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
threeor@react-three/fiber
Verified by: (deferred — to be filled by the follow-up change)