first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
# @suaveplan/react-audio-player - Track-style media player suite: karaoke (LRC parser + LyricsDisplay) + a thin re-export facade over @suaveplan/react-audio's player components, hooks, and provider. Self-contained karaoke surface ships with this package; multi-peer playback sync via WebRTC is deferred until @suaveplan/react-audio-stream lands.
Track-style media-player surface for the Suaveplan audio family.
The package ships two kinds of exports:
1. **Karaoke** — a self-contained LRC parser, `useLyrics` hook, and
`<LyricsDisplay>` component implemented in this package.
2. **Re-export façade** — namespace and flat aliases for the player
UI that already lives in
[`@suaveplan/react-audio`](../react-audio/README.md):
`<AudioProvider>`, `<AudioPlayer>`, `<BasicPlayer>`,
`<PremiumPlayer>`, `<TransportControls>`, `<Mixer>`,
`<PluginChainView>`, `<PresetManager>`, plus hooks
(`useTransport`, `useTransportTime`, `useAnalyser`, `usePlaylist`,
…).
Consumers can either import directly from `@suaveplan/react-audio`
(the original home of those components) or from
`@suaveplan/react-audio-player`, whichever reads better in their
codebase. The named entry points resolve to the same singletons via
ESM re-export — no duplication, no extra bundle weight.
<!-- begin:auto-docs -->
## Documentation tree
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/react-audio-player`. Every error thrown by a public surface in this package…
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
- [lrc-parser](./src/karaoke/lrc-parser/lrc-parser.md) — `parseLrc(source, opts?)` parses LRC) karaoke source into a timestamp-sorted array of `Lyric` entries suitable for the…
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
- [lyrics-display](./src/karaoke/lyrics-display/lyrics-display.md) — `<LyricsDisplay>` is the karaoke surface that renders a stack of lyric lines with the active line highlighted. It is th…
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
- [use-lyrics](./src/karaoke/use-lyrics/use-lyrics.md) — `useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the active-line state, samples it at a fixed interval (1…
<!-- end:auto-docs -->
## Status
This package targets the *additive* scope of the
`enterprise-react-audio-player` change. The pre-existing player
components (`<BasicPlayer>`, `<AudioPlayer>`, `<PremiumPlayer>`,
visualisers, transport controls, mixer, preset manager) already live
in `@suaveplan/react-audio`; duplicating them here would violate the
"do not duplicate enterprise components" guidance. Instead this
package adds:
- The karaoke surface — LRC parser, `useLyrics`, `<LyricsDisplay>`.
- A namespaced re-export façade (`ReactAudio.*`) plus flat aliases
for the most common entry points.
- Stable `REACT_AUDIO_PLAYER.*` error codes.
### Deferred scope
- **Multi-peer playback sync** (`useMultiPeerSync`) is deferred until
`@suaveplan/react-audio-stream` exists . The spec
requirement is retained in the canonical capability spec with a
`> Deferred (multi-peer)` marker; once the dependency lands, the
hook adds in a follow-up change without breaking this package's
public surface.
- R3F screensavers (`<GLBViewer>`, `<ShaderBackground>`) — the spec
requirement is retained; consumers needing these today can
compose any R3F surface as a sibling of `<PremiumPlayer>`. The
optional `./screensavers/*` subpaths will be filled by a focused
follow-up change.
The two hooks consumed by visualisers (`useAnalyser`) and queues
(`usePlaylist`) **are** shipped — they were added to
`@suaveplan/react-audio` alongside this package and are re-exported
here for ergonomic reach.
## Quick start — karaoke
```tsx
import {
AudioProvider,
AudioPlayer,
LyricsDisplay,
} from "@suaveplan/react-audio-player";
function App({ track, lrc }: { track: string; lrc: string }) {
return (
<AudioProvider options={{ /* … */ }}>
<AudioPlayer source={track} />
<KaraokeLine lrc={lrc} />
</AudioProvider>
);
}
import { useTransportTime } from "@suaveplan/react-audio";
function KaraokeLine({ lrc }: { lrc: string }) {
const seconds = useTransportTime();
return <LyricsDisplay lrc={lrc} currentSec={seconds} />;
}
```
## Quick start — standalone preview (no provider)
```tsx
import {
parseLrc,
LyricsDisplay,
} from "@suaveplan/react-audio-player";
import { useMemo, useState } from "react";
export function Preview({ lrc }: { lrc: string }) {
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
const [t, setT] = useState(0);
return (
<div>
<input
type="range"
min={0}
max={120}
step={0.1}
value={t}
onChange={(e) => setT(Number(e.currentTarget.value))}
aria-label="time"
/>
<LyricsDisplay lyrics={lyrics} currentSec={t} />
</div>
);
}
```
## Subpath exports
| Subpath | Surface |
| --- | --- |
| `.` | Root barrel — karaoke + flat aliases + `ReactAudio` namespace + error codes. |
| `./karaoke` | All karaoke exports. |
| `./karaoke/lrc-parser` | `parseLrc`, `activeLyricIndex`, types. |
| `./karaoke/use-lyrics` | `useLyrics` and types. |
| `./karaoke/lyrics-display` | `<LyricsDisplay>` and types. |
| `./error-codes` | `REACT_AUDIO_PLAYER.*` registry. |
See the per-feature `.md` files for full API references.

View File

@@ -0,0 +1,98 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/react-audio-player`.
Every error thrown by a public surface in this package carries a
code from `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n
resolution, and an optional `context` / `cause` chain. Codes are
namespaced under the `REACT_AUDIO_PLAYER.` prefix so the wire format
`{ code: "REACT_AUDIO_PLAYER.LRC_PARSE_FAILED" }` is greppable and
stable across versions.
The registry is the single source of truth for the package's error
contract — the test suite walks `REACT_AUDIO_PLAYER_ERROR_METADATA`
and confirms every code is reachable from a real call site, so a
code that gets added without a thrower (or a thrower whose code
gets removed) is a build break.
## Features
- Five public codes: `NO_PROVIDER`, `PLAYLIST_EMPTY`,
`PERSISTENCE_FAILED`, `LRC_PARSE_FAILED`, `INVALID_OPTIONS`.
- Frozen metadata map keyed by code; every entry has a `description`
and `severity` (`"error"` | `"warning"`).
- `ReactAudioPlayerError` class extends `SuaveplanError`; carries a
stable `category = "react-audio-player"` field.
- Idempotent `registerReactAudioPlayerErrorCodes()` for parity with
sibling audio packages.
## Usage: Basic
```ts
import {
REACT_AUDIO_PLAYER_CODES,
ReactAudioPlayerError,
} from "@suaveplan/react-audio-player/error-codes";
if (!provider) {
throw new ReactAudioPlayerError(
REACT_AUDIO_PLAYER_CODES.NO_PROVIDER,
"react-audio-player.no-provider"
);
}
```
## Usage: Advanced
```ts
import {
REACT_AUDIO_PLAYER_CODES,
REACT_AUDIO_PLAYER_ERROR_METADATA,
ReactAudioPlayerError,
} from "@suaveplan/react-audio-player/error-codes";
try {
parseLrc(input);
} catch (cause) {
const code = REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED;
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
throw new ReactAudioPlayerError(code, "lrc.parse.failed", {
cause,
context: { severity: meta.severity, bytes: input.length },
});
}
```
## API Reference
- `REACT_AUDIO_PLAYER_CODES` — `Readonly<Record<…, string>>` of the
five public codes (frozen).
- `ReactAudioPlayerErrorCode` — union of the code string-literals.
- `REACT_AUDIO_PLAYER_ERROR_METADATA` — frozen metadata map.
- `ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode>`.
- `registerReactAudioPlayerErrorCodes(): void` — idempotent no-op.
## Implementation Notes
`defineCodes` is the workspace's standard helper for producing
namespaced code maps; it freezes the result and asserts that every
key is a valid identifier at module load. Metadata is declared with
`Object.freeze` so consumers (and the test suite) cannot mutate it
at runtime.
`SuaveplanError` is the universal base error from
`@suaveplan/error`; it carries `code`, `messageKey`, `context`, and
`cause` plus a stable wire-format serialisation. The
`category = "react-audio-player"` literal is the discriminator used
by the cross-package error router when an error crosses a package
boundary.
`name` is set to `ReactAudioPlayerError(<severity>)` so stack-trace
output includes the severity at a glance. This is a string-only
augmentation; it does not change the prototype chain so
`instanceof ReactAudioPlayerError` still narrows correctly.

View File

@@ -0,0 +1,106 @@
# `karaoke/lrc-parser`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`parseLrc(source, opts?)` parses [LRC](https://en.wikipedia.org/wiki/LRC_(file_format))
karaoke source into a timestamp-sorted array of `Lyric` entries
suitable for the package's `<LyricsDisplay>` and any consumer-built
karaoke surface. The parser is universal — it has no React, DOM, or
audio-context dependency — so it is safe to call in workers, on the
server during SSR, or inside other Suaveplan tools that need to
preview lyric timing.
The companion `activeLyricIndex(lyrics, currentSec)` performs a
binary search for the active line and is the bridge between the
transport's `seconds` and the `<LyricsDisplay>` highlight state.
Both functions are pure; the parser throws structured
`ReactAudioPlayerError` for the two invalid-input cases.
## Features
- Recognises `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, and
`[h:mm:ss(.xxx)]` timestamp forms.
- Expands multi-timestamp lines (`[00:10][00:20]hello`) by default;
togglable via `expandMultiTimestamp`.
- Drops the LRC metadata tags `[ar:]`, `[ti:]`, `[al:]`, `[length:]`,
`[by:]`, `[offset:]`, `[re:]`, `[ve:]` — no extraction in v1.
- Skips empty lines, malformed lines, and lines with no leading
timestamp.
- Output is sorted ascending by `timestampSec` and frozen.
- Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
or input exceeding `maxLength` (default 1 MiB).
- `activeLyricIndex` is O(log n) and allocation-free.
## Usage: Basic
```ts
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
const lyrics = parseLrc(`
[ar:Test Artist]
[00:10.50]First line
[00:25.00]Second line
`);
// → [{ timestampSec: 10.5, text: "First line" },
// { timestampSec: 25.0, text: "Second line" }]
```
## Usage: Advanced
```tsx
import {
parseLrc,
activeLyricIndex,
} from "@suaveplan/react-audio-player/karaoke/lrc-parser";
import { useTransportTime } from "@suaveplan/react-audio";
import { useMemo } from "react";
function LyricLine({ source }: { source: string }) {
const lyrics = useMemo(
() => parseLrc(source, { expandMultiTimestamp: false }),
[source]
);
const seconds = useTransportTime();
const idx = activeLyricIndex(lyrics, seconds);
return (
<span aria-live="polite">
{idx >= 0 ? lyrics[idx].text : "…"}
</span>
);
}
```
## API Reference
- `parseLrc(source: unknown, opts?: ParseLrcOptions): readonly Lyric[]`
- `activeLyricIndex(lyrics: readonly Lyric[], currentSec: number): number`
- `Lyric` — `{ timestampSec: number; text: string }`.
- `ParseLrcOptions` — `maxLength?: number` (default 1 MiB),
`expandMultiTimestamp?: boolean` (default `true`).
## Implementation Notes
The timestamp regex `/\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/g`
captures optional hours, minutes, seconds, and an optional
fractional component. The fractional value is padded right with
zeros to three digits then divided by 1000, so `.5` parses to 0.500
seconds, `.50` to 0.500, and `.500` to 0.500 — all equivalent. Only `.` is accepted as the
fractional separator; the `:` form would conflict with the
`[h:mm:ss]` extended timestamp grammar.
Multi-timestamp expansion walks the regex's global state, but only
honours timestamps that form a contiguous prefix of the line. A
bracketed sequence later in the line is treated as text — many
karaoke services emit `[00:10]hello [00:11]world` to mean two
separate lyric lines and this parser handles both layouts
correctly.
`activeLyricIndex` uses the upper-bound binary search idiom: pick
the greatest index whose timestamp is ≤ current. When `current` is
between two entries, the earlier entry wins; when `current`
precedes the first entry, `-1` is returned (so the consumer can
render an empty active line until the first cue).

View File

@@ -0,0 +1,109 @@
# `karaoke/lyrics-display`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`<LyricsDisplay>` is the karaoke surface that renders a stack of
lyric lines with the active line highlighted. It is the visible
end-product of the `parseLrc` → `useLyrics` pipeline and the
component the player variants compose when their `lrc` prop is
populated.
The component is clock-agnostic by design: the caller passes
`currentSec` (a plain number) and the component renders the
highlighted line. The typical wiring is `useTransportTime()` from
`@suaveplan/react-audio` when the display sits inside an
`<AudioProvider>`, but standalone preview and test surfaces can
hand in any number — Playwright spec runs use a deterministic
clock; documentation pages drive a `<range>` input. Keeping the time
source explicit removes a hidden provider dependency.
## Features
- Renders one `<span>` per lyric line with `data-active="true|false"`
for CSS-driven styling; consumers needing structural control can
pass `renderLine` for full custom rendering.
- Active line carries `aria-live="polite"` by default so screen
readers announce lyric changes; togglable via `announceLyrics`.
- Wrapping `<div>` exposes `role="region"` + `aria-label="Lyrics"`.
- Re-binds the active index every 100 ms (configurable) regardless
of the consumer's render cadence.
- Both time-source modes share the same internal render path; the
outer dispatch is a one-time component-type pick.
## Usage: Basic
```tsx
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
import { useTransportTime } from "@suaveplan/react-audio";
function Player({ lrcSource }: { lrcSource: string }) {
const seconds = useTransportTime();
return <LyricsDisplay lrc={lrcSource} currentSec={seconds} />;
}
```
## Usage: Advanced
```tsx
import { LyricsDisplay } from "@suaveplan/react-audio-player/karaoke/lyrics-display";
import { parseLrc } from "@suaveplan/react-audio-player/karaoke/lrc-parser";
import { useMemo } from "react";
function StyledLyrics({ lrc, time }: { lrc: string; time: number }) {
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
return (
<LyricsDisplay
lyrics={lyrics}
currentSec={time}
intervalMs={50}
announceLyrics={false}
renderLine={({ lyric, isActive }) => (
<p
style={{
opacity: isActive ? 1 : 0.4,
fontWeight: isActive ? 700 : 400,
}}
>
{lyric.text || <span aria-hidden="true">·</span>}
</p>
)}
/>
);
}
```
## API Reference
- `<LyricsDisplay>` accepts `LyricsDisplayProps`:
- `lyrics?: readonly Lyric[]` — preferred over `lrc`.
- `lrc?: string` — parsed once per identity change.
- `currentSec: number` — required.
- `intervalMs?: number` (default 100).
- `announceLyrics?: boolean` (default `true`).
- `className?: string`.
- `renderLine?: ({ lyric, isActive, index }) => ReactNode`.
## Implementation Notes
The component is a single-branch render — no dual-component
dispatch — because `currentSec` is always a plain prop. This keeps
the Rules of Hooks trivially satisfied (one render path, one hook
sequence) and removes a hidden coupling to the surrounding
`<AudioProvider>`. Consumers that want the transport-driven path
call `useTransportTime()` themselves and pass the result down.
The `data-active="true|false"` attribute is the recommended CSS
selector hook. Toggling `aria-live` only on the active line ensures
exactly one element narrates per change cycle; without that targeted
gating, every line's render would re-announce on every re-mount,
which is acoustically unusable.
When `renderLine` is supplied, the component delegates to it
verbatim and skips the default `<span>` + `data-active` path. The
delegated node is wrapped in a keyed `<Fragment>` so consumers can
return any element they like — `<li>`, `<p>`, `<div>` — without
fighting an enclosing wrapper.

View File

@@ -0,0 +1,121 @@
# `karaoke/use-lyrics`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`useLyrics(opts)` is the engine-agnostic karaoke hook. It owns the
active-line state, samples it at a fixed interval (100 ms by
default), and returns `{ lyrics, activeIndex, activeLyric }`. The
consumer hands in either pre-parsed `Lyric[]` or raw LRC source,
plus the current playback time in seconds. The hook deliberately
does not call `useTransportTime` itself so it can be driven from any
clock — `<LyricsDisplay>` wires the transport in for you, but a
preview panel running under a deterministic test clock can call this
hook directly.
The 100 ms re-bind ceiling matches the spec requirement that the
active lyric advance within 100 ms of the transport. Sampling at
that cadence (rather than per-frame) saves a substantial amount of
React commit work on songs with hundreds of lyric lines.
## Features
- Pure: no audio context, no global side-effect.
- Pre-parsed `lyrics` or raw `lrc` source — both accepted; if both
provided, `lyrics` wins (no double work).
- Configurable `intervalMs` (default 100). The hook re-evaluates
the active line on each tick and only commits when the index
actually changes — silent renders cost nothing.
- Returns `activeLyric: undefined` before the first cue or when
`lyrics` is empty.
## Usage: Basic
```tsx
import { useLyrics } from "@suaveplan/react-audio-player/karaoke/use-lyrics";
import { useTransportTime } from "@suaveplan/react-audio";
function Lyrics({ lrc }: { lrc: string }) {
const seconds = useTransportTime();
const { activeLyric } = useLyrics({ lrc, currentSec: seconds });
return <span aria-live="polite">{activeLyric?.text ?? "…"}</span>;
}
```
## Usage: Advanced
```tsx
import {
parseLrc,
useLyrics,
} from "@suaveplan/react-audio-player/karaoke";
import { useMemo, useState } from "react";
function StandaloneLyricPreview({ lrc }: { lrc: string }) {
// Parse once at the call site rather than on every mount.
const lyrics = useMemo(() => parseLrc(lrc), [lrc]);
// Deterministic clock — no <AudioProvider> required.
const [tick, setTick] = useState(0);
const { activeIndex, activeLyric, lyrics: ls } = useLyrics({
lyrics,
currentSec: tick * 0.5,
intervalMs: 50,
});
return (
<div>
<button type="button" onClick={() => setTick((t) => t + 1)}>
+0.5 s
</button>
<ol>
{ls.map((l, i) => (
<li
key={`${l.timestampSec}-${i}`}
data-active={i === activeIndex}
>
{l.text}
</li>
))}
</ol>
<p>Active: {activeLyric?.text ?? "—"}</p>
</div>
);
}
```
## API Reference
- `useLyrics(opts: UseLyricsOptions): UseLyricsResult`
- `UseLyricsOptions`:
- `lyrics?: readonly Lyric[]` — preferred.
- `lrc?: string` — parsed once per identity change.
- `intervalMs?: number` (default 100).
- `currentSec: number` — required.
- `UseLyricsResult`:
- `lyrics: readonly Lyric[]`,
- `activeIndex: number` (`-1` for "before first cue / empty"),
- `activeLyric: Lyric | undefined`.
## Implementation Notes
The hook owns one piece of React state — the active index — and one
`setInterval`. Both `lyrics` and `currentSec` are mirrored through
refs so the interval callback always sees the latest values without
re-binding the timer on every render. The interval only fires
`setActiveIndex` when the recomputed index actually differs, which
is the standard React micro-optimisation for high-frequency external
clocks.
`activeLyricIndex` (from the sibling `lrc-parser` module) is a
binary search; combined with the 100 ms sampling cadence, the hook's
steady-state work per frame is one comparison plus a "same index?"
branch. Long-form lyrics (10k+ lines) are not measurably more
expensive than short ones.
The hook lazily initialises its `activeIndex` state from
`activeLyricIndex(lyrics, currentSec)` so the first render — before
the effect mounts — already reflects the correct active line. This
matters for SSR-rendered previews and snapshot tests that read the
hook's output without ever ticking the interval.