Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View 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 -->