Skip to content

Lyrics Sync Deep Dive

This page covers how LyricsPlugin works internally, covering the cue parser registry, CueTracker lifecycle, parser selection, and extending the system with custom formats.

How LyricsPlugin works

  1. LyricsPlugin.use() runs once at registration and installs a current listener; that listener fires on every current event
  2. The plugin calls resolveLyricsUrl(item), which returns item.lyricsUrl or the custom getLyricsUrl(item) result
  3. The URL is passed to resolveParser(url), which calls player.resolveCueParser(url)
  4. The core walks the registered ICueParser[] in registration order. The first parser whose canParse(url) returns true wins.
  5. The plugin fetches the URL via this.fetch(url) (auth-aware, retried)
  6. The raw text is passed to parser.parse(raw) → returns a CueList
  7. A new CueTracker is attached to the player, which listens to the time event and fires enter / exit on the tracker

ICueParser interface

TypeScript
interface ICueParser {
readonly id: string;
canParse(url: string, contentType?: string): boolean;
parse(raw: string, opts?: { baseUrl?: string }): CueList<any>;
}

canParse(url) inspects the URL (typically the file extension) and returns true if this parser can handle it.

parse(raw) receives the raw text content and returns a CueList. Each Cue<T> in the list has start and end fields in seconds (matching the player’s time event units).

Built-in parsers

Parser idExtensionFormat
lrc.lrcLRC timestamp format: [mm:ss.cc] text
vtt.vttWebVTT: HH:MM:SS.mmm --> HH:MM:SS.mmm

Both are pre-registered in the player core.

Registering a custom parser

TypeScript
import type { ICueParser, CueList, Cue } from '@nomercy-entertainment/nomercy-player-core';
import { createCueList } from '@nomercy-entertainment/nomercy-player-core';

interface LrcLine {
text: string;
}

// Simple parser for a JSON format: [{ "t": 12.0, "text": "First line" }]
// t is in seconds (matching the player's time event).
const jsonLrcParser: ICueParser = {
id: 'json-lrc',

canParse(url: string): boolean {
return url.endsWith('.jlrc');
},

parse(raw: string): CueList<LrcLine> {
const data: Array<{ t: number; text: string }> = JSON.parse(raw);
const cues: Cue<LrcLine>[] = data.map((entry, index) => ({
start: entry.t,
end: data[index + 1]?.t ?? Infinity,
payload: { text: entry.text },
}));
return createCueList(cues);
},
};

// Register before setup:
player.registerCueParser(jsonLrcParser);

// Register at the front of the chain (checked first):
player.registerCueParser(jsonLrcParser, /* prepend */ true);

LRC format reference

Standard LRC:

LRC
[00:12.00] First line of lyrics
[00:17.20] Second line
[00:22.40] Third line

Enhanced LRC (word-level timing, not all players support this):

LRC
[00:12.00] <00:12.00> First <00:12.50> line <00:13.00> of lyrics

The built-in LRC parser supports standard LRC. Enhanced LRC (word-level <> tags) requires a custom parser if you want word-level highlighting.

Word-level highlighting

For word-level karaoke effects, parse enhanced LRC into sub-cues:

TypeScript
const enhancedLrcParser: ICueParser = {
id: 'enhanced-lrc',
canParse: (url) => url.endsWith('.elrc'),
parse(raw): CueList<{ text: string; words: Array<{ t: number; text: string }> }> {
// Parse enhanced LRC format and return word-level cues
// Implementation left as an exercise, enhanced LRC is non-standard
return createCueList([]);
},
};

CueTracker lifecycle

LyricsPlugin.attach(cueList):

  1. Calls this.clear(), which disposes any existing tracker
  2. Creates a new CueTracker<LyricPayload>(list)
  3. Binds tracker.on('enter', cue => ...) and tracker.on('exit', cue => ...)
  4. Calls tracker.attach(this.player), so the tracker subscribes to the player’s time event

When the player emits a time event, the tracker scans the cue list and fires enter / exit events for any cues whose [start, end) window crosses the current time.

LyricsPlugin.clear() calls tracker.dispose() which unsubscribes the time listener, so there is no leak.

Handling missing lyrics

LyricsPlugin reports (not throws) when no parser is found or the fetch fails:

TypeScript
// Listen to player errors with a lyrics-specific code:
player.on('warning', (event) => {
if (event.error.code === 'plugin:lyrics/fetch-failed' || event.error.code === 'plugin:lyrics/no-parser') {
console.warn('Lyrics failed:', event.error.message);
showNoLyricsMessage();
}
});

When autoFetch: false, you control when lyrics load and can handle errors from fetchLyrics():

TypeScript
const cueList = await lyricsPlugin.fetchLyrics(url);

if (!cueList) {
showNoLyricsMessage();
}

See also