Skip to content

Commit dbd74e5

Browse files
committed
✨ feat: 本地歌词支持逐字LRC与增强型LRC
1 parent 04743e8 commit dbd74e5

2 files changed

Lines changed: 240 additions & 13 deletions

File tree

src/core/player/LyricManager.ts

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { type SongLyric } from "@/types/lyric";
77
import { isElectron } from "@/utils/env";
88
import { stripLyricMetadata } from "@/utils/lyricStripper";
99
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
10+
import { parseSmartLrc, isWordLevelFormat } from "@/utils/lyricParser";
1011
import { escapeRegExp, isEmpty } from "lodash-es";
1112
import { SongType } from "@/types/main";
1213

@@ -505,9 +506,15 @@ class LyricManager {
505506
statusStore.usingTTMLLyric = true;
506507
return { lrcData: [], yrcData: lines };
507508
}
508-
// 解析本地歌词
509-
const lrcLines = parseLrc(lyric);
510-
let aligned = this.alignLocalLyrics({ lrcData: lrcLines, yrcData: [] });
509+
// 解析本地歌词(智能识别格式)
510+
const { format: lrcFormat, lines: parsedLines } = parseSmartLrc(lyric);
511+
// 如果是逐字格式,直接作为 yrcData
512+
if (isWordLevelFormat(lrcFormat)) {
513+
statusStore.usingTTMLLyric = false;
514+
return { lrcData: [], yrcData: parsedLines };
515+
}
516+
// 普通格式,继续原有逻辑
517+
let aligned = this.alignLocalLyrics({ lrcData: parsedLines, yrcData: [] });
511518
statusStore.usingTTMLLyric = false;
512519
// 如果开启了本地歌曲 QQ 音乐匹配,尝试获取逐字歌词
513520
if (settingStore.localLyricQQMusicMatch && musicStore.playSong) {
@@ -557,12 +564,8 @@ class LyricManager {
557564
const aLang = (a.getAttribute("xml:lang") || a.getAttribute("lang") || "").toLowerCase();
558565
const bLang = (b.getAttribute("xml:lang") || b.getAttribute("lang") || "").toLowerCase();
559566

560-
const aIndex = translationOrder.findIndex((lang) =>
561-
aLang.startsWith(lang.toLowerCase()),
562-
);
563-
const bIndex = translationOrder.findIndex((lang) =>
564-
bLang.startsWith(lang.toLowerCase()),
565-
);
567+
const aIndex = translationOrder.findIndex((lang) => aLang.startsWith(lang.toLowerCase()));
568+
const bIndex = translationOrder.findIndex((lang) => bLang.startsWith(lang.toLowerCase()));
566569

567570
// 如果找不到指定语言,则放在最后
568571
return (aIndex === -1 ? Infinity : aIndex) - (bIndex === -1 ? Infinity : bIndex);
@@ -599,20 +602,23 @@ class LyricManager {
599602
id,
600603
);
601604
statusStore.usingTTMLLyric = Boolean(ttml);
602-
let lrcLines: LyricLine[] = [];
603-
let ttmlLines: LyricLine[] = [];
604605
// 安全解析 LRC
606+
let lrcLines: LyricLine[] = [];
607+
let lrcIsWordLevel = false;
605608
try {
606609
const lrcContent = typeof lrc === "string" ? lrc : "";
607610
if (lrcContent) {
608-
lrcLines = parseLrc(lrcContent);
609-
console.log("检测到本地歌词覆盖", lrcLines);
611+
const { format: lrcFormat, lines } = parseSmartLrc(lrcContent);
612+
lrcIsWordLevel = isWordLevelFormat(lrcFormat);
613+
lrcLines = lines;
614+
console.log("检测到本地歌词覆盖", lrcFormat, lrcLines);
610615
}
611616
} catch (err) {
612617
console.error("parseLrc 本地解析失败:", err);
613618
lrcLines = [];
614619
}
615620
// 安全解析 TTML
621+
let ttmlLines: LyricLine[] = [];
616622
try {
617623
const ttmlContent = typeof ttml === "string" ? ttml : "";
618624
if (ttmlContent) {
@@ -624,6 +630,9 @@ class LyricManager {
624630
statusStore.usingTTMLLyric = false;
625631
ttmlLines = [];
626632
}
633+
if (lrcIsWordLevel && lrcLines.length > 0) {
634+
return { lrcData: [], yrcData: lrcLines };
635+
}
627636
return { lrcData: lrcLines, yrcData: ttmlLines };
628637
} catch (error) {
629638
console.error("读取本地歌词失败:", error);

src/utils/lyricParser.ts

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { type LyricLine, parseLrc } from "@applemusic-like-lyrics/lyric";
2+
3+
/**
4+
* LRC 格式类型
5+
*/
6+
export enum LrcFormat {
7+
/** 普通逐行 LRC */
8+
Line = "line",
9+
/** 逐字 LRC:[00:28.850]曲[00:32.455]:[00:36.060]钱 */
10+
WordByWord = "word-by-word",
11+
/** 增强型 LRC (ESLyric):[01:37.305]<01:37.624>怕<01:37.943>你 */
12+
Enhanced = "enhanced",
13+
}
14+
15+
/** LyricWord 类型 */
16+
type LyricWord = { word: string; startTime: number; endTime: number; romanWord: string };
17+
18+
// 预编译正则表达式
19+
const META_TAG_REGEX = /^\[[a-z]+:/i;
20+
const TIME_TAG_REGEX = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/g;
21+
const ENHANCED_TIME_TAG_REGEX = /<(\d{2}):(\d{2})\.(\d{2,3})>/;
22+
const WORD_BY_WORD_REGEX = /\[(\d{2}):(\d{2})\.(\d{2,3})\]([^\[\]]*)/g;
23+
const ENHANCED_WORD_REGEX = /<(\d{2}):(\d{2})\.(\d{2,3})>([^<]*)/g;
24+
const LINE_TIME_REGEX = /^\[(\d{2}):(\d{2})\.(\d{2,3})\]/;
25+
26+
/**
27+
* 解析时间戳为毫秒
28+
*/
29+
const parseTimeToMs = (min: string, sec: string, ms: string): number => {
30+
const minutes = parseInt(min, 10);
31+
const seconds = parseInt(sec, 10);
32+
const milliseconds = ms.length === 2 ? parseInt(ms, 10) * 10 : parseInt(ms, 10);
33+
return minutes * 60 * 1000 + seconds * 1000 + milliseconds;
34+
};
35+
36+
/**
37+
* 创建 LyricWord 对象
38+
*/
39+
const createWord = (word: string, startTime: number, endTime: number = startTime): LyricWord => ({
40+
word,
41+
startTime,
42+
endTime,
43+
romanWord: "",
44+
});
45+
46+
/**
47+
* 创建 LyricLine 对象
48+
*/
49+
const createLine = (words: LyricWord[], startTime: number, endTime: number = 0): LyricLine => ({
50+
words,
51+
startTime,
52+
endTime,
53+
translatedLyric: "",
54+
romanLyric: "",
55+
isBG: false,
56+
isDuet: false,
57+
});
58+
59+
/**
60+
* 修正歌词行的结束时间
61+
* 每行最后一个字的结束时间 = 下一行的开始时间
62+
*/
63+
const fixLineEndTimes = (lines: LyricLine[]): void => {
64+
const len = lines.length;
65+
for (let i = 0; i < len; i++) {
66+
const line = lines[i];
67+
const lastWord = line.words[line.words.length - 1];
68+
const nextLineStart = lines[i + 1]?.startTime;
69+
// 如果有下一行,使用下一行的开始时间;否则使用最后一个字开始时间 + 1s
70+
lastWord.endTime = nextLineStart ?? lastWord.startTime + 1000;
71+
line.endTime = lastWord.endTime;
72+
}
73+
};
74+
75+
/**
76+
* 检测 LRC 格式类型
77+
*/
78+
export const detectLrcFormat = (content: string): LrcFormat => {
79+
const lines = content.split(/\r?\n/);
80+
for (const rawLine of lines) {
81+
const line = rawLine.trim();
82+
if (!line || META_TAG_REGEX.test(line)) continue;
83+
// 检查增强型LRC
84+
if (ENHANCED_TIME_TAG_REGEX.test(line)) {
85+
return LrcFormat.Enhanced;
86+
}
87+
// 检查逐字LRC
88+
const matches = line.match(TIME_TAG_REGEX);
89+
if (matches && matches.length > 1) {
90+
return LrcFormat.WordByWord;
91+
}
92+
}
93+
return LrcFormat.Line;
94+
};
95+
96+
/**
97+
* 解析逐字 LRC 格式
98+
*/
99+
export const parseWordByWordLrc = (content: string): LyricLine[] => {
100+
const result: LyricLine[] = [];
101+
102+
for (const rawLine of content.split(/\r?\n/)) {
103+
const line = rawLine.trim();
104+
if (!line || META_TAG_REGEX.test(line)) continue;
105+
106+
const words: LyricWord[] = [];
107+
let lineStartTime = Infinity;
108+
let match: RegExpExecArray | null;
109+
110+
// 重置正则状态
111+
WORD_BY_WORD_REGEX.lastIndex = 0;
112+
113+
while ((match = WORD_BY_WORD_REGEX.exec(line)) !== null) {
114+
const startTime = parseTimeToMs(match[1], match[2], match[3]);
115+
const word = match[4];
116+
117+
if (!word && words.length === 0) continue;
118+
119+
lineStartTime = Math.min(lineStartTime, startTime);
120+
121+
// 上一个字的结束时间 = 当前字的开始时间
122+
if (words.length > 0) {
123+
words[words.length - 1].endTime = startTime;
124+
}
125+
126+
if (word) {
127+
words.push(createWord(word, startTime));
128+
}
129+
}
130+
131+
if (words.length > 0) {
132+
result.push(createLine(words, lineStartTime === Infinity ? 0 : lineStartTime));
133+
}
134+
}
135+
136+
fixLineEndTimes(result);
137+
return result;
138+
};
139+
140+
/**
141+
* 解析增强型 LRC 格式 (ESLyric)
142+
*/
143+
export const parseEnhancedLrc = (content: string): LyricLine[] => {
144+
const result: LyricLine[] = [];
145+
146+
for (const rawLine of content.split(/\r?\n/)) {
147+
const line = rawLine.trim();
148+
if (!line || META_TAG_REGEX.test(line)) continue;
149+
150+
const lineTimeMatch = LINE_TIME_REGEX.exec(line);
151+
if (!lineTimeMatch) continue;
152+
153+
const lineStartTime = parseTimeToMs(lineTimeMatch[1], lineTimeMatch[2], lineTimeMatch[3]);
154+
const contentAfterTime = line.slice(lineTimeMatch[0].length);
155+
156+
const words: LyricWord[] = [];
157+
158+
// 检查是否有增强型标记
159+
if (ENHANCED_TIME_TAG_REGEX.test(contentAfterTime)) {
160+
let match: RegExpExecArray | null;
161+
ENHANCED_WORD_REGEX.lastIndex = 0;
162+
163+
while ((match = ENHANCED_WORD_REGEX.exec(contentAfterTime)) !== null) {
164+
const startTime = parseTimeToMs(match[1], match[2], match[3]);
165+
const word = match[4];
166+
167+
if (words.length > 0) {
168+
words[words.length - 1].endTime = startTime;
169+
}
170+
171+
if (word) {
172+
words.push(createWord(word, startTime));
173+
}
174+
}
175+
} else {
176+
// 无增强型标记,作为整行处理
177+
const text = contentAfterTime.trim();
178+
if (text) {
179+
words.push(createWord(text, lineStartTime));
180+
}
181+
}
182+
183+
if (words.length > 0) {
184+
result.push(createLine(words, lineStartTime));
185+
}
186+
}
187+
188+
fixLineEndTimes(result);
189+
return result;
190+
};
191+
192+
/**
193+
* 智能解析 LRC 歌词
194+
*/
195+
export const parseSmartLrc = (content: string): { format: LrcFormat; lines: LyricLine[] } => {
196+
const format = detectLrcFormat(content);
197+
198+
let lines: LyricLine[];
199+
switch (format) {
200+
case LrcFormat.WordByWord:
201+
lines = parseWordByWordLrc(content);
202+
break;
203+
case LrcFormat.Enhanced:
204+
lines = parseEnhancedLrc(content);
205+
break;
206+
default:
207+
lines = parseLrc(content) || [];
208+
}
209+
210+
console.log(`[LyricParser] 检测到歌词格式: ${format}, 共 ${lines.length} 行`);
211+
return { format, lines };
212+
};
213+
214+
/**
215+
* 判断解析结果是否为逐字格式
216+
*/
217+
export const isWordLevelFormat = (format: LrcFormat): boolean =>
218+
format === LrcFormat.WordByWord || format === LrcFormat.Enhanced;

0 commit comments

Comments
 (0)