diff --git a/.nx/version-plans/version-plan-1786786354169.md b/.nx/version-plans/version-plan-1786786354169.md new file mode 100644 index 0000000000..3863efacaa --- /dev/null +++ b/.nx/version-plans/version-plan-1786786354169.md @@ -0,0 +1,8 @@ +--- +core-bundle: minor +--- + +refactor(core)!: 完善歌词优化的边界处理 + +* 现在 `setLyricLines` 会在遇到非法的时间戳抛出错误 +* `convertExcessiveBackgroundLines` 已弃用,该选项不再有效果 diff --git a/packages/core/src/interfaces.ts b/packages/core/src/interfaces.ts index edf02adf8e..37ea3b3193 100644 --- a/packages/core/src/interfaces.ts +++ b/packages/core/src/interfaces.ts @@ -81,8 +81,10 @@ export interface OptimizeLyricOptions { */ resetLineTimestamps?: boolean; /** - * 把多行背景人声转换为单行背景人声 + 主歌词行的形式 - * @default true + * 该选项已不再生效,歌词优化时始终会把多行背景人声转换为 + * 单行背景人声 + 主歌词行的形式 + * + * @deprecated 现有播放器架构已不再支持多个连续的背景人声行 */ convertExcessiveBackgroundLines?: boolean; /** @@ -93,18 +95,22 @@ export interface OptimizeLyricOptions { /** * 清洗非刻意的重叠,以免不必要的多行高亮效果 * - * 如果两行时间轴有重叠的歌词满足下列条件之一: - * * 重叠小于 100ms - * * 重叠时长不足下一行时长的 10% + * 重叠**达到** 500ms 时视为有意重叠并予以保留 * - * 则截断上一行歌词的结束时间为下一行歌词的开始时间 + * 重叠**不足** 500ms,且满足下列条件之一时视为无意重叠: + * * 重叠不超过 100ms + * * 重叠时长不超过下一行时长的 10% + * + * 并截断上一行歌词的结束时间为下一行歌词的开始时间 * @default true */ cleanUnintentionalOverlaps?: boolean; /** - * 尝试让歌词提前最多 1 秒开始 + * 尝试让歌词提前最多 600ms 开始 + * + * 与上一行存在重叠时尝试提前 400ms * - * 有重叠则尝试最多提前 400ms 或上一行时长的 30% + * 若重叠时长不足 400ms,则提前重叠时长的 70% * @default true */ tryAdvanceStartTime?: boolean; diff --git a/packages/core/src/lyric-player/base/index.ts b/packages/core/src/lyric-player/base/index.ts index dce1a45220..026d1270dd 100644 --- a/packages/core/src/lyric-player/base/index.ts +++ b/packages/core/src/lyric-player/base/index.ts @@ -527,6 +527,8 @@ export abstract class LyricPlayerBase * 设置当前播放歌词,要注意传入后这个数组内的信息不得修改,否则会发生错误 * @param lines 歌词数组 * @param initialTime 初始时间,默认为 0 + * @throws {TypeError} 歌词时间戳不是有限的非负数字 + * @throws {RangeError} 任一行、单词或注音的开始时间晚于结束时间 */ setLyricLines(lines: LyricLine[], initialTime = 0): void { if (import.meta.env.DEV) { diff --git a/packages/core/src/lyric-player/base/lyric-data-manager.ts b/packages/core/src/lyric-player/base/lyric-data-manager.ts index 72a4f55b58..56ed571ab1 100644 --- a/packages/core/src/lyric-player/base/lyric-data-manager.ts +++ b/packages/core/src/lyric-player/base/lyric-data-manager.ts @@ -1,6 +1,7 @@ import structuredClone from "@ungap/structured-clone"; import type { LyricLine, LyricWord, OptimizeLyricOptions } from "#interfaces"; import { optimizeLyricLines } from "#utils/optimize-lyric.ts"; +import { assertValidLyricTimestamps } from "#utils/validate-lyric.ts"; import { MaskObsceneWordsMode } from "./consts.ts"; /** @@ -42,6 +43,7 @@ export class LyricDataManager { private hasDuetLine = false; public setOriginalLines(lines: LyricLine[]): void { + assertValidLyricTimestamps(lines); this.rawLines = structuredClone(lines); this.isDirty = true; } diff --git a/packages/core/src/utils/optimize-lyric.ts b/packages/core/src/utils/optimize-lyric.ts index 333bd4f8b0..3d236d1876 100644 --- a/packages/core/src/utils/optimize-lyric.ts +++ b/packages/core/src/utils/optimize-lyric.ts @@ -3,7 +3,6 @@ import type { LyricLine, OptimizeLyricOptions } from "../interfaces.ts"; const DEFAULT_OPTIMIZE_OPTIONS: OptimizeLyricOptions = { normalizeSpaces: true, resetLineTimestamps: true, - convertExcessiveBackgroundLines: true, syncMainAndBackgroundLines: true, cleanUnintentionalOverlaps: true, tryAdvanceStartTime: true, @@ -46,15 +45,16 @@ function resetLineTimestamps(lines: LyricLine[]) { } /** - * 把多行背景人声转换为单行背景人声 + 主歌词行的形式 + * 确保背景人声前存在主歌词,并把多行背景人声转换为单行背景人声 + 主歌词行的形式 */ function convertExcessiveBackgroundLines(lines: LyricLine[]) { let consecutiveBgCount = 0; - for (const line of lines) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; if (line.isBG) { consecutiveBgCount++; - if (consecutiveBgCount > 1) { + if (i === 0 || consecutiveBgCount > 1) { line.isBG = false; } } else { @@ -79,22 +79,59 @@ function syncMainAndBackgroundLines(lines: LyricLine[]) { (w) => w.word.trim().length > 0, ); - if (allWords.length > 0) { - const minStart = Math.min(...allWords.map((w) => w.startTime)); - const maxEnd = Math.max(...allWords.map((w) => w.endTime)); + const finalStart = Math.min( + line.startTime, + nextLine.startTime, + ...allWords.map((w) => w.startTime), + ); + const finalEnd = Math.max( + line.endTime, + nextLine.endTime, + ...allWords.map((w) => w.endTime), + ); - const finalStart = Math.min( - minStart, - line.startTime, - nextLine.startTime, - ); - const finalEnd = Math.max(maxEnd, line.endTime, nextLine.endTime); + line.startTime = finalStart; + line.endTime = finalEnd; + nextLine.startTime = finalStart; + nextLine.endTime = finalEnd; + } + } +} - line.startTime = finalStart; - line.endTime = finalEnd; - nextLine.startTime = finalStart; - nextLine.endTime = finalEnd; - } +/** + * 按主歌词的开始时间稳定排序,并保持主歌词与其背景人声的相对顺序 + */ +function sortLyricLines(lines: LyricLine[]) { + const groups: { + lines: LyricLine[]; + startTime: number; + originalIndex: number; + }[] = []; + + for (let i = 0; i < lines.length; i++) { + const mainLine = lines[i]; + const groupLines = [mainLine]; + + if (!mainLine.isBG && lines[i + 1]?.isBG) { + groupLines.push(lines[++i]); + } + + groups.push({ + lines: groupLines, + startTime: mainLine.startTime, + originalIndex: groups.length, + }); + } + + groups.sort((a, b) => { + const timeDifference = a.startTime - b.startTime; + return timeDifference || a.originalIndex - b.originalIndex; + }); + + let lineIndex = 0; + for (const group of groups) { + for (const line of group.lines) { + lines[lineIndex++] = line; } } } @@ -102,50 +139,71 @@ function syncMainAndBackgroundLines(lines: LyricLine[]) { /** * 清洗非刻意的重叠 * - * 如果重叠大于100ms 且 重叠超过下一行时长的10%,则视为刻意重叠,否则将结束时间设为下一行的开始时间 + * | 重叠情况 | 判定 | + * | :------------------------------------------ | ---- | + * | 重叠 ≥ 500ms | 有意 | + * | 100ms < 重叠 < 500ms 且 > 下一行时长的 10% | 有意 | + * | 重叠 ≤ 100ms 或 ≤ 下一行时长的 10% | 无意 | + * */ -function cleanUnintentionalOverlaps(lines: LyricLine[]) { +function cleanUnintentionalOverlaps( + lines: LyricLine[], + syncBackgroundLines: boolean, +) { for (let i = 0; i < lines.length - 1; i++) { const line = lines[i]; if (line.isBG) continue; - let nextMainIndex = i + 1; - while (nextMainIndex < lines.length && lines[nextMainIndex].isBG) { - nextMainIndex++; - } + // 即使下一行是有意重叠,也继续检查后续仍重叠的歌词,避免不必要的多行高亮,例如: + // A 0 - 3000 + // B 1000 - 2500 + // C 2950 - 3950 + // A 与 B 是有意重叠,但 A 与 C 只有 50ms 重叠,所以 A 最终被截断到 2950ms + for (let j = i + 1; j < lines.length; j++) { + const nextLine = lines[j]; + if (nextLine.isBG) continue; - if (nextMainIndex < lines.length) { - const nextLine = lines[nextMainIndex]; const overlap = line.endTime - nextLine.startTime; - if (overlap > 0) { - const nextDuration = nextLine.endTime - nextLine.startTime; - const percentageThreshold = nextDuration * 0.1; + if (overlap <= 0) { + break; + } + + const nextDuration = nextLine.endTime - nextLine.startTime; + const percentageThreshold = nextDuration * 0.1; - // 重叠大于100ms 且 重叠超过下一行时长的10% - const isIntentionalOverlap = - overlap > 100 && overlap > percentageThreshold; + const isIntentionalOverlap = + overlap >= 500 || (overlap > 100 && overlap > percentageThreshold); - if (!isIntentionalOverlap) { - line.endTime = nextLine.startTime; + if (!isIntentionalOverlap) { + line.endTime = nextLine.startTime; - const attachedBgLine = lines[i + 1]; - if (attachedBgLine?.isBG) { - attachedBgLine.endTime = nextLine.startTime; - } + const attachedBgLine = lines[i + 1]; + if (syncBackgroundLines && attachedBgLine?.isBG) { + attachedBgLine.endTime = nextLine.startTime; } + break; } } } } /** - * 尝试让歌词提前最多 600ms 开始,如果有重叠则尝试最多提前 400ms 或上一行时长的 30% + * 尝试让歌词提前最多 600ms 开始,如果有重叠则尝试提前 400ms,不够 400ms 的提前重叠时长的 70% */ -function tryAdvanceStartTime(lines: LyricLine[]) { +function tryAdvanceStartTime(lines: LyricLine[], syncBackgroundLines: boolean) { + /** + * 适用于第一行歌词或与上一行歌词存在充足间隔的提前量 + */ const defaultAdvanceAmount = 600; + /** + * 适用于与上一行存在重叠的提前量 + */ const fallbackAdvanceAmount = 400; - const fallbackAdvanceRatio = 0.3; + /** + * 与上一行重叠但重叠时长不够 400ms 时,按重叠时长提前的比率 + */ + const fallbackAdvanceRatio = 0.7; let prevLineStartTime = 0; let prevLineEndTime = 0; @@ -167,14 +225,23 @@ function tryAdvanceStartTime(lines: LyricLine[]) { const originallyHadGap = originalStartTime >= prevLineEndTime; if (originallyHadGap) { + // 与上一行有空隙或严丝合缝,最多提前 600ms,不超过上一行的结束时间 targetAdvanceAmount = defaultAdvanceAmount; safeBoundary = prevMainGroupEndTime; } else { - targetAdvanceAmount = fallbackAdvanceAmount; - const prevDuration = prevLineEndTime - prevLineStartTime; - safeBoundary = prevLineStartTime + prevDuration * fallbackAdvanceRatio; + // 与上一行有重叠,尝试提前 400ms,重叠时长不足则提前重叠时长的 70% + const overlapDuration = prevLineEndTime - originalStartTime; + + if (overlapDuration < fallbackAdvanceAmount) { + targetAdvanceAmount = overlapDuration * fallbackAdvanceRatio; + } else { + targetAdvanceAmount = fallbackAdvanceAmount; + } + // 使用上一条主歌词的时间,不能依赖可能独立计时的背景行 + safeBoundary = prevLineStartTime; } } else { + // 第一行歌词 targetAdvanceAmount = defaultAdvanceAmount; safeBoundary = 0; } @@ -186,11 +253,13 @@ function tryAdvanceStartTime(lines: LyricLine[]) { line.startTime = newStartTime; } + // 启用时间同步时,给背景人声同步开始时间 const nextLine = lines[i + 1]; - if (nextLine?.isBG) { + if (syncBackgroundLines && nextLine?.isBG) { nextLine.startTime = line.startTime; } + // 为连续重叠的歌词行都加到一个组里,以便接下来不重叠的歌词行看到的是整个组的时间边界而不仅限于上一行的边界 if (hasPrevLine) { const overlapsPrevGroup = originalStartTime < prevMainGroupEndTime && @@ -211,7 +280,7 @@ function tryAdvanceStartTime(lines: LyricLine[]) { prevMainGroupEndTime = originalEndTime; } - prevLineStartTime = originalStartTime; + prevLineStartTime = line.startTime; prevLineEndTime = originalEndTime; hasPrevLine = true; } @@ -238,13 +307,14 @@ export function areOptimizeOptionsEqual( * * 注意会直接原地修改入参,确保你已经提前深克隆了歌词行数组 * @param lines 歌词行数组 - * @param options 优化的可选配置,默认全部开启 + * @param options 优化的可选配置,除已弃用选项外默认全部开启 */ export function optimizeLyricLines( lines: LyricLine[], options?: OptimizeLyricOptions, ): void { const config = { ...DEFAULT_OPTIMIZE_OPTIONS, ...options }; + const syncBackgroundLines = config.syncMainAndBackgroundLines ?? true; if (config.normalizeSpaces) { normalizeSpaces(lines); @@ -252,16 +322,15 @@ export function optimizeLyricLines( if (config.resetLineTimestamps) { resetLineTimestamps(lines); } - if (config.convertExcessiveBackgroundLines) { - convertExcessiveBackgroundLines(lines); - } - if (config.syncMainAndBackgroundLines) { + convertExcessiveBackgroundLines(lines); + if (syncBackgroundLines) { syncMainAndBackgroundLines(lines); } + sortLyricLines(lines); if (config.cleanUnintentionalOverlaps) { - cleanUnintentionalOverlaps(lines); + cleanUnintentionalOverlaps(lines, syncBackgroundLines); } if (config.tryAdvanceStartTime) { - tryAdvanceStartTime(lines); + tryAdvanceStartTime(lines, syncBackgroundLines); } } diff --git a/packages/core/src/utils/validate-lyric.ts b/packages/core/src/utils/validate-lyric.ts new file mode 100644 index 0000000000..0a373c106e --- /dev/null +++ b/packages/core/src/utils/validate-lyric.ts @@ -0,0 +1,58 @@ +import type { LyricLine } from "../interfaces.ts"; + +function formatValue(value: unknown): string { + return typeof value === "string" ? JSON.stringify(value) : String(value); +} + +function assertTimestamp( + value: unknown, + path: string, +): asserts value is number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new TypeError( + `Invalid lyric timestamp at ${path}: ${formatValue(value)}`, + ); + } +} + +function assertTimestampRange( + value: { startTime: unknown; endTime: unknown }, + path: string, +): void { + const startTime = value.startTime; + const endTime = value.endTime; + assertTimestamp(startTime, `${path}.startTime`); + assertTimestamp(endTime, `${path}.endTime`); + + if (startTime > endTime) { + throw new RangeError( + `Invalid lyric timestamp range at ${path}: startTime ${startTime} is greater than endTime ${endTime}`, + ); + } +} + +/** + * 验证歌词数据中的时间戳满足基本数值约束,例如非负、有限等 + */ +export function assertValidLyricTimestamps(lines: readonly LyricLine[]): void { + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const linePath = `lines[${lineIndex}]`; + assertTimestampRange(line, linePath); + + for (let wordIndex = 0; wordIndex < line.words.length; wordIndex++) { + const word = line.words[wordIndex]; + const wordPath = `${linePath}.words[${wordIndex}]`; + assertTimestampRange(word, wordPath); + + if (word.ruby) { + for (let rubyIndex = 0; rubyIndex < word.ruby.length; rubyIndex++) { + assertTimestampRange( + word.ruby[rubyIndex], + `${wordPath}.ruby[${rubyIndex}]`, + ); + } + } + } + } +} diff --git a/packages/core/test/optimize-lyric.test.ts b/packages/core/test/optimize-lyric.test.ts new file mode 100644 index 0000000000..fc8d9407eb --- /dev/null +++ b/packages/core/test/optimize-lyric.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "vitest"; +import type { LyricLine, OptimizeLyricOptions } from "#interfaces"; +import { + areOptimizeOptionsEqual, + optimizeLyricLines, +} from "#utils/optimize-lyric.ts"; + +const DISABLED_OPTIONS: OptimizeLyricOptions = { + normalizeSpaces: false, + resetLineTimestamps: false, + syncMainAndBackgroundLines: false, + cleanUnintentionalOverlaps: false, + tryAdvanceStartTime: false, +}; + +function createLine( + startTime = 0, + endTime = 0, + options: Partial> = {}, +): LyricLine { + return { + words: [{ word: "line", startTime, endTime }], + translatedLyric: "", + romanLyric: "", + startTime, + endTime, + isBG: false, + isDuet: false, + ...options, + }; +} + +function optimizeWith( + lines: LyricLine[], + options: OptimizeLyricOptions = {}, +): void { + optimizeLyricLines(lines, { ...DISABLED_OPTIONS, ...options }); +} + +describe("areOptimizeOptionsEqual", () => { + it("considers options with the same values equal regardless of key order", () => { + expect( + areOptimizeOptionsEqual( + { normalizeSpaces: true, tryAdvanceStartTime: false }, + { tryAdvanceStartTime: false, normalizeSpaces: true }, + ), + ).toBe(true); + }); + + it("distinguishes missing or changed option values", () => { + expect(areOptimizeOptionsEqual()).toBe(true); + expect( + areOptimizeOptionsEqual( + { normalizeSpaces: true }, + { normalizeSpaces: false }, + ), + ).toBe(false); + expect(areOptimizeOptionsEqual({}, { normalizeSpaces: undefined })).toBe( + false, + ); + }); +}); + +describe("optimizeLyricLines", () => { + it("handles an empty lyrics array", () => { + const lines: LyricLine[] = []; + + optimizeLyricLines(lines); + + expect(lines).toEqual([]); + }); + + it("normalizes all whitespace runs without trimming word boundaries", () => { + const line = createLine(0, 1000, { + words: [{ word: "\thello \n world ", startTime: 0, endTime: 1000 }], + }); + + optimizeWith([line], { normalizeSpaces: true }); + + expect(line.words[0].word).toBe(" hello world "); + }); + + it("copies line timestamps to a single zero-timestamp word", () => { + const line = createLine(1000, 2000, { + words: [{ word: "line", startTime: 0, endTime: 0 }], + }); + + optimizeWith([line], { resetLineTimestamps: true }); + + expect(line.words[0]).toMatchObject({ startTime: 1000, endTime: 2000 }); + }); + + it("uses the first and last word timestamps as the line bounds", () => { + const line = createLine(0, 0, { + words: [ + { word: "first", startTime: 1000, endTime: 1400 }, + { word: "middle", startTime: 1400, endTime: 1800 }, + { word: "last", startTime: 1800, endTime: 2300 }, + ], + }); + + optimizeWith([line], { resetLineTimestamps: true }); + + expect(line).toMatchObject({ startTime: 1000, endTime: 2300 }); + }); + + it("keeps line timestamps when there are no words", () => { + const line = createLine(1000, 2000, { words: [] }); + + optimizeWith([line], { resetLineTimestamps: true }); + + expect(line).toMatchObject({ startTime: 1000, endTime: 2000 }); + }); + + it("always converts excessive background lines", () => { + const lines = [ + createLine(), + createLine(0, 0, { isBG: true }), + createLine(0, 0, { isBG: true }), + createLine(0, 0, { isBG: true }), + ]; + + optimizeLyricLines(lines, { + ...DISABLED_OPTIONS, + convertExcessiveBackgroundLines: false, + }); + + expect(lines.map((line) => line.isBG)).toEqual([false, true, false, false]); + }); + + it("converts a leading background line before sorting", () => { + const leadingBackground = createLine(3000, 4000, { isBG: true }); + const mainLine = createLine(1000, 2000); + const lines = [leadingBackground, mainLine]; + + optimizeWith(lines); + + expect(lines).toEqual([mainLine, leadingBackground]); + expect(leadingBackground.isBG).toBe(false); + }); + + it("synchronizes line and nonblank word bounds for a main/background pair", () => { + const mainLine = createLine(1000, 2000, { + words: [ + { word: " ", startTime: 0, endTime: 500 }, + { word: "main", startTime: 1200, endTime: 1800 }, + ], + }); + const backgroundLine = createLine(1100, 2100, { + isBG: true, + words: [{ word: "background", startTime: 900, endTime: 2300 }], + }); + + optimizeWith([mainLine, backgroundLine], { + syncMainAndBackgroundLines: true, + }); + + expect(mainLine).toMatchObject({ startTime: 900, endTime: 2300 }); + expect(backgroundLine).toMatchObject({ startTime: 900, endTime: 2300 }); + }); + + it("synchronizes a main/background pair without nonblank words", () => { + const mainLine = createLine(1000, 2000, { words: [] }); + const backgroundLine = createLine(800, 2200, { + isBG: true, + words: [{ word: " ", startTime: 0, endTime: 0 }], + }); + + optimizeWith([mainLine, backgroundLine], { + syncMainAndBackgroundLines: true, + }); + + expect(mainLine).toMatchObject({ startTime: 800, endTime: 2200 }); + expect(backgroundLine).toMatchObject({ startTime: 800, endTime: 2200 }); + }); + + it("sorts main lines without separating their background lines", () => { + const laterMain = createLine(3000, 4000); + const laterBackground = createLine(500, 4500, { isBG: true }); + const earlierMain = createLine(1000, 2000); + const earlierBackground = createLine(4000, 5000, { isBG: true }); + const lines = [laterMain, laterBackground, earlierMain, earlierBackground]; + + optimizeWith(lines); + + expect(lines).toEqual([ + earlierMain, + earlierBackground, + laterMain, + laterBackground, + ]); + }); + + it("keeps the original group order when main lines start together", () => { + const firstMain = createLine(1000, 2000); + const firstBackground = createLine(1000, 2000, { isBG: true }); + const secondMain = createLine(1000, 3000); + const lines = [firstMain, firstBackground, secondMain]; + + optimizeWith(lines); + + expect(lines).toEqual([firstMain, firstBackground, secondMain]); + }); + + it("sorts by the line timestamps produced from word timestamps", () => { + const laterLine = createLine(0, 0, { + words: [{ word: "later", startTime: 3000, endTime: 4000 }], + }); + const earlierLine = createLine(5000, 6000, { + words: [{ word: "earlier", startTime: 1000, endTime: 2000 }], + }); + const lines = [laterLine, earlierLine]; + + optimizeWith(lines, { resetLineTimestamps: true }); + + expect(lines).toEqual([earlierLine, laterLine]); + }); + + it.each([ + ["100ms overlap", 100, 1000, 1000], + ["just over 100ms and 10%", 101, 1000, 1101], + ["exactly 10%", 200, 2000, 1000], + ["just over 10%", 201, 2000, 1201], + ["499ms below 10%", 499, 10000, 1000], + ["500ms regardless of percentage", 500, 10000, 1500], + ])( + "classifies %s at the overlap boundaries", + (_, overlap, duration, endTime) => { + const line = createLine(0, 1000 + overlap); + const nextLine = createLine(1000, 1000 + duration); + + optimizeWith([line, nextLine], { cleanUnintentionalOverlaps: true }); + + expect(line.endTime).toBe(endTime); + }, + ); + + it("checks later overlapping lines after an intentional overlap", () => { + const longLine = createLine(0, 3000); + const intentionallyOverlappingLine = createLine(1000, 2500); + const slightlyOverlappingLine = createLine(2950, 3950); + + optimizeWith( + [longLine, intentionallyOverlappingLine, slightlyOverlappingLine], + { cleanUnintentionalOverlaps: true }, + ); + + expect(longLine.endTime).toBe(2950); + }); + + it("does not truncate an independent background line", () => { + const mainLine = createLine(0, 1100); + const backgroundLine = createLine(1050, 2000, { isBG: true }); + const nextMainLine = createLine(1000, 2000); + + optimizeWith([mainLine, backgroundLine, nextMainLine], { + cleanUnintentionalOverlaps: true, + }); + + expect(mainLine.endTime).toBe(1000); + expect(backgroundLine.endTime).toBe(2000); + }); + + it("keeps synchronized background end time aligned after overlap cleaning", () => { + const mainLine = createLine(0, 1100); + const backgroundLine = createLine(100, 1050, { isBG: true }); + const nextMainLine = createLine(1000, 2000); + + optimizeWith([mainLine, backgroundLine, nextMainLine], { + syncMainAndBackgroundLines: true, + cleanUnintentionalOverlaps: true, + }); + + expect(mainLine.endTime).toBe(1000); + expect(backgroundLine.endTime).toBe(1000); + }); + + it("advances the first main line by at most 600ms without going below zero", () => { + const laterLine = createLine(1000, 2000); + const earlyLine = createLine(400, 800); + + optimizeWith([laterLine], { tryAdvanceStartTime: true }); + optimizeWith([earlyLine], { tryAdvanceStartTime: true }); + + expect(laterLine.startTime).toBe(400); + expect(earlyLine.startTime).toBe(0); + }); + + it.each([ + ["a sufficient gap", 3000, 4000, 2400], + ["an exact boundary", 2000, 3000, 2000], + ["a 100ms overlap", 1900, 2900, 1830], + ["a 400ms overlap", 1600, 2600, 1200], + ["an overlap greater than 400ms", 1100, 2100, 700], + ])("advances a following line with %s", (_, startTime, endTime, expected) => { + const firstLine = createLine(1000, 2000); + const secondLine = createLine(startTime, endTime); + + optimizeWith([firstLine, secondLine], { tryAdvanceStartTime: true }); + + expect(secondLine.startTime).toBe(expected); + }); + + it("uses the entire previous overlap group as the gap boundary", () => { + const longLine = createLine(1000, 3000); + const overlappingLine = createLine(2000, 2500); + const followingLine = createLine(3100, 4000); + + optimizeWith([longLine, overlappingLine, followingLine], { + tryAdvanceStartTime: true, + }); + + expect(followingLine.startTime).toBe(3000); + }); + + it("does not synchronize background start time when synchronization is disabled", () => { + const mainLine = createLine(1000, 2000); + const backgroundLine = createLine(1500, 1800, { isBG: true }); + + optimizeWith([mainLine, backgroundLine], { + tryAdvanceStartTime: true, + }); + + expect(mainLine.startTime).toBe(400); + expect(backgroundLine.startTime).toBe(1500); + }); + + it("synchronizes background start time when synchronization is enabled", () => { + const mainLine = createLine(1000, 2000); + const backgroundLine = createLine(1500, 1800, { isBG: true }); + + optimizeWith([mainLine, backgroundLine], { + syncMainAndBackgroundLines: true, + tryAdvanceStartTime: true, + }); + + expect(mainLine.startTime).toBe(400); + expect(backgroundLine.startTime).toBe(400); + }); + + it("uses the advanced previous main line as the overlap boundary", () => { + const firstMainLine = createLine(1000, 2000); + const backgroundLine = createLine(1850, 2200, { isBG: true }); + const secondMainLine = createLine(1100, 2100); + + optimizeWith([firstMainLine, backgroundLine, secondMainLine], { + tryAdvanceStartTime: true, + }); + + expect(secondMainLine.startTime).toBe(700); + }); +}); diff --git a/packages/core/test/validate-lyric.test.ts b/packages/core/test/validate-lyric.test.ts new file mode 100644 index 0000000000..e03b5a661b --- /dev/null +++ b/packages/core/test/validate-lyric.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import type { LyricLine, LyricWord } from "#interfaces"; +import { LyricDataManager } from "#lyric/base/lyric-data-manager.ts"; +import { assertValidLyricTimestamps } from "#utils/validate-lyric.ts"; + +function createWord(overrides: Partial = {}): LyricWord { + return { + word: "word", + startTime: 1000, + endTime: 2000, + ...overrides, + }; +} + +function createLine(overrides: Partial = {}): LyricLine { + return { + words: [createWord()], + translatedLyric: "", + romanLyric: "", + startTime: 1000, + endTime: 2000, + isBG: false, + isDuet: false, + ...overrides, + }; +} + +function setField(target: object, key: string, value: unknown): void { + (target as Record)[key] = value; +} + +describe("assertValidLyricTimestamps", () => { + it("accepts unsorted, overlapping, zero-duration, and cross-boundary timestamps", () => { + const laterLine = createLine({ + startTime: 2000, + endTime: 2000, + words: [ + createWord({ startTime: 0, endTime: 3000 }), + createWord({ + word: " ", + startTime: 1000, + endTime: 1000, + ruby: [{ word: "", startTime: 1000, endTime: 1000 }], + }), + ], + }); + const earlierOverlappingLine = createLine({ + startTime: 1000, + endTime: 2500, + words: [], + }); + + expect(() => + assertValidLyricTimestamps([laterLine, earlierOverlappingLine]), + ).not.toThrow(); + }); + + it("reports the exact nested path for an invalid timestamp", () => { + const lines = Array.from({ length: 4 }, () => createLine()); + lines[3].words = Array.from({ length: 6 }, () => createWord()); + lines[3].words[5].endTime = Number.NaN; + + expect(() => assertValidLyricTimestamps(lines)).toThrow( + new TypeError( + "Invalid lyric timestamp at lines[3].words[5].endTime: NaN", + ), + ); + }); + + it.each<[string, unknown, string, (line: LyricLine, value: unknown) => void]>( + [ + [ + "null", + null, + "lines[0].startTime", + (line, value) => setField(line, "startTime", value), + ], + [ + "undefined", + undefined, + "lines[0].endTime", + (line, value) => setField(line, "endTime", value), + ], + [ + "negative numbers", + -1, + "lines[0].words[0].startTime", + (line, value) => setField(line.words[0], "startTime", value), + ], + [ + "positive infinity", + Number.POSITIVE_INFINITY, + "lines[0].words[0].ruby[0].startTime", + (line, value) => { + line.words[0].ruby = [ + { word: "ruby", startTime: 1000, endTime: 2000 }, + ]; + setField(line.words[0].ruby[0], "startTime", value); + }, + ], + [ + "negative infinity", + Number.NEGATIVE_INFINITY, + "lines[0].words[0].ruby[0].endTime", + (line, value) => { + line.words[0].ruby = [ + { word: "ruby", startTime: 1000, endTime: 2000 }, + ]; + setField(line.words[0].ruby[0], "endTime", value); + }, + ], + [ + "non-number values", + "1000", + "lines[0].words[0].endTime", + (line, value) => setField(line.words[0], "endTime", value), + ], + ], + )("rejects %s", (_, value, path, mutate) => { + const line = createLine(); + mutate(line, value); + + expect(() => assertValidLyricTimestamps([line])).toThrow( + new TypeError( + `Invalid lyric timestamp at ${path}: ${typeof value === "string" ? JSON.stringify(value) : String(value)}`, + ), + ); + }); + + it.each<[string, string, (line: LyricLine) => void]>([ + [ + "line", + "Invalid lyric timestamp range at lines[0]: startTime 2000 is greater than endTime 1000", + (line) => { + line.startTime = 2000; + line.endTime = 1000; + }, + ], + [ + "word", + "Invalid lyric timestamp range at lines[0].words[0]: startTime 2000 is greater than endTime 1000", + (line) => { + line.words[0].startTime = 2000; + line.words[0].endTime = 1000; + }, + ], + [ + "ruby syllable", + "Invalid lyric timestamp range at lines[0].words[0].ruby[0]: startTime 2000 is greater than endTime 1000", + (line) => { + line.words[0].ruby = [{ word: "ruby", startTime: 2000, endTime: 1000 }]; + }, + ], + ])("rejects reversed %s timestamp ranges", (_, message, mutate) => { + const line = createLine(); + mutate(line); + + expect(() => assertValidLyricTimestamps([line])).toThrow( + new RangeError(message), + ); + }); +}); + +describe("LyricDataManager lyric timestamp validation", () => { + it("validates and clones lines before storing them", () => { + const manager = new LyricDataManager(); + const lines = [createLine()]; + + manager.setOriginalLines(lines); + + expect(manager.getRawLines()).toEqual(lines); + expect(manager.getRawLines()).not.toBe(lines); + }); + + it("rejects invalid timestamps without replacing stored lines", () => { + const manager = new LyricDataManager(); + const originalLines = [createLine()]; + manager.setOriginalLines(originalLines); + const invalidLines = [createLine()]; + invalidLines[0].startTime = Number.NaN; + + expect(() => manager.setOriginalLines(invalidLines)).toThrow( + new TypeError("Invalid lyric timestamp at lines[0].startTime: NaN"), + ); + expect(manager.getRawLines()).toEqual(originalLines); + }); +});