/* ============================================================================ * 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) * | You can do anything continuation, same cue * 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); } function display(line) { const text = String(line?.text || ''), divider = text.indexOf(' / '); return { text: divider < 0 ? text : text.slice(0, divider).trim(), secondary: line?.secondary || (divider < 0 ? '' : text.slice(divider + 3).trim()), phonetic: line?.phonetic || '' }; } // Editor text → { lines, tags, offset }. function fromText(text) { const lines = []; const tags = []; let offset = null; let group = []; for (const rawLine of String(text || '').replace(/\r/g, '').split('\n')) { let rest = rawLine.trim(); if (!rest) continue; if (/^!pinyin:\s*/i.test(rest) && group.some(l => l.kind === 'line')) { const value = rest.replace(/^!pinyin:\s*/i, '').trim(); for (const line of group) if (value) line.phonetic = line.phonetic ? line.phonetic + '\n' + value : value; continue; } if (rest.startsWith('| ') && group.length) { const extra = display({ text: rest.slice(2).trim() }); for (const line of group) { line.text += '\n' + extra.text; if (extra.secondary) line.secondary = line.secondary ? line.secondary + '\n' + extra.secondary : extra.secondary; } continue; } group = []; 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; group = stamps.length ? stamps.map(t => ({ t: Math.round(t * 100) / 100, text: rest, kind })) : [{ t: null, text: rest, kind }]; if (kind === 'line') for (const line of group) { const parts = display(line); line.text = parts.text; if (parts.secondary) line.secondary = parts.secondary; } lines.push(...group); } // 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' ? '! ' : ''; const parts = (l.kind === 'line' ? display(l) : { text: l.text || '', secondary: '' }); const primary = parts.text.split('\n'), secondary = parts.secondary.split('\n'); const combined = primary.map((part, i) => part + (secondary[i] ? ' / ' + secondary[i] : '')); out.push(stamp + mark + combined[0], ...combined.slice(1).map(part => '| ' + part)); if (l.phonetic) out.push(...String(l.phonetic).split('\n').map(part => '!pinyin: ' + part)); } 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, display, activeIndex, activeSection, timedCount, activeMarker, toYouTubeTimestamps, }; if (typeof module !== 'undefined' && module.exports) { module.exports = LyricsCore; } else { root.LyricsCore = LyricsCore; } })(typeof globalThis !== 'undefined' ? globalThis : this);