Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .nx/version-plans/version-plan-1786786354169.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
core-bundle: minor
---

refactor(core)!: 完善歌词优化的边界处理

* 现在 `setLyricLines` 会在遇到非法的时间戳抛出错误
* `convertExcessiveBackgroundLines` 已弃用,该选项不再有效果
22 changes: 14 additions & 8 deletions packages/core/src/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,10 @@ export interface OptimizeLyricOptions {
*/
resetLineTimestamps?: boolean;
/**
* 把多行背景人声转换为单行背景人声 + 主歌词行的形式
* @default true
* 该选项已不再生效,歌词优化时始终会把多行背景人声转换为
* 单行背景人声 + 主歌词行的形式
*
* @deprecated 现有播放器架构已不再支持多个连续的背景人声行
*/
convertExcessiveBackgroundLines?: boolean;
/**
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lyric-player/base/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/lyric-player/base/lyric-data-manager.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -42,6 +43,7 @@ export class LyricDataManager {
private hasDuetLine = false;

public setOriginalLines(lines: LyricLine[]): void {
assertValidLyricTimestamps(lines);
this.rawLines = structuredClone(lines);
this.isDirty = true;
}
Expand Down
175 changes: 122 additions & 53 deletions packages/core/src/utils/optimize-lyric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -79,73 +79,131 @@ 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;
}
}
}

/**
* 清洗非刻意的重叠
*
* 如果重叠大于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;
Expand All @@ -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;
}
Expand All @@ -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 &&
Expand All @@ -211,7 +280,7 @@ function tryAdvanceStartTime(lines: LyricLine[]) {
prevMainGroupEndTime = originalEndTime;
}

prevLineStartTime = originalStartTime;
prevLineStartTime = line.startTime;
prevLineEndTime = originalEndTime;
hasPrevLine = true;
}
Expand All @@ -238,30 +307,30 @@ 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);
}
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);
}
}
Loading