feat: 完善歌词模块

This commit is contained in:
imsyy
2025-11-16 23:35:52 +08:00
parent 6d367f1fd5
commit 392c64f06b
5 changed files with 278 additions and 34 deletions

View File

@@ -199,20 +199,50 @@ const initFileIpc = (): void => {
// 读取本地歌词
ipcMain.handle(
"read-local-lyric",
async (_, lyricDir: string, id: number, ext: string): Promise<string> => {
const pattern = `**/{,*.}${id}.${ext}`;
async (_, lyricDirs: string[], id: number): Promise<{ lrc: string; ttml: string }> => {
const result = { lrc: "", ttml: "" };
try {
const files = await FastGlob(pattern, { cwd: lyricDir });
if (files.length > 0) {
const firstMatch = join(lyricDir, files[0]);
await access(firstMatch);
const lyric = await readFile(firstMatch, "utf-8");
if (lyric) return lyric;
// 定义需要查找的模式
const patterns = {
ttml: `**/${id}.ttml`,
lrc: `**/${id}.lrc`,
};
// 遍历每一个目录
for (const dir of lyricDirs) {
try {
// 查找 ttml
if (!result.ttml) {
const ttmlFiles = await FastGlob(patterns.ttml, { cwd: dir });
if (ttmlFiles.length > 0) {
const filePath = join(dir, ttmlFiles[0]);
await access(filePath);
result.ttml = await readFile(filePath, "utf-8");
}
}
// 查找 lrc
if (!result.lrc) {
const lrcFiles = await FastGlob(patterns.lrc, { cwd: dir });
if (lrcFiles.length > 0) {
const filePath = join(dir, lrcFiles[0]);
await access(filePath);
result.lrc = await readFile(filePath, "utf-8");
}
}
// 如果两种文件都找到了就提前结束搜索
if (result.ttml && result.lrc) break;
} catch {
// 某个路径异常,跳过
}
}
} catch {
/* empty */
/* 忽略错误 */
}
return "";
return result;
},
);

View File

@@ -40,7 +40,7 @@
"@electron-toolkit/utils": "^4.0.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@neteasecloudmusicapienhanced/api": "^4.29.16",
"@neteasecloudmusicapienhanced/api": "^4.29.17",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",

10
pnpm-lock.yaml generated
View File

@@ -34,8 +34,8 @@ importers:
specifier: ^0.3.0
version: 0.3.0
'@neteasecloudmusicapienhanced/api':
specifier: ^4.29.16
version: 4.29.16
specifier: ^4.29.17
version: 4.29.17
'@pixi/app':
specifier: ^7.4.3
version: 7.4.3(@pixi/core@7.4.3)(@pixi/display@7.4.3(@pixi/core@7.4.3))
@@ -946,8 +946,8 @@ packages:
'@material/material-color-utilities@0.3.0':
resolution: {integrity: sha512-ztmtTd6xwnuh2/xu+Vb01btgV8SQWYCaK56CkRK8gEkWe5TuDyBcYJ0wgkMRn+2VcE9KUmhvkz+N9GHrqw/C0g==}
'@neteasecloudmusicapienhanced/api@4.29.16':
resolution: {integrity: sha512-ml6cTErjJ/fn+E0wvlNGGoW74rDj3mfogO85xySuTCuX/Mdhuayv+pPtoSm5YDM6j26hHmS8+rV2ma4o16pUiw==}
'@neteasecloudmusicapienhanced/api@4.29.17':
resolution: {integrity: sha512-zKqmA7NoP+H3dK0b4/1K7SkxAYz69z9zwPd6+9wXcQNA42EO4AK/rDZ0ZPC9bonQjigHrvK4beFMaIl01S1iig==}
engines: {node: '>=12'}
hasBin: true
@@ -5262,7 +5262,7 @@ snapshots:
'@material/material-color-utilities@0.3.0': {}
'@neteasecloudmusicapienhanced/api@4.29.16':
'@neteasecloudmusicapienhanced/api@4.29.17':
dependencies:
'@unblockneteasemusic/server': 0.28.0
axios: 1.13.2

View File

@@ -1,4 +1,4 @@
import { LyricLine } from "@applemusic-like-lyrics/core";
import { type LyricLine } from "@applemusic-like-lyrics/lyric";
/**
* 歌词数据类型

View File

@@ -1,5 +1,9 @@
import { useStatusStore, useMusicStore } from "@/stores";
import { SongLyric } from "@/types/lyric";
import { useStatusStore, useMusicStore, useSettingStore } from "@/stores";
import { songLyric, songLyricTTML } from "@/api/song";
import { type SongLyric } from "@/types/lyric";
import { type LyricLine, parseLrc, parseTTML, parseYrc } from "@applemusic-like-lyrics/lyric";
import { isElectron } from "./env";
import { isEmpty } from "lodash-es";
// TODO: 实现歌词统一管理类
// 先区分是在线还是本地
@@ -12,6 +16,17 @@ class LyricManager {
// Store
private musicStore = useMusicStore();
private statusStore = useStatusStore();
private settingStore = useSettingStore();
/**
* 在线歌词请求序列
* 每次发起新请求递增
*/
private lyricReqSeq = 0;
/**
* 当前有效的请求序列
* 用于校验返回是否属于当前歌曲的最新请求
*/
private activeLyricReq = 0;
/**
* 重置当前歌曲的歌词数据
* 包括清空歌词数据、重置歌词索引、关闭 TTMLL 歌词等
@@ -23,50 +38,249 @@ class LyricManager {
// 重置歌词索引
this.statusStore.lyricIndex = -1;
}
/**
* 歌词内容对齐
* @param lyrics 歌词数据
* @param otherLyrics 其他歌词数据
* @param key 对齐类型
* @returns 对齐后的歌词数据
*/
private alignLyrics(
lyrics: LyricLine[],
otherLyrics: LyricLine[],
key: "translatedLyric" | "romanLyric",
): LyricLine[] {
const lyricsData = lyrics;
if (lyricsData.length && otherLyrics.length) {
lyricsData.forEach((v: LyricLine) => {
otherLyrics.forEach((x: LyricLine) => {
if (v.startTime === x.startTime || Math.abs(v.startTime - x.startTime) < 0.6) {
v[key] = x.words.map((word) => word.word).join("");
}
});
});
}
return lyricsData;
}
/**
* 对齐本地歌词
* @param lyrics 本地歌词数据
* @param otherLyrics 其他歌词数据
* @returns 对齐后的本地歌词数据
*/
private alignLocalLyrics(lyricData: SongLyric): SongLyric {
// 同一时间的两/三行分别作为主句、翻译、音译
const toTime = (line: LyricLine) => Number(line?.startTime ?? line?.words?.[0]?.startTime ?? 0);
// 取内容
const toText = (line: LyricLine) => String(line?.words?.[0]?.word || "").trim();
const lrc = lyricData.lrcData || [];
if (!lrc.length) return lyricData;
// 按开始时间分组,时间差 < 0.6s 视为同组
const sorted = [...lrc].sort((a, b) => toTime(a) - toTime(b));
const groups: LyricLine[][] = [];
for (const line of sorted) {
const st = toTime(line);
const last = groups[groups.length - 1]?.[0];
if (last && Math.abs(st - toTime(last)) < 0.6) groups[groups.length - 1].push(line);
else groups.push([line]);
}
// 组装:第 1 行主句;第 2 行翻译;第 3 行音译;不调整时长
const aligned = groups.map((group) => {
const base = { ...group[0] } as LyricLine;
const tran = group[1] ? toText(group[1]) : "";
const roma = group[2] ? toText(group[2]) : "";
if (!base.translatedLyric) base.translatedLyric = tran;
if (!base.romanLyric) base.romanLyric = roma;
return base;
});
return { lrcData: aligned, yrcData: lyricData.yrcData };
}
/**
* 处理在线歌词
* @param id 歌曲 ID
* @returns 歌词数据
*/
private async handleOnlineLyric(id: number): Promise<SongLyric> {
console.log("处理在线歌词", id);
return {
lrcData: [],
yrcData: [],
const req = this.activeLyricReq;
const settingStore = this.settingStore;
// 请求是否成功
let adopted = false;
let result: SongLyric = { lrcData: [], yrcData: [] };
// 过期判断
const isStale = () => this.activeLyricReq !== req || this.musicStore.playSong?.id !== id;
// 处理 TTML 歌词
const adoptTTML = async () => {
try {
if (!settingStore.enableTTMLLyric) return;
const ttmlContent = await songLyricTTML(id);
if (isStale()) return;
if (!ttmlContent || typeof ttmlContent !== "string") return;
const parsed = parseTTML(ttmlContent);
const lines = parsed?.lines || [];
if (!lines.length) return;
result = { lrcData: [], yrcData: lines };
adopted = true;
} catch {
/* empty */
}
};
// 处理 LRC 歌词
const adoptLRC = async () => {
try {
const data = await songLyric(id);
if (isStale()) return;
if (!data || data.code !== 200) return;
let lrcLines: LyricLine[] = [];
let yrcLines: LyricLine[] = [];
// 普通歌词
if (data?.lrc?.lyric) {
lrcLines = parseLrc(data.lrc.lyric) || [];
// 普通歌词翻译
if (data?.tlyric?.lyric)
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.tlyric.lyric), "translatedLyric");
// 普通歌词音译
if (data?.romalrc?.lyric)
lrcLines = this.alignLyrics(lrcLines, parseLrc(data.romalrc.lyric), "romanLyric");
}
// 逐字歌词
if (data?.yrc?.lyric) {
yrcLines = parseYrc(data.yrc.lyric) || [];
// 逐字歌词翻译
if (data?.ytlrc?.lyric)
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.ytlrc.lyric), "translatedLyric");
// 逐字歌词音译
if (data?.yromalrc?.lyric)
yrcLines = this.alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
}
if (adopted) return;
result = { lrcData: lrcLines, yrcData: yrcLines };
adopted = true;
} catch {
/* empty */
}
};
// 统一判断与设置 TTML
await Promise.allSettled([adoptTTML(), adoptLRC()]);
this.statusStore.usingTTMLLyric = Boolean(
settingStore.enableTTMLLyric && result.yrcData?.length && !result.lrcData?.length,
);
return result;
}
/**
* 处理本地歌词
* @param id 歌曲 ID
* @param path 本地歌词路径
* @returns 歌词数据
*/
private async handleLocalLyric(id: number, path: string): Promise<SongLyric> {
console.log("处理本地歌词", id, path);
private async handleLocalLyric(path: string): Promise<SongLyric> {
try {
const { lyric, format }: { lyric?: string; format?: "lrc" | "ttml" } =
await window.electron.ipcRenderer.invoke("get-music-lyric", path);
if (!lyric) return { lrcData: [], yrcData: [] };
// TTML 直接返回
if (format === "ttml") {
const ttml = parseTTML(lyric);
const lines = ttml?.lines || [];
this.statusStore.usingTTMLLyric = true;
return { lrcData: [], yrcData: lines };
}
// 解析本地歌词并对其
const lrcLines = parseLrc(lyric);
const aligned = this.alignLocalLyrics({ lrcData: lrcLines, yrcData: [] });
this.statusStore.usingTTMLLyric = false;
return aligned;
} catch {
return { lrcData: [], yrcData: [] };
}
}
/**
* 检测本地歌词覆盖
* @param id 歌曲 ID
* @returns 歌词数据
*/
private async checkLocalLyricOverride(id: number): Promise<SongLyric> {
console.log("检测本地歌词覆盖", id);
const { localLyricPath } = this.settingStore;
if (!isElectron || !localLyricPath.length) return { lrcData: [], yrcData: [] };
// 从本地遍历
const { lrc, ttml } = await window.electron.ipcRenderer.invoke(
"read-local-lyric",
localLyricPath,
id,
);
this.statusStore.usingTTMLLyric = Boolean(ttml);
return { lrcData: parseLrc(lrc || ""), yrcData: parseTTML(ttml || "").lines || [] };
}
/**
* 处理歌词排除
* @param lyricData 歌词数据
* @returns 处理后的歌词数据
*/
private handleLyricExclude(lyricData: SongLyric): SongLyric {
const { enableExcludeLyrics, excludeKeywords, excludeRegexes } = this.settingStore;
// 未开启排除
if (!enableExcludeLyrics) return lyricData;
// 处理正则表达式
const regexes = (excludeRegexes || []).map((r: string) => new RegExp(r));
/**
* 判断歌词是否被排除
* @param line 歌词行
* @returns 是否被排除
*/
const isExcluded = (line: LyricLine) => {
const content = (line?.words || [])
.map((w) => String(w.word || ""))
.join("")
.trim();
if (!content) return true;
return (
(excludeKeywords || []).some((k: string) => content.includes(k)) ||
regexes.some((re) => re.test(content))
);
};
/**
* 过滤排除的歌词行
* @param lines 歌词行数组
* @returns 过滤后的歌词行数组
*/
const filterLines = (lines: LyricLine[]) => (lines || []).filter((l) => !isExcluded(l));
return {
lrcData: [],
yrcData: [],
lrcData: filterLines(lyricData.lrcData || []),
yrcData: filterLines(lyricData.yrcData || []),
};
}
// 处理歌词
/**
* 处理歌词
* @param id 歌曲 ID
* @param path 本地歌词路径(可选)
*/
public async handleLyric(id: number, path?: string) {
try {
// 歌词加载状态
this.statusStore.lyricLoading = true;
// 重置歌词
this.resetSongLyric();
// 标记当前歌词请求(避免旧请求覆盖新请求)
this.activeLyricReq = ++this.lyricReqSeq;
// 检查歌词覆盖
let lyricData = await this.checkLocalLyricOverride(id);
// 开始获取歌词
let lyricData: Partial<SongLyric> = {};
if (path) {
lyricData = await this.handleLocalLyric(id, path);
if (isEmpty(lyricData.lrcData) || isEmpty(lyricData.yrcData)) {
// 进行本地歌词对齐
lyricData = this.alignLocalLyrics(lyricData);
} else if (path) {
lyricData = await this.handleLocalLyric(path);
} else {
lyricData = await this.handleOnlineLyric(id);
}
console.log("歌词数据", lyricData);
// 排除内容
lyricData = this.handleLyricExclude(lyricData);
console.log("最终歌词数据", lyricData);
} catch (error) {
} finally {
// 歌词加载状态
this.statusStore.lyricLoading = false;
if (this.musicStore.playSong?.id === undefined || this.activeLyricReq === this.lyricReqSeq) {
this.statusStore.lyricLoading = false;
}
}
}
}