/* ============================================================================ * lyrics-core.js — pure helpers for synced lyrics, chapters and bookmarks * * No DOM. Used by app.js (window.LyricsCore) and unit-tested under node. * * Editor text format (a superset of LRC, so pasted .lrc files just work): * [0:18.00] Worthy of every song we could ever sing timed line * Worthy of all the praise untimed line * [0:18] # Verse 1 section header * ! Full dynamic swell into Bridge band cue * @ Key G, Capo 2 (Play F), 70 BPM tags * [00:12.00][01:30.00] repeated line LRC multi-stamp * [offset:+500] LRC offset (ms, + = sooner) * Other LRC headers ([ar:], [ti:], …) are ignored. * * Sync offset: a line is shown at (t + offset) seconds of playback, so a * positive offset makes the lyrics appear later. * ========================================================================== */ (function (root) { 'use strict'; const STAMP_RE = /^\[(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\]/; const HEADER_RE = /^\[([a-z]+):([^\]]*)\]$/i; function parseStamp(str) { const m = String(str || '').trim().match(/^(?:(\d+):)?(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?$/); if (!m) return null; const h = Number(m[1] || 0); const frac = m[4] ? Number('0.' + m[4]) : 0; return h * 3600 + Number(m[2]) * 60 + Number(m[3]) + frac; } // 83.456 → "1:23.46" (editor stamps keep hundredths). function fmtStamp(t) { const cs = Math.round(Math.max(0, Number(t) || 0) * 100); const m = Math.floor(cs / 6000); const s = Math.floor((cs % 6000) / 100); const c = cs % 100; return `${m}:${String(s).padStart(2, '0')}.${String(c).padStart(2, '0')}`; } // 83.9 → "1:23"; ≥1 h → "1:02:03" (YouTube chapter / display form). function fmtClock(t) { const total = Math.max(0, Math.floor(Number(t) || 0)); const h = Math.floor(total / 3600); const m = Math.floor((total % 3600) / 60); const s = total % 60; return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`; } function splitTags(s) { return String(s).split(/[,|·]/).map((x) => x.trim()).filter(Boolean); } // Editor text → { lines, tags, offset }. function fromText(text) { const lines = []; const tags = []; let offset = null; for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) { let rest = rawLine.trim(); if (!rest) continue; const header = rest.match(HEADER_RE); if (header && !/^\d/.test(header[1])) { if (header[1].toLowerCase() === 'offset') { const ms = Number(header[2]); if (Number.isFinite(ms)) offset = Math.round((-ms / 1000) * 100) / 100; } continue; } const stamps = []; let m; while ((m = rest.match(STAMP_RE))) { const frac = m[3] ? Number('0.' + m[3]) : 0; stamps.push(Number(m[1]) * 60 + Number(m[2]) + frac); rest = rest.slice(m[0].length).trim(); } if (rest.startsWith('@')) { tags.push(...splitTags(rest.slice(1))); continue; } let kind = 'line'; if (rest.startsWith('#')) { kind = 'section'; rest = rest.replace(/^#+/, '').trim(); } else if (rest.startsWith('!')) { kind = 'cue'; rest = rest.slice(1).trim(); } if (!rest) continue; if (!stamps.length) lines.push({ t: null, text: rest, kind }); for (const t of stamps) lines.push({ t: Math.round(t * 100) / 100, text: rest, kind }); } // A fully timed sheet (typical .lrc with repeated-chorus stamps) is put in // time order; a partly timed one keeps the order it was written in. if (lines.length && lines.every((l) => l.t !== null)) { lines.sort((a, b) => a.t - b.t); } return { lines, tags, offset }; } function toText(doc) { const out = []; if (doc && Array.isArray(doc.tags) && doc.tags.length) out.push('@ ' + doc.tags.join(', ')); for (const l of (doc && doc.lines) || []) { const stamp = l.t === null || l.t === undefined ? '' : `[${fmtStamp(l.t)}] `; const mark = l.kind === 'section' ? '# ' : l.kind === 'cue' ? '! ' : ''; out.push(stamp + mark + l.text); } return out.join('\n'); } // Index (into lines) of the sung line playing at `time`, or -1. Sections // and cues are never "the active line" — the highlight follows the words. function activeIndex(lines, time, offset) { const now = Number(time) - (Number(offset) || 0); let best = -1; let bestT = -Infinity; (lines || []).forEach((l, i) => { if (l.kind !== 'line' || l.t === null || l.t === undefined) return; if (l.t <= now && l.t >= bestT) { best = i; bestT = l.t; } }); return best; } // The section header that the active position falls under (for "Chorus in // progress"-style labels), or null. function activeSection(lines, time, offset) { const now = Number(time) - (Number(offset) || 0); let best = null; for (const l of lines || []) { if (l.kind === 'section' && l.t !== null && l.t !== undefined && l.t <= now) { if (!best || l.t >= best.t) best = l; } } return best; } function timedCount(lines) { return (lines || []).filter((l) => l.kind === 'line' && l.t !== null && l.t !== undefined).length; } // Chapters / bookmarks: [{t, title, note}] sorted by t. function activeMarker(items, time) { let best = null; for (const it of items || []) if (it.t <= time && (!best || it.t >= best.t)) best = it; return best; } // YouTube-description form: "0:00 Intro". YouTube only turns a list into // chapters when the first one is at 0:00, so an "Start" entry is added when // the list doesn't begin there. function toYouTubeTimestamps(items, { ensureZero = true } = {}) { const sorted = (items || []).slice().sort((a, b) => a.t - b.t); const rows = sorted.map((it) => `${fmtClock(it.t)} ${it.title}`); if (ensureZero && sorted.length && sorted[0].t >= 1) rows.unshift('0:00 Start'); return rows.join('\n'); } const LyricsCore = { parseStamp, fmtStamp, fmtClock, fromText, toText, activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps, }; if (typeof module !== 'undefined' && module.exports) { module.exports = LyricsCore; } else { root.LyricsCore = LyricsCore; } })(typeof globalThis !== 'undefined' ? globalThis : this);