初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
export const _sig = [76, 97, 111, 83, 104, 117, 105]
export const _sys = [86, 111, 105, 99, 101, 72, 117, 98]
export const _repo = [
104, 116, 116, 112, 115, 58, 47, 47, 103, 105, 116, 104, 117, 98, 46, 99, 111, 109, 47, 108, 97,
111, 115, 104, 117, 105, 107, 97, 105, 120, 117, 101, 47, 86, 111, 105, 99, 101, 72, 117, 98
]
export const getCopyrightOwner = () => String.fromCharCode(..._sig)
export const getSystemName = () => String.fromCharCode(..._sys)
export const getRepoUrl = () => String.fromCharCode(..._repo)
+309
View File
@@ -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 = [
'(?:【.*?音乐人.*?】|\\(.*?音乐人.*?\\)|「.*?音乐人.*?」|(.*?音乐人.*?)|『.*?音乐人.*?』)',
'.*?未经.*?不得.*?'
]
+230
View File
@@ -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
}
+506
View File
@@ -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)
}
+411
View File
@@ -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)
}
+69
View File
@@ -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
}
+64
View File
@@ -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(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/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
+300
View File
@@ -0,0 +1,300 @@
/**
* 歌词数据适配器
* 将现有的歌词数据格式转换为 WordByWordLyrics 组件所需的 AMLL 格式
*/
import type { ParsedLyricLine as ExistingLyricLine } from '~/composables/useLyrics'
// AMLL 格式的接口定义
export interface LyricWord {
startTime: number
endTime: number
word: string
romanWord: string
obscene: boolean
}
export interface AdapterLyricLine {
words: LyricWord[]
translatedLyric: string
romanLyric: string
startTime: number
endTime: number
isBG: boolean
isDuet: boolean
}
/**
* 将现有的歌词数据转换为 AMLL 格式
* @param existingLyrics 现有的歌词数据
* @param translationLyrics 翻译歌词数据
* @param options 转换选项
*/
export function convertToAmllFormat(
existingLyrics: ExistingLyricLine[],
translationLyrics: ExistingLyricLine[] = [],
options: {
enableWordByWord?: boolean
defaultDuration?: number
romanization?: boolean
} = {}
): AdapterLyricLine[] {
const { enableWordByWord = true, defaultDuration = 1000, romanization = false } = options
return existingLyrics.map((line, index) => {
// 计算行的结束时间
const nextLine = existingLyrics[index + 1]
const endTime = nextLine ? nextLine.time : line.time + defaultDuration
// 查找对应的翻译歌词
const translation = findMatchingTranslation(line, translationLyrics)
// 处理逐字歌词
const words = processWords(line, endTime, enableWordByWord)
return {
words,
translatedLyric:
translation &&
translation.content &&
translation.content.trim() &&
translation.content !== '//'
? translation.content
: '',
romanLyric: romanization ? generateRomanization(line.content) : '',
startTime: line.time,
endTime,
isBG: false, // 可以根据需要扩展
isDuet: false // 可以根据需要扩展
}
})
}
/**
* 处理单行歌词的词汇数据
*/
function processWords(
line: ExistingLyricLine,
lineEndTime: number,
enableWordByWord: boolean
): LyricWord[] {
// 如果有逐字歌词数据,直接使用
if (enableWordByWord && line.words && line.words.length > 0) {
return line.words.map((word) => ({
startTime: word.time,
endTime: word.time + word.duration,
word: word.content,
romanWord: '',
obscene: false
}))
}
// 如果没有逐字歌词,将整行作为一个词处理
if (line.content.trim()) {
return [
{
startTime: line.time,
endTime: lineEndTime,
word: line.content,
romanWord: '',
obscene: false
}
]
}
return []
}
/**
* 查找匹配的翻译歌词
*/
function findMatchingTranslation(
line: ExistingLyricLine,
translations: ExistingLyricLine[]
): ExistingLyricLine | null {
if (!translations.length) return null
// 查找时间最接近的翻译
let bestMatch: ExistingLyricLine | null = null
let minTimeDiff = Infinity
for (const translation of translations) {
const timeDiff = Math.abs(translation.time - line.time)
if (timeDiff < minTimeDiff) {
minTimeDiff = timeDiff
bestMatch = translation
}
}
// 如果时间差超过5秒,认为不匹配
return minTimeDiff <= 5000 ? bestMatch : null
}
/**
* 生成音译(简单实现,可以根据需要扩展)
*/
function generateRomanization(text: string): string {
// 这里可以集成更复杂的音译逻辑
// 目前返回空字符串,可以根据需要实现
return ''
}
/**
* 智能分词处理(用于没有逐字歌词的情况)
*/
export function smartWordSegmentation(
text: string,
startTime: number,
endTime: number,
options: {
minWordDuration?: number
maxWordDuration?: number
} = {}
): LyricWord[] {
const { minWordDuration = 200, maxWordDuration = 2000 } = options
if (!text.trim()) return []
// 分词逻辑:
// 1. 对于英文,按空格分割单词
// 2. 对于中文,只在句末标点符号处分割,避免拆分歌名中的数字和符号
// 检测是否包含英文字母
const hasEnglish = /[a-zA-Z]/.test(text)
let segments: string[]
if (hasEnglish) {
// 英文内容:按空格和句末标点符号分割
segments = text.split(/(\s+|[.!?。!?])/g).filter((s) => s.trim())
} else {
// 中文内容:只按句末标点符号分割,避免拆分歌名中的数字和符号
segments = text.split(/([。!?])/g).filter((s) => s.trim() && !/^[。!?\s]+$/.test(s))
}
// 如果没有明显的分割点,或者分割后只有一个片段,将整行作为一个词处理
if (segments.length <= 1) {
return [
{
startTime,
endTime,
word: text.trim(),
romanWord: '',
obscene: false
}
]
}
const totalDuration = endTime - startTime
const avgDuration = Math.max(
minWordDuration,
Math.min(maxWordDuration, totalDuration / segments.length)
)
return segments.map((segment, index) => {
const wordStartTime = startTime + index * avgDuration
const wordEndTime = Math.min(wordStartTime + avgDuration, endTime)
return {
startTime: wordStartTime,
endTime: wordEndTime,
word: segment.trim(),
romanWord: '',
obscene: false
}
})
}
/**
* 检测和标记背景歌词
*/
export function detectBackgroundLyrics(lyrics: AdapterLyricLine[]): AdapterLyricLine[] {
return lyrics.map((line) => {
// 简单的背景歌词检测逻辑
const isBG =
line.words.length === 0 ||
line.words.every((word) => word.word.trim().length === 0) ||
(line.translatedLyric.includes('(') && line.translatedLyric.includes(')'))
return {
...line,
isBG
}
})
}
/**
* 检测和标记对唱歌词
*/
export function detectDuetLyrics(lyrics: AdapterLyricLine[]): AdapterLyricLine[] {
return lyrics.map((line) => {
// 简单的对唱检测逻辑
const isDuet =
line.translatedLyric.includes('(对唱)') ||
line.translatedLyric.includes('(合唱)') ||
line.words.some((word) => word.word.includes('(') && word.word.includes(')'))
return {
...line,
isDuet
}
})
}
/**
* 完整的歌词数据转换流程
*/
export function fullLyricConversion(
existingLyrics: ExistingLyricLine[],
translationLyrics: ExistingLyricLine[] = [],
options: {
enableWordByWord?: boolean
enableSmartSegmentation?: boolean
enableBackgroundDetection?: boolean
enableDuetDetection?: boolean
romanization?: boolean
} = {}
): AdapterLyricLine[] {
const {
enableWordByWord = true,
enableSmartSegmentation = true,
enableBackgroundDetection = true,
enableDuetDetection = true,
romanization = false
} = options
// 基础转换
let convertedLyrics = convertToAmllFormat(existingLyrics, translationLyrics, {
enableWordByWord,
romanization
})
// 智能分词(对于没有逐字歌词的行)
if (enableSmartSegmentation) {
convertedLyrics = convertedLyrics.map((line) => {
if (line.words.length === 1 && line.words[0].word === line.words[0].word.trim()) {
const smartWords = smartWordSegmentation(line.words[0].word, line.startTime, line.endTime)
if (smartWords.length > 1) {
return {
...line,
words: smartWords
}
}
}
return line
})
}
// 背景歌词检测
if (enableBackgroundDetection) {
convertedLyrics = detectBackgroundLyrics(convertedLyrics)
}
// 对唱歌词检测
if (enableDuetDetection) {
convertedLyrics = detectDuetLyrics(convertedLyrics)
}
return convertedLyrics
}
+162
View File
@@ -0,0 +1,162 @@
/**
* 音源配置文件
* 定义音源接口、配置和默认设置
*/
/**
* 音源接口定义
*/
export interface MusicSource {
/** 音源唯一标识 */
id: string
/** 音源显示名称 */
name: string
/** API基础URL */
baseUrl: string
/** 优先级,数字越小优先级越高 */
priority: number
/** 是否启用 */
enabled: boolean
/** 请求超时时间(毫秒),可选 */
timeout?: number
/** 自定义请求头,可选 */
headers?: Record<string, string>
}
/**
* 音源配置接口
*/
export interface MusicSourceConfig {
/** 主音源ID */
primarySource: string
/** 是否启用故障转移 */
enableFailover: boolean
/** 音源列表 */
sources: MusicSource[]
/** 默认超时时间(毫秒) */
timeout: number
/** 重试次数 */
retryAttempts: number
}
/**
* 音源状态接口
*/
export interface SourceStatus {
/** 状态:在线、离线、错误 */
status: 'online' | 'offline' | 'error'
/** 响应时间(毫秒) */
responseTime?: number
/** 最后检查时间 */
lastCheck: Date
/** 错误信息 */
errorMessage?: string
}
/**
* 音乐搜索参数接口
*/
export interface MusicSearchParams {
/** 搜索关键词 */
keywords: string
/** 音乐平台:目前仅支持 kugou=酷狗音乐 */
platform?: string
/** 返回数量限制 */
limit?: number
/** 偏移量 */
offset?: number
/** 搜索类型:1=单曲, 10=专辑, 100=歌手, 1000=歌单, 1009=播客/电台, 2000=声音 */
type?: number
}
/**
* 音乐搜索结果接口
*/
export interface MusicSearchResult {
/** 是否成功 */
success: boolean
/** 使用的音源ID */
source: string
/** 搜索结果数据 */
data: any[]
/** 错误信息 */
error?: string
}
/**
* 歌曲详情参数接口
*/
export interface SongDetailParams {
/** 歌曲ID,可以是单个或多个 */
ids: string | string[]
}
/**
* 歌曲详情结果接口
*/
export interface SongDetailResult {
/** 是否成功 */
success: boolean
/** 使用的音源ID */
source: string
/** 歌曲详情数据 */
data: {
songs: any[]
}
/** 错误信息 */
error?: string
}
/**
* 默认音源配置
* 包含主音源 Vkeys 和两个网易云备用音源端点
*/
export const MUSIC_SOURCE_CONFIG: MusicSourceConfig = {
primarySource: 'kugou',
enableFailover: false,
timeout: 10000,
retryAttempts: 2,
sources: [
{
id: 'kugou',
name: '酷狗音乐',
baseUrl: '/api/music',
priority: 1,
enabled: true,
timeout: 10000,
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
}
]
}
/**
* 获取启用的音源列表(按优先级排序)
*/
export function getEnabledSources(): MusicSource[] {
return MUSIC_SOURCE_CONFIG.sources
.filter((source) => source.enabled)
.sort((a, b) => a.priority - b.priority)
}
/**
* 根据ID获取音源
*/
export function getSourceById(id: string): MusicSource | undefined {
return MUSIC_SOURCE_CONFIG.sources.find((source) => source.id === id)
}
/**
* 获取主音源
*/
export function getPrimarySource(): MusicSource | undefined {
return getSourceById(MUSIC_SOURCE_CONFIG.primarySource)
}
/**
* 获取备用音源列表
*/
export function getBackupSources(): MusicSource[] {
return getEnabledSources().filter((source) => source.id !== MUSIC_SOURCE_CONFIG.primarySource)
}
+51
View File
@@ -0,0 +1,51 @@
import { useAudioQuality } from '~/composables/useAudioQuality'
import { useMusicSources } from '~/composables/useMusicSources'
/**
* 动态获取音乐播放URL
* @param platform 音乐平台
* @param musicId 音乐ID
* @param playUrl 用户提供的播放链接(可选)
* @param options 额外选项,例如 { unblock: boolean, quality: number }
* @returns Promise<string | null> 返回播放URL或null
*/
export async function getMusicUrl(
platform: string,
musicId: string | number,
playUrl?: string,
options?: { unblock?: boolean; quality?: number }
): Promise<string | null> {
if (playUrl && playUrl.trim()) {
return playUrl.trim()
}
if (
!platform ||
!musicId ||
platform === 'unknown' ||
platform === '' ||
musicId === null ||
musicId === ''
) {
throw new Error('缺少音乐平台或音乐ID信息')
}
const { getQuality } = useAudioQuality()
const { getSongUrl } = useMusicSources()
const quality =
options?.quality !== undefined ? options.quality : getQuality(platform)
const result = await getSongUrl(
musicId,
quality,
platform,
undefined,
options
)
if (result.success && result.url) {
return result.url
}
throw new Error(result.error || '获取音乐播放链接失败')
}
+8
View File
@@ -0,0 +1,8 @@
export const getProviderDisplayName = (provider: string): string => {
const map: Record<string, string> = {
github: 'GitHub',
casdoor: 'Casdoor',
google: 'Google'
}
return map[provider.toLowerCase()] || provider.charAt(0).toUpperCase() + provider.slice(1)
}
+157
View File
@@ -0,0 +1,157 @@
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import timezone from 'dayjs/plugin/timezone.js'
dayjs.extend(utc)
dayjs.extend(timezone)
const BEIJING_TIMEZONE = 'Asia/Shanghai'
/**
* 时间工具函数
* 使用 dayjs 处理北京时间(UTC+8)相关逻辑
*
* 核心原则:
* 1. 系统内部传递的 Date 对象保持为真实的 UTC 时间 (new Date())。
* 2. 数据库中存储的 Timestamp 字段应为真实 UTC 时间。
* 3. 只有在需要"显示"或"按北京时间逻辑计算(如每日限额)"时,才转换为北京时间处理。
*
* 注意:此前版本可能使用了"偏移后的 Date 对象"(即 UTC 读数等于北京时间的 Date),
* 本次重构将其纠正为标准模式,以避免混淆和潜在的 Bug。
*/
/**
* 获取当前时间 (真实 UTC)
* 用于替代 new Date(),方便统一管理
*/
export function getBeijingTime(): Date {
return new Date()
}
/**
* 转换时间 (保留函数签名以兼容旧代码)
* 直接返回输入的时间,不再进行偏移处理
*/
export function toBeijingTime(date: Date): Date {
return date
}
/**
* 创建北京时间对应的 UTC Date
* 例如:传入 2023, 0, 1, 0, 0, 0 (北京时间 2023-01-01 00:00:00)
* 返回:UTC 2022-12-31 16:00:00
*/
export function createBeijingTime(
year: number,
month: number, // 0-11
day: number,
hour: number = 0,
minute: number = 0,
second: number = 0
): Date {
return dayjs()
.tz(BEIJING_TIMEZONE)
.set('year', year)
.set('month', month)
.set('date', day)
.set('hour', hour)
.set('minute', minute)
.set('second', second)
.set('millisecond', 0)
.toDate()
}
/**
* 格式化时间为北京时间字符串
* @param date 真实 UTC 时间
* @param format 格式字符串
*/
export function formatDateTime(date: Date, format: string = 'YYYY/M/D H:mm:ss'): string {
return dayjs(date).tz(BEIJING_TIMEZONE).format(format)
}
/**
* 获取时间戳 (真实 UTC)
*/
export function getBeijingTimestamp(): Date {
return new Date()
}
/**
* 解析北京时间字符串为真实 UTC Date
*/
export function parseToBeijingTime(dateString: string): Date {
return dayjs.tz(dateString, BEIJING_TIMEZONE).toDate()
}
/**
* 获取当前北京时间的 ISO 字符串 (YYYY-MM-DDTHH:mm:ss)
* 用于与数据库中存储的字符串比较 (requestTimes 表)
*/
export function getBeijingTimeISOString(): string {
return dayjs().tz(BEIJING_TIMEZONE).format('YYYY-MM-DDTHH:mm:ss')
}
/**
* 获取北京时间的小时 (0-23)
*/
export function getBeijingHour(date: Date): number {
return dayjs(date).tz(BEIJING_TIMEZONE).hour()
}
/**
* 获取“北京时间某天开始”对应的真实 UTC 时间
* 例如:北京时间 2023-01-02 00:00:00 -> 返回 UTC 2023-01-01 16:00:00
* @param date 参考日期 (真实 UTC),默认为当前时间
*/
export function getBeijingStartOfDay(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs().tz(BEIJING_TIMEZONE)
return target.startOf('day').toDate()
}
/**
* 获取“北京时间某天结束”对应的真实 UTC 时间
*/
export function getBeijingEndOfDay(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs().tz(BEIJING_TIMEZONE)
return target.endOf('day').toDate()
}
/**
* 获取“北京时间本周开始”对应的真实 UTC 时间
* (周一 00:00:00)
*/
export function getBeijingStartOfWeek(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs().tz(BEIJING_TIMEZONE)
// 处理周一开始 (dayjs 默认周日为 0)
const day = target.day()
const diff = day === 0 ? 6 : day - 1
return target.subtract(diff, 'day').startOf('day').toDate()
}
/**
* 获取“北京时间本周结束”对应的真实 UTC 时间
* (周日 23:59:59)
*/
export function getBeijingEndOfWeek(date?: Date): Date {
// 获取本周开始,然后加 6 天并取结束
// 注意:这里需要再次转回 dayjs 对象处理
const startOfWeek = dayjs(getBeijingStartOfWeek(date)).tz(BEIJING_TIMEZONE)
return startOfWeek.add(6, 'day').endOf('day').toDate()
}
/**
* 获取“北京时间本月开始”对应的真实 UTC 时间
*/
export function getBeijingStartOfMonth(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs().tz(BEIJING_TIMEZONE)
return target.startOf('month').toDate()
}
/**
* 获取“北京时间本月结束”对应的真实 UTC 时间
*/
export function getBeijingEndOfMonth(date?: Date): Date {
const target = date ? dayjs(date).tz(BEIJING_TIMEZONE) : dayjs().tz(BEIJING_TIMEZONE)
return target.endOf('month').toDate()
}
+123
View File
@@ -0,0 +1,123 @@
/**
* 将HTTP URL转换为HTTPS
* @param url - 要转换的URL
* @returns 转换后的HTTPS URL
*/
export const convertToHttps = (url: string | null | undefined): string => {
if (!url) return url || ''
if (url.startsWith('http://')) {
return url.replace('http://', 'https://')
}
return url
}
/**
* 验证URL格式是否有效
* @param url - 要验证的URL字符串
* @returns 验证结果对象
*/
export const validateUrl = (url: string): { valid: boolean; error?: string } => {
if (!url) return { valid: true } // 空URL视为有效(可选字段)
if (typeof url !== 'string') {
return { valid: false, error: 'URL必须是字符串类型' }
}
// 检查是否以http://或https://开头
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return { valid: false, error: 'URL必须以http://或https://开头' }
}
// 检查是否包含://结构
if (!url.includes('://')) {
return { valid: false, error: 'URL格式不正确,缺少://结构' }
}
try {
// 使用URL构造函数验证基本格式
new URL(url)
return { valid: true }
} catch {
return { valid: false, error: 'URL格式不正确' }
}
}
/**
* 批量验证URL
* @param urls - URL对象数组,包含url和type字段
* @returns Promise<ValidationResult[]> - 验证结果数组
*/
export interface UrlValidationItem {
url: string
type?: 'image' | 'audio' // 保持兼容性,但不再使用
name?: string // 用于标识URL的名称
}
export interface ValidationResult {
url: string
type?: 'image' | 'audio'
name?: string
valid: boolean
error?: string
}
export const validateUrls = async (urls: UrlValidationItem[]): Promise<ValidationResult[]> => {
const results: ValidationResult[] = []
for (const item of urls) {
const result = validateUrl(item.url)
results.push({
url: item.url,
type: item.type,
name: item.name,
valid: result.valid,
error: result.error
})
}
return results
}
/**
* 获取 Bilibili 视频链接
* @param song - 歌曲对象,包含 playUrl 和 musicId
* @returns Bilibili 视频链接
*/
export const getBilibiliUrl = (song: {
playUrl?: string | null
musicId?: string | null
}): string => {
if (!song) return '#'
// 优先使用 playUrl,如果它已经是 Bilibili 链接
if (song.playUrl && song.playUrl.includes('bilibili.com')) {
return song.playUrl
}
if (song.musicId) {
// 处理带分P的情况,支持以下格式:
// 1. BVxxx (只有 BV 号) -> 跳转到 BV 视频页
// 2. BVxxx:cid (BV + CID) -> 跳转到 BV 视频页 (默认 P1)
// 3. BVxxx:cid:p (BV + CID + 分P页码) -> 跳转到指定 P
const parts = song.musicId.split(':')
const bvId = parts[0]
// 检查是否有第三个参数(分P信息)
// 如果没有冒号 (length=1) 或只有一段冒号 (length=2),则 p=1
const parsedP = parts.length >= 3 ? parseInt(parts[2]) : 1
const p = isNaN(parsedP) ? 1 : parsedP
let url = `https://www.bilibili.com/video/${bvId}/`
// 只有当 p > 1 时才添加 p 参数
if (p > 1) {
url += `?p=${p}`
}
return url
}
return '#'
}