初次提交
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,506 @@
|
||||
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)
|
||||
}
|
||||
@@ -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