feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
+298
View File
@@ -0,0 +1,298 @@
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 }
}