diff --git a/electron/main/apis/common/lyric/kugou.ts b/electron/main/apis/common/lyric/kugou.ts index fba4cb29..d9b3ddb6 100644 --- a/electron/main/apis/common/lyric/kugou.ts +++ b/electron/main/apis/common/lyric/kugou.ts @@ -11,9 +11,9 @@ import { callKugou } from "@main/apis/kugou"; import { getCachedLyric, setCachedLyric } from "@main/database/lyricCache"; -import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache"; +import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache"; import { coreLog } from "@main/utils/logger"; -import type { LyricMatchResult } from "@shared/types/lyrics"; +import type { LyricMatchQueryOptions, LyricMatchResult } from "@shared/types/lyrics"; import type { Track } from "@shared/types/player"; import { buildLyricSearchKeyword, pickBestCandidate, type LyricCandidate } from "./utils"; @@ -82,15 +82,36 @@ export const getByPlatformId = (hash: string): Promise fetchLyric({ hash }); /** 按 Track 元数据模糊搜索:search → 挑最佳 → 单次请求歌词 */ -export const getByQuery = async (track: Track): Promise => { +export const getByQuery = async ( + track: Track, + options: LyricMatchQueryOptions = {}, +): Promise => { const fingerprint = buildFingerprint(track); const cached = getMatchedId(fingerprint, "kugou"); - if (cached) { - return fetchLyric({ + if ( + cached && + options.validationKey && + cached.extra?.validationKey === options.validationKey && + !options.excludedIds?.includes(cached.platformId) + ) { + const lyric = await fetchLyric({ hash: cached.platformId, name: track.title, durationMs: track.duration, }); + if (!lyric) return null; + return { + ...lyric, + candidate: { + platformId: cached.platformId, + name: track.title, + artist: track.artists.map((artist) => artist.name).join(" / "), + album: track.album?.name, + duration: track.duration, + extra: cached.extra, + validated: true, + }, + }; } const keyword = buildLyricSearchKeyword(track); @@ -114,15 +135,28 @@ export const getByQuery = async (track: Track): Promise return null; } - const best = pickBestCandidate(candidates, track); + const best = pickBestCandidate( + candidates.filter((candidate) => !options.excludedIds?.includes(candidate.extra.hash)), + track, + ); coreLog.info( `[lyric:kugou] fuzzy "${keyword}" → ${candidates.length} hits, best=${best?.name ?? "none"}`, ); if (!best) return null; - setMatchedId(fingerprint, "kugou", best.extra.hash); - return fetchLyric({ + const lyric = await fetchLyric({ hash: best.extra.hash, name: best.name, durationMs: best.duration, }); + if (!lyric) return null; + return { + ...lyric, + candidate: { + platformId: best.extra.hash, + name: best.name, + artist: best.artist, + album: best.album, + duration: best.duration, + }, + }; }; diff --git a/electron/main/apis/common/lyric/netease.ts b/electron/main/apis/common/lyric/netease.ts index d5b6201f..6662889c 100644 --- a/electron/main/apis/common/lyric/netease.ts +++ b/electron/main/apis/common/lyric/netease.ts @@ -10,9 +10,9 @@ import { callNetease } from "@main/apis/netease"; import { getCachedLyric, setCachedLyric } from "@main/database/lyricCache"; -import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache"; +import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache"; import { coreLog } from "@main/utils/logger"; -import type { LyricMatchResult } from "@shared/types/lyrics"; +import type { LyricMatchQueryOptions, LyricMatchResult } from "@shared/types/lyrics"; import type { Track } from "@shared/types/player"; import { prefetchTTML } from "./ttml"; import { buildLyricSearchKeyword, pickBestCandidate, type LyricCandidate } from "./utils"; @@ -88,11 +88,34 @@ export const getByPlatformId = async (id: string): Promise => { +export const getByQuery = async ( + track: Track, + options: LyricMatchQueryOptions = {}, +): Promise => { // 命中映射缓存:跳过 search → 直接走 byId const fingerprint = buildFingerprint(track); const cached = getMatchedId(fingerprint, "netease"); - if (cached) return getByPlatformId(cached.platformId); + if ( + cached && + options.validationKey && + cached.extra?.validationKey === options.validationKey && + !options.excludedIds?.includes(cached.platformId) + ) { + const lyric = await getByPlatformId(cached.platformId); + if (!lyric) return null; + return { + ...lyric, + candidate: { + platformId: cached.platformId, + name: track.title, + artist: track.artists.map((artist) => artist.name).join(" / "), + album: track.album?.name, + duration: track.duration, + extra: cached.extra, + validated: true, + }, + }; + } const keyword = buildLyricSearchKeyword(track); if (!keyword) return null; @@ -119,11 +142,24 @@ export const getByQuery = async (track: Track): Promise coreLog.warn(`[lyric:netease] search("${keyword}") failed:`, err); return null; } - const best = pickBestCandidate(candidates, track); + const best = pickBestCandidate( + candidates.filter((candidate) => !options.excludedIds?.includes(candidate.extra.id)), + track, + ); coreLog.info( `[lyric:netease] fuzzy "${keyword}" → ${candidates.length} hits, best=${best?.name ?? "none"}`, ); if (!best) return null; - setMatchedId(fingerprint, "netease", best.extra.id); - return getByPlatformId(best.extra.id); + const lyric = await getByPlatformId(best.extra.id); + if (!lyric) return null; + return { + ...lyric, + candidate: { + platformId: best.extra.id, + name: best.name, + artist: best.artist, + album: best.album, + duration: best.duration, + }, + }; }; diff --git a/electron/main/apis/common/lyric/qqmusic.ts b/electron/main/apis/common/lyric/qqmusic.ts index c123e4d4..1d7073c2 100644 --- a/electron/main/apis/common/lyric/qqmusic.ts +++ b/electron/main/apis/common/lyric/qqmusic.ts @@ -10,9 +10,9 @@ import { callQQMusic } from "@main/apis/qqmusic"; import { getCachedLyric, setCachedLyric } from "@main/database/lyricCache"; -import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache"; +import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache"; import { coreLog } from "@main/utils/logger"; -import type { LyricMatchResult } from "@shared/types/lyrics"; +import type { LyricMatchQueryOptions, LyricMatchResult } from "@shared/types/lyrics"; import type { Track } from "@shared/types/player"; import { prefetchTTML } from "./ttml"; import { buildLyricSearchKeyword, pickBestCandidate, type LyricCandidate } from "./utils"; @@ -78,10 +78,33 @@ export const getByPlatformId = async ( }; /** 按 Track 元数据模糊搜索:search → 挑最佳 → 单次请求歌词 */ -export const getByQuery = async (track: Track): Promise => { +export const getByQuery = async ( + track: Track, + options: LyricMatchQueryOptions = {}, +): Promise => { const fingerprint = buildFingerprint(track); const cached = getMatchedId(fingerprint, "qqmusic"); - if (cached) return getByPlatformId(cached.platformId, cached.extra?.mid); + if ( + cached && + options.validationKey && + cached.extra?.validationKey === options.validationKey && + !options.excludedIds?.includes(cached.platformId) + ) { + const lyric = await getByPlatformId(cached.platformId, cached.extra?.mid); + if (!lyric) return null; + return { + ...lyric, + candidate: { + platformId: cached.platformId, + name: track.title, + artist: track.artists.map((artist) => artist.name).join(" / "), + album: track.album?.name, + duration: track.duration, + extra: cached.extra, + validated: true, + }, + }; + } const keyword = buildLyricSearchKeyword(track); if (!keyword) return null; @@ -104,11 +127,25 @@ export const getByQuery = async (track: Track): Promise return null; } - const best = pickBestCandidate(candidates, track); + const best = pickBestCandidate( + candidates.filter((candidate) => !options.excludedIds?.includes(candidate.extra.id)), + track, + ); coreLog.info( `[lyric:qqmusic] fuzzy "${keyword}" → ${candidates.length} hits, best=${best?.name ?? "none"}`, ); if (!best) return null; - setMatchedId(fingerprint, "qqmusic", best.extra.id, { mid: best.extra.mid }); - return getByPlatformId(best.extra.id, best.extra.mid); + const lyric = await getByPlatformId(best.extra.id, best.extra.mid); + if (!lyric) return null; + return { + ...lyric, + candidate: { + platformId: best.extra.id, + name: best.name, + artist: best.artist, + album: best.album, + duration: best.duration, + extra: { mid: best.extra.mid }, + }, + }; }; diff --git a/electron/main/apis/common/lyric/utils.test.ts b/electron/main/apis/common/lyric/utils.test.ts index 8a91adc8..c1682882 100644 --- a/electron/main/apis/common/lyric/utils.test.ts +++ b/electron/main/apis/common/lyric/utils.test.ts @@ -59,6 +59,64 @@ describe("pickBestCandidate", () => { assert.equal(best?.extra.id, "fallback"); }); + + it("拒绝时长相差 42 秒的同名同歌手版本", () => { + const candidates: LyricCandidate<{ id: string }>[] = [ + { + name: "同名歌曲", + artist: "目标歌手", + duration: 233_000, + extra: { id: "old-version" }, + }, + ]; + + assert.equal(pickBestCandidate(candidates, track({ duration: 191_000 })), null); + }); + + it("拒绝标题版本标记不一致的候选", () => { + const candidates: LyricCandidate<{ id: string }>[] = [ + { + name: "同名歌曲 Live", + artist: "目标歌手", + duration: 180_000, + extra: { id: "live" }, + }, + ]; + + assert.equal(pickBestCandidate(candidates, track({})), null); + }); + + it("允许中英文等价的现场版标记", () => { + const candidates: LyricCandidate<{ id: string }>[] = [ + { + name: "同名歌曲 Live", + artist: "目标歌手", + duration: 180_000, + extra: { id: "live" }, + }, + ]; + + assert.equal( + pickBestCandidate(candidates, track({ title: "同名歌曲 现场" }))?.extra.id, + "live", + ); + }); + + it("歌曲名恰好为版本关键字时保留原标题", () => { + const candidates: LyricCandidate<{ id: string }>[] = [ + { + name: "Live", + artist: "目标歌手", + duration: 180_000, + extra: { id: "literal-live-title" }, + }, + ]; + + assert.equal( + pickBestCandidate(candidates, track({ title: "Live" }))?.extra.id, + "literal-live-title", + ); + }); }); describe("buildLyricSearchKeyword", () => { diff --git a/electron/main/apis/common/lyric/utils.ts b/electron/main/apis/common/lyric/utils.ts index 5cd1e6a9..ef3fbb8c 100644 --- a/electron/main/apis/common/lyric/utils.ts +++ b/electron/main/apis/common/lyric/utils.ts @@ -76,11 +76,46 @@ const durationClose = (leftMs?: number, rightMs?: number, tolMs = 5000): boolean * 时长差是否大到能确认"不是同一首" * @param leftMs - 左时长(ms) * @param rightMs - 右时长(ms) - * @param tolMs - 容差(ms) */ -const durationFar = (leftMs?: number, rightMs?: number, tolMs = 20000): boolean => { +const durationFar = (leftMs?: number, rightMs?: number): boolean => { if (!leftMs || !rightMs) return false; - return Math.abs(leftMs - rightMs) > tolMs; + return Math.abs(leftMs - rightMs) > Math.max(8000, rightMs * 0.04); +}; + +const VERSION_MARKERS: Array<{ key: string; pattern: RegExp }> = [ + { key: "live", pattern: /\blive\b|现场/i }, + { key: "remix", pattern: /\bremix\b|混音/i }, + { key: "instrumental", pattern: /\binstrumental\b|伴奏/i }, + { key: "acoustic", pattern: /\bacoustic\b|不插电/i }, + { key: "cover", pattern: /\bcover\b|翻唱/i }, + { key: "demo", pattern: /\bdemo\b/i }, + { key: "remaster", pattern: /\bremaster(?:ed)?\b|重制/i }, + { key: "edit", pattern: /\bedit\b/i }, + { key: "new", pattern: /新版/i }, + { key: "old", pattern: /旧版/i }, +]; + +/** 提取标题中的录音版本标记 */ +const versionMarkers = (text: string): string[] => + VERSION_MARKERS.filter(({ pattern }) => pattern.test(text)) + .map(({ key }) => key) + .sort(); + +/** 双方显式版本标记必须一致 */ +const versionMatches = (left: string, right: string): boolean => { + const leftMarkers = versionMarkers(left); + const rightMarkers = versionMarkers(right); + if (leftMarkers.length === 0 && rightMarkers.length === 0) return true; + return leftMarkers.join("|") === rightMarkers.join("|"); +}; + +/** 去掉已经单独比较的版本标记,避免中英文标记影响歌名主体 */ +const stripVersionMarkers = (text: string): string => { + const stripped = VERSION_MARKERS.reduce( + (result, { pattern }) => result.replace(pattern, ""), + text, + ).trim(); + return stripped || text; }; /** 子串命中时短串占长串的最低长度比,过低视为巧合 */ @@ -91,7 +126,8 @@ const NAME_CONTAIN_MIN_RATIO = 0.34; * * 硬性条件(不满足直接跳过) * - name 全等,或双向 includes 且短串占长串比例 ≥ NAME_CONTAIN_MIN_RATIO - * - 双方都给了 duration 时,差距不能超过 20s + * - 双方都给了 duration 时,差距不能超过 max(8s, 曲长 × 4%) + * - 标题中的现场、混音、伴奏等版本标记必须一致 * - track 有 artist 时,候选必须命中至少一个 artist,避免同名异歌手误匹配 * * 打分规则(分数越高越优先) @@ -104,7 +140,7 @@ export const pickBestCandidate = ( candidates: LyricCandidate[], track: Track, ): LyricCandidate | null => { - const trackName = normalize(track.title); + const trackName = normalize(stripVersionMarkers(track.title)); const trackArtists = normalizeTrackArtists(track); const trackAlbum = normalize(track.album?.name); const trackDuration = track.duration; @@ -113,9 +149,11 @@ export const pickBestCandidate = ( let bestScore = 0; for (const candidate of candidates) { - const candName = normalize(candidate.name); + const candName = normalize(stripVersionMarkers(candidate.name)); const candAlbum = normalize(candidate.album); + if (!versionMatches(candidate.name, track.title)) continue; + const nameExact = candName.length > 0 && candName === trackName; if (!nameExact) { if (!bothContains(candName, trackName)) continue; diff --git a/electron/main/database/lyricMatchCache.ts b/electron/main/database/lyricMatchCache.ts index ca22feaf..5c286319 100644 --- a/electron/main/database/lyricMatchCache.ts +++ b/electron/main/database/lyricMatchCache.ts @@ -20,7 +20,7 @@ const TTL_MS = 30 * 24 * 60 * 60 * 1000; /** 时长按 5s 桶归一,避免不同来源元数据微差导致 miss */ const DURATION_BUCKET_MS = 5000; /** 指纹规则版本,变更匹配规则时递增以避开旧误匹配缓存 */ -const FINGERPRINT_VERSION = "v2"; +const FINGERPRINT_VERSION = "v3"; /** 命中记录 */ export interface MatchedRecord { diff --git a/electron/main/ipc/lyrics.ts b/electron/main/ipc/lyrics.ts index 596bc3ca..56ab8aa4 100644 --- a/electron/main/ipc/lyrics.ts +++ b/electron/main/ipc/lyrics.ts @@ -14,9 +14,14 @@ import * as qqmusic from "@main/apis/common/lyric/qqmusic"; import * as kugou from "@main/apis/common/lyric/kugou"; import { fetchTTML } from "@main/apis/common/lyric/ttml"; import { matchLocalTTML } from "@main/services/localLyricRepo"; -import { buildFingerprint, getMatchedId } from "@main/database/lyricMatchCache"; +import { buildFingerprint, getMatchedId, setMatchedId } from "@main/database/lyricMatchCache"; import { coreLog } from "@main/utils/logger"; -import type { LyricMatchResponse, LyricTTMLResponse } from "@shared/types/lyrics"; +import type { + LyricMatchConfirmation, + LyricMatchQueryOptions, + LyricMatchResponse, + LyricTTMLResponse, +} from "@shared/types/lyrics"; import type { Platform } from "@shared/types/platform"; import type { Track } from "@shared/types/player"; @@ -63,15 +68,19 @@ const resolveById = async (platform: Platform, id: string): Promise => { +const resolveByQuery = async ( + platform: Platform, + track: Track, + options: LyricMatchQueryOptions, +): Promise => { try { switch (platform) { case "netease": - return { ok: true, data: await netease.getByQuery(track) }; + return { ok: true, data: await netease.getByQuery(track, options) }; case "qqmusic": - return { ok: true, data: await qqmusic.getByQuery(track) }; + return { ok: true, data: await qqmusic.getByQuery(track, options) }; case "kugou": - return { ok: true, data: await kugou.getByQuery(track) }; + return { ok: true, data: await kugou.getByQuery(track, options) }; default: return { ok: false, error: `unsupported platform: ${platform}` }; } @@ -115,9 +124,22 @@ export const registerLyricsIpc = (): void => { ipcMain.handle("lyrics:matchById", (_evt, platform: Platform, id: string) => dedup(`byId:${platform}:${id}`, () => resolveById(platform, id)), ); - ipcMain.handle("lyrics:matchByQuery", (_evt, platform: Platform, track: Track) => - dedup(`byQuery:${platform}:${track.id}`, () => resolveByQuery(platform, track)), + ipcMain.handle( + "lyrics:matchByQuery", + (_evt, platform: Platform, track: Track, options: LyricMatchQueryOptions = {}) => + dedup( + `byQuery:${platform}:${track.id}:${options.validationKey ?? "none"}:${(options.excludedIds ?? []).join(",")}`, + () => resolveByQuery(platform, track, options), + ), ); + ipcMain.handle("lyrics:confirmMatch", (_evt, confirmation: LyricMatchConfirmation): void => { + const { platform, track, candidate, validationKey } = confirmation; + setMatchedId(buildFingerprint(track), platform, candidate.platformId, { + ...candidate.extra, + validationKey, + }); + coreLog.info(`[lyrics] confirmed ${platform}:${candidate.platformId} for "${track.title}"`); + }); ipcMain.handle("lyrics:fetchTTMLOverlay", (_evt, track: Track, platform: "netease" | "qqmusic") => dedup(`ttml:${platform}:${track.id}`, () => resolveTTMLOverlay(track, platform)), ); diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 1fcf4b2d..4072d734 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -16,6 +16,7 @@ import type { TagEditRequest } from "@shared/types/tagEditor"; import type { UpdateEvent } from "@shared/types/update"; import type { CloudUploadProgress } from "@shared/types/cloudUpload"; import type { MusicCommentQuery } from "@shared/types/comment"; +import type { LyricMatchConfirmation, LyricMatchQueryOptions } from "@shared/types/lyrics"; /** 订阅主进程推送的事件 */ const subscribe = (channel: string, callback: (data: T) => void): (() => void) => { @@ -355,8 +356,11 @@ const api = { matchById: (platform: string, id: string) => ipcRenderer.invoke("lyrics:matchById", platform, id), // 按 Track 元数据在某平台模糊搜索歌词 - matchByQuery: (platform: string, track: unknown) => - ipcRenderer.invoke("lyrics:matchByQuery", platform, track), + matchByQuery: (platform: string, track: unknown, options?: LyricMatchQueryOptions) => + ipcRenderer.invoke("lyrics:matchByQuery", platform, track, options), + // 歌词一致性校验通过后写入模糊匹配缓存 + confirmMatch: (confirmation: LyricMatchConfirmation) => + ipcRenderer.invoke("lyrics:confirmMatch", confirmation), // 获取 AMLL TTML DB 的 TTML fetchTTMLOverlay: (track: unknown, platform: string) => ipcRenderer.invoke("lyrics:fetchTTMLOverlay", track, platform), diff --git a/shared/types/lyrics.ts b/shared/types/lyrics.ts index a2bb6fca..37526f82 100644 --- a/shared/types/lyrics.ts +++ b/shared/types/lyrics.ts @@ -86,6 +86,22 @@ export interface LyricInput { export interface LyricMatchExtra { /** QM 的 mid */ mid?: string; + /** 校验通过时使用的基准歌词摘要 */ + validationKey?: string; +} + +/** 模糊搜索命中的候选元数据 */ +export interface LyricMatchCandidate { + /** 候选平台 ID */ + platformId: string; + name: string; + artist: string; + album?: string; + /** 毫秒 */ + duration?: number; + extra?: LyricMatchExtra; + /** 是否来自已经通过当前基准校验的匹配缓存 */ + validated?: boolean; } /** 歌词匹配结果 */ @@ -93,8 +109,26 @@ export interface LyricMatchResult extends LyricInput { platform: Platform; /** 主歌词格式 */ format: LyricFormat; - /** 平台额外字段,netease/kugou 暂未使用 */ + /** 平台额外字段与匹配校验信息 */ extra?: LyricMatchExtra; + /** 仅模糊搜索结果携带,用于歌词一致性校验与确认缓存 */ + candidate?: LyricMatchCandidate; +} + +/** 模糊搜索选项 */ +export interface LyricMatchQueryOptions { + /** 本轮已拒绝的平台 ID */ + excludedIds?: string[]; + /** 基准歌词摘要,只复用相同基准下验证过的缓存 */ + validationKey?: string; +} + +/** 确认模糊匹配 */ +export interface LyricMatchConfirmation { + platform: Platform; + track: Track; + candidate: LyricMatchCandidate; + validationKey: string; } /** 歌词匹配 IPC 响应 */ @@ -110,7 +144,13 @@ export interface LyricsApi { /** 按 id 直取某平台歌词 */ matchById: (platform: Platform, id: string) => Promise; /** 按 Track 元数据在某平台模糊搜索歌词 */ - matchByQuery: (platform: Platform, track: Track) => Promise; + matchByQuery: ( + platform: Platform, + track: Track, + options?: LyricMatchQueryOptions, + ) => Promise; + /** 歌词一致性校验通过后持久化模糊匹配 */ + confirmMatch: (confirmation: LyricMatchConfirmation) => Promise; /** 抓取 AMLL TTML DB 的 TTML 歌词,仅 NCM/QM 适用 */ fetchTTMLOverlay: (track: Track, platform: "netease" | "qqmusic") => Promise; /** 在本地 TTML 歌词库中按元信息匹配,命中返回 TTML 原文 */ diff --git a/src/components/layout/SideBar.vue b/src/components/layout/SideBar.vue index 6c5c68ee..48941a5f 100644 --- a/src/components/layout/SideBar.vue +++ b/src/components/layout/SideBar.vue @@ -58,9 +58,7 @@ const handleCreate = (): void => { /** 新建成功后跳转到该歌单 */ const handleCreated = (playlistId: string, scope: ContentScope): void => { - router.push( - `/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`, - ); + router.push(`/collection/${scope === "local" ? "local" : "netease"}/playlist/${playlistId}`); }; /** 我的歌单分组头部 */ diff --git a/src/services/lyricLoader.ts b/src/services/lyricLoader.ts index 3ae50a3a..9670671a 100644 --- a/src/services/lyricLoader.ts +++ b/src/services/lyricLoader.ts @@ -242,6 +242,10 @@ export const loadForTrack = async (detail: TrackDetail | null): Promise => const online = await resolveOnlineByPreference(track, { hasLocal: hasUsableLocal, localFormat, + reference: + hasUsableLocal && local + ? { source: local.source, input: { content: local.content } } + : undefined, onCandidate: (result) => commit(token, result.source, result.input), shouldContinue: () => token === currentToken, }); @@ -288,6 +292,7 @@ const refreshPreference = async (): Promise => { const online = await resolveOnlineByPreference(track, { hasLocal: !!local, localFormat, + reference: local ? { source: local.source, input: { content: local.content } } : undefined, onCandidate: (result) => commit(token, result.source, result.input), shouldContinue: () => token === currentToken, }); diff --git a/src/services/lyricResolve.ts b/src/services/lyricResolve.ts index 7b939cae..e82c8464 100644 --- a/src/services/lyricResolve.ts +++ b/src/services/lyricResolve.ts @@ -7,11 +7,13 @@ import { useSettingsStore } from "@/stores/settings"; import { useStreamingStore } from "@/stores/streaming"; import { usePluginsStore } from "@/stores/plugins"; import { DEFAULT_LYRIC_FORMAT_ORDER, DEFAULT_LYRIC_SOURCE_ORDER } from "@/types/settings"; +import { buildLyricValidationKey, evaluateLyricMatch } from "@/utils/lyric/matchQuality"; /** 一次在线 fetch 的结果 */ export interface OnlineResult { source: { source: "online"; format: LyricFormat; platform: Platform }; input: LyricInput; + candidate?: LyricMatchResult["candidate"]; } /** 已解析的原始歌词候选 */ @@ -36,6 +38,7 @@ export const toLyricInput = (data: LyricMatchResult): LyricInput => ({ export const toOnlineResult = (data: LyricMatchResult): OnlineResult => ({ source: { source: "online", format: data.format, platform: data.platform }, input: toLyricInput(data), + candidate: data.candidate, }); /** 提取内嵌歌词兜底 */ @@ -54,16 +57,57 @@ export const embeddedLyricFromDetail = (detail: TrackDetail | null): LocalLyric export const fetchFromPlatform = async ( platform: Platform, track: Track, + reference?: ResolvedLyric, ): Promise => { const mode = track.source === platform ? "byId" : "byQuery"; // QM lyric 接口要数字 songID const lookupId = platform === "qqmusic" ? (track.extId ?? track.id) : track.id; - const resp = - mode === "byId" - ? await window.api.lyrics.matchById(platform, lookupId) - : await window.api.lyrics.matchByQuery(platform, track); - if (!resp.ok || !resp.data) return null; - return toOnlineResult(resp.data); + if (mode === "byId") { + const resp = await window.api.lyrics.matchById(platform, lookupId); + if (!resp.ok || !resp.data) return null; + return toOnlineResult(resp.data); + } + + const excludedIds: string[] = []; + const validationKey = reference + ? buildLyricValidationKey(reference.input, reference.source.format) + : undefined; + for (let attempt = 0; attempt < 3; attempt++) { + const resp = await window.api.lyrics.matchByQuery(platform, track, { + excludedIds, + validationKey, + }); + if (!resp.ok || !resp.data) return null; + const result = toOnlineResult(resp.data); + if (!reference) return result; + if (!result.candidate) return null; + + const decision = evaluateLyricMatch( + reference.input, + reference.source.format, + result.input, + result.source.format, + track.duration, + result.candidate.duration, + ); + console.info( + `[lyrics] ${platform}:${result.candidate.platformId} ${decision.status}/${decision.reason}`, + decision.metrics, + ); + if (decision.status === "accepted") { + if (!result.candidate.validated) { + await window.api.lyrics.confirmMatch({ + platform, + track: toRaw(track), + candidate: result.candidate, + validationKey: decision.validationKey, + }); + } + return result; + } + excludedIds.push(result.candidate.platformId); + } + return null; }; /** 平台主格式可达列表 */ @@ -110,6 +154,7 @@ const isOnlineResultUpgrade = (result: OnlineResult, localFormat: LyricFormat): interface OnlinePreferenceOptions { hasLocal: boolean; localFormat: LyricFormat | null; + reference?: ResolvedLyric; onCandidate?: (result: OnlineResult) => void; shouldContinue?: () => boolean; } @@ -126,27 +171,33 @@ export const resolveOnlineByPreference = async ( const settings = useSettingsStore(); const preference = settings.lyric.lyricSourcePreference; const isCurrent = options.shouldContinue ?? (() => true); + const sourceResult = isPlatform(track.source) + ? await fetchFromPlatform(track.source, track) + : null; + if (!isCurrent()) return null; + const reference: ResolvedLyric | undefined = options.reference ?? sourceResult ?? undefined; if (preference === "self") { - if (isPlatform(track.source)) return fetchFromPlatform(track.source, track); - return null; + return sourceResult; } if (preference === "auto") { const order = settings.lyric.lyricSourceOrder ?? DEFAULT_LYRIC_SOURCE_ORDER; const formatOrder = settings.lyric.lyricFormatOrder ?? DEFAULT_LYRIC_FORMAT_ORDER; - let candidates: Platform[] = [...order]; - if (options.hasLocal) { - if (!settings.lyric.smartPreferOnline || !options.localFormat) return null; - candidates = order.filter((p) => platformCanUpgrade(p, options.localFormat!, formatOrder)); - if (candidates.length === 0) return null; + const baselineFormat = options.localFormat ?? sourceResult?.source.format ?? null; + let candidates: Platform[] = order.filter((platform) => platform !== track.source); + if (options.hasLocal && !settings.lyric.smartPreferOnline) return null; + if (settings.lyric.smartPreferOnline && baselineFormat) { + candidates = candidates.filter((platform) => + platformCanUpgrade(platform, baselineFormat, formatOrder), + ); } + if (candidates.length === 0) return sourceResult; if (settings.lyric.smartPreferOnline) { - let best: OnlineResult | null = null; - const localIdx = - options.hasLocal && options.localFormat ? formatOrder.indexOf(options.localFormat) : -1; - let bestRank = localIdx === -1 ? Infinity : localIdx; + let best: OnlineResult | null = sourceResult; + const baselineIdx = baselineFormat ? formatOrder.indexOf(baselineFormat) : -1; + let bestRank = baselineIdx === -1 ? Infinity : baselineIdx; await Promise.all( candidates.map(async (platform) => { - const result = await fetchFromPlatform(platform, track); + const result = await fetchFromPlatform(platform, track, reference); if (!isCurrent() || !result) return; const idx = formatOrder.indexOf(result.source.format); const rank = idx === -1 ? Infinity : idx; @@ -161,7 +212,7 @@ export const resolveOnlineByPreference = async ( return best; } for (const platform of candidates) { - const result = await fetchFromPlatform(platform, track); + const result = await fetchFromPlatform(platform, track, reference); if (!isCurrent()) return null; if (!result) continue; if ( @@ -173,9 +224,10 @@ export const resolveOnlineByPreference = async ( } return result; } - return null; + return sourceResult; } - return fetchFromPlatform(preference, track); + if (preference === track.source) return sourceResult; + return (await fetchFromPlatform(preference, track, reference)) ?? sourceResult; }; /** @@ -211,10 +263,23 @@ export const resolveTTMLOverlay = async ( if (!shouldTryTTML(online.source.platform, online.source.format)) return null; const resp = await window.api.lyrics.fetchTTMLOverlay(track, online.source.platform); if (!resp.ok || !resp.data) return null; - return { + const resolved: ResolvedLyric = { source: { source: "online", format: "ttml", platform: online.source.platform }, input: { content: resp.data }, }; + const decision = evaluateLyricMatch( + online.input, + online.source.format, + resolved.input, + resolved.source.format, + track.duration, + track.duration, + ); + console.info( + `[lyrics] ttml:${online.source.platform} ${decision.status}/${decision.reason}`, + decision.metrics, + ); + return decision.status === "accepted" ? resolved : null; }; /** diff --git a/src/utils/lyric/matchQuality.test.ts b/src/utils/lyric/matchQuality.test.ts new file mode 100644 index 00000000..d5f5228a --- /dev/null +++ b/src/utils/lyric/matchQuality.test.ts @@ -0,0 +1,97 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { evaluateLyricMatch } from "./matchQuality"; + +const buildYrc = (texts: string[], timing: (index: number) => number): string => + texts + .map((text, index) => { + const start = timing(index); + return `[${start},2000](${start},2000,0)${text}`; + }) + .join("\n"); + +const buildQrc = (texts: string[], timing: (index: number) => number): string => + texts + .map((text, index) => { + const start = timing(index); + return `[${start},2000]${text}(${start},2000)`; + }) + .join("\n"); + +const lyrics = Array.from({ length: 12 }, (_, index) => `这是第${index + 1}句完整歌词`); + +describe("evaluateLyricMatch", () => { + it("接受正文相同且时间轴只有轻微误差的跨平台歌词", () => { + const result = evaluateLyricMatch( + { content: buildYrc(lyrics, (index) => index * 10_000) }, + "yrc", + { content: buildQrc(lyrics, (index) => index * 10_000 + 500) }, + "qrc", + 120_000, + 121_000, + ); + + assert.equal(result.status, "accepted"); + assert.equal(result.reason, "matched"); + }); + + it("拒绝 3:11 新版匹配到 3:53 旧版", () => { + const result = evaluateLyricMatch( + { content: buildYrc(lyrics, (index) => index * 10_000) }, + "yrc", + { content: buildQrc(lyrics, (index) => index * 10_000) }, + "qrc", + 191_000, + 233_000, + ); + + assert.equal(result.status, "rejected"); + assert.equal(result.reason, "duration_mismatch"); + }); + + it("拒绝同名但正文不同的歌词", () => { + const otherLyrics = lyrics.map((_, index) => `完全不同的改编内容${index + 1}`); + const result = evaluateLyricMatch( + { content: buildYrc(lyrics, (index) => index * 10_000) }, + "yrc", + { content: buildQrc(otherLyrics, (index) => index * 10_000) }, + "qrc", + 120_000, + 120_000, + ); + + assert.equal(result.status, "rejected"); + assert.equal(result.reason, "content_mismatch"); + }); + + it("拒绝正文相同但演唱速度明显不同的版本", () => { + const result = evaluateLyricMatch( + { content: buildYrc(lyrics, (index) => index * 10_000) }, + "yrc", + { content: buildQrc(lyrics, (index) => index * 11_000) }, + "qrc", + 120_000, + 120_000, + ); + + assert.equal(result.status, "rejected"); + assert.equal(result.reason, "timeline_mismatch"); + }); + + it("允许相邻两行在另一平台合并为一行", () => { + const merged = Array.from({ length: 6 }, (_, index) => + lyrics.slice(index * 2, index * 2 + 2).join(""), + ); + const result = evaluateLyricMatch( + { content: buildYrc(lyrics, (index) => index * 10_000) }, + "yrc", + { content: buildQrc(merged, (index) => index * 20_000) }, + "qrc", + 120_000, + 120_000, + ); + + assert.equal(result.status, "accepted"); + }); +}); diff --git a/src/utils/lyric/matchQuality.ts b/src/utils/lyric/matchQuality.ts new file mode 100644 index 00000000..4afa7692 --- /dev/null +++ b/src/utils/lyric/matchQuality.ts @@ -0,0 +1,287 @@ +import type { LyricFormat, LyricInput, LyricLine } from "@shared/types/lyrics"; +import { parseLyric } from "./parse"; + +const MIN_CONTENT_SCORE = 0.82; +const MAX_ANCHOR_DIFF_MS = 3000; +const MAX_P90_DIFF_MS = 2500; +const MAX_TIMELINE_DRIFT_MS = 4000; +const MIN_TIMELINE_RATIO = 0.985; +const MAX_TIMELINE_RATIO = 1.015; +const MIN_LINE_SIMILARITY = 0.68; +const METADATA_LINE_RE = + /^(?:作词|作曲|编曲|制作人|混音|母带|录音|词|曲|composer|lyricist|arranger|producer|mixed by)\s*[::]/i; + +interface ComparableLine { + text: string; + startTime: number; + endTime: number; +} + +interface MatchAnchor { + referenceStart: number; + candidateStart: number; +} + +export interface LyricMatchMetrics { + durationDiffMs?: number; + durationLimitMs?: number; + contentScore?: number; + anchorCount?: number; + timelineCoverage?: number; + withinTimeRatio?: number; + p90DiffMs?: number; + timelineDriftMs?: number; + timelineRatio?: number; +} + +export interface LyricMatchDecision { + status: "accepted" | "rejected" | "uncertain"; + reason: string; + validationKey: string; + metrics: LyricMatchMetrics; +} + +/** 归一化歌词正文,仅用于跨来源一致性比较 */ +const normalizeText = (text: string): string => + text + .normalize("NFKC") + .toLowerCase() + .replace(/[\p{P}\p{S}\s]+/gu, ""); + +/** 提取参与匹配的主歌词行 */ +const toComparableLines = (lines: LyricLine[]): ComparableLine[] => + lines.flatMap((line) => { + if (line.isBG) return []; + const raw = line.words + .map((word) => word.word) + .join("") + .trim(); + if (!raw || METADATA_LINE_RE.test(raw)) return []; + const text = normalizeText(raw); + if (text.length < 2) return []; + return [{ text, startTime: line.startTime, endTime: line.endTime }]; + }); + +/** 计算两个短文本的归一化编辑相似度 */ +const textSimilarity = (left: string, right: string): number => { + if (left === right) return 1; + if (!left || !right) return 0; + if (left.length < right.length) return textSimilarity(right, left); + const previous = new Uint16Array(right.length + 1); + const current = new Uint16Array(right.length + 1); + for (let j = 0; j <= right.length; j++) previous[j] = j; + for (let i = 1; i <= left.length; i++) { + current[0] = i; + for (let j = 1; j <= right.length; j++) { + current[j] = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + (left[i - 1] === right[j - 1] ? 0 : 1), + ); + } + previous.set(current); + } + return 1 - previous[right.length] / Math.max(left.length, right.length); +}; + +const spanLength = (lines: ComparableLine[], start: number, count: number): number => + lines[start].text.length + (count === 2 ? lines[start + 1].text.length : 0); + +const joinSpan = (lines: ComparableLine[], start: number, count: number): string => + count === 1 ? lines[start].text : lines[start].text + lines[start + 1].text; + +/** + * 有序对齐歌词行,并允许相邻两行互相合并,兼容不同平台的断句差异 + * @returns 文本匹配权重与时间锚点 + */ +const alignLines = ( + reference: ComparableLine[], + candidate: ComparableLine[], +): { score: number; anchors: MatchAnchor[] } => { + const width = candidate.length + 1; + const size = (reference.length + 1) * width; + const scores = new Float64Array(size); + const prevReference = new Uint8Array(size); + const prevCandidate = new Uint8Array(size); + + const update = ( + fromReference: number, + fromCandidate: number, + referenceCount: number, + candidateCount: number, + addedScore: number, + ): void => { + const nextReference = fromReference + referenceCount; + const nextCandidate = fromCandidate + candidateCount; + const from = fromReference * width + fromCandidate; + const next = nextReference * width + nextCandidate; + const score = scores[from] + addedScore; + if (score <= scores[next]) return; + scores[next] = score; + prevReference[next] = referenceCount; + prevCandidate[next] = candidateCount; + }; + + for (let i = 0; i <= reference.length; i++) { + for (let j = 0; j <= candidate.length; j++) { + if (i < reference.length) update(i, j, 1, 0, 0); + if (j < candidate.length) update(i, j, 0, 1, 0); + for (let referenceCount = 1; referenceCount <= 2; referenceCount++) { + if (i + referenceCount > reference.length) break; + for (let candidateCount = 1; candidateCount <= 2; candidateCount++) { + if (j + candidateCount > candidate.length) break; + const referenceLength = spanLength(reference, i, referenceCount); + const candidateLength = spanLength(candidate, j, candidateCount); + if ( + Math.min(referenceLength, candidateLength) / + Math.max(referenceLength, candidateLength) < + MIN_LINE_SIMILARITY + ) { + continue; + } + const referenceText = joinSpan(reference, i, referenceCount); + const candidateText = joinSpan(candidate, j, candidateCount); + const similarity = textSimilarity(referenceText, candidateText); + if (similarity < MIN_LINE_SIMILARITY) continue; + update( + i, + j, + referenceCount, + candidateCount, + Math.min(referenceText.length, candidateText.length) * similarity, + ); + } + } + } + } + + const anchors: MatchAnchor[] = []; + let i = reference.length; + let j = candidate.length; + while (i > 0 || j > 0) { + const index = i * width + j; + const referenceCount = prevReference[index]; + const candidateCount = prevCandidate[index]; + if (referenceCount === 0 && candidateCount === 0) break; + const previousI = i - referenceCount; + const previousJ = j - candidateCount; + if (referenceCount > 0 && candidateCount > 0) { + anchors.push({ + referenceStart: reference[previousI].startTime, + candidateStart: candidate[previousJ].startTime, + }); + } + i = previousI; + j = previousJ; + } + anchors.reverse(); + return { score: scores[size - 1], anchors }; +}; + +/** 为基准歌词生成轻量摘要,绑定已经验证的模糊匹配缓存 */ +export const buildLyricValidationKey = (input: LyricInput, format: LyricFormat): string => { + const lines = toComparableLines(parseLyric(input, format)); + const payload = lines.map((line) => `${Math.round(line.startTime / 500)}:${line.text}`).join("|"); + let hash = 2166136261; + for (let i = 0; i < payload.length; i++) { + hash ^= payload.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return `lyric-v1:${(hash >>> 0).toString(16)}`; +}; + +/** + * 检测高阶歌词是否与基准歌词属于同一录音版本 + * @param referenceInput - 基准歌词 + * @param referenceFormat - 基准歌词格式 + * @param candidateInput - 候选歌词 + * @param candidateFormat - 候选歌词格式 + * @param referenceDuration - 基准歌曲时长(毫秒) + * @param candidateDuration - 候选歌曲时长(毫秒) + */ +export const evaluateLyricMatch = ( + referenceInput: LyricInput, + referenceFormat: LyricFormat, + candidateInput: LyricInput, + candidateFormat: LyricFormat, + referenceDuration?: number, + candidateDuration?: number, +): LyricMatchDecision => { + const validationKey = buildLyricValidationKey(referenceInput, referenceFormat); + const metrics: LyricMatchMetrics = {}; + + if (referenceDuration && candidateDuration) { + metrics.durationDiffMs = Math.abs(referenceDuration - candidateDuration); + metrics.durationLimitMs = Math.max(8000, referenceDuration * 0.04); + if (metrics.durationDiffMs > metrics.durationLimitMs) { + return { status: "rejected", reason: "duration_mismatch", validationKey, metrics }; + } + } + + const reference = toComparableLines(parseLyric(referenceInput, referenceFormat)); + const candidate = toComparableLines(parseLyric(candidateInput, candidateFormat)); + if (reference.length < 3 || candidate.length < 3) { + return { status: "uncertain", reason: "insufficient_lines", validationKey, metrics }; + } + + const aligned = alignLines(reference, candidate); + const referenceChars = reference.reduce((sum, line) => sum + line.text.length, 0); + const candidateChars = candidate.reduce((sum, line) => sum + line.text.length, 0); + metrics.contentScore = (2 * aligned.score) / (referenceChars + candidateChars); + if (metrics.contentScore < MIN_CONTENT_SCORE) { + return { status: "rejected", reason: "content_mismatch", validationKey, metrics }; + } + + const anchors = aligned.anchors.filter( + (anchor) => Number.isFinite(anchor.referenceStart) && Number.isFinite(anchor.candidateStart), + ); + metrics.anchorCount = anchors.length; + const timelineDuration = + referenceDuration || + reference[reference.length - 1].endTime || + reference.at(-1)?.startTime || + 0; + const minAnchors = timelineDuration > 0 && timelineDuration < 60_000 ? 3 : 6; + if (anchors.length < minAnchors) { + return { status: "uncertain", reason: "insufficient_anchors", validationKey, metrics }; + } + + const first = anchors[0]; + const last = anchors[anchors.length - 1]; + const referenceSpan = last.referenceStart - first.referenceStart; + const candidateSpan = last.candidateStart - first.candidateStart; + metrics.timelineCoverage = timelineDuration > 0 ? referenceSpan / timelineDuration : 0; + const minimumCoverage = timelineDuration > 0 && timelineDuration < 60_000 ? 0.35 : 0.5; + if (metrics.timelineCoverage < minimumCoverage) { + return { + status: "uncertain", + reason: "insufficient_timeline_coverage", + validationKey, + metrics, + }; + } + + const differences = anchors + .map((anchor) => Math.abs(anchor.candidateStart - anchor.referenceStart)) + .sort((left, right) => left - right); + metrics.withinTimeRatio = + differences.filter((difference) => difference <= MAX_ANCHOR_DIFF_MS).length / + differences.length; + metrics.p90DiffMs = differences[Math.max(0, Math.ceil(differences.length * 0.9) - 1)]; + const firstOffset = first.candidateStart - first.referenceStart; + const lastOffset = last.candidateStart - last.referenceStart; + metrics.timelineDriftMs = Math.abs(lastOffset - firstOffset); + metrics.timelineRatio = referenceSpan > 0 ? candidateSpan / referenceSpan : 0; + + if ( + metrics.withinTimeRatio < 0.8 || + metrics.p90DiffMs > MAX_P90_DIFF_MS || + metrics.timelineDriftMs > MAX_TIMELINE_DRIFT_MS || + metrics.timelineRatio < MIN_TIMELINE_RATIO || + metrics.timelineRatio > MAX_TIMELINE_RATIO + ) { + return { status: "rejected", reason: "timeline_mismatch", validationKey, metrics }; + } + + return { status: "accepted", reason: "matched", validationKey, metrics }; +};