feat: align with upstream 1.5.9.9
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* @fileoverview 跟元数据清理器有关的配置内容
|
||||
*/
|
||||
|
||||
/**
|
||||
* 默认的关键字列表,不包含冒号
|
||||
*
|
||||
* 建议更新这里的列表后运行 scripts\sort-keywords.ts 来排序并去重列表
|
||||
*/
|
||||
export const keywords = [
|
||||
'版权',
|
||||
'贝斯',
|
||||
'贝斯Bass',
|
||||
'本歌曲商用授权,前往小程序',
|
||||
'编曲',
|
||||
'编曲 Arranger',
|
||||
'编曲Arranged By',
|
||||
'编曲Arrangement',
|
||||
'策划',
|
||||
'策划 Planner',
|
||||
'策划统筹',
|
||||
'策划统筹Planner and coordinator',
|
||||
'出品',
|
||||
'出品、发行',
|
||||
'出品/发行',
|
||||
'出品/发行方',
|
||||
'出品/发行公司',
|
||||
'出品方Presented by',
|
||||
'出品方Presenter',
|
||||
'出品公司',
|
||||
'出品公司Production Company',
|
||||
'出品人',
|
||||
'出品人 Publisher',
|
||||
'出品人Chief Producer',
|
||||
'出品人Presenter',
|
||||
'出品人Producer',
|
||||
'出品团队',
|
||||
'出品团队Production Team',
|
||||
'出品Present by',
|
||||
'词',
|
||||
'词 Lyricist',
|
||||
'词曲提供',
|
||||
'词曲提供Lyrics & Composition Provided by',
|
||||
'词曲提供Lyrics and Composition Provided by',
|
||||
'词曲提供Music and Lyrics Provided by',
|
||||
'词曲提供Words and Music by',
|
||||
'词曲协力',
|
||||
'词Lyrics',
|
||||
'大提琴',
|
||||
'大提琴 Cello',
|
||||
'灯光设计',
|
||||
'第二小提琴',
|
||||
'第二小提琴 Second Violin',
|
||||
'第一小提琴',
|
||||
'第一小提琴 First Violin',
|
||||
'发行',
|
||||
'发行方',
|
||||
'发行方Publisher',
|
||||
'发行公司',
|
||||
'发行人',
|
||||
'发行Distributed by',
|
||||
'发行Distribution',
|
||||
'发行Release',
|
||||
'发行Released by',
|
||||
'翻唱',
|
||||
'封面',
|
||||
'封面插画Cover illustration',
|
||||
'封面设计',
|
||||
'封面设计 Cover Design',
|
||||
'钢琴/合成器演奏',
|
||||
'钢琴演奏',
|
||||
'歌名',
|
||||
'歌手',
|
||||
'工作室',
|
||||
'鼓',
|
||||
'鼓Drums',
|
||||
'合成器演奏',
|
||||
'合声',
|
||||
'合声编写',
|
||||
'和声',
|
||||
'和声 Backing Vocals',
|
||||
'和声/和声设计',
|
||||
'和声编写',
|
||||
'和声编写Backing Vocal Arrangement',
|
||||
'和声编写Backing Vocals Design',
|
||||
'和声演唱',
|
||||
'和声Backing Vocal',
|
||||
'和音',
|
||||
'和音 Bvox',
|
||||
'混缩',
|
||||
'混音',
|
||||
'混音 Mixed by',
|
||||
'混音 Mixing Engineer',
|
||||
'混音/母带工程师',
|
||||
'混音工程师',
|
||||
'混音工程师Mixing Engineer',
|
||||
'混音师',
|
||||
'混音室',
|
||||
'混音室Mixing Studio',
|
||||
'吉他',
|
||||
'吉他 Guitar',
|
||||
'吉他Guitars',
|
||||
'监制',
|
||||
'监制 Deputy Executive Producer',
|
||||
'监制Chief Producer',
|
||||
'监制Executive Director',
|
||||
'监制Supervised production',
|
||||
'键盘',
|
||||
'乐队统筹',
|
||||
'乐器演奏',
|
||||
'乐团',
|
||||
'联合策划',
|
||||
'联合出品',
|
||||
'联合出品Co-produced by',
|
||||
'联合出品Co-production',
|
||||
'联合出品Jointly Produced by',
|
||||
'联合出品Published',
|
||||
'联合推广',
|
||||
'录/混音',
|
||||
'录音',
|
||||
'录音 Recording Engineer',
|
||||
'录音师',
|
||||
'录音师Recording Engineer',
|
||||
'录音室',
|
||||
'录音室 Recording Studio',
|
||||
'录音制作',
|
||||
'鸣谢',
|
||||
'母带',
|
||||
'母带 Mastered by',
|
||||
'母带处理工程师',
|
||||
'母带工程师',
|
||||
'母带工程师Mastering Engineer',
|
||||
'母带后期处理工程师',
|
||||
'母带后期处理工程师Mastering Engineer',
|
||||
'母带后期处理录音室',
|
||||
'母带后期处理录音室Mastering Studio',
|
||||
'母带后期制作人',
|
||||
'母带后期制作人Mastering Producer',
|
||||
'配唱制作人',
|
||||
'配唱制作人 Vocals Producer',
|
||||
'配唱制作人Vocal Producer',
|
||||
'企划',
|
||||
'企划宣传',
|
||||
'企划宣传Propaganda',
|
||||
'企划营销',
|
||||
'企划制作A&R Planning',
|
||||
'企划Planning',
|
||||
'曲',
|
||||
'曲 Composer',
|
||||
'设计',
|
||||
'设计Design',
|
||||
'特别鸣谢',
|
||||
'特别鸣谢/艺人支持',
|
||||
'特别鸣谢Acknowledgement',
|
||||
'特别企划',
|
||||
'题记',
|
||||
'统筹',
|
||||
'统筹Coordinator',
|
||||
'统筹Planning',
|
||||
'推广',
|
||||
'推广策划',
|
||||
'推广策划Marketing Strategy',
|
||||
'推广策划Promotion Planning',
|
||||
'推广策划Promotion Strategy',
|
||||
'推广统筹 Promotion Coordinator',
|
||||
'推广宣传',
|
||||
'推广营销 Marketing Promotion',
|
||||
'维伴音乐',
|
||||
'文案',
|
||||
'文案Copywriting',
|
||||
'弦乐',
|
||||
'弦乐 Strings',
|
||||
'弦乐编写',
|
||||
'弦乐编写Strings Arrangement',
|
||||
'弦乐监制',
|
||||
'弦乐录音师',
|
||||
'弦乐录音室',
|
||||
'项目统筹',
|
||||
'项目统筹 Project Coordinator',
|
||||
'宣传',
|
||||
'宣传/推广',
|
||||
'宣推',
|
||||
'演唱',
|
||||
'艺术指导',
|
||||
'艺术指导Art Director',
|
||||
'音乐项目总监Project Executive',
|
||||
'音乐制作',
|
||||
'音乐制作发行',
|
||||
'音乐制作Music Production',
|
||||
'音频编辑',
|
||||
'营销推广',
|
||||
'营销推广/出品/发行',
|
||||
'营销推广Marketing',
|
||||
'营销推广Marketing Promotion',
|
||||
'原唱',
|
||||
'制作发行',
|
||||
'制作公司',
|
||||
'制作公司 Production',
|
||||
'制作公司/OP',
|
||||
'制作公司Produce Company',
|
||||
'制作人',
|
||||
'制作人 Producer',
|
||||
'制作人Music Producer',
|
||||
'制作人Record Producer',
|
||||
'制作统筹',
|
||||
'制作统筹Executive Producer',
|
||||
'制作团队',
|
||||
'制作助理',
|
||||
'中提琴',
|
||||
'中提琴 Viola',
|
||||
'专辑',
|
||||
'总策划',
|
||||
'总策划 Chief Planner',
|
||||
'总策划Chref Planner',
|
||||
'总监制 Chief Executive Producer',
|
||||
'总企划',
|
||||
'作词',
|
||||
'作曲',
|
||||
'Acknowledgement',
|
||||
'Additional Vocal by',
|
||||
'Arranged By',
|
||||
'Arrangement',
|
||||
'Arranger',
|
||||
'Art Director',
|
||||
'Artist',
|
||||
'Background Vocals by',
|
||||
'Backing Vocal',
|
||||
'Backing Vocal Arrangement',
|
||||
'Backing Vocals',
|
||||
'Backing Vocals Design',
|
||||
'Bass',
|
||||
'Cello',
|
||||
'Chief Producer',
|
||||
'Chref Planner',
|
||||
'Co-produced by',
|
||||
'Co-Producer',
|
||||
'Co-production',
|
||||
'Composed by',
|
||||
'Composer',
|
||||
'Copywriting',
|
||||
'Cover Design',
|
||||
'Design',
|
||||
'Distributed by',
|
||||
'Distribution',
|
||||
'Drums',
|
||||
'Executive Producer',
|
||||
'First Violin',
|
||||
'Guitar',
|
||||
'Guitars',
|
||||
'Jointly Produced by',
|
||||
'Lyricist',
|
||||
'Lyrics',
|
||||
'Lyrics & Composition Provided by',
|
||||
'Lyrics and Composition Provided by',
|
||||
'Lyrics by',
|
||||
'Marketing Promotion',
|
||||
'Marketing Strategy',
|
||||
'Mastered by',
|
||||
'Mastering Engineer',
|
||||
'Mixing Engineer',
|
||||
'Music and Lyrics Provided by',
|
||||
'Music Production',
|
||||
'OP',
|
||||
'OP、SP',
|
||||
'OP/发行',
|
||||
'OP/SP',
|
||||
'Planner',
|
||||
'Planner and coordinator',
|
||||
'Presented by',
|
||||
'Presenter',
|
||||
'Produce Company',
|
||||
'Produced by',
|
||||
'Producer',
|
||||
'Production Company',
|
||||
'Production Team',
|
||||
'Promotion Planning',
|
||||
'Promotion Strategy',
|
||||
'Propaganda',
|
||||
'Published by',
|
||||
'Publisher',
|
||||
'Record Producer',
|
||||
'Recorded at',
|
||||
'Recording Engineer',
|
||||
'Recording Studio',
|
||||
'Release',
|
||||
'Released by',
|
||||
'Repertoire Owner',
|
||||
'Second Violin',
|
||||
'Songs Title',
|
||||
'SP',
|
||||
'Strings',
|
||||
'Strings Arrangement',
|
||||
'Supervised production',
|
||||
'Viola',
|
||||
'Vocal Engineer',
|
||||
'Vocal Producer',
|
||||
'Vocals by',
|
||||
'Vocals Produced by',
|
||||
'Words and Music by',
|
||||
'Written by'
|
||||
]
|
||||
|
||||
/**
|
||||
* 默认的正则表达式
|
||||
*/
|
||||
export const regexes = [
|
||||
'(?:【.*?音乐人.*?】|\\(.*?音乐人.*?\\)|「.*?音乐人.*?」|(.*?音乐人.*?)|『.*?音乐人.*?』)',
|
||||
'.*?未经.*?不得.*?'
|
||||
]
|
||||
@@ -0,0 +1,230 @@
|
||||
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
import { stripLyricMetadata, type StripOptions } from './lyricStripper'
|
||||
import { keywords as defaultKeywords, regexes as defaultRegexes } from './exclude'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
/**
|
||||
* 获取替换配置
|
||||
*/
|
||||
const getReplacementConfig = (settings: any) => {
|
||||
const preset = settings.bracketReplacementPreset.value || 'dash'
|
||||
const custom = settings.customBracketReplacement.value || '-'
|
||||
|
||||
let startStr = ' - '
|
||||
const endStr = ' '
|
||||
const isEnclosure = false
|
||||
|
||||
// 预设模式
|
||||
if (preset === 'angleBrackets') {
|
||||
return { startStr: '〔', endStr: '〕', isEnclosure: true }
|
||||
} else if (preset === 'cornerBrackets') {
|
||||
return { startStr: '「', endStr: '」', isEnclosure: true }
|
||||
} else if (preset === 'custom') {
|
||||
const trimmed = custom.trim()
|
||||
// 启发式判断:如果是两个不同字符且不包含减号,视为成对符号 (如 "()")
|
||||
const isPair = trimmed.length === 2 && trimmed[0] !== trimmed[1] && !trimmed.includes('-')
|
||||
if (isPair) {
|
||||
return { startStr: trimmed[0], endStr: trimmed[1], isEnclosure: true }
|
||||
} else {
|
||||
// 否则视为分隔符 (如 "-"),加上空格规范化
|
||||
startStr = ' ' + trimmed + ' '
|
||||
startStr = startStr.replace(/\s+/g, ' ')
|
||||
return { startStr, endStr: ' ', isEnclosure: false }
|
||||
}
|
||||
} else if (preset === 'space') {
|
||||
return { startStr: ' ', endStr: ' ', isEnclosure: false }
|
||||
}
|
||||
|
||||
// 默认 dash 模式
|
||||
return { startStr, endStr, isEnclosure }
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用括号替换逻辑
|
||||
*/
|
||||
const applyBracketReplacement = (lines: LyricLine[], settings: any): LyricLine[] => {
|
||||
if (!settings.replaceLyricBrackets.value) return lines
|
||||
|
||||
const { startStr, endStr, isEnclosure } = getReplacementConfig(settings)
|
||||
|
||||
// 正则:匹配全行括号 (如 "(Music)")
|
||||
const fullBracketRegex = /^\s*[((][^()()]*[))]\s*$/
|
||||
// 正则:匹配左括号
|
||||
const leftBracketRegex = /[((]/g
|
||||
// 正则:匹配右括号
|
||||
const rightBracketRegex = /[))]/g
|
||||
|
||||
/**
|
||||
* 处理普通字符串 (用于翻译和罗马音)
|
||||
*/
|
||||
const processString = (str: string): string => {
|
||||
if (!str) return str
|
||||
|
||||
// 如果是全行括号且非闭合模式 (如分隔符模式),则直接去除括号
|
||||
if (!isEnclosure && fullBracketRegex.test(str)) {
|
||||
return str
|
||||
.replace(/^\s*[((]/, '')
|
||||
.replace(/[))]\s*$/, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
let res = str.replace(leftBracketRegex, startStr)
|
||||
if (isEnclosure) {
|
||||
res = res.replace(rightBracketRegex, endStr)
|
||||
} else {
|
||||
// 分隔符模式:右括号如果是结尾则移除,否则替换为结束符(空格)
|
||||
res = res.replace(/[))](?=\s*$)/g, '').replace(rightBracketRegex, endStr)
|
||||
// 清理重复分隔符 (如 " - - " -> " - ")
|
||||
if (startStr.includes('-')) {
|
||||
res = res.replace(/(?:\s*-\s*){2,}/g, ' - ')
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
return lines.map((line) => {
|
||||
// 处理 words
|
||||
if (line.words) {
|
||||
const fullText = line.words.map((w) => w.word).join('')
|
||||
const isFullBracket = fullBracketRegex.test(fullText)
|
||||
|
||||
// 整行是括号内容,且处于分隔符模式 (非闭合) -> 直接移除首尾括号,不加分隔符
|
||||
if (isFullBracket && !isEnclosure) {
|
||||
let removedStart = false
|
||||
let removedEnd = false
|
||||
|
||||
// 移除第一个左括号
|
||||
for (const word of line.words) {
|
||||
if (!removedStart && leftBracketRegex.test(word.word)) {
|
||||
word.word = word.word.replace(leftBracketRegex, '')
|
||||
removedStart = true
|
||||
}
|
||||
}
|
||||
// 移除最后一个右括号
|
||||
for (let i = line.words.length - 1; i >= 0; i--) {
|
||||
const word = line.words[i]
|
||||
if (!removedEnd && rightBracketRegex.test(word.word)) {
|
||||
const lastIndex = Math.max(word.word.lastIndexOf(')'), word.word.lastIndexOf(')'))
|
||||
if (lastIndex !== -1) {
|
||||
word.word = word.word.substring(0, lastIndex) + word.word.substring(lastIndex + 1)
|
||||
removedEnd = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 普通替换逻辑
|
||||
line.words.forEach((word, index) => {
|
||||
// 替换左括号
|
||||
word.word = word.word.replace(leftBracketRegex, startStr)
|
||||
|
||||
if (isEnclosure) {
|
||||
// 闭合模式:直接替换右括号
|
||||
word.word = word.word.replace(rightBracketRegex, endStr)
|
||||
} else {
|
||||
// 分隔符模式:
|
||||
word.word = word.word.replace(rightBracketRegex, (_, offset, str) => {
|
||||
// 如果右括号在单词末尾,且是整句的最后一个单词 -> 移除 (视为句尾闭合)
|
||||
const isAtWordEnd = offset === str.length - 1
|
||||
const isAtLineEnd = index === line.words.length - 1
|
||||
|
||||
if (isAtWordEnd) {
|
||||
return isAtLineEnd ? '' : endStr
|
||||
}
|
||||
return endStr
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 清理逻辑 (仅针对带减号的分隔符模式)
|
||||
if (!isEnclosure && startStr.includes('-')) {
|
||||
line.words.forEach((word, index) => {
|
||||
// 单词内重复: " - - " -> " - "
|
||||
word.word = word.word.replace(/(?:\s*-\s*){2,}/g, ' - ')
|
||||
|
||||
// 单词间重复: 前一个词以 "- " 结尾,当前词以 " -" 开头 -> 移除前一个的尾部
|
||||
if (index > 0) {
|
||||
const prev = line.words[index - 1]
|
||||
if (/-\s*$/.test(prev.word) && /^\s*-/.test(word.word)) {
|
||||
prev.word = prev.word.replace(/-\s*$/, '')
|
||||
// 确保当前词开头有完整分隔符
|
||||
if (!/^\s*-\s+/.test(word.word)) {
|
||||
word.word = ' - ' + word.word.replace(/^\s*-\s*/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理翻译和罗马音
|
||||
if (line.translatedLyric) line.translatedLyric = processString(line.translatedLyric)
|
||||
if (line.romanLyric) line.romanLyric = processString(line.romanLyric)
|
||||
|
||||
return line
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化歌词
|
||||
* 主要处理括号替换、关键词过滤等
|
||||
* @param lines 歌词行
|
||||
* @param settings 歌词设置对象 (从 useLyricSettings 获取)
|
||||
* @param metadata 可选的歌曲元数据,用于更精确的歌词清洗
|
||||
*/
|
||||
export const formatLyric = (
|
||||
lines: LyricLine[],
|
||||
settings: any,
|
||||
metadata?: { title?: string; artists?: string[] }
|
||||
): LyricLine[] => {
|
||||
const { enableExcludeLyrics, excludeLyricsUserKeywords, excludeLyricsUserRegexes } = settings
|
||||
|
||||
if (!lines || lines.length === 0) return []
|
||||
|
||||
// 使用 cloneDeep 避免修改原始引用 (如果需要)
|
||||
// 但 formatLyric 通常被视为纯函数处理流程,这里我们操作的是 parsedLyrics 的结果
|
||||
// 为了安全起见,克隆一份
|
||||
let processedLines = cloneDeep(lines)
|
||||
|
||||
// 1. 智能元数据清洗 (Smart Metadata Stripping)
|
||||
if (enableExcludeLyrics && enableExcludeLyrics.value) {
|
||||
const userKeywords = excludeLyricsUserKeywords.value || []
|
||||
const userRegexes = excludeLyricsUserRegexes.value || []
|
||||
|
||||
const mergedKeywords = [...new Set([...defaultKeywords, ...userKeywords])]
|
||||
const mergedRegexes = [...new Set([...defaultRegexes, ...userRegexes])]
|
||||
|
||||
const options: StripOptions = {
|
||||
keywords: mergedKeywords,
|
||||
regexPatterns: mergedRegexes,
|
||||
matchMetadata: metadata
|
||||
}
|
||||
|
||||
processedLines = stripLyricMetadata(processedLines, options)
|
||||
}
|
||||
|
||||
// 2. 基础过滤 (保留)
|
||||
processedLines = processedLines.filter((line) => {
|
||||
const text = line.words.map((w) => w.word).join('')
|
||||
const transText = line.translatedLyric || ''
|
||||
|
||||
if (!text.trim()) {
|
||||
if (!transText.trim() || transText.includes('著作权') || transText.includes('QQ音乐')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (text.includes('著作权') || text.includes('QQ音乐')) return false
|
||||
|
||||
if (transText.includes('著作权') || transText.includes('QQ音乐') || transText.trim() === '//') {
|
||||
line.translatedLyric = ''
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 3. 括号替换
|
||||
processedLines = applyBracketReplacement(processedLines, settings)
|
||||
|
||||
return processedLines
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
|
||||
/** 日语假名:平假名 + 片假名 + 半角假名 + 促音/长音符号 */
|
||||
const KANA_RE = /[\p{Script=Hiragana}\p{Script=Katakana}\u30FC\uFF66-\uFF9F]/u
|
||||
|
||||
/** 韩文:谚文音节 + 谚文字母 + 谚文兼容字母 */
|
||||
const HANGUL_RE = /[\p{Script=Hangul}\u3130-\u318F]/u
|
||||
|
||||
/** 中日韩统一表意文字(含扩展区) */
|
||||
const HAN_RE = /\p{Script=Han}/u
|
||||
|
||||
/** 拉丁字母;数字与标点不能作为英文判断依据 */
|
||||
const LATIN_RE = /\p{Script=Latin}/u
|
||||
|
||||
type LanguageSourceLine = Pick<LyricLine, 'words'> & Partial<Pick<LyricLine, 'translatedLyric'>>
|
||||
|
||||
/** 判断是否有实质内容的翻译歌词 */
|
||||
const hasTranslation = (line: LanguageSourceLine): boolean =>
|
||||
(line.translatedLyric ?? '').trim().length > 0
|
||||
|
||||
/**
|
||||
* 基于整首歌词上下文推断每行语言,返回与行数组对齐的 BCP 47 语言标签数组
|
||||
*
|
||||
* Han 脚本无法独立区分中日韩;
|
||||
* - 同一首歌词出现假名时,通常将纯汉字行视为日语;
|
||||
* - 同一首歌词出现谚文时,通常将纯汉字行视为韩语;
|
||||
* - CJK 混合启发式规则:若所有包含假名/谚文的行均有翻译,
|
||||
* 则认定全为汉字且无翻译的行为中文,以此区分双语混合歌词;
|
||||
* - 拉丁文字使用 BCP 47 的 und-Latn,避免误标为英语;
|
||||
* - 无法判断时返回 undefined(不设置 lang 属性)。
|
||||
*
|
||||
* @param lines 已解析的整首歌词
|
||||
*/
|
||||
export const detectLyricLanguages = (
|
||||
lines: ReadonlyArray<LanguageSourceLine>
|
||||
): Array<string | undefined> => {
|
||||
const lineContents = lines.map((line) => line.words.map((word) => word.word).join(''))
|
||||
|
||||
let hasKana = false
|
||||
let hasHangul = false
|
||||
let kanaUntranslatedCount = 0
|
||||
let hangulUntranslatedCount = 0
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
const content = lineContents[i] ?? ''
|
||||
const isTranslated = line ? hasTranslation(line) : false
|
||||
|
||||
if (KANA_RE.test(content)) {
|
||||
hasKana = true
|
||||
if (!isTranslated) kanaUntranslatedCount++
|
||||
}
|
||||
if (HANGUL_RE.test(content)) {
|
||||
hasHangul = true
|
||||
if (!isTranslated) hangulUntranslatedCount++
|
||||
}
|
||||
}
|
||||
|
||||
const allKanaTranslated = hasKana && kanaUntranslatedCount === 0
|
||||
const allHangulTranslated = hasHangul && hangulUntranslatedCount === 0
|
||||
|
||||
return lines.map((line, i) => {
|
||||
const content = lineContents[i] ?? ''
|
||||
const isTranslated = hasTranslation(line)
|
||||
|
||||
if (KANA_RE.test(content)) return 'ja'
|
||||
if (HANGUL_RE.test(content)) return 'ko'
|
||||
if (HAN_RE.test(content)) {
|
||||
if (hasKana) return allKanaTranslated && !isTranslated ? 'zh-CN' : 'ja'
|
||||
if (hasHangul) return allHangulTranslated && !isTranslated ? 'zh-CN' : 'ko'
|
||||
return 'zh-CN'
|
||||
}
|
||||
if (LATIN_RE.test(content)) return 'und-Latn'
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* @fileoverview 元数据行清理器
|
||||
*
|
||||
* 用于清理歌词中开头和结尾的元数据行,例如:
|
||||
*
|
||||
* (歌曲名) - (歌手名)
|
||||
* 词:...
|
||||
* 曲:...
|
||||
* 编曲:...
|
||||
* 和声编唱:...
|
||||
* 人声编辑:...
|
||||
* 混音:...
|
||||
* 母带:...
|
||||
* 监制:...
|
||||
* 出品:...
|
||||
* 真正的歌词行 1
|
||||
* 真正的歌词行 2
|
||||
*
|
||||
* @see https://github.com/apoint123/Unilyric/blob/afd351c54eca7137cf8ee4ea5652d9ee55c20e32/lyrics_helper_rs/src/converter/processors/metadata_stripper.rs
|
||||
*/
|
||||
|
||||
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
|
||||
const STRICT_MATCH_SEPARATORS = [
|
||||
':',
|
||||
':',
|
||||
',',
|
||||
',',
|
||||
'.',
|
||||
'。',
|
||||
'!',
|
||||
'!',
|
||||
'-',
|
||||
'_',
|
||||
'(',
|
||||
'(',
|
||||
'[',
|
||||
'【',
|
||||
'{',
|
||||
'『',
|
||||
'「'
|
||||
]
|
||||
|
||||
/**
|
||||
* 扫描限制配置
|
||||
*/
|
||||
export interface ScanLimitConfig {
|
||||
/**
|
||||
* 扫描比例 (0.0 - 1.0)
|
||||
*/
|
||||
ratio: number
|
||||
/**
|
||||
* 最小扫描行数
|
||||
*/
|
||||
minLines: number
|
||||
/**
|
||||
* 最大扫描行数
|
||||
*/
|
||||
maxLines: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词清理配置
|
||||
*/
|
||||
export interface StripOptions {
|
||||
/**
|
||||
* 要匹配的关键词列表
|
||||
*/
|
||||
keywords: string[]
|
||||
/**
|
||||
* 要匹配的正则字符串列表
|
||||
*/
|
||||
regexPatterns: string[]
|
||||
/**
|
||||
* 这里的正则表达式会被视为弱匹配
|
||||
*/
|
||||
softMatchRegexes?: string[]
|
||||
/**
|
||||
* 歌曲元数据,用于检查第一行是否为 "歌曲 - 歌手" 格式
|
||||
*/
|
||||
matchMetadata?: {
|
||||
title?: string
|
||||
artists?: string[]
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_HEADER_LIMIT: ScanLimitConfig = {
|
||||
ratio: 0.2,
|
||||
minLines: 20,
|
||||
maxLines: 70
|
||||
}
|
||||
|
||||
const DEFAULT_FOOTER_LIMIT: ScanLimitConfig = {
|
||||
ratio: 0.2,
|
||||
minLines: 20,
|
||||
maxLines: 50
|
||||
}
|
||||
|
||||
function calculateScanLimit(config: ScanLimitConfig, totalLines: number): number {
|
||||
const proportional = Math.ceil(totalLines * config.ratio)
|
||||
|
||||
return Math.min(Math.min(Math.max(proportional, config.minLines), config.maxLines), totalLines)
|
||||
}
|
||||
|
||||
function getLineText(line: LyricLine): string {
|
||||
if (!line || !line.words) return ''
|
||||
return line.words
|
||||
.map((w) => w.word)
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文本以便检查
|
||||
*
|
||||
* 移除行首尾的括号,例如 (作曲: xxx) -> 作曲: xxx
|
||||
*/
|
||||
function cleanTextForCheck(text: string): string {
|
||||
let processed = text.trim()
|
||||
const brackets = [
|
||||
['(', ')'],
|
||||
['(', ')'],
|
||||
['【', '】'],
|
||||
['[', ']'],
|
||||
['{', '}'],
|
||||
['『', '』'],
|
||||
['「', '」']
|
||||
]
|
||||
|
||||
let changed = true
|
||||
let loopCount = 0
|
||||
|
||||
while (changed && loopCount < 5) {
|
||||
changed = false
|
||||
loopCount++
|
||||
|
||||
for (const [open, close] of brackets) {
|
||||
if (processed.startsWith(open)) {
|
||||
// 类似于这样的格式:(作曲:周杰伦)
|
||||
if (processed.endsWith(close)) {
|
||||
processed = processed.slice(open.length, processed.length - close.length).trim()
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
|
||||
// 类似于这样的格式:(Live) 作曲:周杰伦
|
||||
const closeIdx = processed.indexOf(close)
|
||||
if (closeIdx > -1) {
|
||||
const contentAfter = processed.slice(closeIdx + close.length).trim()
|
||||
|
||||
if (contentAfter.length > 0) {
|
||||
processed = contentAfter
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return processed
|
||||
}
|
||||
|
||||
// 强匹配行:匹配关键词加冒号,或者匹配正则表达式的行
|
||||
// 弱匹配行:带有冒号,但不匹配关键词或正则表达式的行。如果夹在强匹配行之间,多半是元数据行但是没有对应的规则。但也有可能是演唱者标识,“男:...”这样的,为了避免误删,如果后面全是弱匹配行,就不删它们
|
||||
// 真正的歌词行:既不匹配规则,又没有冒号的行,作为防火墙来阻止对之后行的移除。避免元数据在歌词中间,把中间的歌词也移除了
|
||||
|
||||
function isStrictMatch(text: string, keywords: string[], regexes: RegExp[]): boolean {
|
||||
const cleaned = cleanTextForCheck(text)
|
||||
|
||||
// 转小写并移除空格进行匹配
|
||||
const normalizedText = cleaned.toLowerCase().replace(/\s+/g, '')
|
||||
|
||||
for (const kw of keywords) {
|
||||
const normalizedKw = kw.toLowerCase().replace(/\s+/g, '')
|
||||
|
||||
if (normalizedText.startsWith(normalizedKw)) {
|
||||
const remainder = normalizedText.slice(normalizedKw.length)
|
||||
|
||||
// 如果剩余部分为空,说明完全匹配
|
||||
if (remainder.length === 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查分隔符
|
||||
// 允许的分隔符包括:冒号、逗号、句号、感叹号、连字符、括号等
|
||||
// 注意:normalizedText 已经移除了空格
|
||||
if (STRICT_MATCH_SEPARATORS.includes(remainder.charAt(0))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const reg of regexes) {
|
||||
if (reg.test(text)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function looksLikeMetadata(text: string, softRegexes: RegExp[]): boolean {
|
||||
const cleaned = cleanTextForCheck(text)
|
||||
if (
|
||||
cleaned.includes(':') ||
|
||||
cleaned.includes(':') ||
|
||||
// 第一行的 歌曲名 - 歌手名 这样的格式
|
||||
cleaned.includes('-')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
for (const reg of softRegexes) {
|
||||
if (reg.test(text)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描头部,寻找正文开始的位置
|
||||
*/
|
||||
function findHeaderCutoff(
|
||||
lines: readonly LyricLine[],
|
||||
startIndex: number,
|
||||
keywords: string[],
|
||||
regexes: RegExp[],
|
||||
softRegexes: RegExp[],
|
||||
limit: number
|
||||
): number {
|
||||
let lastValidMetadataIndex = startIndex - 1
|
||||
|
||||
console.groupCollapsed(`[LyricStripper] ⬇️ 开始头部扫描 (Start: ${startIndex}, Limit: ${limit})`)
|
||||
|
||||
for (let i = startIndex; i < limit; i++) {
|
||||
if (i >= lines.length) break
|
||||
|
||||
const text = getLineText(lines[i])
|
||||
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const strict = isStrictMatch(text, keywords, regexes)
|
||||
const weak = looksLikeMetadata(text, softRegexes)
|
||||
|
||||
let status = '❌ NONE'
|
||||
if (strict) status = '✅ STRICT'
|
||||
else if (weak) status = '⚠️ WEAK'
|
||||
|
||||
console.log(`Line [${i}]: "${text}" | Result: ${status}`)
|
||||
|
||||
if (!strict && !weak) {
|
||||
break
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
lastValidMetadataIndex = i
|
||||
}
|
||||
}
|
||||
console.groupEnd()
|
||||
|
||||
return lastValidMetadataIndex + 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描尾部,寻找正文结束的位置
|
||||
*/
|
||||
function findFooterCutoff(
|
||||
lines: readonly LyricLine[],
|
||||
startIndex: number,
|
||||
keywords: string[],
|
||||
regexes: RegExp[],
|
||||
softRegexes: RegExp[],
|
||||
limit: number
|
||||
): number {
|
||||
if (startIndex >= lines.length) return startIndex
|
||||
|
||||
const scanEnd = Math.max(startIndex, lines.length - limit)
|
||||
let firstValidFooterIndex = lines.length
|
||||
|
||||
console.groupCollapsed(`[LyricStripper] ⬆️ 开始尾部扫描 (Limit: ${limit})`)
|
||||
|
||||
for (let i = lines.length - 1; i >= scanEnd; i--) {
|
||||
const text = getLineText(lines[i])
|
||||
|
||||
if (!text) {
|
||||
continue
|
||||
}
|
||||
|
||||
const strict = isStrictMatch(text, keywords, regexes)
|
||||
const weak = looksLikeMetadata(text, softRegexes)
|
||||
|
||||
let status = '❌ NONE'
|
||||
if (strict) status = '✅ STRICT'
|
||||
else if (weak) status = '⚠️ WEAK'
|
||||
|
||||
console.log(`Line [${i}]: "${text}" | Result: ${status}`)
|
||||
|
||||
if (!strict && !weak) {
|
||||
break
|
||||
}
|
||||
|
||||
if (strict) {
|
||||
firstValidFooterIndex = i
|
||||
}
|
||||
}
|
||||
console.groupEnd()
|
||||
|
||||
return firstValidFooterIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥离歌词中的元数据行
|
||||
* @param lines 原始歌词行数组
|
||||
* @param options 包含关键词和正则的配置
|
||||
* @returns 清理后的新歌词行数组
|
||||
*/
|
||||
export function stripLyricMetadata(
|
||||
lines: readonly LyricLine[],
|
||||
options: StripOptions
|
||||
): LyricLine[] {
|
||||
if (!lines || lines.length === 0) return []
|
||||
|
||||
let scanStartIndex = 0
|
||||
|
||||
if (options.matchMetadata) {
|
||||
const { title, artists } = options.matchMetadata
|
||||
const firstLineText = getLineText(lines[0])
|
||||
|
||||
if (title && artists && artists.length > 0 && firstLineText) {
|
||||
const lowerText = firstLineText.toLowerCase()
|
||||
const lowerTitle = title.toLowerCase()
|
||||
|
||||
if (lowerText.includes(lowerTitle)) {
|
||||
const hasAnyArtist = artists.some((artist) => lowerText.includes(artist.toLowerCase()))
|
||||
|
||||
if (hasAnyArtist) {
|
||||
console.log(
|
||||
`[LyricStripper] 在第一行匹配到歌曲元数据: "${firstLineText}" (Title: ${title}, Artists: ${artists.join(', ')})`
|
||||
)
|
||||
scanStartIndex = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
scanStartIndex === 0 &&
|
||||
(!options.keywords || options.keywords.length === 0) &&
|
||||
(!options.regexPatterns || options.regexPatterns.length === 0) &&
|
||||
(!options.softMatchRegexes || options.softMatchRegexes.length === 0)
|
||||
) {
|
||||
return [...lines]
|
||||
}
|
||||
|
||||
const regexes: RegExp[] = []
|
||||
if (options.regexPatterns) {
|
||||
options.regexPatterns.forEach((p) => {
|
||||
try {
|
||||
if (p.trim()) {
|
||||
regexes.push(new RegExp(p, 'i')) // 忽略大小写
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[LyricStripper] 无效的正则表达式: ${p}`, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const softRegexes: RegExp[] = []
|
||||
if (options.softMatchRegexes) {
|
||||
options.softMatchRegexes.forEach((p) => {
|
||||
try {
|
||||
if (p.trim()) softRegexes.push(new RegExp(p, 'i'))
|
||||
} catch (e) {
|
||||
console.warn(`[LyricStripper] 无效的正则表达式: ${p}`, e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const keywords = options.keywords || []
|
||||
const totalLines = lines.length
|
||||
|
||||
const headerConfig = DEFAULT_HEADER_LIMIT
|
||||
const headerLimit = calculateScanLimit(headerConfig, totalLines)
|
||||
|
||||
const startIdx = findHeaderCutoff(
|
||||
lines,
|
||||
scanStartIndex,
|
||||
keywords,
|
||||
regexes,
|
||||
softRegexes,
|
||||
headerLimit
|
||||
)
|
||||
|
||||
const footerConfig = DEFAULT_FOOTER_LIMIT
|
||||
const footerLimit = calculateScanLimit(footerConfig, totalLines)
|
||||
|
||||
const endIdx = findFooterCutoff(lines, startIdx, keywords, regexes, softRegexes, footerLimit)
|
||||
|
||||
if (startIdx === 0 && endIdx === lines.length) {
|
||||
return [...lines]
|
||||
}
|
||||
|
||||
const newLength = endIdx - startIdx
|
||||
console.log(`[LyricStripper] 清理完成,总行数从 ${totalLines} 变为 ${newLength}`)
|
||||
|
||||
return lines.slice(startIdx, endIdx)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
|
||||
const TIME_TAG_REGEX = /\[(\d{2}):(\d{2})\.(\d{2,3})\]/g
|
||||
const META_TAG_REGEX = /^\[[a-z]+:/i
|
||||
|
||||
export const parseLrc = (lrcContent: string): LyricLine[] => {
|
||||
if (!lrcContent) return []
|
||||
|
||||
const lines = lrcContent.split(/\r?\n/)
|
||||
const result: LyricLine[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmedLine = line.trim()
|
||||
if (!trimmedLine) continue
|
||||
|
||||
// 跳过元数据
|
||||
if (META_TAG_REGEX.test(trimmedLine)) continue
|
||||
|
||||
const matches = [...trimmedLine.matchAll(TIME_TAG_REGEX)]
|
||||
if (matches.length === 0) continue
|
||||
|
||||
// 提取歌词文本(移除所有时间标签)
|
||||
const text = trimmedLine.replace(TIME_TAG_REGEX, '').trim()
|
||||
// 允许空行(作为间奏),但通常我们忽略完全空的行,除非为了显示间隔
|
||||
if (!text) continue
|
||||
|
||||
// 处理一行多个时间标签的情况(重复歌词)
|
||||
for (const match of matches) {
|
||||
const min = parseInt(match[1], 10)
|
||||
const sec = parseInt(match[2], 10)
|
||||
// 补齐毫秒
|
||||
const msStr = match[3].padEnd(3, '0').slice(0, 3)
|
||||
const ms = parseInt(msStr, 10)
|
||||
const startTime = min * 60 * 1000 + sec * 1000 + ms
|
||||
|
||||
result.push({
|
||||
startTime,
|
||||
endTime: 0, // 稍后计算
|
||||
words: [{ word: text, startTime, endTime: 0, romanWord: '' }],
|
||||
translatedLyric: '',
|
||||
romanLyric: '',
|
||||
isBG: false,
|
||||
isDuet: false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 按时间排序
|
||||
result.sort((a, b) => a.startTime - b.startTime)
|
||||
|
||||
// 计算结束时间
|
||||
for (let i = 0; i < result.length; i++) {
|
||||
const line = result[i]
|
||||
const nextLine = result[i + 1]
|
||||
|
||||
if (nextLine) {
|
||||
line.endTime = nextLine.startTime
|
||||
} else {
|
||||
line.endTime = line.startTime + 5000 // 最后一行默认 5秒
|
||||
}
|
||||
|
||||
// 更新单词结束时间
|
||||
if (line.words.length > 0) {
|
||||
line.words[0].endTime = line.endTime
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* QRC 解析
|
||||
*/
|
||||
|
||||
// 定义函数签名类型
|
||||
type QRCParserFn = (xmlStr: string) => string
|
||||
|
||||
// 实现 DOM 策略
|
||||
const domParser: QRCParserFn = (xmlStr) => {
|
||||
if (!xmlStr || !xmlStr.trim()) return ''
|
||||
try {
|
||||
const parser = new DOMParser()
|
||||
const doc = parser.parseFromString(xmlStr, 'text/xml')
|
||||
// 检查解析错误(如未转义的 & 等)
|
||||
const parseError = doc.querySelector('parsererror')
|
||||
if (parseError) {
|
||||
// DOM 解析失败,回退到正则解析
|
||||
return regexParser(xmlStr)
|
||||
}
|
||||
|
||||
return doc.documentElement.getAttribute('LyricContent') || ''
|
||||
} catch (e) {
|
||||
console.error('[QRC Parser] Fatal Error:', e)
|
||||
// 回退到正则解析
|
||||
return regexParser(xmlStr)
|
||||
}
|
||||
}
|
||||
|
||||
const decodeXml = (str: string) => {
|
||||
return str
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/g, '&')
|
||||
}
|
||||
|
||||
// 实现正则策略
|
||||
const regexParser: QRCParserFn = (xmlStr) => {
|
||||
if (!xmlStr) return ''
|
||||
|
||||
// 尝试贪婪匹配 (处理 LyricContent="... "..." ..." 这种非标准 XML 引号未转义的情况)
|
||||
// 匹配从 LyricContent=" 开始,直到标签结束前的最后一个引号
|
||||
const greedyMatch = /LyricContent\s*=\s*"([\s\S]*)"\s*\/?>/.exec(xmlStr)
|
||||
if (greedyMatch) {
|
||||
const content = greedyMatch[1]
|
||||
// 启发式检查:如果提取的内容中包含类似 ` Attribute="` 的结构,说明贪婪匹配吃掉了其他属性
|
||||
// 这种情况下回退到非贪婪匹配
|
||||
if (!/\s+\w+\s*=\s*"/.test(content)) {
|
||||
return decodeXml(content)
|
||||
}
|
||||
}
|
||||
|
||||
// 标准非贪婪匹配 (兼容标准 XML 属性)
|
||||
const match = /LyricContent\s*=\s*"([^"]*)"/.exec(xmlStr)
|
||||
|
||||
// 如果没有匹配到 XML 结构,假设输入本身就是内容 (保持原有逻辑的兼容性)
|
||||
const result = match?.[1] || xmlStr
|
||||
return decodeXml(result)
|
||||
}
|
||||
|
||||
// 避免每次调用时的运行时检查
|
||||
export const extractLyricContent: QRCParserFn =
|
||||
typeof DOMParser !== 'undefined' ? domParser : regexParser
|
||||
Reference in New Issue
Block a user