From 3d57813d782ffb267b5bccf21361641a211eed9e Mon Sep 17 00:00:00 2001 From: Shomi-FJS <3213531873+Shomi-FJS@users.noreply.github.com> Date: Sat, 11 Apr 2026 03:11:53 +0800 Subject: [PATCH] =?UTF-8?q?fix(lyric):=20=E5=A2=9E=E5=BC=BA=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E8=A7=A3=E6=9E=90=E4=B8=8E=E5=A4=84=E7=90=86=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增非滚动歌词检测功能,自动过滤无效时间戳歌词 - 优化 YRC/LRC 解析器,支持更多格式并修复时间单位问题 - 改进翻译对齐逻辑,使用时间戳匹配替代索引映射 - 增加调试日志和错误处理,提升问题排查能力 - 修复歌词切换时的状态同步问题,避免封面闪烁 --- src/adapters/ExternalLyricAdapter.ts | 29 ++- src/adapters/v2/index.ts | 279 ++++++++++++++++++++-- src/adapters/v3/index.ts | 229 ++++++++++++++++-- src/components/headless/AmllStateSync.tsx | 62 ++++- src/core/LyricManager.ts | 22 +- src/core/parsers/lrcParser.ts | 125 +++++++++- src/core/parsers/lyricBuilder.ts | 8 +- src/core/parsers/yrcParser.ts | 197 ++++++++++++--- src/types/ncm.d.ts | 2 +- src/utils/lyricDetector.ts | 97 ++++++++ 10 files changed, 972 insertions(+), 78 deletions(-) create mode 100644 src/utils/lyricDetector.ts diff --git a/src/adapters/ExternalLyricAdapter.ts b/src/adapters/ExternalLyricAdapter.ts index 8ae3032..6ae1388 100644 --- a/src/adapters/ExternalLyricAdapter.ts +++ b/src/adapters/ExternalLyricAdapter.ts @@ -7,6 +7,7 @@ import { parseYrc } from "@/core/parsers/yrcParser"; import type { SongInfo } from "@/types/inflink"; import type { AmllLyricContent } from "@/types/ws"; import { LyricFormat, type LyricSource } from "@/utils/source"; +import { isNonScrollingLyric } from "@/utils/lyricDetector"; import { BaseLyricAdapter } from "./BaseLyricAdapter"; export class ExternalLyricAdapter extends BaseLyricAdapter { @@ -79,9 +80,17 @@ export class ExternalLyricAdapter extends BaseLyricAdapter { const rawLrc = parseLrc(text); if (rawLrc.length === 0) return null; + const lines = buildAmllLyricLines(rawLrc, [], []); + + // 检测非滚动歌词 + if (isNonScrollingLyric(lines)) { + console.log("[ExternalAdapter] LRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } + return { format: "structured", - lines: buildAmllLyricLines(rawLrc, [], []), + lines, }; } @@ -89,6 +98,12 @@ export class ExternalLyricAdapter extends BaseLyricAdapter { const yrcLines = parseYrc(text); if (yrcLines.length === 0) return null; + // 检测非滚动歌词 + if (isNonScrollingLyric(yrcLines)) { + console.log("[ExternalAdapter] YRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } + return { format: "structured", lines: yrcLines, @@ -99,6 +114,12 @@ export class ExternalLyricAdapter extends BaseLyricAdapter { const lysLines = parseLys(text); if (lysLines.length === 0) return null; + // 检测非滚动歌词 + if (isNonScrollingLyric(lysLines)) { + console.log("[ExternalAdapter] LYS 歌词是非滚动歌词,触发 cover 模式"); + return null; + } + return { format: "structured", lines: lysLines, @@ -109,6 +130,12 @@ export class ExternalLyricAdapter extends BaseLyricAdapter { const qrcLines = parseQrc(text); if (qrcLines.length === 0) return null; + // 检测非滚动歌词 + if (isNonScrollingLyric(qrcLines)) { + console.log("[ExternalAdapter] QRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } + return { format: "structured", lines: qrcLines, diff --git a/src/adapters/v2/index.ts b/src/adapters/v2/index.ts index cc49de1..179d8f5 100644 --- a/src/adapters/v2/index.ts +++ b/src/adapters/v2/index.ts @@ -1,5 +1,6 @@ import { feature } from "bun:bundle"; import type { LrcLine } from "@/core/parsers/lrcParser"; +import { isMetadataLine } from "@/core/parsers/lrcParser"; import { buildAmllLyricLines, mergeSubLyrics, @@ -7,10 +8,57 @@ import { import { parseYrc } from "@/core/parsers/yrcParser"; import type { v2 } from "@/types/ncm"; import type { AmllLyricContent, AmllLyricLine } from "@/types/ws"; +import type { SongInfo } from "@/types/inflink"; import { extractRawLyricData } from "@/utils/format-lyric"; +import { isNonScrollingLyric } from "@/utils/lyricDetector"; import { LYRIC_SOURCE_UUID_BUILTIN_NCM } from "@/utils/source"; import { BaseLyricAdapter } from "../BaseLyricAdapter"; +/** + * V2 专用:在结构化翻译行中找到与目标时间戳最接近的行 + * + * V2 的 tlyric.lines / romalrc.lines 的 time 字段单位不一致: + * - 部分歌曲为毫秒(与 YRC startTime 一致) + * - 部分歌曲为秒(需要 *1000) + * 使用自适应策略:同时尝试两种单位,选择更接近的匹配 + */ +function matchByTimestampV2( + lines: Array<{ time: number; lyric: string }>, + targetTimeMs: number, +): string { + if (lines.length === 0) return ""; + + // 策略:同时尝试毫秒和秒两种单位,选择差值更小的 + let bestIdxMs = -1, bestDiffMs = Infinity; + let bestIdxSec = -1, bestDiffSec = Infinity; + + for (let i = 0; i < lines.length; i++) { + const timeVal = lines[i].time; + // 尝试毫秒单位 + const diffMs = Math.abs(timeVal - targetTimeMs); + if (diffMs < bestDiffMs) { + bestDiffMs = diffMs; + bestIdxMs = i; + } + // 尝试秒单位(转为毫秒) + const diffSec = Math.abs(timeVal * 1000 - targetTimeMs); + if (diffSec < bestDiffSec) { + bestDiffSec = diffSec; + bestIdxSec = i; + } + } + + // 选择更接近的匹配(3000ms 容差) + const TOLERANCE = 3000; + if (bestDiffMs <= TOLERANCE && bestDiffMs <= bestDiffSec) { + return lines[bestIdxMs].lyric; + } + if (bestDiffSec <= TOLERANCE && bestDiffSec < bestDiffMs) { + return lines[bestIdxSec].lyric; + } + return ""; +} + export class V2LyricAdapter extends BaseLyricAdapter { public readonly id = LYRIC_SOURCE_UUID_BUILTIN_NCM; @@ -19,6 +67,7 @@ export class V2LyricAdapter extends BaseLyricAdapter { private baseLyric: AmllLyricContent | null = null; private currentOffset: number = 0; + private lastPlayId: string | null = null; public async init(): Promise { const bus = window.NEJ?.P( @@ -73,21 +122,46 @@ export class V2LyricAdapter extends BaseLyricAdapter { } this.baseLyric = null; this.currentOffset = 0; + this.lastPlayId = null; } - public fetchLyric(): void { - // V2 版本似乎无论是否需要展示歌词都会自己去获取歌词 + public fetchLyric(songInfo?: SongInfo): void { + // V2 适配器是被动监听 lrcload 事件的,无法主动拉取歌词 + // 如果缓存的是当前歌曲的歌词,重新 emit 以确保 LyricManager 能收到 if (this.baseLyric) { - this.emitAdjustedLyric(); + const payloadPlayId = this.lastPlayId; + if (songInfo && payloadPlayId) { + // 检查缓存是否属于当前歌曲(避免切歌后 emit 旧歌词) + if (payloadPlayId.includes(songInfo.ncmId.toString())) { + this.emitAdjustedLyric(); + } + } else { + this.emitAdjustedLyric(); + } } } private handleLrcLoad(payload: v2.LrcLoadPayload) { if (!payload?.lyric) { + console.warn("[V2LyricAdapter] lrcload 事件无 lyric 数据"); this.dispatch("rawlyric", null); + this.dispatch("update", null); return; } + this.lastPlayId = payload.playid; + + console.log( + "[V2LyricAdapter] lrcload 数据:", + `id=${payload.lyric.id}`, + `nolyric=${payload.lyric.nolyric}`, + `uncollected=${payload.lyric.uncollected}`, + `yrc=${payload.lyric.yrc?.lyric ? `${payload.lyric.yrc.lyric.length}字` : "undefined"}`, + `lrc=${payload.lyric.lrc ? `lines=${payload.lyric.lrc.lines?.length ?? "n/a"}条` : "undefined"}`, + `tlyric=${payload.lyric.tlyric ? `lines=${payload.lyric.tlyric.lines?.length ?? "n/a"}条` : "undefined"}`, + `romalrc=${payload.lyric.romalrc ? `lines=${payload.lyric.romalrc.lines?.length ?? "n/a"}条` : "undefined"}`, + ); + const rawLyricData = extractRawLyricData({ yrc: payload.lyric.yrc?.lyric, lrcLines: payload.lyric.lrc?.lines, @@ -98,7 +172,11 @@ export class V2LyricAdapter extends BaseLyricAdapter { this.dispatch("rawlyric", rawLyricData); this.baseLyric = this.parseV2Payload(payload.lyric); - if (!this.baseLyric) return; + if (!this.baseLyric) { + console.warn("[V2LyricAdapter] 歌词解析返回 null,无法生成 AMLL 歌词"); + this.dispatch("update", null); + return; + } this.currentOffset = payload.lyric.lrc?.offset ?? 0; @@ -167,16 +245,69 @@ export class V2LyricAdapter extends BaseLyricAdapter { lyricObj: NonNullable, ): AmllLyricContent | null { if (lyricObj.yrc?.lyric) { - const yrcLines = parseYrc(lyricObj.yrc.lyric); + const allYrcLines = parseYrc(lyricObj.yrc.lyric); + + console.log( + "[V2LyricAdapter] YRC 解析:", + `原始长度=${lyricObj.yrc.lyric.length}`, + `总行数=${allYrcLines.length}`, + ); + + if (allYrcLines.length > 0) { + const validIndices: number[] = []; + for (let i = 0; i < allYrcLines.length; i++) { + const lineText = allYrcLines[i].words + .map((w) => w.word) + .join("") + .trim(); + if (!isMetadataLine(lineText)) { + validIndices.push(i); + } + } + + // 过滤元数据后如果无有效行,fallback 到 LRC 路径 + if (validIndices.length > 0) { + const filteredYrcLines = validIndices.map((i) => allYrcLines[i]); + // 基于时间戳匹配翻译/罗马音行(比索引映射更稳健) + const tRawLines = lyricObj.tlyric?.lines ?? []; + const rRawLines = lyricObj.romalrc?.lines ?? []; + // 过滤元数据翻译行 + const tFiltered = tRawLines.filter( + (l) => !isMetadataLine(l.lyric), + ); + const rFiltered = rRawLines.filter( + (l) => !isMetadataLine(l.lyric), + ); + + const tTexts = validIndices.map((vi) => { + const yrcTime = allYrcLines[vi].startTime; + return matchByTimestampV2(tFiltered, yrcTime); + }); + const romaTexts = validIndices.map((vi) => { + const yrcTime = allYrcLines[vi].startTime; + return matchByTimestampV2(rFiltered, yrcTime); + }); + + const mergedLines = mergeSubLyrics(filteredYrcLines, tTexts, romaTexts); + + console.log( + "[V2LyricAdapter] YRC 过滤:", + `过滤${allYrcLines.length - mergedLines.length}行元数据`, + `剩余${mergedLines.length}行`, + ); + + if (isNonScrollingLyric(mergedLines) || lyricObj.lrc?.scrollable === 0) { + console.log("[V2LyricAdapter] YRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } - if (yrcLines.length > 0) { - const tTexts = lyricObj.tlyric?.lines?.map((l) => l.lyric) ?? []; - const romaTexts = lyricObj.romalrc?.lines?.map((l) => l.lyric) ?? []; + return { + format: "structured", + lines: mergedLines, + }; + } - return { - format: "structured", - lines: mergeSubLyrics(yrcLines, tTexts, romaTexts), - }; + console.log("[V2LyricAdapter] YRC 过滤元数据后无有效行,fallback 到 LRC 路径"); } } @@ -185,19 +316,129 @@ export class V2LyricAdapter extends BaseLyricAdapter { Array.isArray(lyricObj.lrc.lines) && lyricObj.lrc.lines.length > 0 ) { - const rawLrc: LrcLine[] = lyricObj.lrc.lines.map((l) => ({ - time: l.time * 1000, - text: l.lyric, - })); - const tTexts = lyricObj.tlyric?.lines?.map((l) => l.lyric) ?? []; - const romaTexts = lyricObj.romalrc?.lines?.map((l) => l.lyric) ?? []; + // 先建立有效行的索引映射(过滤掉元数据行) + const hasTranslation = lyricObj.tlyric?.lines && lyricObj.tlyric.lines.length > 0; + const validIndices: number[] = []; + const rawLrc: LrcLine[] = []; + const filteredCount = { metadata: 0, empty: 0 }; + // 当无独立翻译行但 lyric 中包含 "原文/翻译" 时,内联提取的翻译 + const inlineTransTexts: string[] = []; + + for (let i = 0; i < lyricObj.lrc.lines.length; i++) { + const line = lyricObj.lrc.lines[i]; + if (isMetadataLine(line.lyric)) { + filteredCount.metadata++; + continue; + } + + validIndices.push(i); + + // 当存在独立翻译行时,网易云 LRC 的 lyric 字段可能包含 "原文/翻译" 格式, + // 需要剥离翻译部分,只保留原文作为正式歌词文本 + let lyricText = line.lyric; + let inlineTrans = ""; + + if (lyricText.includes("/")) { + if (hasTranslation && lyricObj.tlyric?.lines?.[i]?.lyric) { + // 有独立翻译行时,剥离 lyric 中 / 后的翻译部分 + const slashIndex = lyricText.lastIndexOf("/"); + const beforeSlash = lyricText.substring(0, slashIndex).trim(); + const afterSlash = lyricText.substring(slashIndex + 1).trim(); + if (afterSlash.length > 0 && beforeSlash.length > 0) { + lyricText = beforeSlash; + } + } else if (!hasTranslation) { + // 无独立翻译行时,尝试从 "原文/翻译" 中提取内联翻译 + const slashIndex = lyricText.lastIndexOf("/"); + const beforeSlash = lyricText.substring(0, slashIndex).trim(); + const afterSlash = lyricText.substring(slashIndex + 1).trim(); + if (afterSlash.length > 0 && beforeSlash.length > 0) { + lyricText = beforeSlash; + inlineTrans = afterSlash; + } + } + } + + inlineTransTexts.push(inlineTrans); + rawLrc.push({ + // V2 lrc.lines 时间单位不一致:部分歌曲为秒,部分为毫秒 + // 通过首行时间值自动检测:如果 < 1000 认为是秒,需要 *1000 + time: lyricObj.lrc.lines[0].time < 1000 ? line.time * 1000 : line.time, + text: lyricText, + }); + } + + if (filteredCount.metadata > 0) { + console.log( + "[V2LyricAdapter] LRC 过滤元数据行:", + `过滤${filteredCount.metadata}行元数据`, + `剩余${rawLrc.length}行歌词`, + ); + } + + // 按照有效行索引提取翻译和音译文本 + // 翻译文本:优先使用独立翻译行,否则使用内联提取的翻译 + const allTTexts = lyricObj.tlyric?.lines?.map((l) => l.lyric) ?? []; + const allRomaTexts = + lyricObj.romalrc?.lines?.map((l) => l.lyric) ?? []; + const tTexts = validIndices.map((vi, idx) => + allTTexts[vi] || inlineTransTexts[idx] || "", + ); + const romaTexts = validIndices.map((i) => allRomaTexts[i] || ""); + + // 预检查:检测所有行是否都是无时间轴(全0或无效时间戳) + const validLines = rawLrc.filter((l) => l.text.trim().length > 0); + if (validLines.length > 0) { + const allZeroTime = validLines.every((l) => l.time === 0); + const allSameTime = validLines.every((l) => l.time === validLines[0].time); + + if (allZeroTime || allSameTime) { + console.log( + "[V2LyricAdapter] LRC 预检查:所有行时间戳相同或为0,触发 cover 模式", + `allZeroTime=${allZeroTime}, allSameTime=${allSameTime}`, + ); + return null; + } + } + + console.log( + "[V2LyricAdapter] LRC 路径:", + `lrc.lines=${lyricObj.lrc.lines.length}条`, + `首行 time=${lyricObj.lrc.lines[0]?.time} lyric="${lyricObj.lrc.lines[0]?.lyric?.substring(0, 30)}"`, + `nolyric=${lyricObj.lrc.nolyric}`, + `scrollable=${lyricObj.lrc.scrollable}`, + `offset=${lyricObj.lrc.offset}`, + ); + + const builtLines = buildAmllLyricLines(rawLrc, tTexts, romaTexts); + + console.log( + "[V2LyricAdapter] LRC 构建完成:", + `原始${rawLrc.length}行 -> 构建${builtLines.length}行`, + `首行时间戳=[${builtLines[0]?.startTime ?? "n/a"}, ${builtLines[0]?.endTime ?? "n/a"}]`, + `isNonScrolling=${isNonScrollingLyric(builtLines)}`, + ); + + if (isNonScrollingLyric(builtLines) || lyricObj.lrc?.scrollable === 0) { + console.log("[V2LyricAdapter] LRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } return { format: "structured", - lines: buildAmllLyricLines(rawLrc, tTexts, romaTexts), + lines: builtLines, }; } + console.log( + "[V2LyricAdapter] 所有歌词路径均无效:", + `yrc=${!!lyricObj.yrc?.lyric}`, + `lrc=${!!lyricObj.lrc}`, + `lrc.lines=${lyricObj.lrc?.lines ? (Array.isArray(lyricObj.lrc.lines) ? `${lyricObj.lrc.lines.length}条` : "非数组") : "n/a"}`, + `nolyric=${lyricObj.lrc?.nolyric}`, + `uncollected=${lyricObj.lrc?.uncollected}`, + ); + return null; } } diff --git a/src/adapters/v3/index.ts b/src/adapters/v3/index.ts index 15492ff..7278711 100644 --- a/src/adapters/v3/index.ts +++ b/src/adapters/v3/index.ts @@ -1,4 +1,4 @@ -import { type LrcLine, parseLrc } from "@/core/parsers/lrcParser"; +import { type LrcLine, isMetadataLine, parseLrc } from "@/core/parsers/lrcParser"; import { buildAmllLyricLines, mergeSubLyrics, @@ -7,6 +7,7 @@ import { parseYrc } from "@/core/parsers/yrcParser"; import type { v3 } from "@/types/ncm"; import type { AmllLyricContent } from "@/types/ws"; import { extractRawLyricData } from "@/utils/format-lyric"; +import { isNonScrollingLyric } from "@/utils/lyricDetector"; import { LYRIC_SOURCE_UUID_BUILTIN_NCM } from "@/utils/source"; import { findModule, @@ -15,12 +16,37 @@ import { } from "@/utils/webpack"; import { BaseLyricAdapter } from "../BaseLyricAdapter"; +/** + * 在 LRC 行中找到与目标时间戳最接近的行,返回其文本 + * + * 用于 YRC 路径中将翻译/罗马音与歌词行对齐, + * 因为 yrcTrans/yrcRoma 的行数和索引可能与 yrc 不一致(可能包含或不包含元数据翻译行) + */ +function matchByTimestamp(lines: LrcLine[], targetTime: number): string { + if (lines.length === 0) return ""; + + // 线性扫描找时间最接近的行(通常数据量小,无需二分) + let bestIdx = -1; + let bestDiff = Infinity; + for (let i = 0; i < lines.length; i++) { + const diff = Math.abs(lines[i].time - targetTime); + if (diff < bestDiff) { + bestDiff = diff; + bestIdx = i; + } + } + + // 容差 3 秒内才算有效匹配,否则返回空字符串 + return (bestDiff <= 3000 && bestIdx >= 0) ? lines[bestIdx].text : ""; +} + export class V3LyricAdapter extends BaseLyricAdapter { public readonly id = LYRIC_SOURCE_UUID_BUILTIN_NCM; private store: v3.NCMStore | null = null; private unsubscribeRedux: (() => void) | null = null; private lastSentLyricJson: string | null = null; + private lastLyricStateJson: string | null = null; private initTimer: ReturnType | null = null; public async init(): Promise { @@ -78,6 +104,7 @@ export class V3LyricAdapter extends BaseLyricAdapter { this.store = null; this.lastSentLyricJson = null; + this.lastLyricStateJson = null; } private handleStoreUpdate() { @@ -86,11 +113,42 @@ export class V3LyricAdapter extends BaseLyricAdapter { const state = this.store.getState(); const lyricState = state["async:lyric"]; - if (!lyricState || lyricState.isLoading) return; + if (!lyricState) { + console.warn("[V3LyricAdapter] async:lyric 状态不存在,store keys:", Object.keys(state)); + return; + } + + if (lyricState.isLoading) return; + + // 缓存 lyricState 的关键数据,避免每次 Redux 状态变化(如播放进度更新)都重新解析歌词 + const lyricStateKey = JSON.stringify({ + yrc: lyricState.yrcInfo?.yrc, + yrcTrans: lyricState.yrcInfo?.yrcTrans, + yrcRoma: lyricState.yrcInfo?.yrcRoma, + lyricLines: lyricState.lyricLines, + tlyricLines: lyricState.tlyricLines, + romaLyricLines: lyricState.romaLyricLines, + scrollable: lyricState.scrollable, + displayType: lyricState.displayType, + }); + + if (lyricStateKey === this.lastLyricStateJson) { + return; + } + this.lastLyricStateJson = lyricStateKey; const amllLyric = this.parseNcmLyric(lyricState); if (!amllLyric) { + console.log( + "[V3LyricAdapter] 歌词解析失败", + `yrcInfo=${!!lyricState.yrcInfo?.yrc}`, + `lyricLines数量=${lyricState.lyricLines?.length ?? "null/undefined"}`, + `tlyricLines数量=${lyricState.tlyricLines?.length ?? "null/undefined"}`, + `isCloudLyric=${lyricState.isCloudLyric}`, + `isLyricFetchFailed=${lyricState.isLyricFetchFailed}`, + ); this.dispatch("rawlyric", null); + this.dispatch("update", null); return; } @@ -117,8 +175,9 @@ export class V3LyricAdapter extends BaseLyricAdapter { this.dispatch("update", amllLyric); } - public fetchLyric(): void { + public fetchLyric(_songInfo?: import("@/types/inflink").SongInfo): void { this.lastSentLyricJson = null; + this.lastLyricStateJson = null; // async:lyric 只有在用户打开了会显示歌词的页面或者组件才会有歌词 // dispatch 这个 action 以便我们无论如何都能获取到歌词 @@ -134,38 +193,164 @@ export class V3LyricAdapter extends BaseLyricAdapter { rawState: v3.NcmAsyncLyricState, ): AmllLyricContent | null { if (rawState.yrcInfo?.yrc) { - const yrcLines = parseYrc(rawState.yrcInfo.yrc); + const yrcInfo = rawState.yrcInfo; + const allYrcLines = parseYrc(yrcInfo.yrc); + + console.log("[V3LyricAdapter] YRC 解析:", `原始长度=${yrcInfo.yrc.length}`, `解析行数=${allYrcLines.length}`); + + if (allYrcLines.length > 0) { + // 过滤元数据行(与 V2 对齐) + const validIndices: number[] = []; + for (let i = 0; i < allYrcLines.length; i++) { + const lineText = allYrcLines[i].words + .map((w) => w.word) + .join("") + .trim(); + if (!isMetadataLine(lineText)) { + validIndices.push(i); + } + } - if (yrcLines.length > 0) { - const tTexts = parseLrc(rawState.yrcInfo.yrcTrans || "").map( - (l) => l.text, - ); - const romaTexts = parseLrc(rawState.yrcInfo.yrcRoma || "").map( - (l) => l.text, - ); + // 过滤元数据后如果无有效行,fallback 到 LRC 路径 + if (validIndices.length > 0) { + const yrcLines = validIndices.map((i) => allYrcLines[i]); + // 翻译/罗马音解析:不过滤元数据(因为无法确定 yrcTrans 是否包含元数据翻译行) + // 使用基于时间戳匹配的方式对齐翻译和歌词行 + const tRaw = parseLrc(yrcInfo.yrcTrans || "", { skipMetadataFilter: true }); + const rRaw = parseLrc(yrcInfo.yrcRoma || "", { skipMetadataFilter: true }); + + // 基于时间戳匹配:对于每个有效 YRC 行,在翻译中找时间最接近的行 + // 这比索引映射更稳健——无论 yrcTrans 是否包含元数据翻译行都能正确工作 + const tTexts = validIndices.map((vi) => { + const yrcTime = allYrcLines[vi].startTime; + return matchByTimestamp(tRaw, yrcTime); + }); + const romaTexts = validIndices.map((vi) => { + const yrcTime = allYrcLines[vi].startTime; + return matchByTimestamp(rRaw, yrcTime); + }); + + const mergedLines = mergeSubLyrics(yrcLines, tTexts, romaTexts); + + if (isNonScrollingLyric(mergedLines) || rawState.scrollable === false) { + console.log("[V3LyricAdapter] YRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } - return { - format: "structured", - lines: mergeSubLyrics(yrcLines, tTexts, romaTexts), - }; + return { + format: "structured", + lines: mergedLines, + }; + } + + console.log("[V3LyricAdapter] YRC 过滤元数据后无有效行,fallback 到 LRC 路径"); } } const lines = rawState.lyricLines; + console.log( + "[V3LyricAdapter] 尝试 LRC 路径:", + `lyricLines=${lines ? (Array.isArray(lines) ? `${lines.length}行` : "非数组") : "null/undefined"}`, + `tlyricLines=${rawState.tlyricLines?.length ?? "n/a"}行`, + `romaLyricLines=${rawState.romaLyricLines?.length ?? "n/a"}行`, + `displayType=${rawState.displayType}`, + `currentUsedLyric=${rawState.currentUsedLyric?.substring(0, 50) ?? "n/a"}`, + ); + if (!lines || !Array.isArray(lines) || lines.length === 0) { return null; } - const rawLrc: LrcLine[] = lines.map((l) => ({ - time: l.time * 1000, - text: l.lyric, - })); - const tTexts = rawState.tlyricLines?.map((l) => l.lyric) ?? []; - const romaTexts = rawState.romaLyricLines?.map((l) => l.lyric) ?? []; + // 使用与 V2 一致的索引映射方式,确保翻译和罗马音对齐 + const hasTranslation = rawState.tlyricLines && rawState.tlyricLines.length > 0; + const validIndices: number[] = []; + const rawLrc: LrcLine[] = []; + // 当无独立翻译行但 lyric 中包含 "原文/翻译" 时,内联提取的翻译 + const inlineTransTexts: string[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + if (isMetadataLine(line.lyric)) { + continue; + } + + validIndices.push(i); + + // 当存在独立翻译行时,网易云 LRC 的 lyric 字段可能包含 "原文/翻译" 格式, + // 需要剥离翻译部分,只保留原文作为正式歌词文本 + let lyricText = line.lyric; + let inlineTrans = ""; + + if (lyricText.includes("/")) { + if (hasTranslation && rawState.tlyricLines?.[i]?.lyric) { + // 有独立翻译行时,剥离 lyric 中 / 后的翻译部分 + const slashIndex = lyricText.lastIndexOf("/"); + const beforeSlash = lyricText.substring(0, slashIndex).trim(); + const afterSlash = lyricText.substring(slashIndex + 1).trim(); + if (afterSlash.length > 0 && beforeSlash.length > 0) { + lyricText = beforeSlash; + } + } else if (!hasTranslation) { + // 无独立翻译行时,尝试从 "原文/翻译" 中提取内联翻译 + const slashIndex = lyricText.lastIndexOf("/"); + const beforeSlash = lyricText.substring(0, slashIndex).trim(); + const afterSlash = lyricText.substring(slashIndex + 1).trim(); + if (afterSlash.length > 0 && beforeSlash.length > 0) { + lyricText = beforeSlash; + inlineTrans = afterSlash; + } + } + } + + inlineTransTexts.push(inlineTrans); + rawLrc.push({ + // V3 lyricLines 时间单位不一致:部分歌曲为秒,部分为毫秒 + // 通过首行时间值自动检测:如果 < 1000 认为是秒,需要 *1000 + time: lines[0].time < 1000 ? line.time * 1000 : line.time, + text: lyricText, + }); + } + + // 翻译文本:优先使用独立翻译行,否则使用内联提取的翻译 + const tTexts = validIndices.map((vi, idx) => + rawState.tlyricLines?.[vi]?.lyric || inlineTransTexts[idx] || "", + ); + const romaTexts = validIndices.map( + (i) => rawState.romaLyricLines?.[i]?.lyric ?? "", + ); + + // 预检查:检测所有行是否都是无时间轴(全0或无效时间戳) + const validLines = rawLrc.filter((l) => l.text.trim().length > 0); + if (validLines.length > 0) { + const allZeroTime = validLines.every((l) => l.time === 0); + const allSameTime = validLines.every((l) => l.time === validLines[0].time); + + if (allZeroTime || allSameTime) { + console.log( + "[V3LyricAdapter] LRC 预检查:所有行时间戳相同或为0,触发 cover 模式", + `allZeroTime=${allZeroTime}, allSameTime=${allSameTime}`, + ); + return null; + } + } + + const builtLines = buildAmllLyricLines(rawLrc, tTexts, romaTexts); + + console.log( + "[V3LyricAdapter] LRC 构建完成:", + `原始${rawLrc.length}行 -> 构建${builtLines.length}行`, + `首行时间戳=[${builtLines[0]?.startTime ?? "n/a"}, ${builtLines[0]?.endTime ?? "n/a"}]`, + `isNonScrolling=${isNonScrollingLyric(builtLines)}`, + ); + + if (isNonScrollingLyric(builtLines) || rawState.scrollable === false) { + console.log("[V3LyricAdapter] LRC 歌词是非滚动歌词,触发 cover 模式"); + return null; + } return { format: "structured", - lines: buildAmllLyricLines(rawLrc, tTexts, romaTexts), + lines: builtLines, }; } diff --git a/src/components/headless/AmllStateSync.tsx b/src/components/headless/AmllStateSync.tsx index 2579ac7..c4da384 100644 --- a/src/components/headless/AmllStateSync.tsx +++ b/src/components/headless/AmllStateSync.tsx @@ -11,6 +11,7 @@ import { connectionIntentAtom, connectionStatusAtom, lyricAtom, + lyricSearchStatusAtom, nextAtom, pauseAtom, playAtom, @@ -55,6 +56,7 @@ export function AmllStateSync() { const timelineOffset = useAtomValue(timelineOffsetAtom); const lyricContent = useAtomValue(lyricAtom); + const lyricSearchStatus = useAtomValue(lyricSearchStatusAtom); const play = useSetAtom(playAtom); const pause = useSetAtom(pauseAtom); @@ -67,6 +69,9 @@ export function AmllStateSync() { const setReconnectCountdown = useSetAtom(reconnectCountdownAtom); + const lastSentSongIdRef = useRef(null); + const lyricTimeoutRef = useRef | null>(null); + const handleIncomingMessage = useCallback( (event: MessageEvent) => { if (typeof event.data !== "string") return; @@ -247,13 +252,66 @@ export function AmllStateSync() { }, [playMode, status, sendStateUpdate]); useEffect(() => { - if (!lyricContent || status !== "connected") return; + if (status !== "connected") return; + + const currentSongId = songInfo?.ncmId ?? null; + + if (!lyricContent) { + const sendEmptyLyric = () => { + console.log( + "[AmllStateSync] 发送空歌词(封面模式)", + `songId=${currentSongId}`, + ); + sendStateUpdate({ + update: "setLyric", + format: "structured", + lines: [], + }); + lastSentSongIdRef.current = currentSongId; + }; + + if (lastSentSongIdRef.current === currentSongId) { + clearTimeout(lyricTimeoutRef.current ?? undefined); + lyricTimeoutRef.current = null; + sendEmptyLyric(); + } else { + const statuses = Object.values(lyricSearchStatus); + const hasStatuses = statuses.length > 0; + const allDone = statuses.every( + (s) => s === "found" || s === "not_found" || s === "skipped", + ); + + if (hasStatuses && allDone) { + clearTimeout(lyricTimeoutRef.current ?? undefined); + lyricTimeoutRef.current = null; + sendEmptyLyric(); + } else if (!lyricTimeoutRef.current) { + console.log( + "[AmllStateSync] 启动超时定时器等待歌词搜索完成", + `songId=${currentSongId}`, + ); + lyricTimeoutRef.current = setTimeout(() => { + console.warn( + "[AmllStateSync] 歌词搜索超时(2s),强制发送空歌词兜底", + `songId=${currentSongId}`, + ); + sendEmptyLyric(); + }, 2000); + } + } + return; + } + + clearTimeout(lyricTimeoutRef.current ?? undefined); + lyricTimeoutRef.current = null; + + lastSentSongIdRef.current = currentSongId; sendStateUpdate({ update: "setLyric", ...lyricContent, }); - }, [lyricContent, status, sendStateUpdate]); + }, [lyricContent, status, sendStateUpdate, lyricSearchStatus, songInfo?.ncmId]); useEffect(() => { const handleAudioData = (e: Event) => { diff --git a/src/core/LyricManager.ts b/src/core/LyricManager.ts index 15952a9..4044daf 100644 --- a/src/core/LyricManager.ts +++ b/src/core/LyricManager.ts @@ -51,11 +51,14 @@ export class LyricManager extends TypedEventTarget { } public fetchLyric(songInfo: SongInfo): void { + console.log("[LyricManager] fetchLyric:", `ncmId=${songInfo.ncmId}`, `adapters=${this.adapters.map((a) => a.id).join(", ")}`); + if (this.currentMusicId !== songInfo.ncmId) { this.currentMusicId = songInfo.ncmId; + // 只清除缓存,不立即发送 null(避免切歌时封面闪烁) + // 如果新歌确实无歌词,adapter 会返回 null 并触发封面 this.caches.clear(); this.rawLyricDataCache = {}; - this.dispatch("update", null); } this.statuses = {}; @@ -83,6 +86,14 @@ export class LyricManager extends TypedEventTarget { const adapter = event.currentTarget as BaseLyricAdapter; const lyric = event.detail; + console.log( + "[LyricManager] 收到 adapter 更新:", + `id=${adapter.id}`, + `有歌词=${!!lyric}`, + `lyric格式=${lyric?.format ?? "null"}`, + `行数=${lyric?.format === "structured" ? lyric.lines.length : "n/a"}`, + ); + this.caches.set(adapter.id, lyric); if (lyric) { @@ -128,6 +139,15 @@ export class LyricManager extends TypedEventTarget { } } + console.log( + "[LyricManager] evaluateAndDispatch:", + `statuses=${JSON.stringify(this.statuses)}`, + `isDebouncing=${this.isDebouncing}`, + `hasFoundHigherPriority=${hasFoundHigherPriority}`, + `isWaitingForHigherPriority=${isWaitingForHigherPriority}`, + `finalLyric=${finalLyric ? `${finalLyric.format}` : "null"}`, + ); + this.dispatch("statuschange", { ...this.statuses }); if (isWaitingForHigherPriority) { diff --git a/src/core/parsers/lrcParser.ts b/src/core/parsers/lrcParser.ts index eda4f8e..b32e2b3 100644 --- a/src/core/parsers/lrcParser.ts +++ b/src/core/parsers/lrcParser.ts @@ -6,7 +6,125 @@ export interface LrcLine { text: string; } -export function parseLrc(lrcStr: string): LrcLine[] { +/** + * 元数据关键词 — 用于过滤网易云歌词中的作词、作曲等信息 + */ +const METADATA_KEYWORDS = [ + "作词", + "作曲", + "制作人", + "编曲", + "吉他", + "贝斯", + "鼓", + "键盘", + "合唱", + "和声", + "和音", + "配乐", + "弦乐", + "混音", + "母带", + "录制", + "录音", + "演唱", + "词", + "曲", + "音乐制作", + "录音工作室", + "混音工作室", + "母带后期", + "后期", + "出品", + "音乐营销", + "composer", + "prod", +]; + +/** + * 检测歌词行是否为元数据行(作词、作曲等) + */ +export function isMetadataLine(text: string): boolean { + if (!text) return false; + + // NFKC 规范化:将兼容性字符(如康熙部首 ⾳ U+2FB3)统一为标准字符(音 U+97F3) + // 网易云部分歌词数据使用兼容性变体,导致关键词匹配失败 + const normalized = text.normalize("NFKC"); + const trimmed = normalized.trim(); + + if (trimmed.length === 0) return false; + + // 提取冒号前的部分作为"标签",检查标签中是否包含元数据关键词 + // 这样 "混音师:" 包含关键词 "混音","母带后期制作人:" 包含关键词 "母带后期" 或 "制作人" + // 安全约束:短关键词(≤2字)只做精确匹配,避免 "词汇:" "曲调:" 等正常歌词被误过滤 + const colonIndex = trimmed.search(/[::]/); + if (colonIndex > 0) { + const label = trimmed.substring(0, colonIndex).trim(); + const labelLower = label.toLowerCase(); + for (const keyword of METADATA_KEYWORDS) { + const kwLower = keyword.toLowerCase(); + if (keyword.length <= 1) { + // 单字关键词精确匹配,防止 "词汇:" 被误判为含 "词","曲调:" 被误判为含 "曲" + if (labelLower === kwLower) return true; + } else { + // 两字及以上关键词使用 includes 匹配 + // "作词"/"作曲"/"编曲" 等双字关键词足够特异,不会误伤正常歌词 + if (labelLower === kwLower || labelLower.includes(kwLower)) { + return true; + } + } + } + } else { + // 无冒号时,检查是否完全匹配关键词 + const trimmedLower = trimmed.toLowerCase(); + for (const keyword of METADATA_KEYWORDS) { + if (trimmedLower === keyword.toLowerCase()) { + return true; + } + } + } + + // 检查复合格式如 "词/曲" 或 "作词/作曲" + if (trimmed.includes("/") && (trimmed.includes("词") || trimmed.includes("曲"))) { + const parts = trimmed.split(/[::]/); + if (parts.length > 0) { + const header = parts[0].trim(); + if (header.includes("词") && header.includes("曲") && header.length <= 10) { + return true; + } + } + } + + // 额外检查:检查是否看起来像人名/艺人列表(通常包含多个斜杠或者逗号分隔的名字) + // 但要排除正常的歌词内容 — 歌词中 "原文/翻译" 格式很常见 + if (trimmed.includes("/") && !trimmed.match(/\w+\/\w+/)) { + const parts = trimmed.split("/"); + + // 如果任意段包含假名(平假名/片假名),则很可能是日文歌词而非元数据 + const hasKana = parts.some((part) => + /[\u3040-\u309F\u30A0-\u30FF]/.test(part), + ); + if (hasKana) return false; + + // 如果任意段超过10字符,则很可能是歌词行而非人名列表 + const hasLongPart = parts.some((part) => part.trim().length > 10); + if (hasLongPart) return false; + + const hasOnlyNamesAndDelimiters = parts.every((part) => { + const cleaned = part.trim(); + // 检查是否只包含汉字、字母或空格(排除假名,因为假名已在上面排除) + return cleaned.match(/^[\u4E00-\u9FFFa-zA-Z0-9\s]+$/); + }); + + if (hasOnlyNamesAndDelimiters && parts.length >= 2) { + return true; + } + } + + return false; +} + +export function parseLrc(lrcStr: string, options?: { skipMetadataFilter?: boolean }): LrcLine[] { if (!lrcStr) return []; const lines = lrcStr.split("\n"); const result: LrcLine[] = []; @@ -18,6 +136,11 @@ export function parseLrc(lrcStr: string): LrcLine[] { if (match?.groups) { const { min, sec, ms, text } = match.groups; + // 过滤元数据行(除非显式跳过) + if (!options?.skipMetadataFilter && isMetadataLine(text)) { + continue; + } + const minVal = parseInt(min, 10); const secVal = parseInt(sec, 10); let msVal = 0; diff --git a/src/core/parsers/lyricBuilder.ts b/src/core/parsers/lyricBuilder.ts index 8107d14..69e14ed 100644 --- a/src/core/parsers/lyricBuilder.ts +++ b/src/core/parsers/lyricBuilder.ts @@ -37,20 +37,24 @@ export function buildAmllLyricLines( const next = lrcLines[i + 1]; + // 防护: 如果没有下一行,使用当前行 + 100 秒作为 endTime,而不是 Infinity const defaultEndTime = next ? Math.max(0, Math.round(next.time)) : startTime + 100000; const safeEndTime = Math.max(startTime, defaultEndTime); + // 额外防护: 确保 endTime 是有限的数字 + const finalEndTime = Number.isFinite(safeEndTime) ? safeEndTime : startTime + 100000; + parsedLines.push({ startTime, - endTime: safeEndTime, + endTime: finalEndTime, translatedLyric: transTexts[i] || "", romanLyric: romaTexts[i] || "", words: [ { startTime, - endTime: safeEndTime, + endTime: finalEndTime, word: current.text, }, ], diff --git a/src/core/parsers/yrcParser.ts b/src/core/parsers/yrcParser.ts index 0f9130c..36367cd 100644 --- a/src/core/parsers/yrcParser.ts +++ b/src/core/parsers/yrcParser.ts @@ -1,51 +1,190 @@ import type { AmllLyricLine, AmllLyricWord } from "@/types/ws"; +/** + * 网易云新版 JSON 格式的 YRC 单行数据 + */ +interface JsonYrcLine { + t: number; // 行起始时间(毫秒) + c: Array<{ tx: string }>; // 逐字词组 +} + +function tryParseJsonYrc(line: string): AmllLyricLine[] | null { + const trimmed = line.trim(); + if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null; + + try { + const parsed: JsonYrcLine = JSON.parse(trimmed); + if (typeof parsed.t !== "number" || !Array.isArray(parsed.c) || parsed.c.length === 0) { + return null; + } + + const words: AmllLyricWord[] = []; + + for (const chunk of parsed.c) { + if (!chunk.tx || typeof chunk.tx !== "string") continue; + const text = chunk.tx.trim(); + if (text.length === 0) continue; + + // JSON 格式中每个 chunk 没有独立的 duration 信息, + // startTime 设为行起始时间,endTime 由后处理步骤根据下一行回填 + words.push({ + startTime: parsed.t, + endTime: 0, // 后处理中按字符比例分配并回填 + word: text, + }); + } + + if (words.length === 0) return null; + + return [ + { + startTime: parsed.t, + endTime: 0, // 后处理中回填 + words: words, + translatedLyric: "", + romanLyric: "", + }, + ]; + } catch { + return null; + } +} + +/** + * 尝试解析 LRC 行级格式 [MM:SS.ms]text 或 [MM:SS:xx]text + * 网易云有时会将 LRC 格式的歌词混入 yrcInfo.yrc 字段中 + */ +function tryParseLrcLine(line: string): AmllLyricLine | null { + const lrcMatch = line.match( + /^\[(?\d{1,3}):(?\d{2})(?:[:.](?\d{2,3}))?\](?.*)/, + ); + if (!lrcMatch?.groups) return null; + + const { min, sec, ms, text } = lrcMatch.groups; + if (!text || text.trim().length === 0) return null; + + const minVal = parseInt(min, 10); + const secVal = parseInt(sec, 10); + let msVal = 0; + if (ms) { + msVal = parseInt(ms, 10); + if (ms.length === 2) msVal *= 10; + } + + const startTime = minVal * 60000 + secVal * 1000 + msVal; + const trimmedText = text.trim(); + + return { + startTime, + endTime: startTime + 5000, // 默认5秒,后处理会修正 + words: [{ + startTime, + endTime: startTime + 5000, + word: trimmedText, + }], + translatedLyric: "", + romanLyric: "", + }; +} + export function parseYrc(yrcStr: string): AmllLyricLine[] { if (!yrcStr) return []; const lines = yrcStr.split("\n"); - const result: AmllLyricLine[] = []; + const rawResult: AmllLyricLine[] = []; for (const line of lines) { if (!line.trim()) continue; - const contentMatch = line.match( + // 尝试 JSON 格式解析(新版网易云 YRC) + const jsonParsed = tryParseJsonYrc(line); + if (jsonParsed && jsonParsed.length > 0) { + rawResult.push(...jsonParsed); + continue; + } + + // 旧版括号格式解析 [lineStart,lineDur](wStart,wDur,0)text + const bracketMatch = line.match( /^\[(?\d+),(?\d+)\](?.*)/, ); - const content = contentMatch?.groups?.content ?? line; + if (bracketMatch?.groups) { + const content = bracketMatch.groups.content; + const wordRegex = + /\((?\d+),(?\d+),\d+\)(?.*?)(?=\(\d+,\d+,\d+\)|$)/g; + const words: AmllLyricWord[] = []; - // (wordStart,wordDuration,不知道有什么用的0)wordText - const wordRegex = - /\((?\d+),(?\d+),\d+\)(?.*?)(?=\(\d+,\d+,\d+\)|$)/g; - const words: AmllLyricWord[] = []; + for (const match of content.matchAll(wordRegex)) { + if (!match.groups) continue; + const { wordStart: startStr, wordDur: durStr, wordText } = match.groups; + const wordStart = parseInt(startStr, 10); + const wordDur = parseInt(durStr, 10); - for (const match of content.matchAll(wordRegex)) { - if (!match.groups) continue; + if (wordText.trim() === "") continue; - const { wordStart: startStr, wordDur: durStr, wordText } = match.groups; - const wordStart = parseInt(startStr, 10); - const wordDur = parseInt(durStr, 10); + words.push({ + startTime: wordStart, + endTime: wordStart + wordDur, + word: wordText, + }); + } - if (wordText.trim() === "") { - continue; + if (words.length > 0) { + rawResult.push({ + startTime: words[0].startTime, + endTime: words[words.length - 1].endTime, + words: words, + translatedLyric: "", + romanLyric: "", + }); } + continue; + } - words.push({ - startTime: wordStart, - endTime: wordStart + wordDur, - word: wordText, - }); + // LRC 行级格式 fallback [MM:SS.ms]text 或 [MM:SS:xx]text + // 网易云有时会将 LRC 格式歌词混入 yrc 字段 + const lrcLine = tryParseLrcLine(line); + if (lrcLine) { + rawResult.push(lrcLine); + continue; } + } - if (words.length > 0) { - // 丢弃行时间戳以免错误的行时间戳影响到视觉效果 - result.push({ - startTime: words[0].startTime, - endTime: words[words.length - 1].endTime, - words: words, - translatedLyric: "", - romanLyric: "", - }); + // 后处理:用下一行的 startTime 回填当前行的 endTime + if (rawResult.length > 0) { + for (let i = 0; i < rawResult.length; i++) { + const current = rawResult[i]; + const nextLineStartTime = i + 1 < rawResult.length + ? rawResult[i + 1].startTime + : current.startTime + 5000; + + current.endTime = Math.max(current.endTime, nextLineStartTime); + + // 检测是否需要修正 word 级别的时间(JSON 和 LRC 解析的行都需要) + const needsWordFixup = current.words.some( + (w) => w.endTime <= w.startTime || w.endTime - w.startTime < 50, + ); + + if (needsWordFixup && current.words.length > 0) { + const lineDuration = current.endTime - current.startTime; + const totalChars = current.words.reduce( + (sum, w) => sum + w.word.length, 0, + ); + let accTime = current.startTime; + for (let j = 0; j < current.words.length; j++) { + const word = current.words[j]; + const charRatio = totalChars > 0 + ? word.word.length / totalChars + : 1 / current.words.length; + const wordDur = Math.round(lineDuration * charRatio); + word.startTime = accTime; + word.endTime = accTime + wordDur; + accTime += wordDur; + } + if (current.words.length > 0) { + current.words[current.words.length - 1].endTime = current.endTime; + } + } } } - return result; + + return rawResult; } diff --git a/src/types/ncm.d.ts b/src/types/ncm.d.ts index f37d926..8bd335f 100644 --- a/src/types/ncm.d.ts +++ b/src/types/ncm.d.ts @@ -37,7 +37,7 @@ export namespace v3 { * 网易云基础的行歌词格式 */ export interface NcmLyricLine { - /** 歌词对应的时间,单位为秒 */ + /** 歌词对应的时间,单位为毫秒 */ time: number; /** 歌词文本 */ lyric: string; diff --git a/src/utils/lyricDetector.ts b/src/utils/lyricDetector.ts new file mode 100644 index 0000000..3f6054e --- /dev/null +++ b/src/utils/lyricDetector.ts @@ -0,0 +1,97 @@ +import type { AmllLyricLine } from "@/types/ws"; + +/** + * 检测歌词是否为非滚动歌词(纯文本/元数据类型) + * + * 判定条件(满足任一即判定为非滚动): + * 1. 所有非空行的时间戳均为 0(典型的元数据歌词) + * 2. 所有行的时间戳呈严格等差数列且差值为固定值(网易云给非滚动歌词分配的伪时间戳) + * 3. 所有行的 startTime 都相同且 startTime === endTime(无播放持续时间) + * 4. 所有行都超出合理范围(总长度 > 24小时) + */ +export function isNonScrollingLyric(lines: AmllLyricLine[]): boolean { + if (!lines || lines.length === 0) return false; + + const nonEmptyLines = lines.filter((line) => { + const text = line.words.map((w) => w.word).join("").trim(); + return text.length > 0; + }); + + if (nonEmptyLines.length === 0) return false; + + // 条件1: 所有行 startTime=0 且 endTime=0 + const allZeroTime = nonEmptyLines.every( + (line) => line.startTime === 0 && line.endTime === 0, + ); + + if (allZeroTime) { + console.log( + "[lyricDetector] 全零时间戳 → 非滚动歌词:", + `总行数=${lines.length}, 非空行=${nonEmptyLines.length}`, + `前3行内容=${nonEmptyLines.slice(0, 3).map((l) => l.words.map((w) => w.word).join("")).join(" | ")}`, + ); + return true; + } + + // 条件2: 所有行的 endTime - startTime 为同一固定值(伪时间戳特征) + // 非滚动歌词通常被分配 startTime=[0,1000,2000,...], endTime=[1000,2000,...] + // 同时要求 startTime 呈等差数列(0, 1000, 2000, ...),以避免误判正常的短歌词 + if (nonEmptyLines.length >= 3) { + const durations = nonEmptyLines.map( + (line) => (line.endTime || 0) - (line.startTime || 0), + ); + const firstDuration = durations[0]; + const allSameDuration = firstDuration > 0 && durations.every((d) => d === firstDuration); + + if (allSameDuration) { + // 进一步检查 startTime 是否呈等差数列(非滚动歌词的典型特征) + const startTimes = nonEmptyLines.map((l) => l.startTime); + const intervals = startTimes.slice(1).map((t, i) => t - startTimes[i]); + const allSameInterval = intervals.length > 0 && intervals.every((iv) => iv === intervals[0]); + + if (allSameInterval) { + console.log( + "[lyricDetector] 固定持续时间+等差时间戳 → 非滚动歌词:", + `duration=${firstDuration}ms`, + `interval=${intervals[0]}ms`, + `行数=${nonEmptyLines.length}`, + `前3行内容=${nonEmptyLines.slice(0, 3).map((l) => l.words.map((w) => w.word).join("")).join(" | ")}`, + ); + return true; + } + } + } + + // 条件3: 所有行的 startTime 都相同且 startTime === endTime(无持续时间) + const firstStartTime = nonEmptyLines[0].startTime; + const allSameStart = nonEmptyLines.every((line) => line.startTime === firstStartTime); + const allNoDisplay = nonEmptyLines.every((line) => line.startTime === line.endTime); + + if (allSameStart && allNoDisplay) { + console.log( + "[lyricDetector] 所有行同一时刻无持续时间 → 非滚动歌词:", + `startTime=${firstStartTime}ms`, + `行数=${nonEmptyLines.length}`, + `前3行内容=${nonEmptyLines.slice(0, 3).map((l) => l.words.map((w) => w.word).join("")).join(" | ")}`, + ); + return true; + } + + // 条件4: 检查是否所有行都在合理的时间范围内(< 24小时) + // 如果最后一行的时间戳非常大(超过24小时),可能是无效时间戳 + const maxTime = Math.max( + ...nonEmptyLines.map((l) => Math.max(l.startTime, l.endTime)), + ); + + // 24小时 = 86400000ms,如果超过,可能是无效数据 + if (maxTime > 86400000) { + console.log( + "[lyricDetector] 时间戳超出24小时范围(无效数据)→ 非滚动歌词:", + `maxTime=${maxTime}ms`, + `行数=${nonEmptyLines.length}`, + ); + return true; + } + + return false; +}