4.2 KiB
karaoke/use-lyrics
(no submodules)
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
lyricsor rawlrcsource — both accepted; if both provided,lyricswins (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: undefinedbefore the first cue or whenlyricsis empty.
Usage: Basic
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
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): UseLyricsResultUseLyricsOptions:lyrics?: readonly Lyric[]— preferred.lrc?: string— parsed once per identity change.intervalMs?: number(default 100).currentSec: number— required.
UseLyricsResult:lyrics: readonly Lyric[],activeIndex: number(-1for "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.