@applemusic-like-lyrics/lyric 1.0.2 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -14
- package/dist/amll-lyric.cjs +717 -218
- package/dist/amll-lyric.cjs.map +1 -1
- package/dist/amll-lyric.d.cts +278 -131
- package/dist/amll-lyric.d.mts +278 -131
- package/dist/amll-lyric.mjs +714 -219
- package/dist/amll-lyric.mjs.map +1 -1
- package/package.json +6 -7
package/dist/amll-lyric.cjs
CHANGED
|
@@ -19,8 +19,16 @@ const createWord = (word) => ({
|
|
|
19
19
|
...word
|
|
20
20
|
});
|
|
21
21
|
const parseTime = (time) => Math.round(time.split(":").map(Number).reverse().reduce((acc, cur, idx) => acc + cur * 60 ** idx, 0) * 1e3);
|
|
22
|
+
/**
|
|
23
|
+
* 将一个时间戳的分、秒、毫秒三段文本转换为毫秒。
|
|
24
|
+
*
|
|
25
|
+
* 毫秒段可以省略,省略时按 `0` 计;
|
|
26
|
+
* 毫秒不足三位时视为在后位省略了 `0`,即 `.1` 为 100 毫秒、`.02` 为 20 毫秒;
|
|
27
|
+
* 超过三位的部分直接截断。
|
|
28
|
+
*/
|
|
29
|
+
const parseTimestampParts = (minStr, secStr, msStr = "") => parseInt(minStr, 10) * 6e4 + parseInt(secStr, 10) * 1e3 + parseInt(`${msStr}000`.slice(0, 3), 10);
|
|
22
30
|
const formatTime = (ms) => {
|
|
23
|
-
return `${Math.floor(ms / 6e4).toString().padStart(2, "0")}:${Math.floor(ms % 6e4 / 1e3).toString().padStart(2, "0")}.${Math.
|
|
31
|
+
return `${Math.floor(ms / 6e4).toString().padStart(2, "0")}:${Math.floor(ms % 6e4 / 1e3).toString().padStart(2, "0")}.${Math.floor(ms % 1e3).toString().padStart(3, "0")}`;
|
|
24
32
|
};
|
|
25
33
|
const normalizeTimestamp = (ms) => {
|
|
26
34
|
if (!Number.isFinite(ms) || ms < 0) return 0;
|
|
@@ -30,22 +38,19 @@ const normalizeDuration = (duration) => {
|
|
|
30
38
|
if (!Number.isFinite(duration) || duration < 0) return 0;
|
|
31
39
|
return duration;
|
|
32
40
|
};
|
|
33
|
-
const MAX_LRC_TIMESTAMP = 60039999;
|
|
34
|
-
const clampTimestamp = (ms, max = MAX_LRC_TIMESTAMP) => Math.min(max, normalizeTimestamp(ms));
|
|
35
41
|
/**
|
|
36
|
-
*
|
|
42
|
+
* LRC 家族时间戳可表示的最大值,即 `999:59.999`
|
|
37
43
|
*
|
|
38
|
-
*
|
|
44
|
+
* 时间戳的分钟为 1 至 3 位、秒为 1 至 2 位、毫秒为 1 至 6 位,
|
|
45
|
+
* 因此能写出来又读得回来的最大时间就到这里
|
|
39
46
|
*/
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
}
|
|
47
|
+
const MAX_LRC_TIMESTAMP = parseTimestampParts("999", "59", "999");
|
|
48
|
+
/**
|
|
49
|
+
* 将时间钳制到 LRC 家族可表示的范围内
|
|
50
|
+
* @param ms 时间,单位为毫秒
|
|
51
|
+
* @returns 钳制后的时间
|
|
52
|
+
*/
|
|
53
|
+
const clampTimestamp = (ms) => Math.min(MAX_LRC_TIMESTAMP, normalizeTimestamp(ms));
|
|
49
54
|
//#endregion
|
|
50
55
|
//#region src/formats/ass.ts
|
|
51
56
|
function writeASSTimestamp(ms) {
|
|
@@ -922,7 +927,7 @@ function rotateLeft28Bit(value, amount) {
|
|
|
922
927
|
* @param mode 加密或解密模式
|
|
923
928
|
*/
|
|
924
929
|
function keySchedule(key, mode) {
|
|
925
|
-
const schedule = new Int32Array(32);
|
|
930
|
+
const schedule = /* @__PURE__ */ new Int32Array(32);
|
|
926
931
|
const c0 = permuteFromKeyBytes(key, KEY_PERM_C);
|
|
927
932
|
const d0 = permuteFromKeyBytes(key, KEY_PERM_D);
|
|
928
933
|
let c = c0 << 4n;
|
|
@@ -1082,10 +1087,10 @@ const INV_IP_RULE = [
|
|
|
1082
1087
|
60,
|
|
1083
1088
|
28
|
|
1084
1089
|
];
|
|
1085
|
-
const IP_LEFT_TABLE = new Int32Array(2048);
|
|
1086
|
-
const IP_RIGHT_TABLE = new Int32Array(2048);
|
|
1087
|
-
const INV_IP_LEFT_TABLE = new Int32Array(2048);
|
|
1088
|
-
const INV_IP_RIGHT_TABLE = new Int32Array(2048);
|
|
1090
|
+
const IP_LEFT_TABLE = /* @__PURE__ */ new Int32Array(2048);
|
|
1091
|
+
const IP_RIGHT_TABLE = /* @__PURE__ */ new Int32Array(2048);
|
|
1092
|
+
const INV_IP_LEFT_TABLE = /* @__PURE__ */ new Int32Array(2048);
|
|
1093
|
+
const INV_IP_RIGHT_TABLE = /* @__PURE__ */ new Int32Array(2048);
|
|
1089
1094
|
function generatePermutationTables() {
|
|
1090
1095
|
const applyPermutation = (input, rule) => {
|
|
1091
1096
|
let output = 0n;
|
|
@@ -1129,7 +1134,7 @@ function applyQqPboxPermutation(input) {
|
|
|
1129
1134
|
}
|
|
1130
1135
|
return output;
|
|
1131
1136
|
}
|
|
1132
|
-
const SP_TABLE = new Int32Array(512);
|
|
1137
|
+
const SP_TABLE = /* @__PURE__ */ new Int32Array(512);
|
|
1133
1138
|
/**
|
|
1134
1139
|
* 生成 S-P 盒合并查找表以提高性能。
|
|
1135
1140
|
*/
|
|
@@ -1141,8 +1146,8 @@ function generateSpTables() {
|
|
|
1141
1146
|
}
|
|
1142
1147
|
}
|
|
1143
1148
|
generateSpTables();
|
|
1144
|
-
const EBOX_HIGH_TABLE = new Int32Array(1024);
|
|
1145
|
-
const EBOX_LOW_TABLE = new Int32Array(1024);
|
|
1149
|
+
const EBOX_HIGH_TABLE = /* @__PURE__ */ new Int32Array(1024);
|
|
1150
|
+
const EBOX_LOW_TABLE = /* @__PURE__ */ new Int32Array(1024);
|
|
1146
1151
|
function generateEBoxTables() {
|
|
1147
1152
|
for (let chunkIdx = 0; chunkIdx < 4; chunkIdx++) {
|
|
1148
1153
|
const shiftIn32 = (3 - chunkIdx) * 8;
|
|
@@ -1273,8 +1278,8 @@ var QqMusicCodec = class {
|
|
|
1273
1278
|
* 解密一个8字节的数据块。
|
|
1274
1279
|
*/
|
|
1275
1280
|
decryptBlock(input, output) {
|
|
1276
|
-
const temp1 = new Uint8Array(8);
|
|
1277
|
-
const temp2 = new Uint8Array(8);
|
|
1281
|
+
const temp1 = /* @__PURE__ */ new Uint8Array(8);
|
|
1282
|
+
const temp2 = /* @__PURE__ */ new Uint8Array(8);
|
|
1278
1283
|
desCrypt(input, temp1, this.decryptSchedule[0]);
|
|
1279
1284
|
desCrypt(temp1, temp2, this.decryptSchedule[1]);
|
|
1280
1285
|
desCrypt(temp2, output, this.decryptSchedule[2]);
|
|
@@ -1283,8 +1288,8 @@ var QqMusicCodec = class {
|
|
|
1283
1288
|
* 加密一个8字节的数据块。
|
|
1284
1289
|
*/
|
|
1285
1290
|
encryptBlock(input, output) {
|
|
1286
|
-
const temp1 = new Uint8Array(8);
|
|
1287
|
-
const temp2 = new Uint8Array(8);
|
|
1291
|
+
const temp1 = /* @__PURE__ */ new Uint8Array(8);
|
|
1292
|
+
const temp2 = /* @__PURE__ */ new Uint8Array(8);
|
|
1288
1293
|
desCrypt(input, temp1, this.encryptSchedule[0]);
|
|
1289
1294
|
desCrypt(temp1, temp2, this.encryptSchedule[1]);
|
|
1290
1295
|
desCrypt(temp2, output, this.encryptSchedule[2]);
|
|
@@ -1338,7 +1343,8 @@ function decryptQrcHex(encryptedHexString) {
|
|
|
1338
1343
|
* @returns 十六进制格式的字符串,代表被加密的歌词数据
|
|
1339
1344
|
*/
|
|
1340
1345
|
function encryptQrcHex(plaintext) {
|
|
1341
|
-
const
|
|
1346
|
+
const textBytes = new TextEncoder().encode(plaintext);
|
|
1347
|
+
const paddedData = zeroPad((0, pako.deflate)(textBytes), DES_BLOCK_SIZE);
|
|
1342
1348
|
const encryptedData = new Uint8Array(paddedData.length);
|
|
1343
1349
|
for (let i = 0; i < paddedData.length; i += DES_BLOCK_SIZE) {
|
|
1344
1350
|
const chunk = paddedData.subarray(i, i + DES_BLOCK_SIZE);
|
|
@@ -1348,75 +1354,6 @@ function encryptQrcHex(plaintext) {
|
|
|
1348
1354
|
return uint8ArrayToHex(encryptedData);
|
|
1349
1355
|
}
|
|
1350
1356
|
//#endregion
|
|
1351
|
-
//#region src/formats/eslrc.ts
|
|
1352
|
-
const TIME_REGEX = /^\[((?:\d+:)*\d+(?:\.\d+)?)\]/;
|
|
1353
|
-
function parseTimestampPrefix(src) {
|
|
1354
|
-
const match = src.match(TIME_REGEX);
|
|
1355
|
-
if (!match) return null;
|
|
1356
|
-
const [raw, timeStr] = match;
|
|
1357
|
-
return {
|
|
1358
|
-
time: parseTime(timeStr),
|
|
1359
|
-
length: raw.length
|
|
1360
|
-
};
|
|
1361
|
-
}
|
|
1362
|
-
function parseEslrcLine(rawLine) {
|
|
1363
|
-
let src = rawLine.trim();
|
|
1364
|
-
const first = parseTimestampPrefix(src);
|
|
1365
|
-
if (!first) return null;
|
|
1366
|
-
src = src.slice(first.length);
|
|
1367
|
-
let startTime = first.time;
|
|
1368
|
-
if (!src.trim()) return null;
|
|
1369
|
-
const words = [];
|
|
1370
|
-
while (src.trim().length > 0) {
|
|
1371
|
-
const nextTimePos = src.indexOf("[");
|
|
1372
|
-
if (nextTimePos <= 0) return null;
|
|
1373
|
-
const word = src.slice(0, nextTimePos);
|
|
1374
|
-
const nextTime = parseTimestampPrefix(src.slice(nextTimePos));
|
|
1375
|
-
if (!nextTime) return null;
|
|
1376
|
-
words.push(createWord({
|
|
1377
|
-
word,
|
|
1378
|
-
startTime,
|
|
1379
|
-
endTime: nextTime.time
|
|
1380
|
-
}));
|
|
1381
|
-
src = src.slice(nextTimePos + nextTime.length);
|
|
1382
|
-
startTime = nextTime.time;
|
|
1383
|
-
}
|
|
1384
|
-
return createLine({ words });
|
|
1385
|
-
}
|
|
1386
|
-
/**
|
|
1387
|
-
* 解析 ESLyric 逐词歌词格式字符串
|
|
1388
|
-
* @param eslrc 歌词字符串
|
|
1389
|
-
* @returns 成功解析出来的歌词
|
|
1390
|
-
*/
|
|
1391
|
-
function parseEslrc(eslrc) {
|
|
1392
|
-
const result = [];
|
|
1393
|
-
for (const rawLine of eslrc.split(/\r?\n/)) {
|
|
1394
|
-
const line = parseEslrcLine(rawLine);
|
|
1395
|
-
if (line) result.push(line);
|
|
1396
|
-
}
|
|
1397
|
-
result.sort((a, b) => (a.words[0]?.startTime ?? Number.MAX_SAFE_INTEGER) - (b.words[0]?.startTime ?? Number.MAX_SAFE_INTEGER));
|
|
1398
|
-
for (const line of result) {
|
|
1399
|
-
for (const word of line.words) {
|
|
1400
|
-
word.startTime = clampTimestamp(word.startTime);
|
|
1401
|
-
word.endTime = clampTimestamp(word.endTime);
|
|
1402
|
-
}
|
|
1403
|
-
line.startTime = clampTimestamp(line.words[0]?.startTime ?? 0);
|
|
1404
|
-
line.endTime = clampTimestamp(line.words[line.words.length - 1]?.endTime ?? 0);
|
|
1405
|
-
}
|
|
1406
|
-
return result;
|
|
1407
|
-
}
|
|
1408
|
-
/**
|
|
1409
|
-
* 将歌词数组转换为 ESLyric 逐词歌词格式字符串
|
|
1410
|
-
* @param lines 歌词数组
|
|
1411
|
-
* @returns ESLyric 逐词歌词格式字符串
|
|
1412
|
-
*/
|
|
1413
|
-
function stringifyEslrc(lines) {
|
|
1414
|
-
return lines.map((line) => {
|
|
1415
|
-
if (!line.words.length) return "";
|
|
1416
|
-
return `[${formatTime(clampTimestamp(line.words[0].startTime))}]${line.words.map((word) => `${word.word}[${formatTime(clampTimestamp(word.endTime))}]`).join("")}`;
|
|
1417
|
-
}).filter(Boolean).join("\n");
|
|
1418
|
-
}
|
|
1419
|
-
//#endregion
|
|
1420
1357
|
//#region src/formats/lys.ts
|
|
1421
1358
|
/**
|
|
1422
1359
|
* 解析 LYS 格式中的属性值
|
|
@@ -1593,147 +1530,701 @@ function stringifyLqe(lines) {
|
|
|
1593
1530
|
].filter((section) => section !== null).join("\n\n\n")].join("\n\n");
|
|
1594
1531
|
}
|
|
1595
1532
|
//#endregion
|
|
1596
|
-
//#region src/formats/lrc.ts
|
|
1533
|
+
//#region src/formats/lrc/helpers.ts
|
|
1597
1534
|
/**
|
|
1598
|
-
*
|
|
1599
|
-
* @param
|
|
1600
|
-
* @returns
|
|
1535
|
+
* 复制一行歌词并归一化其中所有的时间
|
|
1536
|
+
* @param line 一行歌词
|
|
1537
|
+
* @returns 归一化后的副本
|
|
1601
1538
|
*/
|
|
1602
|
-
function
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1539
|
+
function normalizeLine(line) {
|
|
1540
|
+
return {
|
|
1541
|
+
...line,
|
|
1542
|
+
startTime: normalizeTimestamp(line.startTime),
|
|
1543
|
+
endTime: normalizeTimestamp(line.endTime),
|
|
1544
|
+
words: line.words.map((word) => ({
|
|
1545
|
+
...word,
|
|
1546
|
+
startTime: normalizeTimestamp(word.startTime),
|
|
1547
|
+
endTime: normalizeTimestamp(word.endTime)
|
|
1548
|
+
}))
|
|
1549
|
+
};
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* 将一组音节拼接为完整文本
|
|
1553
|
+
* @param words 音节数组
|
|
1554
|
+
* @returns 拼接后的文本
|
|
1555
|
+
*/
|
|
1556
|
+
function joinWords(words) {
|
|
1557
|
+
return words.map((word) => word.word).join("");
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* 判断一行歌词是否有文本内容
|
|
1561
|
+
* @param line 一行歌词
|
|
1562
|
+
* @returns 是否有文本内容
|
|
1563
|
+
*/
|
|
1564
|
+
function hasText(line) {
|
|
1565
|
+
return joinWords(line.words).trim() !== "";
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* 判断一组音节是否为逐字歌词
|
|
1569
|
+
*
|
|
1570
|
+
* 逐行歌词只有一个音节,且该音节与歌词同时开始,
|
|
1571
|
+
* 因此可以直接由音节推导出来,无需额外的标记
|
|
1572
|
+
* @param words 音节数组
|
|
1573
|
+
* @param startTime 歌词行的开始时间
|
|
1574
|
+
* @returns 是否为逐字歌词
|
|
1575
|
+
*/
|
|
1576
|
+
function isWordSyncLyric(words, startTime) {
|
|
1577
|
+
if (words.length === 0) return false;
|
|
1578
|
+
if (words.length > 1) return true;
|
|
1579
|
+
return words[0].startTime !== startTime;
|
|
1580
|
+
}
|
|
1581
|
+
const BG_PAREN_BEGIN = /^[((]/;
|
|
1582
|
+
const BG_PAREN_END = /[))]$/;
|
|
1583
|
+
/**
|
|
1584
|
+
* 判断一段歌词文本是否为背景人声
|
|
1585
|
+
*
|
|
1586
|
+
* 只有整段文本都被圆括号包裹时才算数,半角与全角括号都接受。
|
|
1587
|
+
* 括号内没有内容的不算,避免把 `()` 这样的写法当作背景人声
|
|
1588
|
+
* @param text 一段歌词文本
|
|
1589
|
+
* @returns 是否为背景人声
|
|
1590
|
+
*/
|
|
1591
|
+
function isBackgroundVocalText(text) {
|
|
1592
|
+
return text.length > 2 && BG_PAREN_BEGIN.test(text) && BG_PAREN_END.test(text);
|
|
1593
|
+
}
|
|
1594
|
+
/**
|
|
1595
|
+
* 去掉背景人声最外层的圆括号
|
|
1596
|
+
* @param words 音节数组,会被就地修改
|
|
1597
|
+
*/
|
|
1598
|
+
function trimBackgroundVocalParentheses(words) {
|
|
1599
|
+
if (words.length === 0) return;
|
|
1600
|
+
words[0].word = words[0].word.slice(1);
|
|
1601
|
+
const lastWord = words[words.length - 1];
|
|
1602
|
+
lastWord.word = lastWord.word.slice(0, -1);
|
|
1603
|
+
}
|
|
1604
|
+
//#endregion
|
|
1605
|
+
//#region src/formats/lrc/metadata.ts
|
|
1606
|
+
const LRC_METADATA_REGEX = /^\[\s*(?<key>[a-zA-Z]+)\s*:\s*(?<value>.*?)\s*\]$/;
|
|
1607
|
+
/**
|
|
1608
|
+
* 解析一行 LRC 元数据,如 `[ti:标题]`
|
|
1609
|
+
* @param line 已去除首尾空白的单行文本
|
|
1610
|
+
* @returns 元数据键值对,若不是元数据行则为 `null`
|
|
1611
|
+
*/
|
|
1612
|
+
function parseLrcMetadataLine(line) {
|
|
1613
|
+
const match = line.match(LRC_METADATA_REGEX);
|
|
1614
|
+
if (!match?.groups) return null;
|
|
1615
|
+
const key = match.groups.key.toLowerCase();
|
|
1616
|
+
const value = match.groups.value.trim();
|
|
1617
|
+
if (!value) return null;
|
|
1618
|
+
return [[key, [value]]];
|
|
1619
|
+
}
|
|
1620
|
+
/**
|
|
1621
|
+
* 将元数据合并进目标元数据表
|
|
1622
|
+
*
|
|
1623
|
+
* 同名键的取值会按顺序合并并去重
|
|
1624
|
+
* @param target 目标元数据表,会被就地修改
|
|
1625
|
+
* @param source 待合并的元数据表
|
|
1626
|
+
*/
|
|
1627
|
+
function mergeMetadata(target, source) {
|
|
1628
|
+
for (const [key, values] of source) {
|
|
1629
|
+
const existing = target.find(([targetKey]) => targetKey === key);
|
|
1630
|
+
if (!existing) {
|
|
1631
|
+
target.push([key, [...values]]);
|
|
1632
|
+
continue;
|
|
1619
1633
|
}
|
|
1620
|
-
if (
|
|
1621
|
-
lineStr = lineStr.trim();
|
|
1622
|
-
const backgroundMatch = lineStr.match(bgRegex);
|
|
1623
|
-
const isBG = Boolean(backgroundMatch);
|
|
1624
|
-
if (backgroundMatch) lineStr = backgroundMatch[1];
|
|
1625
|
-
for (const t of timeStamps) lyricLines.push(createLine({
|
|
1626
|
-
startTime: t,
|
|
1627
|
-
endTime: MAX_LRC_TIMESTAMP,
|
|
1628
|
-
words: [createWord({
|
|
1629
|
-
word: lineStr,
|
|
1630
|
-
startTime: t,
|
|
1631
|
-
endTime: t
|
|
1632
|
-
})],
|
|
1633
|
-
isBG
|
|
1634
|
-
}));
|
|
1634
|
+
for (const value of values) if (!existing[1].includes(value)) existing[1].push(value);
|
|
1635
1635
|
}
|
|
1636
|
-
lyricLines.sort((a, b) => a.startTime - b.startTime);
|
|
1637
|
-
for (const [prev, curr] of pairwise(lyricLines)) prev.endTime = prev.words[0].endTime = curr.startTime;
|
|
1638
|
-
return lyricLines.filter((line) => line.words[0].word);
|
|
1639
1636
|
}
|
|
1640
1637
|
/**
|
|
1641
|
-
*
|
|
1642
|
-
* @param
|
|
1643
|
-
* @returns
|
|
1638
|
+
* 将元数据表生成为 LRC 元数据行
|
|
1639
|
+
* @param metadata 元数据表
|
|
1640
|
+
* @returns 元数据行数组,没有可输出的元数据时为空数组
|
|
1644
1641
|
*/
|
|
1645
|
-
function
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
const
|
|
1649
|
-
|
|
1650
|
-
|
|
1642
|
+
function generateLrcMetadataLines(metadata) {
|
|
1643
|
+
const lines = [];
|
|
1644
|
+
for (const [key, values] of metadata) {
|
|
1645
|
+
const printableValues = values.filter((value) => value.trim() !== "");
|
|
1646
|
+
if (!key.trim() || printableValues.length === 0) continue;
|
|
1647
|
+
lines.push(`[${key}:${printableValues.join("/")}]`);
|
|
1648
|
+
}
|
|
1649
|
+
return lines;
|
|
1651
1650
|
}
|
|
1652
1651
|
//#endregion
|
|
1653
|
-
//#region src/formats/
|
|
1652
|
+
//#region src/formats/lrc/types.ts
|
|
1654
1653
|
/**
|
|
1655
|
-
*
|
|
1656
|
-
* @param lrc 歌词字符串
|
|
1657
|
-
* @returns 成功解析出来的歌词
|
|
1654
|
+
* 各歌词模式的行为,由解析器与生成器共用
|
|
1658
1655
|
*/
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1656
|
+
const LRC_MODE_FEATURES = {
|
|
1657
|
+
plain: {
|
|
1658
|
+
wordLevel: false,
|
|
1659
|
+
inlineAuxiliary: true
|
|
1660
|
+
},
|
|
1661
|
+
enhanced: {
|
|
1662
|
+
wordLevel: true,
|
|
1663
|
+
inlineAuxiliary: false
|
|
1664
|
+
},
|
|
1665
|
+
spl: {
|
|
1666
|
+
wordLevel: true,
|
|
1667
|
+
inlineAuxiliary: false
|
|
1668
|
+
}
|
|
1669
|
+
};
|
|
1670
|
+
//#endregion
|
|
1671
|
+
//#region src/formats/lrc/generator.ts
|
|
1672
|
+
/**
|
|
1673
|
+
* 展开单种辅助行的默认配置
|
|
1674
|
+
*/
|
|
1675
|
+
function resolveAuxiliaryLineOptions(options) {
|
|
1676
|
+
return {
|
|
1677
|
+
enabled: options?.enabled ?? true,
|
|
1678
|
+
inline: options?.inline ?? false
|
|
1679
|
+
};
|
|
1680
|
+
}
|
|
1681
|
+
var LrcGenerator = class {
|
|
1682
|
+
options;
|
|
1683
|
+
features;
|
|
1684
|
+
constructor(options) {
|
|
1685
|
+
this.options = {
|
|
1686
|
+
mode: options?.mode ?? "plain",
|
|
1687
|
+
inlineBracket: options?.inlineBracket ?? "angle",
|
|
1688
|
+
auxiliaryLines: {
|
|
1689
|
+
order: options?.auxiliaryLines?.order ?? "translation-first",
|
|
1690
|
+
translation: resolveAuxiliaryLineOptions(options?.auxiliaryLines?.translation),
|
|
1691
|
+
romanization: resolveAuxiliaryLineOptions(options?.auxiliaryLines?.romanization),
|
|
1692
|
+
backgroundVocal: resolveAuxiliaryLineOptions(options?.auxiliaryLines?.backgroundVocal)
|
|
1693
|
+
},
|
|
1694
|
+
endTimestamp: {
|
|
1695
|
+
mode: options?.endTimestamp?.mode ?? "none",
|
|
1696
|
+
intervalGap: options?.endTimestamp?.intervalGap ?? 5e3
|
|
1697
|
+
}
|
|
1698
|
+
};
|
|
1699
|
+
this.features = LRC_MODE_FEATURES[this.options.mode];
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* 行首时间戳是否改用首个音节的开始时间
|
|
1703
|
+
*
|
|
1704
|
+
* 生成方括号时间戳时,按常见实现省略行首时间戳,只在行首写入首个音节的开始时间,
|
|
1705
|
+
* 生成尖括号时则写入行时间戳
|
|
1706
|
+
*/
|
|
1707
|
+
get usesFirstWordStartTime() {
|
|
1708
|
+
return this.options.inlineBracket === "square";
|
|
1709
|
+
}
|
|
1710
|
+
/**
|
|
1711
|
+
* 生成 LRC 家族歌词
|
|
1712
|
+
* @param input 歌词行,或带元数据的解析结果
|
|
1713
|
+
* @returns 歌词文本
|
|
1714
|
+
*/
|
|
1715
|
+
generate(input) {
|
|
1716
|
+
const metadata = Array.isArray(input) ? [] : input.metadata;
|
|
1717
|
+
const sourceLines = Array.isArray(input) ? input : input.lines;
|
|
1718
|
+
const outputLines = [];
|
|
1719
|
+
const metaLines = generateLrcMetadataLines(metadata);
|
|
1720
|
+
if (metaLines.length > 0) outputLines.push(...metaLines);
|
|
1721
|
+
const lines = sourceLines.map(normalizeLine).filter(hasText);
|
|
1722
|
+
const backgroundVocals = /* @__PURE__ */ new Set();
|
|
1723
|
+
for (const [index, line] of lines.entries()) {
|
|
1724
|
+
if (line.isBG) continue;
|
|
1725
|
+
const nextLine = lines[index + 1];
|
|
1726
|
+
const backgroundVocal = nextLine?.isBG ? nextLine : void 0;
|
|
1727
|
+
if (backgroundVocal) backgroundVocals.add(backgroundVocal);
|
|
1728
|
+
}
|
|
1729
|
+
for (const [index, line] of lines.entries()) {
|
|
1730
|
+
if (backgroundVocals.has(line)) continue;
|
|
1731
|
+
const nextLine = lines[index + 1];
|
|
1732
|
+
const backgroundVocal = !line.isBG && nextLine?.isBG ? nextLine : void 0;
|
|
1733
|
+
let nextMainLine;
|
|
1734
|
+
for (let nextIndex = index + 1; nextIndex < lines.length; nextIndex++) if (!lines[nextIndex].isBG) {
|
|
1735
|
+
nextMainLine = lines[nextIndex];
|
|
1736
|
+
break;
|
|
1737
|
+
}
|
|
1738
|
+
this.processSingleLine(line, backgroundVocal, nextMainLine, outputLines);
|
|
1739
|
+
}
|
|
1740
|
+
return outputLines.join("\n");
|
|
1741
|
+
}
|
|
1742
|
+
processSingleLine(line, backgroundVocal, nextLine, outputLines) {
|
|
1743
|
+
const { auxiliaryLines: auxConfig } = this.options;
|
|
1744
|
+
const { inlineAuxiliary } = this.features;
|
|
1745
|
+
const transInline = inlineAuxiliary && auxConfig.translation.inline;
|
|
1746
|
+
const romaInline = inlineAuxiliary && !transInline && auxConfig.romanization.inline;
|
|
1747
|
+
const bgvInline = inlineAuxiliary && !transInline && !romaInline && auxConfig.backgroundVocal.inline;
|
|
1748
|
+
const lineTimeTag = this.formatTimeTag(line.startTime, "square");
|
|
1749
|
+
let transText = auxConfig.translation.enabled ? line.translatedLyric : "";
|
|
1750
|
+
let romaText = auxConfig.romanization.enabled ? line.romanLyric : "";
|
|
1751
|
+
let bgLine = auxConfig.backgroundVocal.enabled ? backgroundVocal : void 0;
|
|
1752
|
+
let mainLineText = joinWords(line.words);
|
|
1753
|
+
if (inlineAuxiliary) {
|
|
1754
|
+
const applyInline = (text) => {
|
|
1755
|
+
mainLineText += `${mainLineText ? " " : ""}(${text})`;
|
|
1756
|
+
};
|
|
1757
|
+
if (transInline && transText) {
|
|
1758
|
+
applyInline(transText);
|
|
1759
|
+
transText = "";
|
|
1760
|
+
} else if (romaInline && romaText) {
|
|
1761
|
+
applyInline(romaText);
|
|
1762
|
+
romaText = "";
|
|
1763
|
+
} else if (bgvInline && bgLine) {
|
|
1764
|
+
mainLineText += `${mainLineText ? " " : ""}(${joinWords(bgLine.words)})`;
|
|
1765
|
+
if (auxConfig.translation.enabled && bgLine.translatedLyric) transText = transText ? `${transText} (${bgLine.translatedLyric})` : `(${bgLine.translatedLyric})`;
|
|
1766
|
+
if (auxConfig.romanization.enabled && bgLine.romanLyric) romaText = romaText ? `${romaText} (${bgLine.romanLyric})` : `(${bgLine.romanLyric})`;
|
|
1767
|
+
bgLine = void 0;
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
outputLines.push(this.renderBaseItem(line, mainLineText, line.isBG));
|
|
1771
|
+
if (bgLine) outputLines.push(this.renderBaseItem(bgLine, joinWords(bgLine.words), true));
|
|
1772
|
+
const auxTexts = auxConfig.order === "translation-first" ? [transText, romaText] : [romaText, transText];
|
|
1773
|
+
for (const auxText of auxTexts) if (auxText) outputLines.push(`${lineTimeTag}${auxText}`);
|
|
1774
|
+
this.processEndTimestamp(line, nextLine, outputLines);
|
|
1775
|
+
}
|
|
1776
|
+
/**
|
|
1777
|
+
* 渲染一行歌词,`lineText` 是已经处理过内联的整行文本
|
|
1778
|
+
*/
|
|
1779
|
+
renderBaseItem(item, lineText, isBgv) {
|
|
1780
|
+
const { inlineBracket } = this.options;
|
|
1781
|
+
const { wordLevel } = this.features;
|
|
1782
|
+
if (wordLevel && isWordSyncLyric(item.words, item.startTime)) {
|
|
1783
|
+
const words = item.words;
|
|
1784
|
+
const leadingTimestamp = this.usesFirstWordStartTime ? words[0].startTime : item.startTime;
|
|
1785
|
+
let output = this.formatTimeTag(leadingTimestamp, "square");
|
|
1786
|
+
for (let i = 0; i < words.length; i++) {
|
|
1787
|
+
const word = words[i];
|
|
1788
|
+
if (i > 0 || !this.usesFirstWordStartTime) output += this.formatTimeTag(word.startTime, inlineBracket);
|
|
1789
|
+
let wordText = word.word;
|
|
1790
|
+
if (isBgv) {
|
|
1791
|
+
if (i === 0) wordText = `(${wordText}`;
|
|
1792
|
+
if (i === words.length - 1) wordText = `${wordText})`;
|
|
1793
|
+
}
|
|
1794
|
+
output += wordText;
|
|
1795
|
+
if (i === words.length - 1) output += this.formatTimeTag(word.endTime, inlineBracket);
|
|
1796
|
+
}
|
|
1797
|
+
return output;
|
|
1798
|
+
}
|
|
1799
|
+
let output = `${this.formatTimeTag(item.startTime, "square")}${isBgv ? `(${lineText})` : lineText}`;
|
|
1800
|
+
if (wordLevel) output += this.formatTimeTag(item.endTime, "square");
|
|
1801
|
+
return output;
|
|
1802
|
+
}
|
|
1803
|
+
processEndTimestamp(line, nextLine, outputLines) {
|
|
1804
|
+
const { mode: endTsMode, intervalGap: endTsGap } = this.options.endTimestamp;
|
|
1805
|
+
if (this.features.wordLevel || endTsMode === "none") return;
|
|
1806
|
+
const gap = nextLine ? nextLine.startTime - line.endTime : Infinity;
|
|
1807
|
+
if (endTsMode === "always" || endTsMode === "interval" && gap >= endTsGap) outputLines.push(this.formatTimeTag(line.endTime, "square"));
|
|
1808
|
+
}
|
|
1809
|
+
/**
|
|
1810
|
+
* 将毫秒渲染为时间戳
|
|
1811
|
+
*
|
|
1812
|
+
* 传入的时间都已由 {@link normalizeLine} 归一化过,这里再钳制到时间戳可表示的最大值,
|
|
1813
|
+
* 保证写出来的时间戳都能被解析回来
|
|
1814
|
+
*/
|
|
1815
|
+
formatTimeTag(ms, bracket = "square") {
|
|
1816
|
+
const content = formatTime(clampTimestamp(ms));
|
|
1817
|
+
return bracket === "angle" ? `<${content}>` : `[${content}]`;
|
|
1818
|
+
}
|
|
1819
|
+
};
|
|
1820
|
+
//#endregion
|
|
1821
|
+
//#region src/formats/lrc/parser.ts
|
|
1822
|
+
/**
|
|
1823
|
+
* 注释行的行首标记
|
|
1824
|
+
*
|
|
1825
|
+
* `#` 是 LRC 生态中常见的注释写法,`//` 则是 SPL 标准在示例里标注说明的写法,
|
|
1826
|
+
* 两者都没有被写进格式规范,这里一并按整行丢弃处理
|
|
1827
|
+
*/
|
|
1828
|
+
const COMMENT_PREFIXES = ["#", "//"];
|
|
1829
|
+
var LrcParser = class LrcParser {
|
|
1830
|
+
mode;
|
|
1831
|
+
/**
|
|
1832
|
+
* 匹配行内所有的逐字时间戳
|
|
1833
|
+
*
|
|
1834
|
+
* 例如 `<05:21.22>` 或 `[05:23.22]`,`[00:13]` 这样省略毫秒段的写法同样算数,省略时视作 `0`。
|
|
1835
|
+
* 秒与毫秒之间本应使用半角句号,这里也接受半角冒号,以兼容不规范的既有歌词文件
|
|
1836
|
+
*/
|
|
1837
|
+
static WORD_TIMESTAMP_REGEX = /(?:\[|<)(?<min>\d{1,3}):(?<sec>\d{1,2})(?:[:.](?<ms>\d{1,6}))?(?:\]|>)/g;
|
|
1838
|
+
constructor(options) {
|
|
1839
|
+
this.mode = options?.mode ?? "spl";
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* 解析 LRC 家族歌词
|
|
1843
|
+
* @param text 歌词文本
|
|
1844
|
+
* @returns 解析结果
|
|
1845
|
+
*/
|
|
1846
|
+
parse(text) {
|
|
1847
|
+
const metadata = [];
|
|
1848
|
+
const allParsedLines = [];
|
|
1849
|
+
const state = {
|
|
1850
|
+
lastMainLines: [],
|
|
1851
|
+
mainLinesByStartTime: /* @__PURE__ */ new Map(),
|
|
1852
|
+
translations: /* @__PURE__ */ new Map()
|
|
1853
|
+
};
|
|
1854
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
1855
|
+
const trimmedLine = rawLine.trim();
|
|
1856
|
+
if (!trimmedLine) continue;
|
|
1857
|
+
if (COMMENT_PREFIXES.some((prefix) => trimmedLine.startsWith(prefix))) continue;
|
|
1858
|
+
const parsedMeta = parseLrcMetadataLine(trimmedLine);
|
|
1859
|
+
if (parsedMeta) {
|
|
1860
|
+
mergeMetadata(metadata, parsedMeta);
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
const tokens = this.tokenizeLine(trimmedLine);
|
|
1864
|
+
if (tokens.every((t) => t.type === "text")) {
|
|
1865
|
+
this.appendTranslation(trimmedLine, [], state);
|
|
1682
1866
|
continue;
|
|
1683
1867
|
}
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1868
|
+
const features = this.getLineFeatures(tokens);
|
|
1869
|
+
if (features.text.trim() === "") {
|
|
1870
|
+
this.appendExplicitEndTime(tokens, state.lastMainLines);
|
|
1871
|
+
continue;
|
|
1872
|
+
}
|
|
1873
|
+
const newLines = features.isWordSync ? this.parseWordSyncLine(tokens, features, state) : this.parsePlainLines(tokens, features, state);
|
|
1874
|
+
if (newLines.length > 0) {
|
|
1875
|
+
this.applyBackgroundVocal(newLines, features.isBG);
|
|
1876
|
+
allParsedLines.push(...newLines);
|
|
1877
|
+
state.lastMainLines = newLines;
|
|
1878
|
+
for (const line of newLines) if (!state.mainLinesByStartTime.has(line.startTime)) state.mainLinesByStartTime.set(line.startTime, line);
|
|
1879
|
+
}
|
|
1688
1880
|
}
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
if (
|
|
1881
|
+
return {
|
|
1882
|
+
metadata,
|
|
1883
|
+
lines: this.expandTranslations(this.finalizeLyricLines(allParsedLines), state.translations)
|
|
1884
|
+
};
|
|
1885
|
+
}
|
|
1886
|
+
/**
|
|
1887
|
+
* 语法分析器
|
|
1888
|
+
*/
|
|
1889
|
+
tokenizeLine(line) {
|
|
1890
|
+
const tokens = [];
|
|
1891
|
+
let lastIndex = 0;
|
|
1892
|
+
for (const match of line.matchAll(LrcParser.WORD_TIMESTAMP_REGEX)) {
|
|
1893
|
+
if (!match.groups) continue;
|
|
1894
|
+
const matchIndex = match.index ?? 0;
|
|
1895
|
+
const textBefore = line.substring(lastIndex, matchIndex);
|
|
1896
|
+
if (textBefore) tokens.push({
|
|
1897
|
+
type: "text",
|
|
1898
|
+
val: textBefore
|
|
1899
|
+
});
|
|
1900
|
+
const time = parseTimestampParts(match.groups.min, match.groups.sec, match.groups.ms);
|
|
1901
|
+
tokens.push({
|
|
1902
|
+
type: "time",
|
|
1903
|
+
val: time
|
|
1904
|
+
});
|
|
1905
|
+
lastIndex = matchIndex + match[0].length;
|
|
1906
|
+
}
|
|
1907
|
+
const textAfter = line.substring(lastIndex);
|
|
1908
|
+
if (textAfter) tokens.push({
|
|
1909
|
+
type: "text",
|
|
1910
|
+
val: textAfter
|
|
1702
1911
|
});
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1912
|
+
return tokens;
|
|
1913
|
+
}
|
|
1914
|
+
getLineFeatures(tokens) {
|
|
1915
|
+
const firstTextIndex = tokens.findIndex((t) => t.type === "text");
|
|
1916
|
+
let isWordSync = false;
|
|
1917
|
+
if (firstTextIndex > 0) {
|
|
1918
|
+
for (let i = firstTextIndex + 1; i < tokens.length; i++) if (tokens[i].type === "time") {
|
|
1919
|
+
isWordSync = true;
|
|
1920
|
+
break;
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
const text = tokens.filter((t) => t.type === "text").map((t) => t.val).join("");
|
|
1924
|
+
return {
|
|
1925
|
+
firstTextIndex,
|
|
1926
|
+
isWordSync,
|
|
1927
|
+
isBG: isBackgroundVocalText(text),
|
|
1928
|
+
text
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
/**
|
|
1932
|
+
* 把整行被圆括号包裹的歌词行标记为背景人声,并去掉最外层的括号
|
|
1933
|
+
*
|
|
1934
|
+
* 与 YRC、QRC 的做法一致,只按整行是否被括号包裹判断。
|
|
1935
|
+
* 多个背景人声行各自独立处理,不做归并或配对
|
|
1936
|
+
* @param lines 已解析出来的歌词行
|
|
1937
|
+
* @param isBG 该行是否为背景人声行
|
|
1938
|
+
*/
|
|
1939
|
+
applyBackgroundVocal(lines, isBG) {
|
|
1940
|
+
if (!isBG) return;
|
|
1941
|
+
for (const line of lines) {
|
|
1942
|
+
line.isBG = true;
|
|
1943
|
+
trimBackgroundVocalParentheses(line.words);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
/**
|
|
1947
|
+
* 尝试将一段文本记录为已出现过的某(组)歌词行的翻译
|
|
1948
|
+
*
|
|
1949
|
+
* 时间信息必须一致才会认为是翻译,否则视为独立的歌词行
|
|
1950
|
+
* @returns 是否成功记录为翻译
|
|
1951
|
+
*/
|
|
1952
|
+
appendTranslation(text, baseTimes, state) {
|
|
1953
|
+
if (text.trim() === "") return false;
|
|
1954
|
+
const targets = baseTimes.length === 0 ? state.lastMainLines : this.matchLinesByTime(baseTimes, state.mainLinesByStartTime);
|
|
1955
|
+
if (!targets || targets.length === 0) return false;
|
|
1956
|
+
for (const line of targets) {
|
|
1957
|
+
const texts = state.translations.get(line);
|
|
1958
|
+
if (texts) texts.push(text);
|
|
1959
|
+
else state.translations.set(line, [text]);
|
|
1960
|
+
}
|
|
1961
|
+
return true;
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* 按时间戳找出与之对应的歌词行
|
|
1965
|
+
*
|
|
1966
|
+
* 翻译行可以不紧挨着主歌词行,所以在全部已出现的主歌词行里按开始时间查找。
|
|
1967
|
+
* 重复行写法的翻译需要每个时间戳都能找到宿主,只要有一个落空就不算翻译
|
|
1968
|
+
* @returns 对应的歌词行,时间信息不一致时为 `null`
|
|
1969
|
+
*/
|
|
1970
|
+
matchLinesByTime(baseTimes, mainLinesByStartTime) {
|
|
1971
|
+
const targets = [];
|
|
1972
|
+
for (const baseTime of baseTimes) {
|
|
1973
|
+
const line = mainLinesByStartTime.get(baseTime);
|
|
1974
|
+
if (!line) return null;
|
|
1975
|
+
targets.push(line);
|
|
1976
|
+
}
|
|
1977
|
+
return targets;
|
|
1978
|
+
}
|
|
1979
|
+
/**
|
|
1980
|
+
* 将一行只有时间戳的歌词行作为上一(组)歌词行的显式结束时间
|
|
1981
|
+
*
|
|
1982
|
+
* 与行内写法 `文本[结束时间]` 一致,取该行最后一个时间戳作为结束时间。
|
|
1983
|
+
* 上一(组)歌词行不存在、已经有结束时间,或该时间戳不晚于其开始时间时,整行被忽略
|
|
1984
|
+
*
|
|
1985
|
+
* 重复行写法(`[t1][t2]文本`)会产生多条内容相同的歌词行,
|
|
1986
|
+
* 此时这个结束时间应该归属哪一条存在语义模糊:
|
|
1987
|
+
* 给最早的一条会让组内各行的结束时间不一致,给所有行又会造成时间区间相互重叠,
|
|
1988
|
+
* 格式本身没有说明哪种解读正确。这里选择只标记最后一条(即时间上最近一次出现)的行,
|
|
1989
|
+
* 组内其余行仍交给后续的推导逻辑决定结束时间
|
|
1990
|
+
*/
|
|
1991
|
+
appendExplicitEndTime(tokens, lastMainLines) {
|
|
1992
|
+
const lastTimeToken = tokens.findLast((token) => token.type === "time");
|
|
1993
|
+
const lastLine = lastMainLines[lastMainLines.length - 1];
|
|
1994
|
+
if (!lastTimeToken || !lastLine) return;
|
|
1995
|
+
const endTime = lastTimeToken.val;
|
|
1996
|
+
if (lastLine.endTime !== -1 || endTime <= lastLine.startTime) return;
|
|
1997
|
+
lastLine.endTime = endTime;
|
|
1998
|
+
}
|
|
1999
|
+
parsePlainLines(tokens, features, state) {
|
|
2000
|
+
const baseTimes = [];
|
|
2001
|
+
const endIndex = features.firstTextIndex !== -1 ? features.firstTextIndex : tokens.length;
|
|
2002
|
+
for (let i = 0; i < endIndex; i++) {
|
|
2003
|
+
const token = tokens[i];
|
|
2004
|
+
if (token.type === "time" && !baseTimes.includes(token.val)) baseTimes.push(token.val);
|
|
2005
|
+
}
|
|
2006
|
+
if (!features.isBG && this.appendTranslation(features.text, baseTimes, state)) return [];
|
|
2007
|
+
return baseTimes.map((baseTime) => createLine({
|
|
2008
|
+
startTime: baseTime,
|
|
2009
|
+
endTime: -1,
|
|
2010
|
+
words: [createWord({
|
|
2011
|
+
word: features.text,
|
|
2012
|
+
startTime: baseTime,
|
|
2013
|
+
endTime: -1
|
|
2014
|
+
})]
|
|
1708
2015
|
}));
|
|
1709
2016
|
}
|
|
1710
|
-
|
|
2017
|
+
parseWordSyncLine(tokens, features, state) {
|
|
2018
|
+
const firstToken = tokens[0];
|
|
2019
|
+
const delayToken = tokens[features.firstTextIndex - 1];
|
|
2020
|
+
if (firstToken?.type !== "time" || delayToken?.type !== "time") return [];
|
|
2021
|
+
const baseTime = firstToken.val;
|
|
2022
|
+
if (!features.isBG && this.appendTranslation(features.text, [baseTime], state)) return [];
|
|
2023
|
+
const syllableStartTimeInitial = delayToken.val;
|
|
2024
|
+
let explicitEnd;
|
|
2025
|
+
const syllables = [];
|
|
2026
|
+
let currentText = "";
|
|
2027
|
+
let syllableStartTime = syllableStartTimeInitial;
|
|
2028
|
+
const pushSyllable = (text, start, end) => {
|
|
2029
|
+
syllables.push(createWord({
|
|
2030
|
+
word: text,
|
|
2031
|
+
startTime: start,
|
|
2032
|
+
endTime: end
|
|
2033
|
+
}));
|
|
2034
|
+
};
|
|
2035
|
+
for (let i = features.firstTextIndex; i < tokens.length; i++) {
|
|
2036
|
+
const tok = tokens[i];
|
|
2037
|
+
if (tok.type === "text") currentText += tok.val;
|
|
2038
|
+
else if (tok.type === "time") {
|
|
2039
|
+
const nextTime = tok.val;
|
|
2040
|
+
if (currentText !== "") {
|
|
2041
|
+
pushSyllable(currentText, syllableStartTime, nextTime);
|
|
2042
|
+
currentText = "";
|
|
2043
|
+
}
|
|
2044
|
+
syllableStartTime = nextTime;
|
|
2045
|
+
if (i === tokens.length - 1) explicitEnd = nextTime;
|
|
2046
|
+
}
|
|
2047
|
+
}
|
|
2048
|
+
if (currentText !== "") pushSyllable(currentText, syllableStartTime, -1);
|
|
2049
|
+
return [createLine({
|
|
2050
|
+
startTime: baseTime,
|
|
2051
|
+
endTime: explicitEnd ?? -1,
|
|
2052
|
+
words: isWordSyncLyric(syllables, baseTime) ? syllables : [createWord({
|
|
2053
|
+
word: features.text,
|
|
2054
|
+
startTime: baseTime,
|
|
2055
|
+
endTime: -1
|
|
2056
|
+
})]
|
|
2057
|
+
})];
|
|
2058
|
+
}
|
|
2059
|
+
finalizeLyricLines(allParsedLines) {
|
|
2060
|
+
allParsedLines.sort((a, b) => a.startTime - b.startTime);
|
|
2061
|
+
this.resolveEndTimes(allParsedLines);
|
|
2062
|
+
if (!LRC_MODE_FEATURES[this.mode].wordLevel) this.stripWordTimings(allParsedLines);
|
|
2063
|
+
return allParsedLines;
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* 决定所有尚未推导的时间,逐行歌词取下一行的开始时间,逐字歌词还会收尾最后一个音节
|
|
2067
|
+
*/
|
|
2068
|
+
resolveEndTimes(allParsedLines) {
|
|
2069
|
+
for (let i = 0; i < allParsedLines.length; i++) {
|
|
2070
|
+
const line = allParsedLines[i];
|
|
2071
|
+
const isLastLine = i === allParsedLines.length - 1;
|
|
2072
|
+
if (line.endTime === -1) {
|
|
2073
|
+
if (isLastLine && !isWordSyncLyric(line.words, line.startTime)) line.endTime = MAX_LRC_TIMESTAMP;
|
|
2074
|
+
else if (!isLastLine) line.endTime = allParsedLines[i + 1].startTime;
|
|
2075
|
+
}
|
|
2076
|
+
const words = line.words;
|
|
2077
|
+
if (words.length > 0) for (let j = 0; j < words.length; j++) {
|
|
2078
|
+
const word = words[j];
|
|
2079
|
+
if (word.endTime !== -1) continue;
|
|
2080
|
+
if (j + 1 < words.length) word.endTime = words[j + 1].startTime;
|
|
2081
|
+
else if (isLastLine && line.endTime === -1) {
|
|
2082
|
+
word.endTime = word.startTime + 1e3;
|
|
2083
|
+
line.endTime = word.endTime;
|
|
2084
|
+
} else word.endTime = line.endTime;
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
/**
|
|
2089
|
+
* 普通 LRC 模式丢弃逐字时间戳,只留下覆盖整行的单个音节
|
|
2090
|
+
*/
|
|
2091
|
+
stripWordTimings(allParsedLines) {
|
|
2092
|
+
for (const line of allParsedLines) {
|
|
2093
|
+
if (line.words.length <= 1) continue;
|
|
2094
|
+
line.words = [createWord({
|
|
2095
|
+
word: joinWords(line.words),
|
|
2096
|
+
startTime: line.startTime,
|
|
2097
|
+
endTime: line.endTime
|
|
2098
|
+
})];
|
|
2099
|
+
}
|
|
2100
|
+
}
|
|
2101
|
+
/**
|
|
2102
|
+
* 将翻译展开成与主歌词行同时开始、同时结束的独立歌词行
|
|
2103
|
+
*/
|
|
2104
|
+
expandTranslations(lines, translations) {
|
|
2105
|
+
if (translations.size === 0) return lines;
|
|
2106
|
+
const result = [];
|
|
2107
|
+
for (const line of lines) {
|
|
2108
|
+
result.push(line);
|
|
2109
|
+
for (const text of translations.get(line) ?? []) result.push(createLine({
|
|
2110
|
+
startTime: line.startTime,
|
|
2111
|
+
endTime: line.endTime,
|
|
2112
|
+
words: [createWord({
|
|
2113
|
+
word: text,
|
|
2114
|
+
startTime: line.startTime,
|
|
2115
|
+
endTime: line.endTime
|
|
2116
|
+
})]
|
|
2117
|
+
}));
|
|
2118
|
+
}
|
|
2119
|
+
return result;
|
|
2120
|
+
}
|
|
2121
|
+
};
|
|
2122
|
+
//#endregion
|
|
2123
|
+
//#region src/formats/lrc/index.ts
|
|
2124
|
+
/**
|
|
2125
|
+
* 解析任意 LRC 家族歌词,包括普通 LRC、增强型 LRC、ESLyric 逐词歌词与 Salt Player Lyrics
|
|
2126
|
+
*
|
|
2127
|
+
* 因为 Salt Player Lyrics 是其余格式的超集,所以一个接口即可解析全部种类
|
|
2128
|
+
*
|
|
2129
|
+
* 同一时间可能出现多条歌词行(例如翻译与音译),
|
|
2130
|
+
* 本接口不做多语言适配,由使用者自行决定如何使用这些时间相同的歌词行
|
|
2131
|
+
*
|
|
2132
|
+
* @param text 歌词文本
|
|
2133
|
+
* @param options 解析选项
|
|
2134
|
+
* @returns 解析出来的歌词与元数据
|
|
2135
|
+
*/
|
|
2136
|
+
function parseLrcLike(text, options) {
|
|
2137
|
+
return new LrcParser(options).parse(text);
|
|
2138
|
+
}
|
|
2139
|
+
/**
|
|
2140
|
+
* 生成 LRC 家族歌词
|
|
2141
|
+
* @param input 歌词行,或带元数据的解析结果
|
|
2142
|
+
* @param options 生成选项
|
|
2143
|
+
* @returns 歌词文本
|
|
2144
|
+
*/
|
|
2145
|
+
function stringifyLrcLike(input, options) {
|
|
2146
|
+
return new LrcGenerator(options).generate(input);
|
|
1711
2147
|
}
|
|
1712
2148
|
/**
|
|
1713
|
-
*
|
|
2149
|
+
* 解析 LyRiC 格式的歌词字符串
|
|
2150
|
+
* @param lrc 歌词字符串
|
|
2151
|
+
* @returns 成功解析出来的歌词
|
|
2152
|
+
*/
|
|
2153
|
+
function parseLrc(lrc) {
|
|
2154
|
+
return parseLrcLike(lrc).lines;
|
|
2155
|
+
}
|
|
2156
|
+
/**
|
|
2157
|
+
* 解析 ESLyric 逐词歌词格式的歌词字符串
|
|
2158
|
+
* @param eslrc 歌词字符串
|
|
2159
|
+
* @returns 成功解析出来的歌词
|
|
2160
|
+
*/
|
|
2161
|
+
function parseEslrc(eslrc) {
|
|
2162
|
+
return parseLrcLike(eslrc).lines;
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* 解析 LRC A2(增强 LRC)格式的歌词字符串
|
|
2166
|
+
* @param lrc 歌词字符串
|
|
2167
|
+
* @returns 成功解析出来的歌词
|
|
2168
|
+
*/
|
|
2169
|
+
function parseLrcA2(lrc) {
|
|
2170
|
+
return parseLrcLike(lrc).lines;
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* 解析 SPL(Salt Player Lyrics)格式的歌词字符串
|
|
2174
|
+
*
|
|
2175
|
+
* 上述几种格式都语出同源,本接口只是以 SPL 之名调用同一套解析算法
|
|
2176
|
+
* @param spl 歌词字符串
|
|
2177
|
+
* @returns 成功解析出来的歌词
|
|
2178
|
+
*/
|
|
2179
|
+
function parseSPL(spl) {
|
|
2180
|
+
return parseLrcLike(spl).lines;
|
|
2181
|
+
}
|
|
2182
|
+
/**
|
|
2183
|
+
* 将歌词数组转换为 LyRiC 格式的字符串
|
|
2184
|
+
* @param lines 歌词数组
|
|
2185
|
+
* @returns LyRiC 格式的字符串
|
|
2186
|
+
*/
|
|
2187
|
+
function stringifyLrc(lines) {
|
|
2188
|
+
return stringifyLrcLike(lines, { mode: "plain" });
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* 将歌词数组转换为 ESLyric 逐词歌词格式的字符串
|
|
2192
|
+
*
|
|
2193
|
+
* ESLyric 的逐词语法为「文本后跟该词的结束时间」,行首时间戳即首个词的开始时间,
|
|
2194
|
+
* 因此不写行自身的时间戳,行首时间戳直接取首个词的开始时间
|
|
2195
|
+
* @param lines 歌词数组
|
|
2196
|
+
* @returns ESLyric 逐词歌词格式的字符串
|
|
2197
|
+
*/
|
|
2198
|
+
function stringifyEslrc(lines) {
|
|
2199
|
+
return stringifyLrcLike(lines, {
|
|
2200
|
+
mode: "spl",
|
|
2201
|
+
inlineBracket: "square"
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* 将歌词数组转换为 LRC A2(增强 LRC)格式的字符串
|
|
1714
2206
|
* @param lines 歌词数组
|
|
1715
2207
|
* @returns LRC A2 格式的字符串
|
|
1716
2208
|
*/
|
|
1717
2209
|
function stringifyLrcA2(lines) {
|
|
1718
|
-
return lines
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
}).join("\n");
|
|
2210
|
+
return stringifyLrcLike(lines, {
|
|
2211
|
+
mode: "spl",
|
|
2212
|
+
inlineBracket: "angle"
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
/**
|
|
2216
|
+
* 生成 SPL(Salt Player Lyrics)格式的歌词字符串
|
|
2217
|
+
*
|
|
2218
|
+
* SPL 与增强型 LRC 的生成行为一致,逐字时间戳同样使用尖括号,
|
|
2219
|
+
* 因此本接口与 {@link stringifyLrcA2} 的输出完全相同
|
|
2220
|
+
* @param input 歌词行,或带元数据的解析结果
|
|
2221
|
+
* @returns SPL 格式的字符串
|
|
2222
|
+
*/
|
|
2223
|
+
function stringifySPL(input) {
|
|
2224
|
+
return stringifyLrcLike(input, {
|
|
2225
|
+
mode: "spl",
|
|
2226
|
+
inlineBracket: "angle"
|
|
2227
|
+
});
|
|
1737
2228
|
}
|
|
1738
2229
|
//#endregion
|
|
1739
2230
|
//#region src/formats/lyl.ts
|
|
@@ -1833,7 +2324,8 @@ function parseQrc(qrc) {
|
|
|
1833
2324
|
function stringifyQrc(lines) {
|
|
1834
2325
|
return lines.map((line) => {
|
|
1835
2326
|
const lineStart = normalizeTimestamp(line.startTime);
|
|
1836
|
-
const
|
|
2327
|
+
const lineEnd = normalizeTimestamp(line.endTime);
|
|
2328
|
+
const lineDuration = normalizeDuration(lineEnd - lineStart);
|
|
1837
2329
|
const lineWords = [];
|
|
1838
2330
|
for (const [index, { word, startTime, endTime }] of line.words.entries()) {
|
|
1839
2331
|
if (!word.trim() && lineWords.length) {
|
|
@@ -1846,7 +2338,8 @@ function stringifyQrc(lines) {
|
|
|
1846
2338
|
if (index === line.words.length - 1) printedWord += ")";
|
|
1847
2339
|
}
|
|
1848
2340
|
const normalizedWordStart = normalizeTimestamp(startTime);
|
|
1849
|
-
const
|
|
2341
|
+
const normalizedWordEnd = normalizeTimestamp(endTime);
|
|
2342
|
+
const wordDuration = normalizeDuration(normalizedWordEnd - normalizedWordStart);
|
|
1850
2343
|
lineWords.push(`${printedWord}(${normalizedWordStart},${wordDuration})`);
|
|
1851
2344
|
}
|
|
1852
2345
|
return `[${lineStart},${lineDuration}]${lineWords.join("")}`;
|
|
@@ -1939,7 +2432,8 @@ function makeParenthesesFull(text) {
|
|
|
1939
2432
|
function stringifyYrc(lines) {
|
|
1940
2433
|
return lines.map((line) => {
|
|
1941
2434
|
const lineStart = normalizeTimestamp(line.startTime);
|
|
1942
|
-
const
|
|
2435
|
+
const lineEnd = normalizeTimestamp(line.endTime);
|
|
2436
|
+
const lineDuration = normalizeDuration(lineEnd - lineStart);
|
|
1943
2437
|
const lineWords = [];
|
|
1944
2438
|
for (const [index, { word, startTime, endTime }] of line.words.entries()) {
|
|
1945
2439
|
if (!word.trim() && lineWords.length) {
|
|
@@ -1952,7 +2446,8 @@ function stringifyYrc(lines) {
|
|
|
1952
2446
|
if (index === line.words.length - 1) printedWord += ")";
|
|
1953
2447
|
}
|
|
1954
2448
|
const normalizedWordStart = normalizeTimestamp(startTime);
|
|
1955
|
-
const
|
|
2449
|
+
const normalizedWordEnd = normalizeTimestamp(endTime);
|
|
2450
|
+
const wordDuration = normalizeDuration(normalizedWordEnd - normalizedWordStart);
|
|
1956
2451
|
lineWords.push(`(${normalizedWordStart},${wordDuration},0)${printedWord}`);
|
|
1957
2452
|
}
|
|
1958
2453
|
return `[${lineStart},${lineDuration}]${lineWords.join("")}`;
|
|
@@ -1975,9 +2470,11 @@ exports.parseEslrc = parseEslrc;
|
|
|
1975
2470
|
exports.parseLqe = parseLqe;
|
|
1976
2471
|
exports.parseLrc = parseLrc;
|
|
1977
2472
|
exports.parseLrcA2 = parseLrcA2;
|
|
2473
|
+
exports.parseLrcLike = parseLrcLike;
|
|
1978
2474
|
exports.parseLyl = parseLyl;
|
|
1979
2475
|
exports.parseLys = parseLys;
|
|
1980
2476
|
exports.parseQrc = parseQrc;
|
|
2477
|
+
exports.parseSPL = parseSPL;
|
|
1981
2478
|
exports.parseTTML = parseTTML;
|
|
1982
2479
|
exports.parseYrc = parseYrc;
|
|
1983
2480
|
exports.stringifyAss = stringifyAss;
|
|
@@ -1985,9 +2482,11 @@ exports.stringifyEslrc = stringifyEslrc;
|
|
|
1985
2482
|
exports.stringifyLqe = stringifyLqe;
|
|
1986
2483
|
exports.stringifyLrc = stringifyLrc;
|
|
1987
2484
|
exports.stringifyLrcA2 = stringifyLrcA2;
|
|
2485
|
+
exports.stringifyLrcLike = stringifyLrcLike;
|
|
1988
2486
|
exports.stringifyLyl = stringifyLyl;
|
|
1989
2487
|
exports.stringifyLys = stringifyLys;
|
|
1990
2488
|
exports.stringifyQrc = stringifyQrc;
|
|
2489
|
+
exports.stringifySPL = stringifySPL;
|
|
1991
2490
|
exports.stringifyTTML = stringifyTTML;
|
|
1992
2491
|
exports.stringifyYrc = stringifyYrc;
|
|
1993
2492
|
exports.stringifylrcA2 = stringifylrcA2;
|