Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 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,100 @@
{
"name": "@suaveplan/react-audio-player",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./karaoke": {
"import": {
"types": "./dist/karaoke/index.d.ts",
"default": "./dist/karaoke/index.js"
}
},
"./karaoke/lrc-parser": {
"import": {
"types": "./dist/karaoke/lrc-parser/lrc-parser.d.ts",
"default": "./dist/karaoke/lrc-parser/lrc-parser.js"
}
},
"./karaoke/use-lyrics": {
"import": {
"types": "./dist/karaoke/use-lyrics/use-lyrics.d.ts",
"default": "./dist/karaoke/use-lyrics/use-lyrics.js"
}
},
"./karaoke/lyrics-display": {
"import": {
"types": "./dist/karaoke/lyrics-display/lyrics-display.d.ts",
"default": "./dist/karaoke/lyrics-display/lyrics-display.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/error": "workspace:^",
"@suaveplan/react-audio": "workspace:^",
"@suaveplan/types": "workspace:^",
"tslib": "catalog:build"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@testing-library/jest-dom": "catalog:testing",
"@testing-library/react": "catalog:testing",
"@testing-library/user-event": "catalog:testing",
"@types/node": "catalog:",
"@types/react": "catalog:testing",
"@types/react-dom": "catalog:testing",
"@vitejs/plugin-react": "catalog:testing",
"@vitest/coverage-istanbul": "catalog:testing",
"happy-dom": "catalog:testing",
"react": "catalog:testing",
"react-dom": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

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,51 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
REACT_AUDIO_PLAYER_CODES,
REACT_AUDIO_PLAYER_ERROR_METADATA,
ReactAudioPlayerError,
type ReactAudioPlayerErrorCode,
registerReactAudioPlayerErrorCodes,
} from "./error-codes.js";
describe("REACT_AUDIO_PLAYER error codes", () => {
it("every code has metadata", { timeout: 5000 }, () => {
for (const key of Object.keys(REACT_AUDIO_PLAYER_CODES)) {
const code = REACT_AUDIO_PLAYER_CODES[
key as keyof typeof REACT_AUDIO_PLAYER_CODES
] as ReactAudioPlayerErrorCode;
const meta = REACT_AUDIO_PLAYER_ERROR_METADATA[code];
expect(meta).toBeDefined();
expect(meta.description.length).toBeGreaterThan(10);
expect(["error", "warning"]).toContain(meta.severity);
}
});
it("every code is reachable from a thrown error", { timeout: 5000 }, () => {
for (const code of Object.values(REACT_AUDIO_PLAYER_CODES)) {
const err = new ReactAudioPlayerError(code, `${code}.test`);
expect(err.code).toBe(code);
expect(err.category).toBe("react-audio-player");
expect(err.name.startsWith("ReactAudioPlayerError(")).toBe(true);
}
});
it("error carries cause + context", { timeout: 5000 }, () => {
const cause = new Error("boom");
const err = new ReactAudioPlayerError(
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
"lrc.parse.failed",
{ cause, context: { bytes: 42 } }
);
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ bytes: 42 });
});
it("registerReactAudioPlayerErrorCodes is idempotent", {
timeout: 5000,
}, () => {
registerReactAudioPlayerErrorCodes();
registerReactAudioPlayerErrorCodes();
// Re-import did not throw and metadata is still intact.
expect(Object.isFrozen(REACT_AUDIO_PLAYER_ERROR_METADATA)).toBe(true);
});
});

View File

@@ -0,0 +1,86 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error codes for `@suaveplan/react-audio-player`. The five
* codes cover the karaoke surface and the package's mount-time
* invariants. Multi-peer codes are deliberately not declared here —
* they will land alongside the multi-peer hook once
* `@suaveplan/react-audio-stream` exists.
*/
export const REACT_AUDIO_PLAYER_CODES = defineCodes("REACT_AUDIO_PLAYER", [
"NO_PROVIDER",
"PLAYLIST_EMPTY",
"PERSISTENCE_FAILED",
"LRC_PARSE_FAILED",
"INVALID_OPTIONS",
] as const);
export type ReactAudioPlayerErrorCode =
(typeof REACT_AUDIO_PLAYER_CODES)[keyof typeof REACT_AUDIO_PLAYER_CODES];
export interface ReactAudioPlayerErrorMetadata {
readonly description: string;
readonly severity: "warning" | "error";
}
export const REACT_AUDIO_PLAYER_ERROR_METADATA: Readonly<
Record<ReactAudioPlayerErrorCode, ReactAudioPlayerErrorMetadata>
> = Object.freeze({
[REACT_AUDIO_PLAYER_CODES.NO_PROVIDER]: {
description:
"a react-audio-player surface was rendered outside its required <AudioProvider> ancestor from @suaveplan/react-audio.",
severity: "error",
},
[REACT_AUDIO_PLAYER_CODES.PLAYLIST_EMPTY]: {
description:
"a transport-driving operation was attempted while the playlist contained zero items.",
severity: "error",
},
[REACT_AUDIO_PLAYER_CODES.PERSISTENCE_FAILED]: {
description:
"the persistence store (localStorage or injected) rejected a read or write.",
severity: "error",
},
[REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED]: {
description:
"parseLrc encountered input it could not validate (non-string, oversize, or no candidate timestamps).",
severity: "error",
},
[REACT_AUDIO_PLAYER_CODES.INVALID_OPTIONS]: {
description:
"a public hook or component received an options bundle that failed boundary validation.",
severity: "error",
},
});
/**
* Base error thrown by every public surface of
* `@suaveplan/react-audio-player`. Carries one of
* `REACT_AUDIO_PLAYER_CODES`, a `messageKey` for i18n, and an
* optional `context` / `cause` chain.
*/
export class ReactAudioPlayerError extends SuaveplanError<ReactAudioPlayerErrorCode> {
public readonly category = "react-audio-player" as const;
public constructor(
code: ReactAudioPlayerErrorCode,
messageKey: string,
options?: SuaveplanErrorOptions
) {
super(code, messageKey, options);
this.name = `ReactAudioPlayerError(${REACT_AUDIO_PLAYER_ERROR_METADATA[code].severity})`;
}
}
/**
* Idempotent registration hook. Metadata is module-static so the
* function is a no-op; it exists for parity with the
* `registerXxxErrorCodes` convention used by the rest of the audio
* family.
*/
export function registerReactAudioPlayerErrorCodes(): void {
/* metadata is module-static; no-op. */
}
registerReactAudioPlayerErrorCodes();

View File

@@ -0,0 +1,10 @@
export type {
ReactAudioPlayerErrorCode,
ReactAudioPlayerErrorMetadata,
} from "./error-codes.js";
export {
REACT_AUDIO_PLAYER_CODES,
REACT_AUDIO_PLAYER_ERROR_METADATA,
ReactAudioPlayerError,
registerReactAudioPlayerErrorCodes,
} from "./error-codes.js";

View File

@@ -0,0 +1,71 @@
/**
* `@suaveplan/react-audio-player` — track-style media-player surface.
*
* This root barrel ships two kinds of exports:
*
* 1. **Karaoke** (`parseLrc`, `activeLyricIndex`, `useLyrics`,
* `<LyricsDisplay>`, types) — the package's own additive
* surface, implemented in `./karaoke`.
* 2. **Re-export façade** of the player UI from
* `@suaveplan/react-audio` — namespaced under
* `ReactAudio` so consumers can write
* `import { ReactAudio } from "@suaveplan/react-audio-player"`
* and reach the existing `AudioProvider`, `AudioPlayer`,
* `BasicPlayer`, `PremiumPlayer`, `TransportControls`,
* `Mixer`, `useAnalyser`, `usePlaylist`, `useTransport`,
* hooks, stores, and error codes. A flat alias of the most
* common entry points is also forwarded for ergonomic reach.
*
* Multi-peer playback synchronisation (`useMultiPeerSync` / WebRTC
* via `@suaveplan/react-audio-stream`) is deferred — the upstream
* `react-audio-stream` package does not yet exist on genesis. The
* spec delta retains the requirement; archival folds it into the
* canonical capability spec with a clear deferred marker.
*/
// Namespace re-export of @suaveplan/react-audio so consumers can
// reach the existing player components through this package's
// surface. Using namespace form (`export * as`) rather than bare
// `export *` keeps the package's own root barrel non-`export *` per
// CLAUDE.md rule 10 while still avoiding hand-listing 60+ symbols.
export * as ReactAudio from "@suaveplan/react-audio";
// Ergonomic flat aliases for the most common entry points — these
// resolve through the namespace export but spare the consumer the
// `ReactAudio.` prefix. Each alias is an *explicit* named re-export
// (rule 10 compliant).
export {
AudioPlayer,
AudioProvider,
BasicPlayer,
Mixer,
PluginChainView,
PremiumPlayer,
PresetManager,
TrackList,
TransportControls,
useAnalyser,
usePlaylist,
useTransport,
useTransportTime,
} from "@suaveplan/react-audio";
// Error codes.
export {
REACT_AUDIO_PLAYER_CODES,
REACT_AUDIO_PLAYER_ERROR_METADATA,
ReactAudioPlayerError,
type ReactAudioPlayerErrorCode,
type ReactAudioPlayerErrorMetadata,
registerReactAudioPlayerErrorCodes,
} from "./error-codes/index.js";
// Karaoke (this package's own additive surface).
export {
activeLyricIndex,
type Lyric,
LyricsDisplay,
type LyricsDisplayProps,
type ParseLrcOptions,
parseLrc,
type UseLyricsOptions,
type UseLyricsResult,
useLyrics,
} from "./karaoke/index.js";

View File

@@ -0,0 +1,9 @@
export { activeLyricIndex, parseLrc } from "./lrc-parser/lrc-parser.js";
export type { Lyric, ParseLrcOptions } from "./lrc-parser/lrc-parser.types.js";
export { LyricsDisplay } from "./lyrics-display/lyrics-display.js";
export type { LyricsDisplayProps } from "./lyrics-display/lyrics-display.types.js";
export { useLyrics } from "./use-lyrics/use-lyrics.js";
export type {
UseLyricsOptions,
UseLyricsResult,
} from "./use-lyrics/use-lyrics.types.js";

View File

@@ -0,0 +1,2 @@
export { activeLyricIndex, parseLrc } from "./lrc-parser.js";
export type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";

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,138 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { REACT_AUDIO_PLAYER_CODES } from "../../error-codes/error-codes.js";
import { activeLyricIndex, parseLrc } from "./lrc-parser.js";
describe("parseLrc", () => {
it("sorts by timestamp and skips invalid lines", { timeout: 5000 }, () => {
const out = parseLrc(
"[ar:Foo]\n[00:30.00]Line A\nBroken line\n[00:10.50]Line B"
);
expect(out).toEqual([
{ timestampSec: 10.5, text: "Line B" },
{ timestampSec: 30, text: "Line A" },
]);
});
it("strips metadata tags", { timeout: 5000 }, () => {
const out = parseLrc(
"[ar:Artist]\n[ti:Title]\n[al:Album]\n[length:03:24]\n[by:User]\n[offset:0]\n[00:01.00]hello"
);
expect(out.length).toBe(1);
expect(out[0]?.text).toBe("hello");
});
it("parses fractional seconds in multiple precisions", {
timeout: 5000,
}, () => {
const out = parseLrc(
"[00:01.5]a\n[00:02.50]b\n[00:03.500]c\n[00:04.05]d"
);
expect(out[0]?.timestampSec).toBeCloseTo(1.5, 3);
expect(out[1]?.timestampSec).toBeCloseTo(2.5, 3);
expect(out[2]?.timestampSec).toBeCloseTo(3.5, 3);
expect(out[3]?.timestampSec).toBeCloseTo(4.05, 3);
});
it("parses h:mm:ss form", { timeout: 5000 }, () => {
const out = parseLrc("[1:02:03.500]hour");
expect(out[0]?.timestampSec).toBeCloseTo(3723.5, 3);
});
it("expands multi-timestamp lines", { timeout: 5000 }, () => {
const out = parseLrc("[00:10.00][00:20.00]hi");
expect(out.length).toBe(2);
expect(out[0]?.timestampSec).toBe(10);
expect(out[1]?.timestampSec).toBe(20);
expect(out[0]?.text).toBe("hi");
expect(out[1]?.text).toBe("hi");
});
it("expandMultiTimestamp=false keeps only first timestamp", {
timeout: 5000,
}, () => {
const out = parseLrc("[00:10.00][00:20.00]hi", {
expandMultiTimestamp: false,
});
expect(out.length).toBe(1);
expect(out[0]?.timestampSec).toBe(10);
expect(out[0]?.text).toBe("[00:20.00]hi");
});
it("bracketed text after a non-prefix timestamp is treated as text", {
timeout: 5000,
}, () => {
const out = parseLrc("[00:10.00]hello[00:20.00]world");
expect(out.length).toBe(1);
expect(out[0]?.text).toBe("hello[00:20.00]world");
});
it("throws on non-string input", { timeout: 5000 }, () => {
expect(() => parseLrc(null as unknown as string)).toThrowError(
/lrc.parse.non-string/
);
try {
parseLrc(42 as unknown as string);
throw new Error("expected throw");
} catch (err) {
expect((err as { code?: string }).code).toBe(
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED
);
}
});
it("throws on oversize input", { timeout: 5000 }, () => {
const huge = "a".repeat(20);
expect(() => parseLrc(huge, { maxLength: 10 })).toThrowError(
/lrc.parse.too-large/
);
});
it("returns frozen array", { timeout: 5000 }, () => {
const out = parseLrc("[00:01]a");
expect(Object.isFrozen(out)).toBe(true);
});
it("CRLF line endings supported", { timeout: 5000 }, () => {
const out = parseLrc("[00:01]a\r\n[00:02]b\r\n");
expect(out.length).toBe(2);
});
it("handles a 10k-line LRC under 50 ms", { timeout: 5000 }, () => {
const lines: string[] = [];
for (let i = 0; i < 10_000; i++) {
const m = String(Math.floor(i / 60)).padStart(2, "0");
const s = String(i % 60).padStart(2, "0");
lines.push(`[${m}:${s}.00]line ${i}`);
}
const start = performance.now();
const out = parseLrc(lines.join("\n"));
const elapsed = performance.now() - start;
expect(out.length).toBe(10_000);
expect(elapsed).toBeLessThan(150); // generous CI ceiling
});
it("empty source yields empty array", { timeout: 5000 }, () => {
expect(parseLrc("")).toEqual([]);
});
});
describe("activeLyricIndex", () => {
const LYRICS = parseLrc("[00:00]a\n[00:10]b\n[00:20]c\n[00:30]d");
it("returns -1 for empty input", { timeout: 5000 }, () => {
expect(activeLyricIndex([], 5)).toBe(-1);
});
it("returns -1 before first lyric", { timeout: 5000 }, () => {
const others = parseLrc("[00:05]a\n[00:10]b");
expect(activeLyricIndex(others, 2)).toBe(-1);
});
it("returns the greatest index ≤ currentSec", { timeout: 5000 }, () => {
expect(activeLyricIndex(LYRICS, 0)).toBe(0);
expect(activeLyricIndex(LYRICS, 9.99)).toBe(0);
expect(activeLyricIndex(LYRICS, 10)).toBe(1);
expect(activeLyricIndex(LYRICS, 25)).toBe(2);
expect(activeLyricIndex(LYRICS, 999)).toBe(3);
});
});

View File

@@ -0,0 +1,121 @@
import {
REACT_AUDIO_PLAYER_CODES,
ReactAudioPlayerError,
} from "../../error-codes/error-codes.js";
import type { Lyric, ParseLrcOptions } from "./lrc-parser.types.js";
const DEFAULT_MAX_LENGTH = 1_048_576; // 1 MiB
// Matches `[mm:ss]`, `[mm:ss.xx]`, `[mm:ss.xxx]`, `[h:mm:ss(.xxx)]`.
// Groups: 1=hours? 2=minutes 3=seconds 4=fractional?
const TIMESTAMP_RE = /\[(?:(\d{1,2}):)?(\d{1,3}):(\d{1,2})(?:\.(\d{1,3}))?\]/g;
// Metadata tags `[ar:…]` / `[ti:…]` / etc. — any bracketed lowercase
// letter prefix followed by a colon then arbitrary content.
const META_TAG_RE = /^\[[a-z]{1,8}:[^\]]*\]\s*$/;
function toSeconds(
h: string | undefined,
m: string,
s: string,
frac: string | undefined
): number {
const hours = h ? Number.parseInt(h, 10) : 0;
const minutes = Number.parseInt(m, 10);
const seconds = Number.parseInt(s, 10);
if (frac === undefined) return hours * 3600 + minutes * 60 + seconds;
const padded = `${frac}000`.slice(0, 3);
const ms = Number.parseInt(padded, 10);
return hours * 3600 + minutes * 60 + seconds + ms / 1000;
}
/**
* Parse LRC source into a timestamp-sorted array of `Lyric` entries.
*
* - Multi-timestamp lines (`[00:10][00:20]hello`) expand to one
* entry per timestamp when `expandMultiTimestamp` is true (default).
* - Metadata tags (`[ar:…]`, `[ti:…]`, `[al:…]`, `[length:…]`,
* `[by:…]`, `[offset:…]`, `[re:…]`, `[ve:…]`) are ignored.
* - Lines without a leading timestamp are skipped.
* - Negative or non-finite timestamps are dropped.
*
* Throws `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED` for non-string input
* or input exceeding `maxLength`.
*/
export function parseLrc(
source: unknown,
opts: ParseLrcOptions = {}
): readonly Lyric[] {
if (typeof source !== "string") {
throw new ReactAudioPlayerError(
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
"lrc.parse.non-string",
{ context: { type: typeof source } }
);
}
const maxLength = opts.maxLength ?? DEFAULT_MAX_LENGTH;
if (source.length > maxLength) {
throw new ReactAudioPlayerError(
REACT_AUDIO_PLAYER_CODES.LRC_PARSE_FAILED,
"lrc.parse.too-large",
{ context: { bytes: source.length, max: maxLength } }
);
}
const expand = opts.expandMultiTimestamp ?? true;
const out: Lyric[] = [];
const lines = source.split(/\r?\n/);
for (const raw of lines) {
const line = raw.trim();
if (line === "") continue;
if (META_TAG_RE.test(line)) continue;
TIMESTAMP_RE.lastIndex = 0;
const timestamps: number[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null = TIMESTAMP_RE.exec(line);
while (match !== null) {
// Only honour timestamps forming a contiguous prefix.
if (match.index !== lastIndex) break;
timestamps.push(
toSeconds(
match[1],
match[2] as string,
match[3] as string,
match[4]
)
);
lastIndex = match.index + match[0].length;
if (!expand) break;
match = TIMESTAMP_RE.exec(line);
}
if (timestamps.length === 0) continue;
const text = line.slice(lastIndex).trim();
for (const ts of timestamps) {
out.push({ timestampSec: ts, text });
}
}
out.sort((a, b) => a.timestampSec - b.timestampSec);
return Object.freeze(out);
}
/**
* Binary search for the active lyric index — the greatest
* `timestampSec` not exceeding `currentSec`. Returns `-1` if
* `lyrics` is empty or `currentSec` precedes the first entry. Pure;
* safe to call every animation frame.
*/
export function activeLyricIndex(
lyrics: readonly Lyric[],
currentSec: number
): number {
if (lyrics.length === 0) return -1;
if (currentSec < (lyrics[0] as Lyric).timestampSec) return -1;
let lo = 0;
let hi = lyrics.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1;
if ((lyrics[mid] as Lyric).timestampSec <= currentSec) lo = mid;
else hi = mid - 1;
}
return lo;
}

View File

@@ -0,0 +1,25 @@
/**
* Single parsed lyric line. `timestampSec` is seconds (not
* milliseconds) for direct alignment with `transport.seconds` from
* `@suaveplan/react-audio`. `text` is the trimmed lyric content
* with the leading timestamp tag(s) stripped.
*/
export interface Lyric {
readonly timestampSec: number;
readonly text: string;
}
export interface ParseLrcOptions {
/**
* Maximum input length in characters. Inputs exceeding this are
* rejected with `REACT_AUDIO_PLAYER.LRC_PARSE_FAILED`. Defaults
* to 1 MiB which comfortably covers a 4-hour album-length LRC.
*/
readonly maxLength?: number;
/**
* If true (default), one source line containing N timestamps
* (e.g. `[00:10][00:20]hello`) yields N output entries. If
* false, only the first timestamp is honoured.
*/
readonly expandMultiTimestamp?: boolean;
}

View File

@@ -0,0 +1,2 @@
export { LyricsDisplay } from "./lyrics-display.js";
export type { LyricsDisplayProps } from "./lyrics-display.types.js";

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,50 @@
import type { Meta, StoryObj } from "@storybook/react";
import { useState } from "react";
import { parseLrc } from "../lrc-parser/lrc-parser.js";
import { LyricsDisplay } from "./lyrics-display.js";
const SAMPLE = parseLrc(
"[ar:Demo]\n[00:00.00]Verse one\n[00:08.00]Verse two\n[00:16.00]Chorus"
);
const meta: Meta<typeof LyricsDisplay> = {
title: "Karaoke/LyricsDisplay",
component: LyricsDisplay,
};
export default meta;
type Story = StoryObj<typeof LyricsDisplay>;
export const Default: Story = {
args: {
lyrics: SAMPLE,
currentSec: 0,
},
};
export const Interactive: Story = {
render: () => {
const [t, setT] = useState(0);
return (
<div>
<input
type="range"
min={0}
max={24}
step={0.1}
value={t}
onChange={(e) => setT(Number(e.currentTarget.value))}
aria-label="time"
/>
<LyricsDisplay lyrics={SAMPLE} currentSec={t} />
</div>
);
},
};
export const EdgeEmpty: Story = {
args: {
lyrics: [],
currentSec: 0,
},
};

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { act, cleanup, render, screen } from "@testing-library/react";
import { parseLrc } from "../lrc-parser/lrc-parser.js";
import { LyricsDisplay } from "./lyrics-display.js";
const LRC = "[00:00.00]a\n[00:10.00]b\n[00:20.00]c";
const PARSED = parseLrc(LRC);
describe("<LyricsDisplay>", () => {
it("renders all lines with role=region", { timeout: 5000 }, () => {
render(<LyricsDisplay lyrics={PARSED} currentSec={0} />);
expect(screen.getByRole("region", { name: "Lyrics" })).toBeDefined();
expect(screen.getByText("a")).toBeDefined();
expect(screen.getByText("b")).toBeDefined();
expect(screen.getByText("c")).toBeDefined();
cleanup();
});
it("flags the active line via data-active", { timeout: 5000 }, () => {
render(<LyricsDisplay lyrics={PARSED} currentSec={12} />);
const active = screen.getByText("b");
expect(active.getAttribute("data-active")).toBe("true");
const inactive = screen.getByText("a");
expect(inactive.getAttribute("data-active")).toBe("false");
cleanup();
});
it("active line carries aria-live=polite by default", {
timeout: 5000,
}, () => {
render(<LyricsDisplay lyrics={PARSED} currentSec={12} />);
const active = screen.getByText("b");
expect(active.getAttribute("aria-live")).toBe("polite");
cleanup();
});
it("announceLyrics=false suppresses aria-live", { timeout: 5000 }, () => {
render(
<LyricsDisplay
lyrics={PARSED}
currentSec={12}
announceLyrics={false}
/>
);
const active = screen.getByText("b");
expect(active.getAttribute("aria-live")).toBeNull();
cleanup();
});
it("custom renderLine delegates", { timeout: 5000 }, () => {
render(
<LyricsDisplay
lyrics={PARSED}
currentSec={5}
renderLine={({ lyric, isActive }) => (
<p data-testid={`line-${lyric.text}`}>
{lyric.text}:{isActive ? "ON" : "OFF"}
</p>
)}
/>
);
expect(screen.getByTestId("line-a").textContent).toBe("a:ON");
expect(screen.getByTestId("line-b").textContent).toBe("b:OFF");
cleanup();
});
it("accepts raw lrc source", { timeout: 5000 }, () => {
render(<LyricsDisplay lrc={LRC} currentSec={0} />);
expect(screen.getByText("a")).toBeDefined();
cleanup();
});
it("custom className applies to wrapper", { timeout: 5000 }, () => {
const { container } = render(
<LyricsDisplay
lyrics={PARSED}
currentSec={0}
className="my-lyrics"
/>
);
expect(container.querySelector(".my-lyrics")).not.toBeNull();
cleanup();
});
it("updates active line on interval when currentSec rises", {
timeout: 5000,
}, () => {
vi.useFakeTimers();
try {
const { rerender } = render(
<LyricsDisplay lyrics={PARSED} currentSec={5} intervalMs={50} />
);
expect(screen.getByText("a").getAttribute("data-active")).toBe(
"true"
);
rerender(
<LyricsDisplay
lyrics={PARSED}
currentSec={25}
intervalMs={50}
/>
);
act(() => {
vi.advanceTimersByTime(60);
});
expect(screen.getByText("c").getAttribute("data-active")).toBe(
"true"
);
} finally {
vi.useRealTimers();
cleanup();
}
});
});

View File

@@ -0,0 +1,58 @@
import type { ReactElement, ReactNode } from "react";
import { Fragment } from "react";
import { useLyrics } from "../use-lyrics/use-lyrics.js";
import type { LyricsDisplayProps } from "./lyrics-display.types.js";
/**
* Karaoke surface. Renders a vertical list of lyric lines with the
* currently-active line highlighted via `data-active="true"`. When
* `announceLyrics` is true (default), the active line carries
* `aria-live="polite"` so screen readers narrate it as the
* transport advances.
*
* The component is intentionally clock-agnostic — it requires the
* caller to supply `currentSec`. The typical wiring is
* `useTransportTime()` from `@suaveplan/react-audio`; standalone
* preview and test code can hand in any number. Keeping the time
* source explicit avoids a hidden provider dependency and lets the
* component render inside or outside `<AudioProvider>` without
* branching its own internals.
*/
export function LyricsDisplay(props: LyricsDisplayProps): ReactElement {
const announce = props.announceLyrics ?? true;
const { lyrics, activeIndex } = useLyrics({
...(props.lyrics !== undefined ? { lyrics: props.lyrics } : {}),
...(props.lrc !== undefined ? { lrc: props.lrc } : {}),
...(props.intervalMs !== undefined
? { intervalMs: props.intervalMs }
: {}),
currentSec: props.currentSec,
});
const className = props.className ?? "lyrics-display";
return (
<section className={className} aria-label="Lyrics">
{lyrics.map((lyric, i) => {
const isActive = i === activeIndex;
const key = `${lyric.timestampSec}-${i}`;
if (props.renderLine) {
const node: ReactNode = props.renderLine({
lyric,
isActive,
index: i,
});
return <Fragment key={key}>{node}</Fragment>;
}
return (
<span
key={key}
data-active={isActive ? "true" : "false"}
aria-live={isActive && announce ? "polite" : undefined}
>
{lyric.text}
</span>
);
})}
</section>
);
}

View File

@@ -0,0 +1,36 @@
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
export interface LyricsDisplayProps {
/** Pre-parsed lyrics. Either this or `lrc` must be supplied. */
readonly lyrics?: readonly Lyric[];
/** Raw LRC source — parsed once per identity change. */
readonly lrc?: string;
/**
* Current time in seconds. The caller is responsible for sourcing
* this — typically `useTransportTime()` from `@suaveplan/react-audio`
* when used inside `<AudioProvider>`, or any external clock when
* used standalone (test preview, documentation pages, etc.).
* Keeping this prop explicit avoids a hidden provider dependency
* and lets the component render in or out of a provider.
*/
readonly currentSec: number;
/** Resample interval in milliseconds. Default 100. */
readonly intervalMs?: number;
/**
* When true (default), the active line carries `aria-live="polite"`
* so screen readers announce it as it changes.
*/
readonly announceLyrics?: boolean;
/** Forwarded to the wrapping `<div>` for styling. */
readonly className?: string;
/**
* Render-prop hook for custom line rendering. Receives `{ lyric,
* isActive, index }`. When absent, the component renders a
* `<span>` per line with `data-active="true|false"` attributes.
*/
readonly renderLine?: (args: {
readonly lyric: Lyric;
readonly isActive: boolean;
readonly index: number;
}) => React.ReactNode;
}

View File

@@ -0,0 +1,2 @@
export { useLyrics } from "./use-lyrics.js";
export type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";

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.

View File

@@ -0,0 +1,151 @@
import { describe, expect, it, vi } from "@suaveplan/testing/runner";
import { act, renderHook } from "@testing-library/react";
import { parseLrc } from "../lrc-parser/lrc-parser.js";
import { useLyrics } from "./use-lyrics.js";
const LYRICS = parseLrc("[00:00.00]a\n[00:10.00]b\n[00:20.00]c\n[00:30.00]d");
describe("useLyrics", () => {
it("derives initial active index from currentSec", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
useLyrics({ lyrics: LYRICS, currentSec: 15 })
);
expect(result.current.activeIndex).toBe(1);
expect(result.current.activeLyric?.text).toBe("b");
});
it("returns -1 / undefined before first cue", { timeout: 5000 }, () => {
const earlier = parseLrc("[00:05]a");
const { result } = renderHook(() =>
useLyrics({ lyrics: earlier, currentSec: 0 })
);
expect(result.current.activeIndex).toBe(-1);
expect(result.current.activeLyric).toBeUndefined();
});
it("parses raw lrc source", { timeout: 5000 }, () => {
const { result } = renderHook(() =>
useLyrics({ lrc: "[00:01.00]x", currentSec: 5 })
);
expect(result.current.lyrics.length).toBe(1);
expect(result.current.activeLyric?.text).toBe("x");
});
it("no lyrics + no lrc → empty list", { timeout: 5000 }, () => {
const { result } = renderHook(() => useLyrics({ currentSec: 0 }));
expect(result.current.lyrics).toEqual([]);
expect(result.current.activeIndex).toBe(-1);
});
it("updates active index on rerender when currentSec changes", {
timeout: 5000,
}, () => {
const { result, rerender } = renderHook(
({ t }: { t: number }) =>
useLyrics({ lyrics: LYRICS, currentSec: t }),
{ initialProps: { t: 5 } }
);
expect(result.current.activeIndex).toBe(0);
rerender({ t: 25 });
// Even before the interval fires, the next interval tick will
// pick it up; we advance time to confirm.
vi.useFakeTimers();
try {
// Re-render schedules the effect; advance fake timers to
// run it.
act(() => {
vi.advanceTimersByTime(101);
});
} finally {
vi.useRealTimers();
}
expect([1, 2]).toContain(result.current.activeIndex);
});
it("samples on interval", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
let t = 0;
const { result, rerender } = renderHook(
({ x }: { x: number }) =>
useLyrics({
lyrics: LYRICS,
currentSec: x,
intervalMs: 50,
}),
{ initialProps: { x: t } }
);
expect(result.current.activeIndex).toBe(0);
t = 25;
rerender({ x: t });
act(() => {
vi.advanceTimersByTime(60);
});
expect(result.current.activeIndex).toBe(2);
} finally {
vi.useRealTimers();
}
});
it("interval clamps to 1 ms and cleans up on unmount", {
timeout: 5000,
}, () => {
vi.useFakeTimers();
try {
const { result, unmount } = renderHook(() =>
useLyrics({
lyrics: LYRICS,
currentSec: 5,
intervalMs: 0,
})
);
act(() => {
vi.advanceTimersByTime(5);
});
expect(result.current.activeIndex).toBe(0);
unmount();
act(() => {
vi.advanceTimersByTime(50);
});
// No throw on cleanup.
} finally {
vi.useRealTimers();
}
});
it("lyrics prop wins over lrc prop when both supplied", {
timeout: 5000,
}, () => {
const { result } = renderHook(() =>
useLyrics({
lyrics: LYRICS,
lrc: "[00:01]ignored",
currentSec: 15,
})
);
expect(result.current.lyrics).toBe(LYRICS);
expect(result.current.activeLyric?.text).toBe("b");
});
it("no-op tick when index hasn't changed", { timeout: 5000 }, () => {
vi.useFakeTimers();
try {
const { result } = renderHook(() =>
useLyrics({
lyrics: LYRICS,
currentSec: 5,
intervalMs: 20,
})
);
const idx = result.current.activeIndex;
act(() => {
vi.advanceTimersByTime(100);
});
expect(result.current.activeIndex).toBe(idx);
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -0,0 +1,58 @@
import { useEffect, useMemo, useState } from "react";
import { activeLyricIndex, parseLrc } from "../lrc-parser/lrc-parser.js";
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
import type { UseLyricsOptions, UseLyricsResult } from "./use-lyrics.types.js";
const DEFAULT_INTERVAL_MS = 100;
/**
* Engine-agnostic karaoke hook. Accepts a pre-parsed `lyrics` array
* (preferred) or a raw `lrc` string, plus a `currentSec` value, and
* returns the active line. Sampling cadence is decoupled from the
* caller's render rate: a 100 ms interval (configurable) forces a
* re-render even when neither `lyrics` nor `currentSec` mutate.
*
* The hook is deliberately decoupled from `useTransportTime` so it
* can be driven from any clock — `<LyricsDisplay>` wires the
* transport in for you when used inside `<AudioProvider>`, but a
* preview panel running under a deterministic test clock can call
* this hook directly with a hand-fed `currentSec`.
*/
export function useLyrics(opts: UseLyricsOptions): UseLyricsResult {
const lyrics: readonly Lyric[] = useMemo(() => {
if (opts.lyrics) return opts.lyrics;
if (typeof opts.lrc === "string") return parseLrc(opts.lrc);
return [];
}, [opts.lyrics, opts.lrc]);
const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
// A heartbeat counter forces a re-render every `intervalMs` so
// the memo below recomputes even when `currentSec` did not
// change between commits — relevant when the consumer's clock
// mutates a ref or external store the hook cannot subscribe to.
const [, setTick] = useState(0);
useEffect(() => {
const id = setInterval(
() => {
setTick((t) => (t + 1) % 1_000_000);
},
Math.max(1, intervalMs)
);
return () => {
clearInterval(id);
};
}, [intervalMs]);
const activeIndex = useMemo(
() => activeLyricIndex(lyrics, opts.currentSec),
[lyrics, opts.currentSec]
);
return {
lyrics,
activeIndex,
activeLyric: activeIndex >= 0 ? lyrics[activeIndex] : undefined,
};
}

View File

@@ -0,0 +1,33 @@
import type { Lyric } from "../lrc-parser/lrc-parser.types.js";
export interface UseLyricsOptions {
/**
* Pre-parsed lyric list. Either `lyrics` or `lrc` must be
* supplied; if both are present, `lyrics` wins.
*/
readonly lyrics?: readonly Lyric[];
/** Raw LRC source. Parsed once per identity change. */
readonly lrc?: string;
/**
* Active-line resample interval in milliseconds. Defaults to 100
* ms — the spec ceiling for "active lyric must advance within 100
* ms of the transport time".
*/
readonly intervalMs?: number;
/**
* Current time in seconds. The consumer is responsible for
* sourcing this — typically from `useTransportTime()` in
* `@suaveplan/react-audio`, but any clock works. The hook
* re-evaluates the active line every `intervalMs` regardless of
* how often this value mutates between renders.
*/
readonly currentSec: number;
}
export interface UseLyricsResult {
readonly lyrics: readonly Lyric[];
/** `-1` before the first cue or when `lyrics` is empty. */
readonly activeIndex: number;
/** Convenience: `lyrics[activeIndex]` or `undefined`. */
readonly activeLyric: Lyric | undefined;
}

View File

@@ -0,0 +1,46 @@
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = resolve(__dirname, "..");
const PKG_JSON = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { exports: Record<string, unknown>; dependencies: Record<string, string> };
const REQUIRED_SURFACES = [
".",
"./karaoke",
"./karaoke/lrc-parser",
"./karaoke/use-lyrics",
"./karaoke/lyrics-display",
"./error-codes",
];
describe("subpath exports", () => {
it("every public surface has a subpath entry", { timeout: 5000 }, () => {
for (const surface of REQUIRED_SURFACES) {
expect(PKG_JSON.exports[surface]).toBeDefined();
}
});
it("sideEffects=false", { timeout: 5000 }, () => {
const raw = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as { sideEffects: boolean };
expect(raw.sideEffects).toBe(false);
});
it("runtime dependency set matches allowlist", {
timeout: 5000,
}, () => {
const deps = Object.keys(PKG_JSON.dependencies).sort();
expect(deps).toEqual(
[
"@suaveplan/error",
"@suaveplan/react-audio",
"@suaveplan/types",
"tslib",
].sort()
);
});
});

View File

@@ -0,0 +1,38 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import * as RAP from "../src/index.js";
describe("@suaveplan/react-audio-player root barrel", () => {
it("exposes the namespaced ReactAudio re-export", { timeout: 5000 }, () => {
expect(RAP.ReactAudio).toBeDefined();
// The namespace must include the player components.
expect(typeof RAP.ReactAudio.AudioProvider).toBe("function");
expect(typeof RAP.ReactAudio.AudioPlayer).toBe("function");
expect(typeof RAP.ReactAudio.useTransport).toBe("function");
expect(typeof RAP.ReactAudio.useAnalyser).toBe("function");
expect(typeof RAP.ReactAudio.usePlaylist).toBe("function");
});
it("exposes the flat alias façade", { timeout: 5000 }, () => {
expect(typeof RAP.AudioPlayer).toBe("function");
expect(typeof RAP.AudioProvider).toBe("function");
expect(typeof RAP.BasicPlayer).toBe("function");
expect(typeof RAP.PremiumPlayer).toBe("function");
expect(typeof RAP.PresetManager).toBe("function");
expect(typeof RAP.TransportControls).toBe("function");
expect(typeof RAP.useAnalyser).toBe("function");
expect(typeof RAP.usePlaylist).toBe("function");
});
it("exposes karaoke surface from root", { timeout: 5000 }, () => {
expect(typeof RAP.parseLrc).toBe("function");
expect(typeof RAP.activeLyricIndex).toBe("function");
expect(typeof RAP.useLyrics).toBe("function");
expect(typeof RAP.LyricsDisplay).toBe("function");
});
it("exposes error-code surface from root", { timeout: 5000 }, () => {
expect(RAP.REACT_AUDIO_PLAYER_CODES).toBeDefined();
expect(typeof RAP.ReactAudioPlayerError).toBe("function");
expect(typeof RAP.registerReactAudioPlayerErrorCodes).toBe("function");
});
});

View File

@@ -0,0 +1,19 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../foundation/core/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"src/**/*.bench.ts",
"tests/**",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,54 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/react-audio-player",
plugins: [
react(),
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"karaoke/index": "src/karaoke/index.ts",
"karaoke/lrc-parser/lrc-parser":
"src/karaoke/lrc-parser/lrc-parser.ts",
"karaoke/use-lyrics/use-lyrics":
"src/karaoke/use-lyrics/use-lyrics.ts",
"karaoke/lyrics-display/lyrics-display":
"src/karaoke/lyrics-display/lyrics-display.tsx",
"error-codes/index": "src/error-codes/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@suaveplan/error",
"@suaveplan/react-audio",
"@suaveplan/types",
"react",
"react-dom",
"react/jsx-runtime",
"tslib",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,42 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
esbuild: {
legalComments: "inline",
},
test: {
globals: false,
environment: "happy-dom",
pool: "forks",
fileParallelism: false,
setupFiles: ["@suaveplan/testing/polyfills"],
include: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"tests/**/*.test.ts",
"tests/**/*.test.tsx",
],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts", "src/**/*.tsx"],
exclude: [
"src/**/*.test.ts",
"src/**/*.test.tsx",
"src/**/*.stories.tsx",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});