Files
skills/docs/ui/react/react-audio-player/src/karaoke/use-lyrics/use-lyrics.md
2026-05-29 08:20:56 +01:00

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

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): 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.