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