import { parseTTML, parseYrc } from '@applemusic-like-lyrics/lyric' import { cleanTTMLTranslations, parseQRCLyric, parseSmartLrc } from './lyricParser' 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 export type LyricMatchData = { lrc?: string yrc?: string ttml?: string } type ComparableLine = { text: string startTime: number endTime: number } type MatchAnchor = { referenceStart: number candidateStart: number } export type LyricMatchMetrics = { durationDiffMs?: number durationLimitMs?: number contentScore?: number anchorCount?: number timelineCoverage?: number withinTimeRatio?: number p90DiffMs?: number timelineDriftMs?: number timelineRatio?: number } export type LyricMatchDecision = { status: 'accepted' | 'rejected' | 'uncertain' reason: string metrics: LyricMatchMetrics } /** 归一化歌词正文,仅用于跨来源一致性比较 */ const normalizeText = (text: string): string => text .normalize('NFKC') .toLowerCase() .replace(/[\p{P}\p{S}\s]+/gu, '') /** 将不同解析器的行结构转为一致的比较结构 */ const toComparableLines = (lines: any[]): ComparableLine[] => lines.flatMap((line) => { if (line?.isBG) return [] const raw = Array.isArray(line?.words) ? line.words .map((word: any) => word?.word ?? word?.content ?? '') .join('') .trim() : String(line?.content ?? '').trim() if (!raw || METADATA_LINE_RE.test(raw)) return [] const text = normalizeText(raw) if (text.length < 2) return [] const startTime = Number(line?.startTime ?? line?.time ?? 0) const endTime = Number(line?.endTime ?? startTime) return [{ text, startTime, endTime }] }) /** 按格式优先级解析歌词正文 */ const parseComparableLyrics = (data: LyricMatchData): ComparableLine[] => { if (data.ttml?.trim()) { try { const parsed = parseTTML(cleanTTMLTranslations(data.ttml)) as any const lines = toComparableLines(parsed?.lines ?? []) if (lines.length > 0) return lines } catch { // 继续尝试逐字或普通歌词 } } if (data.yrc?.trim()) { const raw = data.yrc.trim() try { if (raw.startsWith('<')) { const lines = toComparableLines(parseQRCLyric(raw)) if (lines.length > 0) return lines } else { const lines = toComparableLines(parseYrc(raw) ?? []) if (lines.length > 0) return lines } } catch { // 继续回退到通用 LRC } } if (data.lrc?.trim()) { try { return toComparableLines(parseSmartLrc(data.lrc).lines) } catch { return [] } } return [] } /** 计算两个短文本的归一化编辑相似度 */ 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 /** 有序对齐歌词行,并允许相邻两行互相合并 */ 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 ) => { 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 evaluateLyricDataMatch = ( referenceData: LyricMatchData, candidateData: LyricMatchData, referenceDuration?: number, candidateDuration?: number ): LyricMatchDecision => { 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', metrics } } } const reference = parseComparableLyrics(referenceData) const candidate = parseComparableLyrics(candidateData) if (reference.length < 3 || candidate.length < 3) { return { status: 'uncertain', reason: 'insufficient_lines', 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', metrics } } const anchors = aligned.anchors.filter( (anchor) => Number.isFinite(anchor.referenceStart) && Number.isFinite(anchor.candidateStart) ) metrics.anchorCount = anchors.length const lastReference = reference[reference.length - 1] const timelineDuration = referenceDuration || lastReference.endTime || lastReference.startTime || 0 const minAnchors = timelineDuration > 0 && timelineDuration < 60000 ? 3 : 6 if (anchors.length < minAnchors) { return { status: 'uncertain', reason: 'insufficient_anchors', 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 < 60000 ? 0.35 : 0.5 if (metrics.timelineCoverage < minimumCoverage) { return { status: 'uncertain', reason: 'insufficient_timeline_coverage', 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', metrics } } return { status: 'accepted', reason: 'matched', metrics } }