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

412 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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)
}