@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.
@@ -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.round(ms % 1e3).toString().padStart(3, "0")}`;
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
- * Returns consecutive pairs from the given iterable.
42
+ * LRC 家族时间戳可表示的最大值,即 `999:59.999`
37
43
  *
38
- * Example: `0, 1, 2, 3` -> `[0, 1], [1, 2], [2, 3]`
44
+ * 时间戳的分钟为 1 3 位、秒为 1 2 位、毫秒为 1 至 6 位,
45
+ * 因此能写出来又读得回来的最大时间就到这里
39
46
  */
40
- function* pairwise(iterable) {
41
- let prev;
42
- let hasPrev = false;
43
- for (const curr of iterable) {
44
- if (hasPrev) yield [prev, curr];
45
- prev = curr;
46
- hasPrev = true;
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 paddedData = zeroPad((0, pako.deflate)(new TextEncoder().encode(plaintext)), DES_BLOCK_SIZE);
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
- * 解析 LyRiC 格式的歌词字符串
1599
- * @param lrc 歌词字符串
1600
- * @returns 成功解析出来的歌词
1535
+ * 复制一行歌词并归一化其中所有的时间
1536
+ * @param line 一行歌词
1537
+ * @returns 归一化后的副本
1601
1538
  */
1602
- function parseLrc(lrc) {
1603
- const tagRegex = /^\[([a-z]+):([^\]]+)\]$/;
1604
- const timeRegex = /^\[((?:\d+:)*\d+(?:\.\d+)?)\](.*)$/;
1605
- const bgRegex = /^[((](.+)[))]$/;
1606
- const lines = lrc.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
1607
- const lyricLines = [];
1608
- for (let lineStr of lines) {
1609
- if (tagRegex.test(lineStr)) continue;
1610
- const timeStamps = [];
1611
- while (true) {
1612
- const match = lineStr.match(timeRegex);
1613
- if (!match) break;
1614
- const [, timeStr, text] = match;
1615
- const timeStamp = parseTime(timeStr);
1616
- if (Number.isNaN(timeStamp)) break;
1617
- timeStamps.push(timeStamp);
1618
- lineStr = text;
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 (timeStamps.length === 0) continue;
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
- * 将歌词数组转换为 LyRiC 格式的字符串
1642
- * @param lines 歌词数组
1643
- * @returns LyRiC 格式的字符串
1638
+ * 将元数据表生成为 LRC 元数据行
1639
+ * @param metadata 元数据表
1640
+ * @returns 元数据行数组,没有可输出的元数据时为空数组
1644
1641
  */
1645
- function stringifyLrc(lines) {
1646
- return lines.map((line) => {
1647
- const text = line.words.map((w) => w.word).join("");
1648
- const printText = line.isBG ? `(${text})` : text;
1649
- return `[${formatTime(normalizeTimestamp(line.startTime))}]${printText}`;
1650
- }).join("\n");
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/lrca2.ts
1652
+ //#region src/formats/lrc/types.ts
1654
1653
  /**
1655
- * 解析 LRC A2 格式的歌词字符串
1656
- * @param lrc 歌词字符串
1657
- * @returns 成功解析出来的歌词
1654
+ * 各歌词模式的行为,由解析器与生成器共用
1658
1655
  */
1659
- function parseLrcA2(lrc) {
1660
- const lines = lrc.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
1661
- const lyricLines = [];
1662
- const lineTimeStampRegex = /^\[((?:\d+:)*\d+(?:\.\d+)?)\]/;
1663
- const wordTimestampRegex = /<((?:\d+:)*\d+(?:\.\d+)?)>/;
1664
- const wordTimestampPrefixRegex = /^<((?:\d+:)*\d+(?:\.\d+)?)>/;
1665
- for (let lineStr of lines) {
1666
- if (lineStr.match(/^\[([a-z]):(.+)\]$/i)) continue;
1667
- const lineTimeStampmatch = lineStr.match(lineTimeStampRegex);
1668
- if (!lineTimeStampmatch) continue;
1669
- const [lineTimeStamp, lineTimeStr] = lineTimeStampmatch;
1670
- const lineStartTime = parseTime(lineTimeStr);
1671
- if (Number.isNaN(lineStartTime)) continue;
1672
- lineStr = lineStr.slice(lineTimeStamp.length).trim();
1673
- if (!lineStr) continue;
1674
- const lineItems = [];
1675
- while (lineStr.length) {
1676
- const prefixedTimeStampMatch = lineStr.match(wordTimestampPrefixRegex);
1677
- if (prefixedTimeStampMatch) {
1678
- const [wordTimeStamp, wordTimeStr] = prefixedTimeStampMatch;
1679
- const parsedWordTime = parseTime(wordTimeStr);
1680
- if (!Number.isNaN(parsedWordTime)) lineItems.push(parsedWordTime);
1681
- lineStr = lineStr.slice(wordTimeStamp.length);
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 nextWordTimeStampIndex = lineStr.search(wordTimestampRegex);
1685
- const text = nextWordTimeStampIndex === -1 ? lineStr : lineStr.slice(0, nextWordTimeStampIndex);
1686
- lineItems.push(text);
1687
- lineStr = lineStr.slice(text.length);
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
- const words = [];
1690
- lineItems.forEach((item, index) => {
1691
- if (typeof item === "number") return;
1692
- const startTime = lineItems[index - 1] ?? lineStartTime;
1693
- const endTime = lineItems[index + 1] ?? startTime;
1694
- if (typeof startTime !== "number" || typeof endTime !== "number") return;
1695
- if (item.startsWith(" ") && words[words.length - 1]?.word.trim()) words.push(createWord({ word: " " }));
1696
- words.push(createWord({
1697
- word: item.trim(),
1698
- startTime,
1699
- endTime
1700
- }));
1701
- if (item.endsWith(" ")) words.push(createWord({ word: " " }));
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
- const lineEndTime = words[words.length - 1]?.endTime ?? lineStartTime;
1704
- lyricLines.push(createLine({
1705
- startTime: lineStartTime,
1706
- endTime: lineEndTime,
1707
- words
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
- return lyricLines;
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
- * 将歌词数组转换为 LRC A2 格式的字符串
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.map((line) => {
1719
- const normalizedLineStartTime = normalizeTimestamp(line.startTime);
1720
- if (line.words.length === 0) return `[${formatTime(normalizedLineStartTime)}]`;
1721
- const normalizedWords = [];
1722
- line.words.forEach((w) => {
1723
- if (!w.word.trim() && normalizedWords.length) {
1724
- normalizedWords[normalizedWords.length - 1].word += w.word;
1725
- return;
1726
- }
1727
- normalizedWords.push({
1728
- word: w.word,
1729
- startTime: normalizeTimestamp(w.startTime),
1730
- endTime: normalizeTimestamp(w.endTime)
1731
- });
1732
- });
1733
- const lineItems = normalizedWords.flatMap((w) => [w.startTime, w.word]);
1734
- lineItems.push(normalizedWords[normalizedWords.length - 1].endTime);
1735
- return `[${formatTime(normalizedLineStartTime)}]` + lineItems.map((item) => typeof item === "number" ? `<${formatTime(item)}>` : item).join("");
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 lineDuration = normalizeDuration(normalizeTimestamp(line.endTime) - lineStart);
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 wordDuration = normalizeDuration(normalizeTimestamp(endTime) - normalizedWordStart);
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 lineDuration = normalizeDuration(normalizeTimestamp(line.endTime) - lineStart);
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 wordDuration = normalizeDuration(normalizeTimestamp(endTime) - normalizedWordStart);
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;