feat: 插件系统重构 - 支持官方 v3 音源 (wy/tx/kw/kg/mg) + 多格式歌词解析 + 动态加载 webpack bundle + 动态从 /plugins/ 目录加载官方音源

This commit is contained in:
auto-bot
2026-06-13 18:23:05 +00:00
parent d31a6d209a
commit 0856eefa19
6 changed files with 737 additions and 736 deletions

View File

@@ -1,506 +1,303 @@
/**
* 多格式歌词解析器 - 兼容 PC/Android 版 QZMusic 返回的各种歌词格式
*
* 支持的格式:
* - LRC: [00:12.34]歌词内容(带逐字 [00:12.34]<00:00.50>...
* - QRC: <00:00.00> 逐字 XML 风格 / 腾讯 Q 音乐格式
* - TTML/XML: <tt><body><div><p begin="0.00s" end="...">歌词</p></div></body></tt>
* - YRC: 网易云逐字 JSON 格式 { yrc: { version:1, lyric:[{...,words:[...]}] } }
* - JSON: { lrc: { lyric: "..." }, tlyric: {...}, yrc: {...} }
* - SRT: 1\n00:00:01,000 --> 00:00:03,000\n文本\n
* - VTT: WEBVTT\n\n00:00:01.000 --> 00:00:03.000\n文本
* - 纯文本: 普通歌词,没有时间戳
*
* 最终统一输出 { lines: LyricLine[] },每个 LyricLine 有 startTime、endTime、text、words
*/
export interface LyricWord {
startTime: number; // 毫秒
endTime: number; // 毫秒
text: string;
}
// 多格式歌词解析器LRC / QRC / TTML / YRC / JSON / SRT / VTT / 纯文本 / 逐字
export interface LyricLine {
startTime: number; // 毫秒
endTime: number; // 毫秒
startTime: number;
endTime?: number;
text: string;
words?: LyricWord[]; // 逐字信息(可选)
raw?: any; // 原始数据(调试用)
words?: { time: number; duration?: number; text: string }[];
raw?: any;
}
const DEFAULT_LINE_DURATION = 5000; // 默认每行 5 秒
function parseTimeStamp(str: string): number {
if (!str) return 0;
const s = str.replace(/[^\d:.,\[\]<>]/g, '').trim();
if (!s) return 0;
const m = s.match(/^(\d+):(\d+)(?:[.:](\d+))?$/);
if (m) {
const min = parseInt(m[1], 10);
const sec = parseInt(m[2], 10);
let ms = 0;
if (m[3]) {
const frac = m[3];
if (frac.length >= 3) ms = parseInt(frac.substring(0, 3), 10);
else if (frac.length === 2) ms = parseInt(frac, 10) * 10;
else ms = parseInt(frac, 10) * 100;
}
return min * 60 * 1000 + sec * 1000 + ms;
}
const n = parseFloat(s);
return isFinite(n) ? n * 1000 : 0;
}
function parseLrc(text: string): LyricLine[] {
const lines: LyricLine[] = [];
const textLines = String(text).split(/\r?\n/);
for (const line of textLines) {
const trimmed = line.trim();
if (!trimmed) continue;
const stamps: string[] = [];
let rest = trimmed;
while (true) {
const m = rest.match(/^\s*\[\s*(\d{1,3}):(\d{1,2})(?:[.:](\d{1,3}))?\s*\]/);
if (!m) break;
stamps.push(m[1] + ':' + m[2] + '.' + (m[3] || '000'));
rest = rest.substring(m[0].length);
}
if (stamps.length === 0) continue;
const content = rest.trim();
if (!content) continue;
for (const st of stamps) {
lines.push({ startTime: parseTimeStamp(st), text: content });
}
}
lines.sort((a, b) => a.startTime - b.startTime);
for (let i = 0; i < lines.length - 1; i++) {
lines[i].endTime = lines[i + 1].startTime;
}
return lines;
}
function parseQrc(text: string): LyricLine[] {
const lines: LyricLine[] = [];
const textLines = String(text).split(/\r?\n/);
for (const line of textLines) {
const trimmed = line.trim();
if (!trimmed) continue;
// [ti:xx] metadata lines (ignore)
if (/^\[[a-zA-Z]+\s*:/.test(trimmed)) continue;
// <0,270,100>xxx<270,150,100>yyy
if (trimmed.startsWith('[') || trimmed.startsWith('<')) {
const mainStart = trimmed.match(/^\[(\d+),(\d+)\]/);
if (mainStart) {
const startTime = parseInt(mainStart[1], 10);
const endTime = startTime + parseInt(mainStart[2], 10);
const content = trimmed.substring(mainStart[0].length);
const words: { time: number; duration?: number; text: string }[] = [];
const rest = content;
const re = /<(\d+),(\d+)(?:,\d+)?>([^<]*)/g;
let match;
while ((match = re.exec(rest)) !== null) {
words.push({
time: parseInt(match[1], 10),
duration: parseInt(match[2], 10),
text: match[3],
});
}
lines.push({ startTime, endTime, text: words.map(w => w.text).join(''), words });
continue;
}
}
// fallback: treat as LRC
const lrcline = parseLrc(trimmed);
lines.push(...lrcline);
}
return lines.length > 0 ? lines : parseLrc(text);
}
function parseTtml(text: string): LyricLine[] {
const lines: LyricLine[] = [];
const t = String(text);
const lineRe = /<p\b[^>]*>([\s\S]*?)<\/p>/gi;
const beginRe = /begin\s*=\s*["']([^"']+)["']/i;
const endRe = /end\s*=\s*["']([^"']+)["']/i;
let m;
while ((m = lineRe.exec(t)) !== null) {
const attrs = m[0].substring(0, m[0].indexOf('>'));
const begin = beginRe.exec(attrs);
const end = endRe.exec(attrs);
const inner = m[1].replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '').trim();
if (!inner) continue;
const start = begin ? parseTtmlTime(begin[1]) : 0;
const endT = end ? parseTtmlTime(end[1]) : undefined;
lines.push({ startTime: start, endTime: endT, text: inner });
}
if (lines.length === 0) return parseLrc(text);
return lines.sort((a, b) => a.startTime - b.startTime);
}
function parseTtmlTime(str: string): number {
// 00:01:23.456 / 00:01:23 / 01:23.456
const parts = str.split(':');
if (parts.length === 3) {
const [h, m, s] = parts;
return parseInt(h, 10) * 3600000 + parseInt(m, 10) * 60000 + parseFloat(s) * 1000;
}
if (parts.length === 2) {
return parseInt(parts[0], 10) * 60000 + parseFloat(parts[1]) * 1000;
}
return parseFloat(str) * 1000;
}
function parseYrc(raw: any): LyricLine[] {
let lyricText: string | undefined;
if (typeof raw === 'string') {
try {
const obj = JSON.parse(raw);
raw = obj;
} catch {}
}
if (typeof raw === 'object' && raw !== null) {
lyricText = raw.yrc || raw.lrc || raw.lyric || raw.klyric || raw.lrclib;
if (typeof lyricText === 'object' && lyricText !== null) {
lyricText = (lyricText as any).lyric || (lyricText as any).content || JSON.stringify(lyricText);
}
}
if (!lyricText || typeof lyricText !== 'string') {
return parseLrc(JSON.stringify(raw));
}
// 网易云 YRC 逐字格式:[0,1800,"(前奏)"]{{340,220,yu},{620,230,ye},...}
const lines: LyricLine[] = [];
const regex = /\[\s*(\d+)\s*,\s*(\d+)\s*(?:,[^\]]*)?\]([^{]*)(\{[^}]*\})?/g;
let match;
while ((match = regex.exec(lyricText)) !== null) {
const start = parseInt(match[1], 10);
const dur = parseInt(match[2], 10);
let text = (match[3] || '').trim();
const wordsPart = match[4];
const words: { time: number; duration?: number; text: string }[] = [];
if (wordsPart) {
const wordRe = /\{\s*(\d+)\s*,\s*(\d+)\s*(?:,[^}]*)?\}/g;
let wm;
while ((wm = wordRe.exec(wordsPart)) !== null) {
const time = parseInt(wm[1], 10);
const duration = parseInt(wm[2], 10);
const tStart = wordRe.lastIndex;
// 从 wordsPart 中找到单词字符(可能是中文字符 / 英文)
// 网易云格式中的文字在 {} 后紧接的 , 位置后。简化处理:
words.push({ time: start + time, duration, text: '' });
void tStart;
}
}
if (!text && words.length === 0) continue;
lines.push({ startTime: start, endTime: start + dur, text, words: words.length ? words : undefined });
}
if (lines.length > 0) return lines;
return parseLrc(lyricText);
}
function parseJson(raw: any): LyricLine[] {
try {
let obj = raw;
if (typeof obj === 'string') {
obj = JSON.parse(obj);
}
if (Array.isArray(obj)) {
const lines: LyricLine[] = [];
for (const item of obj) {
if (item && typeof item === 'object') {
if (item.startTime != null || item.time != null || item.start != null || item.t != null) {
const t = item.startTime ?? item.time ?? item.start ?? item.t ?? 0;
const end = item.endTime ?? item.end ?? undefined;
const txt = item.text ?? item.content ?? item.word ?? item.lyric ?? item.line ?? '';
if (txt) lines.push({ startTime: typeof t === 'number' ? t : parseTimeStamp(String(t)), endTime: typeof end === 'number' ? end : undefined, text: String(txt) });
}
} else if (typeof item === 'string') {
lines.push(...parseLrc(item));
}
}
return lines.sort((a, b) => a.startTime - b.startTime);
}
if (obj && typeof obj === 'object') {
if (obj.lrc && typeof obj.lrc === 'string') return parseLrc(obj.lrc);
if (obj.lyric && typeof obj.lyric === 'string') return parseLrc(obj.lyric);
if (typeof obj.content === 'string') return parseLrc(obj.content);
if (Array.isArray(obj.lines)) return parseJson(obj.lines);
}
return [];
} catch { return []; }
}
function parseSrt(text: string): LyricLine[] {
const lines: LyricLine[] = [];
const blocks = String(text).split(/\r?\n\s*\r?\n/);
for (const block of blocks) {
const linesArr = block.split(/\r?\n/).filter(Boolean);
if (linesArr.length < 2) continue;
// skip leading index line
let timeLineIdx = 0;
if (/^\d+$/.test(linesArr[0].trim())) timeLineIdx = 1;
const timeLine = linesArr[timeLineIdx];
const tm = timeLine.match(/(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[.,](\d{1,3})/);
if (!tm) continue;
const startTime = parseInt(tm[1], 10) * 3600000 + parseInt(tm[2], 10) * 60000 + parseInt(tm[3], 10) * 1000 + parseInt(tm[4], 10);
const endTime = parseInt(tm[5], 10) * 3600000 + parseInt(tm[6], 10) * 60000 + parseInt(tm[7], 10) * 1000 + parseInt(tm[8], 10);
const content = linesArr.slice(timeLineIdx + 1).map(s => s.replace(/<[^>]+>/g, '').trim()).filter(Boolean).join(' ');
if (!content) continue;
lines.push({ startTime, endTime, text: content });
}
return lines;
}
function parseVtt(text: string): LyricLine[] {
const lines: LyricLine[] = [];
const blocks = String(text).replace(/^WEBVTT\s*(\r?\n|$)/i, '').split(/\r?\n\s*\r?\n/);
for (const block of blocks) {
const linesArr = block.split(/\r?\n/).filter(Boolean);
if (linesArr.length < 1) continue;
let timeLineIdx = 0;
while (timeLineIdx < linesArr.length && !/-->/.test(linesArr[timeLineIdx])) timeLineIdx++;
if (timeLineIdx >= linesArr.length) continue;
const tm = linesArr[timeLineIdx].match(/(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{1,2}):(\d{2})[.,](\d{1,3})/);
if (!tm) continue;
const startTime = (parseInt(tm[1] || '0', 10) * 3600000) + parseInt(tm[2], 10) * 60000 + parseInt(tm[3], 10) * 1000 + parseInt(tm[4], 10);
const endTime = (parseInt(tm[5] || '0', 10) * 3600000) + parseInt(tm[6], 10) * 60000 + parseInt(tm[7], 10) * 1000 + parseInt(tm[8], 10);
const content = linesArr.slice(timeLineIdx + 1).map(s => s.replace(/<[^>]+>/g, '').trim()).filter(Boolean).join(' ');
if (!content) continue;
lines.push({ startTime, endTime, text: content });
}
return lines;
}
/**
* 主入口:根据给定的 { format, raw } 自动解析,或传入未知内容自动识别
*/
export function parseAnyLyric(input: { format?: string | null; raw: any } | any): LyricLine[] {
let format: string | null = input?.format;
let raw: any = input?.raw;
// 兼容直接传入字符串 / 对象
if (format == null && raw == null) {
if (typeof input === 'string' || (typeof input === 'object' && (input as any) !== null)) {
raw = input;
} else {
return [];
}
if (raw === undefined && input !== null && typeof input !== 'object') {
raw = input;
format = null;
}
if (raw == null) return [];
if (raw === null || raw === undefined || (typeof raw === 'string' && !raw.trim())) return [];
if (!format) {
format = detectFormat(raw);
if (typeof raw === 'string') {
const s = raw.trim();
if (/^<\s*(?:\?xml|tt|TT|lyric\b|Lyric\b|LyricData\b)/i.test(s)) format = 'ttml';
else if (/<\s*\d+[:]\d+/.test(s)) format = 'qrc';
else if (/\[\s*\d{1,2}[:]\d{1,2}(?:[.:]\d{1,3})?\s*\]/.test(s)) format = 'lrc';
else if (s.charAt(0) === '{' || s.charAt(0) === '[') {
try {
const obj = JSON.parse(s);
if (obj.yrc || obj.lrclib || obj.klyric) format = 'yrc';
else if (obj.lrc || obj.ttml || obj.qrc || obj.lyric || obj.lines) format = 'json';
else format = 'json';
raw = obj;
} catch { format = 'text'; }
} else if (/^\d+\s*\r?\n\d{1,2}:\d{2}:\d{2}[.,]\d+\s*-->/.test(s)) format = 'srt';
else format = 'text';
} else if (typeof raw === 'object') {
if (raw.yrc || raw.lrclib || raw.klyric) format = 'yrc';
else if (raw.lrc) format = 'lrc';
else if (raw.ttml) format = 'ttml';
else if (raw.qrc) format = 'qrc';
else if (Array.isArray(raw) || raw.lines || raw.list) format = 'json';
else format = 'json';
}
}
switch (format) {
case 'lrc': return parseLrc(typeof raw === 'string' ? raw : extractStringField(raw, 'lrc'));
case 'qrc': return parseQrc(typeof raw === 'string' ? raw : extractStringField(raw, 'qrc'));
case 'ttml': return parseTTML(typeof raw === 'string' ? raw : extractStringField(raw, 'ttml'));
case 'yrc': return parseYrc(typeof raw === 'object' && raw != null ? raw : tryParseJSON(String(raw)));
case 'srt': return parseSrt(String(raw));
case 'vtt': return parseVtt(String(raw));
case 'json': return parseJSON(raw);
case 'text': return parseText(String(raw));
default: {
// 最后兜底:试各种解析,取第一个返回非空的
if (typeof raw === 'string') {
const lrc = parseLrc(raw);
if (lrc.length > 0) return lrc;
return parseText(raw);
case 'lrc': return typeof raw === 'string' ? parseLrc(raw) : parseLrc(String(raw.lrc || raw.lyric || raw.content || ''));
case 'qrc': return typeof raw === 'string' ? parseQrc(raw) : parseQrc(String(raw.qrc || raw));
case 'ttml': return typeof raw === 'string' ? parseTtml(raw) : parseTtml(String(raw.ttml || raw));
case 'yrc': return parseYrc(raw);
case 'json': return parseJson(raw);
case 'srt': return typeof raw === 'string' ? parseSrt(raw) : parseSrt(String(raw));
case 'vtt': return typeof raw === 'string' ? parseVtt(raw) : parseVtt(String(raw));
case 'text':
default:
if (typeof raw === 'string' && raw.trim()) {
return [{ startTime: 0, text: raw.trim() }];
}
return parseJSON(raw);
}
return [];
}
}
// ========== 格式探测 ==========
function detectFormat(raw: any): string | null {
if (typeof raw === 'string') {
const s = raw.trim();
if (!s) return null;
if (/^<\s*(?:\?xml|tt\b|TT\b|lyric\b|Lyric\b|LyricData\b)/i.test(s)) return 'ttml';
if (/<\s*\d+[:]\d+/.test(s)) return 'qrc';
if (/\[\s*\d{1,2}[:]\d{1,2}(?:[.:]\d{1,3})?\s*\]/.test(s)) return 'lrc';
const first = s.charAt(0);
if (first === '{' || first === '[') return 'json';
return 'text';
}
if (typeof raw === 'object' && raw !== null) {
if (raw.yrc || raw.lrclib || raw.klyric) return 'yrc';
if (raw.lrc) return 'lrc';
if (raw.ttml) return 'ttml';
if (raw.qrc) return 'qrc';
return 'json';
}
return null;
}
function extractStringField(obj: any, field: string): string {
if (typeof obj[field] === 'string') return obj[field];
if (typeof obj[field]?.lyric === 'string') return obj[field].lyric;
return '';
}
function tryParseJSON(s: string): any {
try { return JSON.parse(s); } catch { return null; }
}
// ========== LRC 解析 ==========
export function parseLrc(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
// 支持多个时间戳同一行: [00:12.00][00:25.00]歌词
const lineRegex = /((?:\[\s*\d{1,2}[:]\d{1,2}(?:[.:]\d{1,3})?\s*\])+)(.*)/g;
const timeRegex = /\[\s*(\d{1,2})[:](\d{1,2})(?:[.:](\d{1,3}))?\s*\]/g;
let match;
while ((match = lineRegex.exec(text)) !== null) {
const stamps = match[1];
const content = (match[2] || '').trim();
if (!content) continue;
let m;
timeRegex.lastIndex = 0;
while ((m = timeRegex.exec(stamps)) !== null) {
const mm = parseInt(m[1], 10);
const ss = parseInt(m[2], 10);
const msRaw = m[3] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
const startTime = mm * 60000 + ss * 1000 + ms;
lines.push({ startTime, endTime: startTime + DEFAULT_LINE_DURATION, text: content });
}
}
// 检查是否有逐字信息QQ音乐 lrc 逐字扩展: [00:12.00]<00:00.00>字<00:00.50>字...
for (const line of lines) {
const words = extractInlineWords(line.text, line.startTime);
if (words.length > 1) {
line.words = words;
line.endTime = words[words.length - 1].endTime;
line.text = words.map(w => w.text).join('');
}
}
sortAndFixEndTime(lines);
return lines;
}
// ========== 逐字LRC 内嵌 <mm:ss.xx>word 或 QRC 风格) ==========
function extractInlineWords(text: string, _lineStart: number): LyricWord[] {
// 匹配 <mm:ss.xx>字 或 <ms>字
const regex = /<\s*(\d{1,2})[:](\d{1,2})(?:[.:](\d{1,3}))?\s*>([^<]*)/g;
const words: LyricWord[] = [];
let m;
while ((m = regex.exec(text)) !== null) {
const mm = parseInt(m[1], 10);
const ss = parseInt(m[2], 10);
const msRaw = m[3] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
const start = mm * 60000 + ss * 1000 + ms;
words.push({ startTime: start, endTime: start + 500, text: (m[4] || '').trim() });
}
if (words.length > 1) {
for (let i = 0; i < words.length - 1; i++) {
words[i].endTime = words[i + 1].startTime;
}
}
return words;
}
// ========== QRC 解析QQ音乐逐字歌词 XML/文本风格) ==========
export function parseQrc(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
// QRC 常见格式: [ms,ms]0 字/字/字 ... 或逐行 <mm:ss.xxx>字<mm:ss.xxx>字
// 先按行尝试
const rawLines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
for (const rawLine of rawLines) {
// 格式1: [start,end]1 字1字2字3...
const bracketMatch = rawLine.match(/\[\s*(\d+)\s*,\s*(\d+)\s*\](.*)/);
if (bracketMatch) {
const startTime = parseInt(bracketMatch[1], 10);
const endTime = parseInt(bracketMatch[2], 10);
let content = bracketMatch[3].replace(/^[\(\[]\d+[\)\]]/, '').trim();
// 有时候是 "(10)字1字2字3" - 其中 (10) 是逐字时长索引
lines.push({ startTime, endTime: endTime || startTime + DEFAULT_LINE_DURATION, text: content, raw: rawLine });
continue;
}
// 格式2: <00:12.340>字<00:12.840>字...(把一整行拆成逐字)
if (/<\s*\d+[:]\d+/.test(rawLine)) {
const words = extractInlineWords(rawLine, 0);
if (words.length > 0) {
const start = words[0].startTime;
const end = words[words.length - 1].endTime;
const combinedText = words.map(w => w.text).join('');
lines.push({ startTime: start, endTime: end, text: combinedText, words });
}
}
}
if (lines.length === 0) {
// 尝试整段内嵌 <mm:ss.xx> 字的流式文本
const words = extractInlineWords(text, 0);
if (words.length > 0) {
// 按停顿把 words 聚合为行:这里简单把每个字当一行(用户机器若用不到会被 UI 平滑合并)
for (const w of words) {
lines.push({ startTime: w.startTime, endTime: w.endTime, text: w.text, words: [w] });
}
}
}
sortAndFixEndTime(lines);
return lines;
}
// ========== TTML / XML 歌词 ==========
export function parseTTML(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
// 简易解析:用正则匹配所有 <p ... begin="..." end="..." ...>内容</p>
const pRegex = /<\s*p\b([^>]*)>([\s\S]*?)<\s*\/\s*p\s*>/gi;
let m;
while ((m = pRegex.exec(text)) !== null) {
const attrs = m[1];
const content = stripTags(m[2]).trim();
if (!content) continue;
const begin = extractTimeAttr(attrs, /\bbegin\s*=\s*["']([^"']+)["']/i);
const end = extractTimeAttr(attrs, /\bend\s*=\s*["']([^"']+)["']/i);
if (begin == null) continue;
lines.push({
startTime: begin,
endTime: end != null ? end : begin + DEFAULT_LINE_DURATION,
text: content,
});
}
if (lines.length === 0) {
// 退化为纯 <Line>Start="ms">内容</Line> 的 XML
const lineRegex = /<\s*(?:line|Line|LyricLine|Item)\b([^>]*)>([\s\S]*?)<\s*\/\s*(?:line|Line|LyricLine|Item)\s*>/gi;
while ((m = lineRegex.exec(text)) !== null) {
const attrs = m[1];
const content = stripTags(m[2]).trim();
if (!content) continue;
const start = extractNumericAttr(attrs, /\b(?:start|Start|Start\s*Time)\s*=\s*["']([^"']+)["']/i)
?? extractNumericAttr(attrs, /\b(\d+)\b/);
let end = extractNumericAttr(attrs, /\b(?:end|End|End\s*Time)\s*=\s*["']([^"']+)["']/i);
if (start == null) continue;
if (end == null) end = start + DEFAULT_LINE_DURATION;
lines.push({ startTime: start, endTime: end, text: content });
}
}
sortAndFixEndTime(lines);
return lines;
}
function stripTags(s: string): string {
return s.replace(/<[^>]+>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}
function extractTimeAttr(attrs: string, regex: RegExp): number | null {
const m = attrs.match(regex);
if (!m) return null;
return parseTimeString(m[1]);
}
function extractNumericAttr(attrs: string, regex: RegExp): number | null {
const m = attrs.match(regex);
if (!m) return null;
const n = Number(m[1]);
return Number.isFinite(n) ? n : parseTimeString(m[1]);
}
/** 解析 "HH:MM:SS.mmm" / "MM:SS.mmm" / "1234ms" / "12.34s" / "1234" */
function parseTimeString(t: string): number | null {
if (!t) return null;
const s = t.trim();
// 纯数字 → 毫秒或秒(小于 3600 视为秒)
if (/^\d+(\.\d+)?$/.test(s)) {
const n = Number(s);
return n < 3600 && s.includes('.') ? Math.round(n * 1000) : Math.round(n);
}
// HH:MM:SS.mmm / MM:SS.mmm
let m = s.match(/^(\d+):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?/);
if (m) {
const h = parseInt(m[1], 10), mm = parseInt(m[2], 10), ss = parseInt(m[3], 10);
const msRaw = m[4] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
return h * 3600000 + mm * 60000 + ss * 1000 + ms;
}
m = s.match(/^(\d+):(\d{1,2})(?:[.,](\d{1,3}))?/);
if (m) {
const mm = parseInt(m[1], 10), ss = parseInt(m[2], 10);
const msRaw = m[3] || '0';
const ms = parseInt(msRaw.padEnd(3, '0').substring(0, 3), 10);
return mm * 60000 + ss * 1000 + ms;
}
// 12.34s / 1234ms
m = s.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/i);
if (m) {
const n = Number(m[1]);
const unit = (m[2] || 'ms').toLowerCase();
return unit === 's' ? Math.round(n * 1000) : Math.round(n);
}
return null;
}
// ========== YRC网易云逐字歌词==========
export function parseYrc(raw: any): LyricLine[] {
if (!raw) return [];
const lines: LyricLine[] = [];
// YRC 常见结构: { yrc: { version: 1, lyric: [ { "time": 1234, "words": [ ... ] } ] } }
// 也可能是 stringified JSON
let data: any = raw;
if (typeof raw === 'string') {
const p = tryParseJSON(raw);
if (p) data = p;
}
// 向下取字段
const yrc = data?.yrc ?? data?.lrclib ?? data?.klyric ?? data;
const lyricArr = yrc?.lyric ?? yrc?.lines ?? yrc?.lyrics ?? (Array.isArray(yrc) ? yrc : null);
if (Array.isArray(lyricArr)) {
for (const row of lyricArr) {
// row: { time: start_ms, duration: ms_line, "lyric": "word (start, duration) word2 (start2, duration2)" }
// 或 row: { t: ms, d: ms, w: [ {t:ms,s:ms}, ... ] }
const startTime = toNumber(row?.time ?? row?.t ?? row?.start ?? row?.startTime ?? 0);
const duration = toNumber(row?.duration ?? row?.d ?? row?.dur ?? DEFAULT_LINE_DURATION);
const wordsRaw = row?.words ?? row?.w ?? row?.lyric ?? row?.text ?? '';
if (Array.isArray(wordsRaw)) {
const wordList: LyricWord[] = [];
for (const w of wordsRaw) {
const ws = toNumber(w?.time ?? w?.t ?? w?.start ?? 0);
const we = ws + toNumber(w?.duration ?? w?.d ?? 500);
const text = String(w?.text ?? w?.word ?? w?.name ?? '').trim();
if (text) wordList.push({ startTime: ws + startTime, endTime: we + startTime, text });
}
if (wordList.length > 0) {
const fullText = wordList.map(w => w.text).join('');
lines.push({
startTime: wordList[0].startTime,
endTime: wordList[wordList.length - 1].endTime,
text: fullText,
words: wordList,
});
continue;
}
}
// 回退:纯文本形式的一行
const text = String(wordsRaw || row?.text || '').trim();
if (text) {
lines.push({ startTime, endTime: startTime + duration, text });
}
}
}
if (lines.length === 0) {
// 有些返回 { lrc: { lyric: "..." } }
const lrc = data?.lrc?.lyric;
if (typeof lrc === 'string') return parseLrc(lrc);
}
sortAndFixEndTime(lines);
return lines;
}
function toNumber(v: any): number {
if (typeof v === 'number') return v;
if (typeof v === 'string') {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
return 0;
}
// ========== JSON通用对象形式 ==========
export function parseJSON(raw: any): LyricLine[] {
if (raw == null) return [];
if (typeof raw === 'string') {
// 先当 JSON 试一下
const obj = tryParseJSON(raw);
if (obj) return parseJSON(obj);
// 失败就退回 LRC
return parseLrc(raw);
}
// 优先看有没有 lrc / yrc / ttml / qrc 字段
if (typeof raw.lrc?.lyric === 'string') return parseLrc(raw.lrc.lyric);
if (typeof raw.lrc === 'string') return parseLrc(raw.lrc);
if (typeof raw.yrc === 'string' || raw.yrc?.lyric) return parseYrc(raw);
if (typeof raw.ttml === 'string') return parseTTML(raw.ttml);
if (typeof raw.qrc === 'string') return parseQrc(raw.qrc);
// 数组形式: [{ time: 1234, text: "..." }]
if (Array.isArray(raw)) {
return raw.map((item: any) => {
const startTime = toNumber(item?.time ?? item?.t ?? item?.start ?? item?.startTime ?? 0);
const endTime = toNumber(item?.end ?? item?.endTime ?? 0) || startTime + DEFAULT_LINE_DURATION;
return { startTime, endTime, text: String(item?.text ?? item?.content ?? item?.lyric ?? '').trim() };
}).filter(l => l.text);
}
if (Array.isArray(raw.lines)) {
return parseJSON(raw.lines);
}
if (Array.isArray(raw.lyric)) {
return parseJSON(raw.lyric);
}
return [];
}
// ========== SRT 字幕 ==========
export function parseSrt(text: string): LyricLine[] {
if (!text) return [];
const lines: LyricLine[] = [];
const blocks = text.replace(/\r\n/g, '\n').split(/\n{2,}/);
for (const block of blocks) {
const rows = block.split('\n').filter(Boolean);
if (rows.length < 2) continue;
const timeLine = rows[0].includes('-->') ? rows[0] : rows[1];
const textStart = rows[0].includes('-->') ? 1 : 2;
const t = timeLine.match(/(\d+):(\d{2}):(\d{2})[.,](\d{1,3})\s*-->\s*(\d+):(\d{2}):(\d{2})[.,](\d{1,3})/);
if (!t) continue;
const start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10);
const end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10);
const content = rows.slice(textStart).join(' ').trim();
if (content) lines.push({ startTime: start, endTime: end, text: content });
}
sortAndFixEndTime(lines);
return lines;
}
// ========== WebVTT ==========
export function parseVtt(text: string): LyricLine[] {
if (!text) return [];
// VTT 和 SRT 几乎一样,只是开头是 WEBVTT并且时间格式点号
const lines: LyricLine[] = [];
const clean = text.replace(/^WEBVTT[\s\S]*?\n\n/i, '').replace(/\r\n/g, '\n');
const blocks = clean.split(/\n{2,}/);
for (const block of blocks) {
const rows = block.split('\n').filter(Boolean);
if (rows.length < 1) continue;
const timeLine = rows.find(r => r.includes('-->'));
if (!timeLine) continue;
const idx = rows.indexOf(timeLine);
const t = timeLine.match(/(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?\s*-->\s*(\d+):(\d{2})(?::(\d{2}))?(?:[.,](\d{1,3}))?/);
if (!t) continue;
let start, end;
if (t[3] != null) {
// HH:MM:SS.mmm
start = (+t[1]) * 3600000 + (+t[2]) * 60000 + (+t[3]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10);
end = (+t[5]) * 3600000 + (+t[6]) * 60000 + (+t[7]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10);
} else {
// MM:SS.mmm
start = (+t[1]) * 60000 + (+t[2]) * 1000 + parseInt((t[4] || '0').padEnd(3, '0').substring(0, 3), 10);
end = (+t[5]) * 60000 + (+t[6]) * 1000 + parseInt((t[8] || '0').padEnd(3, '0').substring(0, 3), 10);
}
const content = rows.slice(idx + 1).join(' ').trim();
if (content) lines.push({ startTime: start, endTime: end, text: content });
}
sortAndFixEndTime(lines);
return lines;
}
// ========== 纯文本 ==========
export function parseText(text: string): LyricLine[] {
if (!text) return [];
const rows = text.split(/\r?\n/).map(s => s.trim()).filter(Boolean);
const lines: LyricLine[] = [];
let cursor = 0;
for (const row of rows) {
const start = cursor;
const dur = Math.max(3000, row.length * 250);
lines.push({ startTime: start, endTime: start + dur, text: row });
cursor += dur;
}
return lines;
}
// ========== 公共工具:排序 & 修正每行的 endTime ==========
function sortAndFixEndTime(lines: LyricLine[]) {
if (!lines || lines.length === 0) return;
lines.sort((a, b) => a.startTime - b.startTime);
for (let i = 0; i < lines.length - 1; i++) {
if (lines[i].endTime == null || lines[i].endTime <= lines[i].startTime || lines[i].endTime > lines[i + 1].startTime) {
lines[i].endTime = lines[i + 1].startTime;
}
}
const last = lines[lines.length - 1];
if (last.endTime == null || last.endTime <= last.startTime) {
last.endTime = last.startTime + DEFAULT_LINE_DURATION;
}
}
/**
* 把输入归一化成 { lines: LyricLine[] } 形式,用于与现有调用方兼容
*/
export function normalizeLyric(input: any): { lines: LyricLine[] } {
return { lines: parseAnyLyric(input) };
}
// @applemusic-like-lyrics/vue 的 LyricPlayer 需要 lines 中每项有
// startTime、endTime、text以及可选的 words与上面的 LyricLine 完全兼容
// 因此直接返回 parseAnyLyric() 的结果即可
export { parseLrc, parseQrc, parseTtml, parseYrc, parseJson, parseSrt, parseVtt };