/** One line of time-synced lyrics; empty text is an instrumental gap. */ export interface LyricLine { timeMs: number; text: string; } export interface LyricsDTO { /** Lines with timestamps, when the song has synced lyrics. */ synced: LyricLine[] | null; /** Plain text, when there are no synced lyrics (or as a fallback). */ plain: string | null; instrumental: boolean; /** Where they came from, for attribution. */ source: { name: string; url: string }; } const TIME_TAG = /\[(\d{1,3}):(\d{1,2}(?:\.\d{1,3})?)\]/g; /** * Parse LRC ("[01:23.45] words"). A line may carry several time tags; tags * that aren't times (like [ar:Artist]) are skipped. Sorted by time. */ export function parseLrc(lrc: string): LyricLine[] { const lines: LyricLine[] = []; for (const raw of lrc.split(/\r?\n/)) { const times = [...raw.matchAll(TIME_TAG)]; if (times.length === 0) continue; const text = raw.replace(TIME_TAG, '').trim(); for (const [, min, sec] of times) lines.push({ timeMs: Math.round((Number(min) * 60 + Number(sec)) * 1000), text }); } return lines.sort((a, b) => a.timeMs - b.timeMs); } /** Index of the line being sung at `positionMs`, or -1 before the first one. */ export function currentLyricIndex(lines: LyricLine[], positionMs: number): number { let lo = 0; let hi = lines.length - 1; let found = -1; while (lo <= hi) { const mid = (lo + hi) >> 1; if (lines[mid]!.timeMs <= positionMs) { found = mid; lo = mid + 1; } else hi = mid - 1; } return found; }