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

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 and t crosses a lyric's timestampSec
  • THEN within 100 ms the active line is the greatest entry whose timestampSec is ≤ 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_METADATA is 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.json is 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 dependencies map 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-stream package 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-sync subpath 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-audio do 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 three or @react-three/fiber

Verified by: (deferred — to be filled by the follow-up change)