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