feat(LyricWithTTMLFormat): 支持从steveXMH仓库获取TTML歌词

This commit is contained in:
ImFurina
2025-10-02 12:25:04 +08:00
parent 00e6f7bb60
commit aa3f9d2ca8
10 changed files with 865 additions and 629 deletions

View File

@@ -40,7 +40,7 @@
"@electron-toolkit/utils": "^3.0.0",
"@imsyy/color-utils": "^1.0.2",
"@material/material-color-utilities": "^0.3.0",
"@neteaseapireborn/api": "^4.29.2",
"@neteaseapireborn/api": "^4.29.7",
"@pixi/app": "^7.4.3",
"@pixi/core": "^7.4.3",
"@pixi/display": "^7.4.3",
@@ -85,9 +85,9 @@
"@types/howler": "^2.2.12",
"@types/js-cookie": "^3.0.6",
"@types/md5": "^2.3.5",
"@types/node": "^22.18.3",
"@typescript-eslint/eslint-plugin": "^8.43.0",
"@typescript-eslint/parser": "^8.43.0",
"@types/node": "^22.18.8",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"@vitejs/plugin-vue": "^5.2.4",
"ajv": "^8.17.1",
"crypto-js": "^4.2.0",
@@ -95,24 +95,24 @@
"electron-builder": "^26.0.12",
"electron-log": "^5.4.3",
"electron-vite": "^3.1.0",
"eslint": "^9.35.0",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^9.33.0",
"fast-glob": "^3.3.3",
"fastify": "^5.6.0",
"fastify": "^5.6.1",
"naive-ui": "^2.43.1",
"node-taglib-sharp": "^6.0.1",
"prettier": "^3.6.2",
"sass": "^1.92.1",
"sass": "^1.93.2",
"terser": "^5.44.0",
"typescript": "^5.9.2",
"typescript": "^5.9.3",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^28.8.0",
"vite": "^5.4.20",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-wasm": "^3.5.0",
"vue": "^3.5.21",
"vue": "^3.5.22",
"vue-router": "^4.5.1",
"vue-tsc": "^3.0.7"
"vue-tsc": "^3.1.0"
},
"pnpm": {
"overrides": {

1170
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -65,6 +65,23 @@ export const songLyric = (id: number) => {
});
};
// 获取格式TTML的歌词
export const songLyricTTML = async (id: number) => {
const url = `https://amll-ttml-db.stevexmh.net/ncm/${id}`;
try {
const response = await fetch(url);
if (response === null || response.status !== 200) {
console.error(`TTML API请求失败或TTML仓库没有歌词, 将会使用默认歌词`);
return null;
}
const data = await response.text();
return data;
} catch (error) {
console.error('TTML API请求出错:', error);
return null;
}
}
/**
* 获取歌曲下载链接
* @param id 音乐 id

View File

@@ -1,27 +1,16 @@
<template>
<Transition>
<div
:key="amLyricsData?.[0]?.startTime"
:class="['lyric-am', { pure: statusStore.pureLyricMode }]"
>
<LyricPlayer
ref="lyricPlayerRef"
:lyricLines="amLyricsData"
:currentTime="playSeek"
:playing="statusStore.playStatus"
:enableSpring="settingStore.useAMSpring"
<div :key="amLyricsData?.[0]?.startTime" :class="['lyric-am', { pure: statusStore.pureLyricMode }]">
<LyricPlayer ref="lyricPlayerRef" :lyricLines="amLyricsData" :currentTime="playSeek"
:playing="statusStore.playStatus" :enableSpring="settingStore.useAMSpring"
:enableScale="settingStore.useAMSpring"
:alignPosition="settingStore.lyricsScrollPosition === 'center' ? 0.5 : 0.2"
:enableBlur="settingStore.lyricsBlur"
:style="{
:enableBlur="settingStore.lyricsBlur" :style="{
'--amll-lyric-view-color': mainColor,
'--amll-lyric-player-font-size': settingStore.lyricFontSize + 'px',
'font-weight': settingStore.lyricFontBold ? 'bold' : 'normal',
'font-family': settingStore.LyricFont !== 'follow' ? settingStore.LyricFont : '',
}"
class="am-lyric"
@line-click="jumpSeek"
/>
}" class="am-lyric" @line-click="jumpSeek" />
</div>
</Transition>
</template>
@@ -54,10 +43,39 @@ const mainColor = computed(() => {
return `rgb(${statusStore.mainColor})`;
});
// 检查是否为纯音乐歌词
const isPureInstrumental = (lyrics: LyricLine[]): boolean => {
if (!lyrics || lyrics.length === 0) return false;
const instrumentalKeywords = ['纯音乐', 'instrumental', '请欣赏'];
if (lyrics.length === 1) {
const content = lyrics[0].words?.[0]?.word || '';
return instrumentalKeywords.some(keyword => content.toLowerCase().includes(keyword.toLowerCase()));
}
if (lyrics.length <= 3) {
const allContent = lyrics.map(line => line.words?.[0]?.word || '').join('');
return instrumentalKeywords.some(keyword => allContent.toLowerCase().includes(keyword.toLowerCase()));
}
return false;
};
// 当前歌词
const amLyricsData = computed<LyricLine[]>(() => {
const isYrc = musicStore.songLyric.yrcData?.length && settingStore.showYrc;
return isYrc ? musicStore.songLyric.yrcAMData : musicStore.songLyric.lrcAMData;
const { songLyric } = musicStore;
if (!songLyric) return [];
// 优先使用逐字歌词(YRC/TTML)
const useYrc = songLyric.yrcAMData?.length && settingStore.showYrc;
const lyrics = useYrc ? songLyric.yrcAMData : songLyric.lrcAMData;
// 简单检查歌词有效性
if (!Array.isArray(lyrics) || lyrics.length === 0) return [];
// 检查是否为纯音乐
if (isPureInstrumental(lyrics)) return [];
return lyrics;
});
// 进度跳转
@@ -85,15 +103,14 @@ onBeforeUnmount(() => {
height: 100%;
overflow: hidden;
filter: drop-shadow(0px 4px 6px rgba(0, 0, 0, 0.2));
mask: linear-gradient(
180deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 75%,
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0)
);
mask: linear-gradient(180deg,
hsla(0, 0%, 100%, 0) 0,
hsla(0, 0%, 100%, 0.6) 5%,
#fff 10%,
#fff 75%,
hsla(0, 0%, 100%, 0.6) 85%,
hsla(0, 0%, 100%, 0));
:deep(.am-lyric) {
width: 100%;
height: 100%;
@@ -104,11 +121,14 @@ onBeforeUnmount(() => {
padding-right: 80px;
margin-left: -2rem;
}
&.pure {
text-align: center;
:deep(.am-lyric) {
margin: 0;
padding: 0 80px;
div {
transform-origin: center;
}

View File

@@ -260,6 +260,15 @@
</div>
<n-switch v-model:value="settingStore.useAMSpring" class="set" :round="false" />
</n-card>
<n-card class="set-item">
<div class="label">
<n-text class="name">启用 TTML 歌词</n-text>
<n-text class="tip" :depth="3">
是否启用 TTML 歌词如有TTML 歌词支持逐字翻译音译等功能, 将会在下一首歌生效
</n-text>
</div>
<n-switch v-model:value="settingStore.enableTTMLLyric" class="set" :round="false" />
</n-card>
</div>
<div v-if="isElectron" class="set-list">
<n-h3 prefix="bar"> 桌面歌词 </n-h3>

View File

@@ -73,6 +73,7 @@ interface SettingState {
showSearchHistory: boolean;
useAMLyrics: boolean;
useAMSpring: boolean;
enableTTMLLyric: boolean;
menuShowCover: boolean;
preventSleep: boolean;
localFilesPath: string[];
@@ -138,6 +139,7 @@ export const useSettingStore = defineStore("setting", {
lyricFontBold: true, // 歌词字体加粗
useAMLyrics: false, // 是否使用 AM 歌词
useAMSpring: false, // 是否使用 AM 歌词弹簧效果
enableTTMLLyric: true, // 启用 TTML 歌词
showYrc: true, // 显示逐字歌词
showYrcAnimation: true, // 显示逐字歌词动画
showTran: true, // 显示歌词翻译

109
src/types/amll.d.ts vendored Normal file
View File

@@ -0,0 +1,109 @@
/**
* AMLL (Apple Music-like Lyrics) 相关类型定义
*/
/**
* 歌词播放器引用类型
*/
export interface LyricPlayerRef {
setCurrentTime?: (time: number) => void;
setPlaying?: (playing: boolean) => void;
lyricPlayer?: { value?: any };
}
/**
* 歌词单词类型
*/
export interface LyricWord {
word: string;
startTime: number;
endTime: number;
}
/**
* 歌词行类型
*/
export interface LyricLine {
startTime: number;
endTime: number;
words: LyricWord[];
translatedLyric?: string;
romanLyric?: string;
isBG?: boolean;
isDuet?: boolean;
}
/**
* 歌词点击事件类型
*/
export interface LyricClickEvent {
line: {
getLine: () => LyricLine;
lyricLine?: LyricLine;
};
}
/**
* 弹簧参数类型
*/
export interface SpringParam {
mass: number; // 质量,影响弹簧的惯性
damping: number; // 阻尼,影响弹簧的减速速度
stiffness: number; // 刚度,影响弹簧的弹力
soft: boolean; // 是否使用软弹簧模式
}
/**
* 弹簧参数集合
*/
export interface SpringParams {
posX?: SpringParam;
posY?: SpringParam;
scale?: SpringParam;
rotation?: SpringParam;
}
/**
* 背景渲染器引用类型
*/
export interface BackgroundRenderRef {
bgRender: any;
wrapperEl?: HTMLDivElement;
}
/**
* 背景渲染器属性类型
*/
export interface BackgroundRenderProps {
album?: string;
albumIsVideo?: boolean;
fps?: number;
playing?: boolean;
flowSpeed?: number;
hasLyric?: boolean;
lowFreqVolume?: number;
renderScale?: number;
staticMode?: boolean;
renderer?: any;
}
/**
* 歌词处理器设置类型
*/
export interface LyricsProcessorSettings {
showYrc: boolean;
showRoma: boolean;
showTransl: boolean;
}
/**
* 歌曲歌词类型
*/
export interface SongLyric {
lrc?: Array<{time: number, content: string, tran?: string, roma?: string}>;
yrc?: Array<{time: number, endTime?: number, content: any[], tran?: string, roma?: string}>;
ttml?: string;
hasYrc?: boolean;
lrcAMData?: LyricLine[];
yrcAMData?: LyricLine[];
}

View File

@@ -1,5 +1,6 @@
import { LyricLine, parseLrc, parseYrc } from "@applemusic-like-lyrics/lyric";
import type { LyricType } from "@/types/main";
import { LyricLine, parseLrc, parseYrc, parseTTML } from "@applemusic-like-lyrics/lyric";
import type { LyricType, } from "@/types/main";
import type { LyricLine as AMLLLyricLine } from "@/types/amll";
import { useMusicStore, useSettingStore } from "@/stores";
import { msToS } from "./time";
@@ -207,3 +208,52 @@ const parseAMData = (lrcData: LyricLine[], tranData?: LyricLine[], romaData?: Ly
isDuet: line.isDuet ?? false,
}));
};
/**
* 从TTML格式解析歌词并转换为AMLL格式
* @param ttmlContent TTML格式的歌词内容
* @returns AMLL格式的歌词行数组
*/
export function parseTTMLToAMLL(ttmlContent: string): LyricLine[] {
if (!ttmlContent) return [];
try {
const parsedResult = parseTTML(ttmlContent);
if (!parsedResult?.lines?.length) return [];
const validLines = parsedResult.lines
.filter((line): line is any =>
line && typeof line === 'object' && Array.isArray(line.words)
)
.map(line => {
const words = line.words
.filter((word: any) => word && typeof word === 'object')
.map((word: any) => ({
word: String(word.word || ' '),
startTime: Number(word.startTime) || 0,
endTime: Number(word.endTime) || 0
}));
if (!words.length) return null;
const startTime = words[0].startTime;
const endTime = words[words.length - 1].endTime;
return {
words,
startTime,
endTime,
translatedLyric: String(line.translatedLyric || ''),
romanLyric: String(line.romanLyric || ''),
isBG: Boolean(line.isBG),
isDuet: Boolean(line.isDuet)
};
})
.filter((line): line is LyricLine => line !== null);
return validLines;
} catch (error) {
console.error('TTML parsing error:', error);
return [];
}
}

View File

@@ -3,8 +3,8 @@ import type { MessageReactive } from "naive-ui";
import { Howl, Howler } from "howler";
import { cloneDeep } from "lodash-es";
import { useMusicStore, useStatusStore, useDataStore, useSettingStore } from "@/stores";
import { parsedLyricsData, resetSongLyric, parseLocalLyric } from "./lyric";
import { songUrl, unlockSongUrl, songLyric, songChorus } from "@/api/song";
import { parsedLyricsData, resetSongLyric, parseLocalLyric, parseTTMLToAMLL } from "./lyric";
import { songUrl, unlockSongUrl, songLyric, songChorus, songLyricTTML } from "@/api/song";
import { getCoverColorData } from "@/utils/color";
import { calculateProgress } from "./time";
import { isElectron, isDev } from "./helper";
@@ -421,8 +421,35 @@ class Player {
resetSongLyric();
return;
}
const lyricRes = await songLyric(id);
parsedLyricsData(lyricRes);
try {
const musicStore = useMusicStore();
const settingStore = useSettingStore();
const [lyricRes, ttmlContent] = await Promise.all([
songLyric(id),
songLyricTTML(id)
]);
parsedLyricsData(lyricRes);
if (ttmlContent && settingStore.enableTTMLLyric) {
const ttmlLyric = parseTTMLToAMLL(ttmlContent);
if (ttmlLyric?.length > 0) {
settingStore.showYrc = true;
musicStore.songLyric = {
...musicStore.songLyric,
yrcAMData: ttmlLyric,
hasLrcTran: ttmlLyric.some(line => line.translatedLyric),
hasLrcRoma: ttmlLyric.some(line => line.romanLyric),
hasYrc: true
};
console.log("✅ TTML lyrics enabled");
return;
}
}
} catch (error) {
console.error("❌ Error loading lyrics:", error);
resetSongLyric();
}
}
/**
* 获取副歌时间

View File

@@ -17,6 +17,8 @@
"paths": {
"@/*": ["src/*"]
},
"types": ["node", "electron", "electron-vite/node", "./auto-imports.d.ts", "./components.d.ts"]
"types": ["node", "electron", "electron-vite/node", "./auto-imports.d.ts", "./components.d.ts"],
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"target": "ES2020"
}
}