190 lines
8.1 KiB
JavaScript
190 lines
8.1 KiB
JavaScript
/* ============================================================================
|
|
* 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);
|