Files
BYW-VoiceHub/app/utils/lyric/lyricParser.ts
T

533 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { cloneDeep } from 'lodash-es'
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
import { parseTTML as parseTTMLLib } from '@applemusic-like-lyrics/lyric'
import { extractLyricContent } from './qrc-parser'
import { parseLrc } from './parseLrc'
/**
* LRC 格式类型
*/
export enum LrcFormat {
/** 普通逐行 LRC */
Line = 'line',
/** 逐字 LRC:[00:28.850]曲[00:32.455]:[00:36.060]钱 */
WordByWord = 'word-by-word',
/** 增强型 LRC (ESLyric):[01:37.305]<01:37.624>怕<01:37.943>你 */
Enhanced = 'enhanced'
}
/** LyricWord 类型 */
type LyricWord = { word: string; startTime: number; endTime: number; romanWord: string }
// 预编译正则表达式
const META_TAG_REGEX = /^\[[a-z]+:/i
const TIME_TAG_REGEX = /\[(\d{2}):(\d{2})\.(\d{1,})\]/g
const ENHANCED_TIME_TAG_REGEX = /<(\d{2}):(\d{2})\.(\d{1,})>/
// 移除全局带状态的正则,改为在函数内使用 matchAll 或重新构建
const LINE_TIME_REGEX = /^\[(\d{2}):(\d{2})\.(\d{1,})\]/
// QRC 解析相关正则 - 提前编译
const QRC_LINE_PATTERN = /^\[(\d+),(\d+)\](.*)$/
const QRC_WORD_PATTERN = /([^(]*)\((\d+),(\d+)\)/g
const DEFAULT_WORD_DURATION = 1000
const ALIGN_TOLERANCE_MS = 300
/** 二次最近邻对齐的漂移容差:兜底时间轴整体漂移较大的来源 */
const DRIFT_TOLERANCE_MS = 1500
/**
* 解析时间戳为毫秒
* 使用字符串补齐处理,避免浮点数计算误差
*/
const parseTimeToMs = (min: string, sec: string, ms: string): number => {
const minutes = parseInt(min, 10)
const seconds = parseInt(sec, 10)
// 补齐到 3 位 (例如 "5" -> "500", "05" -> "050", "1234" -> "123")
const msNormalized = ms.padEnd(3, '0').slice(0, 3)
const milliseconds = parseInt(msNormalized, 10)
return minutes * 60 * 1000 + seconds * 1000 + milliseconds
}
/**
* 创建 LyricWord 对象
*/
const createWord = (word: string, startTime: number, endTime: number = startTime): LyricWord => ({
word,
startTime,
endTime,
romanWord: ''
})
/**
* 创建 LyricLine 对象
*/
const createLine = (words: LyricWord[], startTime: number, endTime: number = 0): LyricLine => ({
words,
startTime,
endTime,
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
})
/**
* 检测 LRC 格式类型
*/
export const detectLrcFormat = (content: string): LrcFormat => {
const lines = content.split(/\r?\n/)
for (const rawLine of lines) {
const line = rawLine.trim()
if (!line || META_TAG_REGEX.test(line)) continue
// 检查增强型LRC
if (ENHANCED_TIME_TAG_REGEX.test(line)) {
return LrcFormat.Enhanced
}
// 检查逐字LRC
const matches = line.match(TIME_TAG_REGEX)
if (matches && matches.length > 1) {
return LrcFormat.WordByWord
}
}
return LrcFormat.Line
}
/**
* 解析逐字 LRC 格式
* 优化:在解析过程中直接计算 endTime,避免二次遍历
*/
export const parseWordByWordLrc = (content: string): LyricLine[] => {
const result: LyricLine[] = []
let prevLine: LyricLine | null = null
const WORD_BY_WORD_PATTERN = /\[(\d{2}):(\d{2})\.(\d{1,})\]([^[\\]]*)/g
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || META_TAG_REGEX.test(line)) continue
const words: LyricWord[] = []
let lineStartTime = Infinity
let prevWord: LyricWord | null = null
const matches = line.matchAll(WORD_BY_WORD_PATTERN)
for (const match of matches) {
const startTime = parseTimeToMs(match[1], match[2], match[3])
const wordText = match[4]
if (!wordText && words.length === 0) continue
lineStartTime = Math.min(lineStartTime, startTime)
// 设置上一个字的结束时间
if (prevWord) {
prevWord.endTime = startTime
}
if (wordText) {
const newWord = createWord(wordText, startTime)
words.push(newWord)
prevWord = newWord
}
}
// 处理行内最后一个字
if (prevWord) {
prevWord.endTime = prevWord.startTime + DEFAULT_WORD_DURATION
}
if (words.length > 0) {
const lineObj = createLine(words, lineStartTime === Infinity ? 0 : lineStartTime)
// 设置行结束时间为最后一个字的结束时间
lineObj.endTime = words[words.length - 1].endTime
// 修正上一行的结束时间 (Single Pass)
if (prevLine) {
const prevLastWord = prevLine.words[prevLine.words.length - 1]
// 只有当当前行开始时间晚于上一行最后一个字的开始时间时,才进行截断
if (lineObj.startTime > prevLastWord.startTime) {
prevLastWord.endTime = Math.min(prevLastWord.endTime, lineObj.startTime)
prevLine.endTime = prevLastWord.endTime
}
}
result.push(lineObj)
prevLine = lineObj
}
}
return result
}
/**
* 解析增强型 LRC 格式 (ESLyric)
*/
export const parseEnhancedLrc = (content: string): LyricLine[] => {
const result: LyricLine[] = []
let prevLine: LyricLine | null = null
const ENHANCED_WORD_PATTERN = /<(\d{2}):(\d{2})\.(\d{1,})>([^<]*)/g
for (const rawLine of content.split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || META_TAG_REGEX.test(line)) continue
const lineTimeMatch = LINE_TIME_REGEX.exec(line)
if (!lineTimeMatch) continue
const lineStartTime = parseTimeToMs(lineTimeMatch[1], lineTimeMatch[2], lineTimeMatch[3])
const contentAfterTime = line.slice(lineTimeMatch[0].length)
const words: LyricWord[] = []
// 检查是否有增强型标记
if (ENHANCED_TIME_TAG_REGEX.test(contentAfterTime)) {
let prevWord: LyricWord | null = null
const matches = contentAfterTime.matchAll(ENHANCED_WORD_PATTERN)
for (const match of matches) {
const startTime = parseTimeToMs(match[1], match[2], match[3])
const wordText = match[4]
if (prevWord) {
prevWord.endTime = startTime
}
if (wordText) {
const newWord = createWord(wordText, startTime)
words.push(newWord)
prevWord = newWord
}
}
if (prevWord) {
prevWord.endTime = prevWord.startTime + DEFAULT_WORD_DURATION // 默认兜底
}
} else {
// 无增强型标记,作为整行处理
const text = contentAfterTime.trim()
if (text) {
words.push(createWord(text, lineStartTime, lineStartTime + DEFAULT_WORD_DURATION)) // 默认持续1s
}
}
if (words.length > 0) {
const lineObj = createLine(words, lineStartTime)
lineObj.endTime = words[words.length - 1].endTime
// 修正上一行的结束时间 (Single Pass)
if (prevLine) {
const prevLastWord = prevLine.words[prevLine.words.length - 1]
if (lineObj.startTime > prevLastWord.startTime) {
prevLastWord.endTime = Math.min(prevLastWord.endTime, lineObj.startTime)
prevLine.endTime = prevLastWord.endTime
}
}
result.push(lineObj)
prevLine = lineObj
}
}
return result
}
/**
* 智能解析 LRC 歌词
*/
export const parseSmartLrc = (content: string): { format: LrcFormat; lines: LyricLine[] } => {
const format = detectLrcFormat(content)
let lines: LyricLine[]
switch (format) {
case LrcFormat.WordByWord:
lines = parseWordByWordLrc(content)
break
case LrcFormat.Enhanced:
lines = parseEnhancedLrc(content)
break
default:
lines = parseLrc(content) || []
}
console.log(`[LyricParser] 检测到歌词格式: ${format}, 共 ${lines.length} 行`)
return { format, lines }
}
/**
* 判断解析结果是否为逐字格式
*/
export const isWordLevelFormat = (format: LrcFormat): boolean =>
format === LrcFormat.WordByWord || format === LrcFormat.Enhanced
/**
* 歌词内容对齐
* 两阶段策略:
* 1. 双指针严格匹配(容差 300ms),与主/翻译行数不一致时按时间方向跳跃
* 2. 对仍未命中的主行,与剩余未使用的翻译行做最近邻匹配(容差放宽到 1500ms)
* 兜底时间轴整体漂移较大的来源(vkeys、第三方翻译 LRC 等),
* 避免个别行漂移导致大面积翻译丢失
* @param lyrics 歌词数据 (Readonly)
* @param otherLyrics 其他歌词数据
* @param key 对齐类型
* @returns 对齐后的歌词数据 (新副本)
*/
export const alignLyrics = (
lyrics: Readonly<LyricLine[]>,
otherLyrics: Readonly<LyricLine[]>,
key: 'translatedLyric' | 'romanLyric'
): LyricLine[] => {
if (!lyrics.length || !otherLyrics.length) return cloneDeep(lyrics) as LyricLine[]
const result = cloneDeep(lyrics) as LyricLine[]
// 防御性排序:部分解析路径(逐字 LRC)不保证输出按时间有序
const others = [...otherLyrics].sort((a, b) => a.startTime - b.startTime)
const textOf = (line: LyricLine): string => line.words.map((word) => word.word).join('')
const used = new Set<number>()
// 第一轮:双指针严格匹配
let i = 0
let j = 0
while (i < result.length && j < others.length) {
const diff = result[i].startTime - others[j].startTime
if (Math.abs(diff) <= ALIGN_TOLERANCE_MS) {
result[i][key] = textOf(others[j])
used.add(j)
i++
j++
} else if (diff < 0) {
i++
} else {
j++
}
}
// 第二轮:最近邻兜底(1:1 约束,只填充第一轮未命中的行)
for (let m = 0; m < result.length; m++) {
if (result[m][key]) continue
let bestK = -1
let bestDiff = Infinity
for (let k = 0; k < others.length; k++) {
if (used.has(k)) continue
const diff = Math.abs(result[m].startTime - others[k].startTime)
if (diff < bestDiff) {
bestDiff = diff
bestK = k
}
}
if (bestK !== -1 && bestDiff <= DRIFT_TOLERANCE_MS) {
result[m][key] = textOf(others[bestK])
used.add(bestK)
}
}
return result
}
/**
* 解析 QRC 内容为行数据
*/
const parseQRCContent = (
rawContent: string
): Array<{
startTime: number
endTime: number
words: Array<{ word: string; startTime: number; endTime: number }>
}> => {
// 使用策略模式提取 LyricContent (自动适配 Browser/Node 环境)
const content = extractLyricContent(rawContent) || rawContent
const result: Array<{
startTime: number
endTime: number
words: Array<{ word: string; startTime: number; endTime: number }>
}> = []
for (const rawLine of content.split('\n')) {
const line = rawLine.trim()
if (!line) continue
// 跳过元数据标签 [ti:xxx] [ar:xxx] 等
if (META_TAG_REGEX.test(line)) continue
const lineMatch = QRC_LINE_PATTERN.exec(line)
if (!lineMatch) continue
const lineStart = parseInt(lineMatch[1], 10)
const lineDuration = parseInt(lineMatch[2], 10)
const lineContent = lineMatch[3]
// 解析逐字
const words: Array<{ word: string; startTime: number; endTime: number }> = []
const matches = lineContent.matchAll(QRC_WORD_PATTERN)
for (const match of matches) {
const wordText = match[1]
const wordStart = parseInt(match[2], 10)
const wordDuration = parseInt(match[3], 10)
if (wordText) {
words.push({
word: wordText,
startTime: wordStart,
endTime: wordStart + wordDuration
})
}
}
if (words.length > 0) {
result.push({
startTime: lineStart,
endTime: lineStart + lineDuration,
words
})
}
}
return result
}
/**
* 解析 QQ 音乐 QRC 格式歌词
* @param qrcContent QRC 原始内容
* @param trans 翻译歌词
* @param roma 罗马音歌词(QRC 格式)
* @returns LyricLine 数组
*/
export const parseQRCLyric = (qrcContent: string, trans?: string, roma?: string): LyricLine[] => {
// 解析主歌词
const qrcLines = parseQRCContent(qrcContent)
let result: LyricLine[] = qrcLines.map((qrcLine) => {
return {
words: qrcLine.words.map((word) => ({
...word,
romanWord: ''
})),
startTime: qrcLine.startTime,
endTime: qrcLine.endTime,
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
}
})
// 处理翻译
if (trans) {
// parseSmartLrc 兼容 1 位毫秒时间戳与逐字变体,比 parseLrc 更稳
let { lines: transLines } = parseSmartLrc(trans)
if (transLines?.length) {
// 过滤包含 "//" 或 "作品的著作权" 的翻译行
transLines = transLines.filter((line) => {
const text = line.words.map((w) => w.word).join('')
return !text.includes('//') && !text.includes('作品的著作权')
})
result = alignLyrics(result, transLines, 'translatedLyric')
}
}
// 处理音译
if (roma) {
const qrcRomaLines = parseQRCContent(roma)
if (qrcRomaLines?.length) {
const romaLines: LyricLine[] = qrcRomaLines.map((line) => {
return {
words: [
{
startTime: line.startTime,
endTime: line.endTime,
word: line.words.map((w) => w.word).join(''),
romanWord: ''
}
],
startTime: line.startTime,
endTime: line.endTime,
translatedLyric: '',
romanLyric: '',
isBG: false,
isDuet: false
}
})
result = alignLyrics(result, romaLines, 'romanLyric')
}
}
return result
}
/**
* 清洗 TTML 中不需要的翻译
* @param ttmlContent 原始 TTML 内容
* @returns 清洗后的 TTML 内容
*/
export const cleanTTMLTranslations = (ttmlContent: string): string => {
const lang_counter = (ttml_text: string) => {
// 使用正则匹配所有 xml:lang="xx-XX" 格式的字符串
const langRegex = /(?<=<(span|translation)[^<>]+)xml:lang="([^"]+)"/g
const matches = ttml_text.matchAll(langRegex)
// 提取匹配结果并去重
const langSet = new Set<string>()
for (const match of matches) {
if (match[2]) langSet.add(match[2])
}
return Array.from(langSet)
}
const lang_filter = (langs: string[]): string | null => {
if (langs.length <= 1) return null
const lang_matcher = (target: string) => {
return langs.find((lang) => {
try {
// @ts-expect-error Intl.Locale type definition is missing maximize method
return new Intl.Locale(lang).maximize().script === target
} catch {
return false
}
})
}
const hans_matched = lang_matcher('Hans')
if (hans_matched) return hans_matched
const hant_matched = lang_matcher('Hant')
if (hant_matched) return hant_matched
const major = langs.find((key) => key.startsWith('zh'))
if (major) return major
return langs[0]
}
const ttml_cleaner = (ttml_text: string, major_lang: string | null): string => {
// 如果没有指定主语言,直接返回原文本(或者根据需求返回空)
if (major_lang === null) return ttml_text
/**
* 替换逻辑回调函数
* @param match 完整匹配到的标签字符串
* @param lang 正则中第一个捕获组匹配到的语言代码
*/
const replacer = (match: string, lang: string) => (lang === major_lang ? match : '')
const translationRegex = /<translation[^>]+xml:lang="([^"]+)"[^>]*>[\s\S]*?<\/translation>/g
const spanRegex = /<span[^>]+xml:lang="([^" ]+)"[^>]*>[\s\S]*?<\/span>/g
return ttml_text.replace(translationRegex, replacer).replace(spanRegex, replacer)
}
const context_lang = lang_counter(ttmlContent)
const major = lang_filter(context_lang)
const cleaned_ttml = ttml_cleaner(ttmlContent, major)
return cleaned_ttml.replace(/\n\s*/g, '')
}
/**
* 简单的 TTML 解析器 (Wrapper for library)
*/
export const parseTTML = (content: string) => {
return parseTTMLLib(content)
}