Files
Henry_Du 4ce4649aaf
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s
初次提交
2026-09-06 08:19:02 +08:00

507 lines
14 KiB
TypeScript
Raw Permalink 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 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
/**
* 歌词内容对齐
* 使用双指针算法实现 O(N) 复杂度
* @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[]
let i = 0
let j = 0
while (i < result.length && j < otherLyrics.length) {
const line = result[i]
const other = otherLyrics[j]
const diff = line.startTime - other.startTime
if (Math.abs(diff) <= ALIGN_TOLERANCE_MS) {
// 匹配成功
line[key] = other.words.map((word) => word.word).join('')
i++
j++
} else if (diff < 0) {
// 当前歌词时间较早,移动当前指针
i++
} else {
// 目标歌词时间较早,移动目标指针
j++
}
}
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) {
let transLines = parseLrc(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)
}