mirror of
https://github.com/lqtmcstudio/QZMusic_PC.git
synced 2026-07-01 06:24:26 +08:00
fork(fix): Clone AMLL 并修复 BUG
- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork) - 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
109
amll-local/packages/lyric/src/formats/ass.ts
Normal file
109
amll-local/packages/lyric/src/formats/ass.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @fileoverview ASS 字幕格式导出。
|
||||
* 注意导出会损失 10ms 以内精度(按厘秒四舍五入)。
|
||||
*
|
||||
* 格式示例:
|
||||
* Dialogue: 0,0:00:12.34, 0:00:15.67, Default, v1,0,0,0,,{\k20}Hello{\k15} world
|
||||
* Dialogue: 0,0:00:12.34, 0:00:15.67, Default, v1-trans,0,0,0,,你好 世界
|
||||
* Dialogue: 0,0:00:12.34, 0:00:15.67, Default, v1-roman,0,0,0,,ni hao shi jie
|
||||
*/
|
||||
import type { LyricLine } from "../types";
|
||||
import { normalizeTimestamp } from "../utils";
|
||||
|
||||
function writeASSTimestamp(ms: number): string {
|
||||
const normalized = normalizeTimestamp(ms);
|
||||
const milli = Math.round(normalized) % 1000;
|
||||
const secTotal = Math.floor(Math.round(normalized) / 1000);
|
||||
const sec = secTotal % 60;
|
||||
const minTotal = Math.floor(secTotal / 60);
|
||||
const hour = Math.floor(minTotal / 60);
|
||||
return `${hour}:${String(minTotal % 60).padStart(2, "0")}:${String(sec).padStart(2, "0")}.${String(Math.floor(milli / 10)).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getSpeakerName(line: LyricLine): string {
|
||||
let name = line.isDuet ? "v2" : "v1";
|
||||
if (line.isBG) name += "-bg";
|
||||
return name;
|
||||
}
|
||||
|
||||
function writeLyricDialogue(
|
||||
result: string[],
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
name: string,
|
||||
text: string,
|
||||
) {
|
||||
result.push(
|
||||
`Dialogue: 0,${writeASSTimestamp(startTime)}, ${writeASSTimestamp(endTime)}, Default, ${name},0,0,0,,${text}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 ASS 字幕格式字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns ASS 字幕格式字符串
|
||||
*/
|
||||
export function stringifyAss(lines: LyricLine[]): string {
|
||||
const result: string[] = [
|
||||
"[Script Info]",
|
||||
"[Events]",
|
||||
"Formats: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
||||
];
|
||||
|
||||
for (const line of lines) {
|
||||
const timedWords = line.words
|
||||
.map((w) => ({
|
||||
...w,
|
||||
startTime: normalizeTimestamp(w.startTime),
|
||||
endTime: normalizeTimestamp(w.endTime),
|
||||
}))
|
||||
.filter((w) => w.endTime > w.startTime);
|
||||
const startTime = Math.min(...timedWords.map((w) => w.startTime));
|
||||
const endTime = Math.max(...timedWords.map((w) => w.endTime));
|
||||
if (!Number.isFinite(startTime) || !Number.isFinite(endTime)) continue;
|
||||
|
||||
let lyricText = "";
|
||||
let previousWordEndTime = startTime;
|
||||
for (const word of line.words) {
|
||||
const wordStart = normalizeTimestamp(word.startTime);
|
||||
const wordEnd = normalizeTimestamp(word.endTime);
|
||||
if (wordStart >= wordEnd) {
|
||||
lyricText += word.word;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (wordStart > previousWordEndTime) {
|
||||
const gapDurationCS = Math.floor(
|
||||
(wordStart - previousWordEndTime + 5) / 10,
|
||||
);
|
||||
if (gapDurationCS > 0) lyricText += `{\\k${gapDurationCS}}`;
|
||||
}
|
||||
|
||||
const wordDurationCS = Math.floor((wordEnd - wordStart + 5) / 10);
|
||||
if (wordDurationCS > 0) lyricText += `{\\k${wordDurationCS}}`;
|
||||
lyricText += word.word;
|
||||
previousWordEndTime = wordEnd;
|
||||
}
|
||||
|
||||
const speaker = getSpeakerName(line);
|
||||
writeLyricDialogue(result, startTime, endTime, speaker, lyricText);
|
||||
if (line.translatedLyric)
|
||||
writeLyricDialogue(
|
||||
result,
|
||||
startTime,
|
||||
endTime,
|
||||
`${speaker}-trans`,
|
||||
line.translatedLyric,
|
||||
);
|
||||
if (line.romanLyric)
|
||||
writeLyricDialogue(
|
||||
result,
|
||||
startTime,
|
||||
endTime,
|
||||
`${speaker}-roman`,
|
||||
line.romanLyric,
|
||||
);
|
||||
}
|
||||
|
||||
return `${result.join("\n")}\n`;
|
||||
}
|
||||
102
amll-local/packages/lyric/src/formats/eqrc/constants.ts
Normal file
102
amll-local/packages/lyric/src/formats/eqrc/constants.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @internal
|
||||
* @module constants
|
||||
* @description
|
||||
* 包含了所有 `custom_des` 模块所需的常量数据。
|
||||
*/
|
||||
|
||||
// 解密使用的3个8字节的DES密钥
|
||||
export const KEY_1: Uint8Array = new TextEncoder().encode("!@#)(*$%");
|
||||
export const KEY_2: Uint8Array = new TextEncoder().encode("123ZXC!@");
|
||||
export const KEY_3: Uint8Array = new TextEncoder().encode("!@#)(NHL");
|
||||
|
||||
// --- QQ 音乐使用的非标准 S 盒定义 ---
|
||||
|
||||
const SBOX1: number[] = [
|
||||
14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13,
|
||||
1, 10, 6, 12, 11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10,
|
||||
5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13,
|
||||
];
|
||||
const SBOX2: number[] = [
|
||||
15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8,
|
||||
15, 12, 0, 1, 10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3,
|
||||
2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9,
|
||||
];
|
||||
const SBOX3: number[] = [
|
||||
10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6,
|
||||
10, 2, 8, 5, 14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10,
|
||||
14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12,
|
||||
];
|
||||
const SBOX4: number[] = [
|
||||
7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0,
|
||||
3, 4, 7, 2, 12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2,
|
||||
8, 4, 3, 15, 0, 6, 10, 10, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14,
|
||||
];
|
||||
const SBOX5: number[] = [
|
||||
2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13,
|
||||
1, 5, 0, 15, 10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0,
|
||||
14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3,
|
||||
];
|
||||
const SBOX6: number[] = [
|
||||
12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9,
|
||||
5, 6, 1, 13, 14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13,
|
||||
11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13,
|
||||
];
|
||||
const SBOX7: number[] = [
|
||||
4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1,
|
||||
10, 14, 3, 5, 12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5,
|
||||
9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12,
|
||||
];
|
||||
const SBOX8: number[] = [
|
||||
13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7,
|
||||
4, 12, 5, 6, 11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3,
|
||||
5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11,
|
||||
];
|
||||
|
||||
export const S_BOXES: number[][] = [
|
||||
SBOX1,
|
||||
SBOX2,
|
||||
SBOX3,
|
||||
SBOX4,
|
||||
SBOX5,
|
||||
SBOX6,
|
||||
SBOX7,
|
||||
SBOX8,
|
||||
];
|
||||
|
||||
// QQ 音乐使用的标准 P 盒置换规则
|
||||
export const P_BOX: number[] = [
|
||||
16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, 2, 8, 24, 14, 32,
|
||||
27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
|
||||
];
|
||||
|
||||
/// QQ 音乐使用的标准扩展置换表。
|
||||
export const E_BOX_TABLE: number[] = [
|
||||
32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16,
|
||||
17, 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29,
|
||||
28, 29, 30, 31, 32, 1,
|
||||
];
|
||||
|
||||
// 每轮循环左移的位数表
|
||||
export const KEY_RND_SHIFT: number[] = [
|
||||
1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1,
|
||||
];
|
||||
|
||||
// 置换选择1 (PC-1) - C部分
|
||||
export const KEY_PERM_C: number[] = [
|
||||
56, 48, 40, 32, 24, 16, 8, 0, 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34,
|
||||
26, 18, 10, 2, 59, 51, 43, 35,
|
||||
];
|
||||
|
||||
// 置换选择1 (PC-1) - D部分
|
||||
export const KEY_PERM_D: number[] = [
|
||||
62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 60, 52, 44, 36,
|
||||
28, 20, 12, 4, 27, 19, 11, 3,
|
||||
];
|
||||
|
||||
// 置换选择2 (PC-2)
|
||||
export const KEY_COMPRESSION: number[] = [
|
||||
13, 16, 10, 23, 0, 4, 2, 27, 14, 5, 20, 9, 22, 18, 11, 3, 25, 7, 15, 6, 26,
|
||||
19, 12, 1, 40, 51, 30, 36, 46, 54, 29, 39, 50, 44, 32, 47, 43, 48, 38, 55, 33,
|
||||
52, 45, 41, 49, 35, 28, 31,
|
||||
];
|
||||
363
amll-local/packages/lyric/src/formats/eqrc/custom-des.ts
Normal file
363
amll-local/packages/lyric/src/formats/eqrc/custom-des.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* @internal
|
||||
* @module custom_des
|
||||
* @description
|
||||
* 本模块包含了为解密 QRC 歌词而移植的、非标准的类 DES 算法的底层实现。
|
||||
*
|
||||
* <h2>
|
||||
* <strong>警告:该 DES 实现并非标准实现!</strong>
|
||||
* </h2>
|
||||
*
|
||||
* 它是结构类似DES的、但完全私有的分组密码算法。
|
||||
* 本实现仅用于 QRC 歌词解密,不应用于实际安全目的。
|
||||
*/
|
||||
|
||||
import {
|
||||
E_BOX_TABLE,
|
||||
KEY_COMPRESSION,
|
||||
KEY_PERM_C,
|
||||
KEY_PERM_D,
|
||||
KEY_RND_SHIFT,
|
||||
P_BOX,
|
||||
S_BOXES,
|
||||
} from "./constants";
|
||||
|
||||
export enum Mode {
|
||||
Encrypt,
|
||||
Decrypt,
|
||||
}
|
||||
|
||||
export type KeySchedule = Int32Array;
|
||||
|
||||
/**
|
||||
* 从8字节密钥中根据置换表提取位,生成一个 BigInt。
|
||||
*
|
||||
* 这个函数对应原始C代码中的天书BITNUM宏,模拟 QQ 音乐特有的非标准的字节序处理方式。
|
||||
* 其将 8 字节密钥视为两个独立的、小端序的32位整数拼接而成。
|
||||
*
|
||||
* 例如,要读取第0位(MSB),它实际访问的是 `key[3]` 的最高位。
|
||||
* 要读取第31位,它访问的是 `key[0]` 的最低位。
|
||||
*
|
||||
* @param key 8字节的密钥 Uint8Array
|
||||
* @param table 0-based 的位索引置换表
|
||||
*/
|
||||
function permuteFromKeyBytes(key: Uint8Array, table: number[]): bigint {
|
||||
let output = 0n;
|
||||
let currentBitMask = 1n << BigInt(table.length - 1);
|
||||
|
||||
for (let i = 0; i < table.length; i++) {
|
||||
const pos = table[i];
|
||||
|
||||
const wordIndex = pos >> 5;
|
||||
const bitInWord = pos & 31;
|
||||
const byteInWord = bitInWord >> 3;
|
||||
const bitInByte = bitInWord & 7;
|
||||
const byteIndex = wordIndex * 4 + 3 - byteInWord;
|
||||
|
||||
const bit = (key[byteIndex] >> (7 - bitInByte)) & 1;
|
||||
|
||||
if (bit) {
|
||||
output |= currentBitMask;
|
||||
}
|
||||
currentBitMask >>= 1n;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一个存储在 BigInt 中的28位密钥部分进行循环左移。
|
||||
* @param value 包含28位数据的高位的 BigInt
|
||||
* @param amount 左移的位数
|
||||
*/
|
||||
function rotateLeft28Bit(value: bigint, amount: number): bigint {
|
||||
const BITS_28_MASK = 0xfffffff0n;
|
||||
const val = value & BITS_28_MASK;
|
||||
const shifted = (val << BigInt(amount)) | (val >> BigInt(28 - amount));
|
||||
return shifted & BITS_28_MASK;
|
||||
}
|
||||
|
||||
/**
|
||||
* DES 密钥调度算法。
|
||||
* 从一个64位的主密钥(实际使用56位,每字节的最低位是奇偶校验位,被忽略)
|
||||
* 生成16个48位的轮密钥。
|
||||
*
|
||||
* @param key 8字节的DES密钥
|
||||
* @param mode 加密或解密模式
|
||||
*/
|
||||
export function keySchedule(key: Uint8Array, mode: Mode): KeySchedule {
|
||||
// 预先分配连续的内存空间
|
||||
const schedule = new Int32Array(32);
|
||||
|
||||
// 应用 PC-1
|
||||
const c0 = permuteFromKeyBytes(key, KEY_PERM_C);
|
||||
const d0 = permuteFromKeyBytes(key, KEY_PERM_D);
|
||||
|
||||
// 将28位的结果左移4位,以匹配 `rotateLeft28Bit` 对高位对齐的期望。
|
||||
let c = c0 << 4n;
|
||||
let d = d0 << 4n;
|
||||
|
||||
for (let i = 0; i < 16; i++) {
|
||||
const shift = KEY_RND_SHIFT[i];
|
||||
c = rotateLeft28Bit(c, shift);
|
||||
d = rotateLeft28Bit(d, shift);
|
||||
|
||||
const toGen = mode === Mode.Decrypt ? 15 - i : i;
|
||||
|
||||
let subkey48bit = 0n;
|
||||
// 应用 PC-2
|
||||
for (let k = 0; k < KEY_COMPRESSION.length; k++) {
|
||||
const pos = KEY_COMPRESSION[k];
|
||||
|
||||
const bitBigInt =
|
||||
pos < 28
|
||||
? (c >> BigInt(31 - pos)) & 1n
|
||||
: (d >> BigInt(31 - (pos - 27))) & 1n; // QQ 音乐特有的怪癖,该算法的规则就是pos - 27
|
||||
|
||||
if (bitBigInt === 1n) {
|
||||
subkey48bit |= 1n << BigInt(47 - k);
|
||||
}
|
||||
}
|
||||
|
||||
// 提取高 24 位 (由第 5, 4, 3 字节组成)
|
||||
const b5 = Number((subkey48bit >> 40n) & 0xffn);
|
||||
const b4 = Number((subkey48bit >> 32n) & 0xffn);
|
||||
const b3 = Number((subkey48bit >> 24n) & 0xffn);
|
||||
const high24 = (b5 << 16) | (b4 << 8) | b3;
|
||||
|
||||
// 提取低 24 位 (由第 2, 1, 0 字节组成)
|
||||
const b2 = Number((subkey48bit >> 16n) & 0xffn);
|
||||
const b1 = Number((subkey48bit >> 8n) & 0xffn);
|
||||
const b0 = Number(subkey48bit & 0xffn);
|
||||
const low24 = (b2 << 16) | (b1 << 8) | b0;
|
||||
|
||||
// 存储到一维数组中
|
||||
schedule[toGen * 2] = high24;
|
||||
schedule[toGen * 2 + 1] = low24;
|
||||
}
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
// 初始置换规则。
|
||||
const IP_RULE: number[] = [
|
||||
34, 42, 50, 58, 2, 10, 18, 26, 36, 44, 52, 60, 4, 12, 20, 28, 38, 46, 54, 62,
|
||||
6, 14, 22, 30, 40, 48, 56, 64, 8, 16, 24, 32, 33, 41, 49, 57, 1, 9, 17, 25,
|
||||
35, 43, 51, 59, 3, 11, 19, 27, 37, 45, 53, 61, 5, 13, 21, 29, 39, 47, 55, 63,
|
||||
7, 15, 23, 31,
|
||||
];
|
||||
|
||||
// 逆初始置换规则。
|
||||
const INV_IP_RULE: number[] = [
|
||||
37, 5, 45, 13, 53, 21, 61, 29, 38, 6, 46, 14, 54, 22, 62, 30, 39, 7, 47, 15,
|
||||
55, 23, 63, 31, 40, 8, 48, 16, 56, 24, 64, 32, 33, 1, 41, 9, 49, 17, 57, 25,
|
||||
34, 2, 42, 10, 50, 18, 58, 26, 35, 3, 43, 11, 51, 19, 59, 27, 36, 4, 44, 12,
|
||||
52, 20, 60, 28,
|
||||
];
|
||||
|
||||
const IP_LEFT_TABLE = new Int32Array(2048);
|
||||
const IP_RIGHT_TABLE = new Int32Array(2048);
|
||||
const INV_IP_LEFT_TABLE = new Int32Array(2048);
|
||||
const INV_IP_RIGHT_TABLE = new Int32Array(2048);
|
||||
|
||||
function generatePermutationTables(): void {
|
||||
const applyPermutation = (input: bigint, rule: number[]): bigint => {
|
||||
let output = 0n;
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const srcBit1Based = rule[i];
|
||||
if ((input >> BigInt(64 - srcBit1Based)) & 1n) {
|
||||
output |= 1n << BigInt(63 - i);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
// 生成 IP 结果查找表
|
||||
for (let bytePos = 0; bytePos < 8; bytePos++) {
|
||||
for (let byteVal = 0; byteVal < 256; byteVal++) {
|
||||
const input = BigInt(byteVal) << BigInt(56 - bytePos * 8);
|
||||
const permuted = applyPermutation(input, IP_RULE);
|
||||
const idx = (bytePos << 8) | byteVal;
|
||||
IP_LEFT_TABLE[idx] = Number((permuted >> 32n) & 0xffffffffn);
|
||||
IP_RIGHT_TABLE[idx] = Number(permuted & 0xffffffffn);
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 InvIP 结果查找表 (一维 TypedArray,分为左右 32 位以避免 BigInt)
|
||||
for (let blockPos = 0; blockPos < 8; blockPos++) {
|
||||
for (let blockVal = 0; blockVal < 256; blockVal++) {
|
||||
const input = BigInt(blockVal) << BigInt(56 - blockPos * 8);
|
||||
const permuted = applyPermutation(input, INV_IP_RULE);
|
||||
const idx = (blockPos << 8) | blockVal;
|
||||
INV_IP_LEFT_TABLE[idx] = Number((permuted >> 32n) & 0xffffffffn);
|
||||
INV_IP_RIGHT_TABLE[idx] = Number(permuted & 0xffffffffn);
|
||||
}
|
||||
}
|
||||
}
|
||||
generatePermutationTables();
|
||||
|
||||
/**
|
||||
* 计算 DES S-盒的查找索引。
|
||||
* @param a 一个包含6位数据的 u8
|
||||
*/
|
||||
function calculateSboxIndex(a: number): number {
|
||||
return (a & 0x20) | ((a & 0x1f) >> 1) | ((a & 0x01) << 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对一个 32 位整数应用非标准的 P 盒置换规则。
|
||||
* @param input S-盒代换后的 32 位中间结果
|
||||
*/
|
||||
function applyQqPboxPermutation(input: number): number {
|
||||
let output = 0;
|
||||
for (let i = 0; i < 32; i++) {
|
||||
const sourceBit1Based = P_BOX[i];
|
||||
const destBitMask = 1 << (31 - i);
|
||||
const sourceBitMask = 1 << (32 - sourceBit1Based);
|
||||
if ((input & sourceBitMask) !== 0) {
|
||||
output |= destBitMask;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const SP_TABLE = new Int32Array(512);
|
||||
|
||||
/**
|
||||
* 生成 S-P 盒合并查找表以提高性能。
|
||||
*/
|
||||
function generateSpTables(): void {
|
||||
for (let sBoxIdx = 0; sBoxIdx < 8; sBoxIdx++) {
|
||||
for (let sBoxInput = 0; sBoxInput < 64; sBoxInput++) {
|
||||
const sBoxIndex = calculateSboxIndex(sBoxInput);
|
||||
const fourBitOutput = S_BOXES[sBoxIdx][sBoxIndex];
|
||||
const prePBoxVal = fourBitOutput << (28 - sBoxIdx * 4);
|
||||
SP_TABLE[(sBoxIdx << 6) | sBoxInput] = applyQqPboxPermutation(prePBoxVal);
|
||||
}
|
||||
}
|
||||
}
|
||||
generateSpTables();
|
||||
|
||||
const EBOX_HIGH_TABLE = new Int32Array(1024);
|
||||
const EBOX_LOW_TABLE = new Int32Array(1024);
|
||||
|
||||
function generateEBoxTables(): void {
|
||||
for (let chunkIdx = 0; chunkIdx < 4; chunkIdx++) {
|
||||
const shiftIn32 = (3 - chunkIdx) * 8;
|
||||
|
||||
for (let byteVal = 0; byteVal < 256; byteVal++) {
|
||||
let high24 = 0;
|
||||
let low24 = 0;
|
||||
const input = byteVal << shiftIn32;
|
||||
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const sourceBitPos = E_BOX_TABLE[i];
|
||||
const bit = (input >>> (32 - sourceBitPos)) & 1;
|
||||
if (bit) {
|
||||
high24 |= 1 << (23 - i);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 24; i < 48; i++) {
|
||||
const sourceBitPos = E_BOX_TABLE[i];
|
||||
const bit = (input >>> (32 - sourceBitPos)) & 1;
|
||||
if (bit) {
|
||||
low24 |= 1 << (47 - i);
|
||||
}
|
||||
}
|
||||
|
||||
const tableIdx = (chunkIdx << 8) | byteVal;
|
||||
EBOX_HIGH_TABLE[tableIdx] = high24;
|
||||
EBOX_LOW_TABLE[tableIdx] = low24;
|
||||
}
|
||||
}
|
||||
}
|
||||
generateEBoxTables();
|
||||
|
||||
/**
|
||||
* DES 的 F 函数。
|
||||
*/
|
||||
function fFunction(state: number, keyHigh24: number, keyLow24: number): number {
|
||||
// 将 32 位状态拆分为 4 个字节,直接查表并进行按位或拼接
|
||||
const b0 = (state >>> 24) & 0xff;
|
||||
const b1 = (state >>> 16) & 0xff;
|
||||
const b2 = (state >>> 8) & 0xff;
|
||||
const b3 = state & 0xff;
|
||||
|
||||
const eboxHigh24 =
|
||||
EBOX_HIGH_TABLE[b0] |
|
||||
EBOX_HIGH_TABLE[256 | b1] |
|
||||
EBOX_HIGH_TABLE[512 | b2] |
|
||||
EBOX_HIGH_TABLE[768 | b3];
|
||||
const eboxLow24 =
|
||||
EBOX_LOW_TABLE[b0] |
|
||||
EBOX_LOW_TABLE[256 | b1] |
|
||||
EBOX_LOW_TABLE[512 | b2] |
|
||||
EBOX_LOW_TABLE[768 | b3];
|
||||
|
||||
const xorHigh24 = eboxHigh24 ^ keyHigh24;
|
||||
const xorLow24 = eboxLow24 ^ keyLow24;
|
||||
|
||||
return (
|
||||
SP_TABLE[(xorHigh24 >>> 18) & 0x3f] |
|
||||
SP_TABLE[64 | ((xorHigh24 >>> 12) & 0x3f)] |
|
||||
SP_TABLE[128 | ((xorHigh24 >>> 6) & 0x3f)] |
|
||||
SP_TABLE[192 | (xorHigh24 & 0x3f)] |
|
||||
SP_TABLE[256 | ((xorLow24 >>> 18) & 0x3f)] |
|
||||
SP_TABLE[320 | ((xorLow24 >>> 12) & 0x3f)] |
|
||||
SP_TABLE[384 | ((xorLow24 >>> 6) & 0x3f)] |
|
||||
SP_TABLE[448 | (xorLow24 & 0x3f)]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* DES 加密/解密单个64位数据块。
|
||||
*
|
||||
* @param input 8字节的输入数据块 (明文或密文)。
|
||||
* @param output 8字节的可变切片,用于存储输出数据块 (密文或明文)。
|
||||
* @param keySchedule 一个包含16个轮密钥的向量的引用,每个轮密钥是6字节。
|
||||
*/
|
||||
export function desCrypt(
|
||||
input: Uint8Array,
|
||||
output: Uint8Array,
|
||||
keySchedule: KeySchedule,
|
||||
): void {
|
||||
let left = 0;
|
||||
let right = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const idx = (i << 8) | input[i];
|
||||
left |= IP_LEFT_TABLE[idx];
|
||||
right |= IP_RIGHT_TABLE[idx];
|
||||
}
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const temp = right;
|
||||
right =
|
||||
(left ^ fFunction(right, keySchedule[i * 2], keySchedule[i * 2 + 1])) >>>
|
||||
0;
|
||||
left = temp;
|
||||
}
|
||||
left = (left ^ fFunction(right, keySchedule[30], keySchedule[31])) >>> 0;
|
||||
|
||||
let outLeft = 0;
|
||||
let outRight = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// 分别计算左侧 32 位和右侧 32 位在 INV_IP 阶段的表现
|
||||
const idxL = (i << 8) | ((left >>> (24 - i * 8)) & 0xff);
|
||||
outLeft |= INV_IP_LEFT_TABLE[idxL];
|
||||
outRight |= INV_IP_RIGHT_TABLE[idxL];
|
||||
|
||||
const idxR = ((i + 4) << 8) | ((right >>> (24 - i * 8)) & 0xff);
|
||||
outLeft |= INV_IP_LEFT_TABLE[idxR];
|
||||
outRight |= INV_IP_RIGHT_TABLE[idxR];
|
||||
}
|
||||
|
||||
// 使用按位移位进行输出数组写入
|
||||
output[0] = (outLeft >>> 24) & 0xff;
|
||||
output[1] = (outLeft >>> 16) & 0xff;
|
||||
output[2] = (outLeft >>> 8) & 0xff;
|
||||
output[3] = outLeft & 0xff;
|
||||
output[4] = (outRight >>> 24) & 0xff;
|
||||
output[5] = (outRight >>> 16) & 0xff;
|
||||
output[6] = (outRight >>> 8) & 0xff;
|
||||
output[7] = outRight & 0xff;
|
||||
}
|
||||
149
amll-local/packages/lyric/src/formats/eqrc/index.ts
Normal file
149
amll-local/packages/lyric/src/formats/eqrc/index.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* @module qrc-codec
|
||||
* @description
|
||||
* 此模块是加密与解密 QRC 歌词的核心。
|
||||
* 提供了两个主要的公共函数:`decryptQrc` 和 `encryptQrc`。
|
||||
*
|
||||
* 非标准 3DES 算法实现由 `custom_des` 模块提供。
|
||||
*
|
||||
* 迁移自 https://github.com/apoint123/qrc-decoder
|
||||
*/
|
||||
|
||||
import { deflate, inflate } from "pako";
|
||||
import { KEY_1, KEY_2, KEY_3 } from "./constants";
|
||||
import { desCrypt, type KeySchedule, keySchedule, Mode } from "./custom-des";
|
||||
import { hexToUint8Array, uint8ArrayToHex } from "./utils";
|
||||
|
||||
const DES_BLOCK_SIZE = 8;
|
||||
|
||||
/**
|
||||
* 非标准 3DES 编解码器
|
||||
*/
|
||||
class QqMusicCodec {
|
||||
private readonly encryptSchedule: KeySchedule[];
|
||||
private readonly decryptSchedule: KeySchedule[];
|
||||
|
||||
constructor() {
|
||||
// 解密流程 D(K3) -> E(K2) -> D(K1)
|
||||
this.decryptSchedule = [
|
||||
keySchedule(KEY_3, Mode.Decrypt),
|
||||
keySchedule(KEY_2, Mode.Encrypt),
|
||||
keySchedule(KEY_1, Mode.Decrypt),
|
||||
];
|
||||
|
||||
// 加密流程 E(K1) -> D(K2) -> E(K3)
|
||||
this.encryptSchedule = [
|
||||
keySchedule(KEY_1, Mode.Encrypt),
|
||||
keySchedule(KEY_2, Mode.Decrypt),
|
||||
keySchedule(KEY_3, Mode.Encrypt),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密一个8字节的数据块。
|
||||
*/
|
||||
public decryptBlock(input: Uint8Array, output: Uint8Array): void {
|
||||
const temp1 = new Uint8Array(8);
|
||||
const temp2 = new Uint8Array(8);
|
||||
desCrypt(input, temp1, this.decryptSchedule[0]);
|
||||
desCrypt(temp1, temp2, this.decryptSchedule[1]);
|
||||
desCrypt(temp2, output, this.decryptSchedule[2]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密一个8字节的数据块。
|
||||
*/
|
||||
public encryptBlock(input: Uint8Array, output: Uint8Array): void {
|
||||
const temp1 = new Uint8Array(8);
|
||||
const temp2 = new Uint8Array(8);
|
||||
desCrypt(input, temp1, this.encryptSchedule[0]);
|
||||
desCrypt(temp1, temp2, this.encryptSchedule[1]);
|
||||
desCrypt(temp2, output, this.encryptSchedule[2]);
|
||||
}
|
||||
}
|
||||
|
||||
const CODEC = new QqMusicCodec();
|
||||
|
||||
/**
|
||||
* 使用零字节对数据进行填充。
|
||||
*
|
||||
* QQ音乐使用的填充方案是零填充。
|
||||
* @param data 需要填充的字节数据
|
||||
* @param blockSize 块大小,对于DES来说是8
|
||||
*/
|
||||
function zeroPad(data: Uint8Array, blockSize: number): Uint8Array {
|
||||
const paddingLen = (blockSize - (data.length % blockSize)) % blockSize;
|
||||
if (paddingLen === 0) {
|
||||
return data;
|
||||
}
|
||||
|
||||
const paddedData = new Uint8Array(data.length + paddingLen);
|
||||
paddedData.set(data, 0);
|
||||
return paddedData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 Zlib 解压缩字节数据。
|
||||
* 同时会尝试移除头部的 UTF-8 BOM (0xEF 0xBB 0xBF)。
|
||||
*/
|
||||
function decompress(data: Uint8Array): Uint8Array {
|
||||
const decompressed = inflate(data);
|
||||
if (
|
||||
decompressed.length >= 3 &&
|
||||
decompressed[0] === 0xef &&
|
||||
decompressed[1] === 0xbb &&
|
||||
decompressed[2] === 0xbf
|
||||
) {
|
||||
return decompressed.slice(3);
|
||||
}
|
||||
return decompressed;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密十六进制字符串格式的 Qrc 歌词数据
|
||||
* 解密后可去头尾 XML 数据后通过调用 `parseQrc` 解析歌词行
|
||||
* @param encryptedHexString 十六进制格式的字符串,代表被加密的歌词数据
|
||||
* @returns 被解密出来的歌词字符串,是前后有 XML 混合的 QRC 歌词
|
||||
*/
|
||||
export function decryptQrcHex(encryptedHexString: string): string {
|
||||
const encryptedBytes = hexToUint8Array(encryptedHexString);
|
||||
|
||||
if (encryptedBytes.length % DES_BLOCK_SIZE !== 0) {
|
||||
throw new Error(`加密数据长度不是${DES_BLOCK_SIZE}的倍数`);
|
||||
}
|
||||
|
||||
const decryptedData = new Uint8Array(encryptedBytes.length);
|
||||
|
||||
for (let i = 0; i < encryptedBytes.length; i += DES_BLOCK_SIZE) {
|
||||
const chunk = encryptedBytes.subarray(i, i + DES_BLOCK_SIZE);
|
||||
const outChunk = decryptedData.subarray(i, i + DES_BLOCK_SIZE);
|
||||
CODEC.decryptBlock(chunk, outChunk);
|
||||
}
|
||||
|
||||
const decompressedBytes = decompress(decryptedData);
|
||||
|
||||
return new TextDecoder("utf-8").decode(decompressedBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 对明文执行加密操作。
|
||||
* @param plaintext 明文字符串
|
||||
* @returns 十六进制格式的字符串,代表被加密的歌词数据
|
||||
*/
|
||||
export function encryptQrcHex(plaintext: string): string {
|
||||
const textBytes = new TextEncoder().encode(plaintext);
|
||||
|
||||
const compressedData = deflate(textBytes);
|
||||
|
||||
const paddedData = zeroPad(compressedData, DES_BLOCK_SIZE);
|
||||
|
||||
const encryptedData = new Uint8Array(paddedData.length);
|
||||
|
||||
for (let i = 0; i < paddedData.length; i += DES_BLOCK_SIZE) {
|
||||
const chunk = paddedData.subarray(i, i + DES_BLOCK_SIZE);
|
||||
const outChunk = encryptedData.subarray(i, i + DES_BLOCK_SIZE);
|
||||
CODEC.encryptBlock(chunk, outChunk);
|
||||
}
|
||||
|
||||
return uint8ArrayToHex(encryptedData);
|
||||
}
|
||||
39
amll-local/packages/lyric/src/formats/eqrc/utils.ts
Normal file
39
amll-local/packages/lyric/src/formats/eqrc/utils.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/// <reference types="node" />
|
||||
/**
|
||||
* @module utils
|
||||
* @description
|
||||
*
|
||||
* 包含一些工具函数。
|
||||
*/
|
||||
|
||||
/**
|
||||
* 将十六进制字符串转换为 Uint8Array。
|
||||
*/
|
||||
export function hexToUint8Array(hex: string): Uint8Array {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(hex, "hex");
|
||||
}
|
||||
|
||||
if (hex.length % 2 !== 0) {
|
||||
throw new Error("无效的十六进制字符串: 长度必须是偶数");
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Uint8Array 转换为十六进制字符串。
|
||||
*/
|
||||
export function uint8ArrayToHex(bytes: Uint8Array): string {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(bytes).toString("hex");
|
||||
}
|
||||
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
109
amll-local/packages/lyric/src/formats/eslrc.ts
Normal file
109
amll-local/packages/lyric/src/formats/eslrc.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* @fileoverview ESLyric 逐词歌词格式解析与生成。
|
||||
* 每行以行首时间戳开头,后续每个单词后都跟一个结束时间戳。
|
||||
*
|
||||
* 格式示例:
|
||||
* [00:10.82]Test[00:10.97] Word[00:12.62]
|
||||
* [00:12.62]Next[00:13.20] line[00:14.10]
|
||||
*/
|
||||
import type { LyricLine, LyricWord } from "../types";
|
||||
import {
|
||||
clampTimestamp,
|
||||
createLine,
|
||||
createWord,
|
||||
formatTime,
|
||||
parseTime,
|
||||
} from "../utils";
|
||||
|
||||
const TIME_REGEX = /^\[((?:\d+:)*\d+(?:\.\d+)?)\]/;
|
||||
|
||||
function parseTimestampPrefix(
|
||||
src: string,
|
||||
): { time: number; length: number } | null {
|
||||
const match = src.match(TIME_REGEX);
|
||||
if (!match) return null;
|
||||
const [raw, timeStr] = match;
|
||||
return { time: parseTime(timeStr), length: raw.length };
|
||||
}
|
||||
|
||||
function parseEslrcLine(rawLine: string): LyricLine | null {
|
||||
let src = rawLine.trim();
|
||||
const first = parseTimestampPrefix(src);
|
||||
if (!first) return null;
|
||||
src = src.slice(first.length);
|
||||
let startTime = first.time;
|
||||
if (!src.trim()) return null;
|
||||
|
||||
const words: LyricWord[] = [];
|
||||
while (src.trim().length > 0) {
|
||||
const nextTimePos = src.indexOf("[");
|
||||
if (nextTimePos <= 0) return null;
|
||||
|
||||
const word = src.slice(0, nextTimePos);
|
||||
const nextTime = parseTimestampPrefix(src.slice(nextTimePos));
|
||||
if (!nextTime) return null;
|
||||
|
||||
words.push(
|
||||
createWord({
|
||||
word,
|
||||
startTime,
|
||||
endTime: nextTime.time,
|
||||
}),
|
||||
);
|
||||
src = src.slice(nextTimePos + nextTime.length);
|
||||
startTime = nextTime.time;
|
||||
}
|
||||
|
||||
return createLine({ words });
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 ESLyric 逐词歌词格式字符串
|
||||
* @param eslrc 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseEslrc(eslrc: string): LyricLine[] {
|
||||
const result: LyricLine[] = [];
|
||||
for (const rawLine of eslrc.split(/\r?\n/)) {
|
||||
const line = parseEslrcLine(rawLine);
|
||||
if (line) result.push(line);
|
||||
}
|
||||
|
||||
result.sort(
|
||||
(a, b) =>
|
||||
(a.words[0]?.startTime ?? Number.MAX_SAFE_INTEGER) -
|
||||
(b.words[0]?.startTime ?? Number.MAX_SAFE_INTEGER),
|
||||
);
|
||||
|
||||
for (const line of result) {
|
||||
for (const word of line.words) {
|
||||
word.startTime = clampTimestamp(word.startTime);
|
||||
word.endTime = clampTimestamp(word.endTime);
|
||||
}
|
||||
line.startTime = clampTimestamp(line.words[0]?.startTime ?? 0);
|
||||
line.endTime = clampTimestamp(
|
||||
line.words[line.words.length - 1]?.endTime ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 ESLyric 逐词歌词格式字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns ESLyric 逐词歌词格式字符串
|
||||
*/
|
||||
export function stringifyEslrc(lines: LyricLine[]): string {
|
||||
return lines
|
||||
.map((line) => {
|
||||
if (!line.words.length) return "";
|
||||
return `[${formatTime(clampTimestamp(line.words[0].startTime))}]${line.words
|
||||
.map(
|
||||
(word) => `${word.word}[${formatTime(clampTimestamp(word.endTime))}]`,
|
||||
)
|
||||
.join("")}`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
140
amll-local/packages/lyric/src/formats/lqe.ts
Normal file
140
amll-local/packages/lyric/src/formats/lqe.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview Lyricify Quick Export(LQE)格式解析与生成。
|
||||
* 该格式本质是组合格式:`lyrics` 部分使用 LYS;`translation`/`pronunciation` 部分使用 LRC。
|
||||
*
|
||||
* 格式示例:
|
||||
* [Lyricify Quick Export]
|
||||
* [version:1.0]
|
||||
*
|
||||
* [lyrics: format@Lyricify Syllable]
|
||||
* [4]A(365,350)ni(715,307)ro(1022,312)dham (1334,419)a(3203,337)nut(3540,350)pā(3890,306)dam(4196,382)
|
||||
*
|
||||
* [translation: format@LRC]
|
||||
* [00:00.365]不生亦不灭
|
||||
*
|
||||
* [pronunciation: format@LRC, language@romaji]
|
||||
* [00:00.365]阿难罗昙 阿耨钵昙
|
||||
*/
|
||||
import type { LyricLine } from "../types";
|
||||
import { formatTime, parseTime } from "../utils";
|
||||
import { parseLys, stringifyLys } from "./lys";
|
||||
|
||||
type AttrType = "translatedLyric" | "romanLyric";
|
||||
|
||||
interface HeaderMatch {
|
||||
index: number;
|
||||
type: "lyric" | "translation" | "romanization" | "unknown";
|
||||
}
|
||||
|
||||
function parseAttr(
|
||||
attr: AttrType,
|
||||
headerMatches: HeaderMatch[],
|
||||
rawLines: string[],
|
||||
lines: LyricLine[],
|
||||
): void {
|
||||
const headerIndex = headerMatches.findIndex((item) => {
|
||||
if (attr === "translatedLyric") return item.type === "translation";
|
||||
return item.type === "romanization";
|
||||
});
|
||||
if (headerIndex === -1) return;
|
||||
|
||||
const timeRegex = /^\[((?:\d+:)*\d+(?:\.\d+)?)\](.*)$/;
|
||||
const attrLines = rawLines
|
||||
.slice(
|
||||
headerMatches[headerIndex].index + 1,
|
||||
headerMatches[headerIndex + 1].index,
|
||||
)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.map((line) => {
|
||||
const match = line.match(timeRegex);
|
||||
if (!match) return null;
|
||||
const [, timeStr, text] = match;
|
||||
const time = parseTime(timeStr);
|
||||
if (Number.isNaN(time)) return null;
|
||||
return { time, text };
|
||||
})
|
||||
.filter((item): item is { time: number; text: string } => item !== null);
|
||||
|
||||
let attrLineIndex = 0;
|
||||
for (const line of lines) {
|
||||
if (attrLines[attrLineIndex]?.time !== line.startTime) continue;
|
||||
line[attr] = attrLines[attrLineIndex].text;
|
||||
attrLineIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LQE 格式的歌词字符串
|
||||
* @param lqe 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseLqe(lqe: string): LyricLine[] {
|
||||
const lines = lqe
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const headerRegex = /^\[([a-zA-Z]+):.+\]$/;
|
||||
const headerMatches: HeaderMatch[] = [];
|
||||
lines.forEach((line, index) => {
|
||||
const match = line.match(headerRegex);
|
||||
if (!match) return;
|
||||
const [, type] = match;
|
||||
if (type === "lyrics") headerMatches.push({ index, type: "lyric" });
|
||||
else if (type === "translation")
|
||||
headerMatches.push({ index, type: "translation" });
|
||||
else if (type === "pronunciation")
|
||||
headerMatches.push({ index, type: "romanization" });
|
||||
else headerMatches.push({ index, type: "unknown" });
|
||||
});
|
||||
headerMatches.push({ index: lines.length, type: "unknown" });
|
||||
|
||||
const lyricHeaderIndex = headerMatches.findIndex(
|
||||
(item) => item.type === "lyric",
|
||||
);
|
||||
if (lyricHeaderIndex === -1) return [];
|
||||
|
||||
const lyricLines = lines.slice(
|
||||
headerMatches[lyricHeaderIndex].index + 1,
|
||||
headerMatches[lyricHeaderIndex + 1].index,
|
||||
);
|
||||
const parsedLines = parseLys(lyricLines.join("\n"));
|
||||
|
||||
parseAttr("translatedLyric", headerMatches, lines, parsedLines);
|
||||
parseAttr("romanLyric", headerMatches, lines, parsedLines);
|
||||
|
||||
return parsedLines;
|
||||
}
|
||||
|
||||
function stringifyAttr(lines: LyricLine[], attr: AttrType): string | null {
|
||||
const header =
|
||||
attr === "translatedLyric"
|
||||
? "[translation: format@LRC]"
|
||||
: "[pronunciation: format@LRC, language@romaji]";
|
||||
const contentLines = lines
|
||||
.map((line) => {
|
||||
const value = line[attr];
|
||||
if (!value) return null;
|
||||
return `[${formatTime(line.startTime)}]${value}`;
|
||||
})
|
||||
.filter((line): line is string => line !== null);
|
||||
if (contentLines.length === 0) return null;
|
||||
return [header, ...contentLines].join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 LQE 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns LQE 格式的字符串
|
||||
*/
|
||||
export function stringifyLqe(lines: LyricLine[]): string {
|
||||
const header = "[Lyricify Quick Export]\n[version:1.0]";
|
||||
const lyricSection = `[lyrics: format@Lyricify Syllable]\n${stringifyLys(lines)}`;
|
||||
const translationSection = stringifyAttr(lines, "translatedLyric");
|
||||
const romanizationSection = stringifyAttr(lines, "romanLyric");
|
||||
const body = [lyricSection, translationSection, romanizationSection]
|
||||
.filter((section): section is string => section !== null)
|
||||
.join("\n\n\n");
|
||||
return [header, body].join("\n\n");
|
||||
}
|
||||
81
amll-local/packages/lyric/src/formats/lrc.ts
Normal file
81
amll-local/packages/lyric/src/formats/lrc.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @fileoverview 基础 LRC 格式解析与生成。
|
||||
* 该格式只支持行级时间戳,不支持词级/音节级时间戳;若需要词级时间戳请使用 LRC A2 等扩展。
|
||||
*
|
||||
* 格式示例:
|
||||
* [01:56.439]Life goes on, through tides of time
|
||||
* [02:01.079]Get in the line, to dream alive
|
||||
* [02:06.103][02:08.916][02:11.135]On the journey
|
||||
*/
|
||||
import type { LyricLine } from "../types";
|
||||
import {
|
||||
createLine,
|
||||
createWord,
|
||||
formatTime,
|
||||
MAX_LRC_TIMESTAMP,
|
||||
normalizeTimestamp,
|
||||
pairwise,
|
||||
parseTime,
|
||||
} from "../utils";
|
||||
|
||||
/**
|
||||
* 解析 LyRiC 格式的歌词字符串
|
||||
* @param lrc 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseLrc(lrc: string): LyricLine[] {
|
||||
const tagRegex = /^\[([a-z]+):([^\]]+)\]$/;
|
||||
const timeRegex = /^\[((?:\d+:)*\d+(?:\.\d+)?)\](.*)$/;
|
||||
const bgRegex = /^[((](.+)[))]$/;
|
||||
const lines = lrc
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const lyricLines: LyricLine[] = [];
|
||||
for (let lineStr of lines) {
|
||||
if (tagRegex.test(lineStr)) continue;
|
||||
const timeStamps: number[] = [];
|
||||
while (true) {
|
||||
const match = lineStr.match(timeRegex);
|
||||
if (!match) break;
|
||||
const [, timeStr, text] = match;
|
||||
const timeStamp = parseTime(timeStr);
|
||||
if (Number.isNaN(timeStamp)) break;
|
||||
timeStamps.push(timeStamp);
|
||||
lineStr = text;
|
||||
}
|
||||
if (timeStamps.length === 0) continue;
|
||||
lineStr = lineStr.trim();
|
||||
const backgroundMatch = lineStr.match(bgRegex);
|
||||
const isBG = Boolean(backgroundMatch);
|
||||
if (backgroundMatch) lineStr = backgroundMatch[1];
|
||||
for (const t of timeStamps)
|
||||
lyricLines.push(
|
||||
createLine({
|
||||
startTime: t,
|
||||
endTime: MAX_LRC_TIMESTAMP,
|
||||
words: [createWord({ word: lineStr, startTime: t, endTime: t })],
|
||||
isBG,
|
||||
}),
|
||||
);
|
||||
}
|
||||
lyricLines.sort((a, b) => a.startTime - b.startTime);
|
||||
for (const [prev, curr] of pairwise(lyricLines))
|
||||
prev.endTime = prev.words[0].endTime = curr.startTime;
|
||||
return lyricLines.filter((line) => line.words[0].word);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 LyRiC 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns LyRiC 格式的字符串
|
||||
*/
|
||||
export function stringifyLrc(lines: LyricLine[]): string {
|
||||
return lines
|
||||
.map((line) => {
|
||||
const text = line.words.map((w) => w.word).join("");
|
||||
const printText = line.isBG ? `(${text})` : text;
|
||||
return `[${formatTime(normalizeTimestamp(line.startTime))}]${printText}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
129
amll-local/packages/lyric/src/formats/lrca2.ts
Normal file
129
amll-local/packages/lyric/src/formats/lrca2.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @fileoverview LRC A2(增强 LRC)格式解析与生成。
|
||||
* 在普通 LRC 行级时间戳基础上,支持词级/音节级时间戳;同一行内时间需连续,词时间由左右时间戳界定。
|
||||
*
|
||||
* 格式示例:
|
||||
* [02:38.850]<02:38.850>Words <02:39.030>are <02:39.120>made <02:39.360>of <02:39.420>plastic<02:40.080>
|
||||
* [02:40.080]<02:40.080>Come <02:40.290>back <02:40.470>like <02:40.680>elastic<02:41.370>
|
||||
*/
|
||||
import type { LyricLine, LyricWord } from "../types";
|
||||
import {
|
||||
createLine,
|
||||
createWord,
|
||||
formatTime,
|
||||
normalizeTimestamp,
|
||||
parseTime,
|
||||
} from "../utils";
|
||||
|
||||
/**
|
||||
* 解析 LRC A2 格式的歌词字符串
|
||||
* @param lrc 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseLrcA2(lrc: string): LyricLine[] {
|
||||
const lines = lrc
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const lyricLines: LyricLine[] = [];
|
||||
const lineTimeStampRegex = /^\[((?:\d+:)*\d+(?:\.\d+)?)\]/;
|
||||
const wordTimestampRegex = /<((?:\d+:)*\d+(?:\.\d+)?)>/;
|
||||
const wordTimestampPrefixRegex = /^<((?:\d+:)*\d+(?:\.\d+)?)>/;
|
||||
for (let lineStr of lines) {
|
||||
const tagMatch = lineStr.match(/^\[([a-z]):(.+)\]$/i);
|
||||
if (tagMatch) continue;
|
||||
const lineTimeStampmatch = lineStr.match(lineTimeStampRegex);
|
||||
if (!lineTimeStampmatch) continue;
|
||||
const [lineTimeStamp, lineTimeStr] = lineTimeStampmatch;
|
||||
const lineStartTime = parseTime(lineTimeStr);
|
||||
if (Number.isNaN(lineStartTime)) continue;
|
||||
lineStr = lineStr.slice(lineTimeStamp.length).trim();
|
||||
if (!lineStr) continue;
|
||||
|
||||
const lineItems: (number | string)[] = [];
|
||||
while (lineStr.length) {
|
||||
const prefixedTimeStampMatch = lineStr.match(wordTimestampPrefixRegex);
|
||||
if (prefixedTimeStampMatch) {
|
||||
const [wordTimeStamp, wordTimeStr] = prefixedTimeStampMatch;
|
||||
const parsedWordTime = parseTime(wordTimeStr);
|
||||
if (!Number.isNaN(parsedWordTime)) lineItems.push(parsedWordTime);
|
||||
lineStr = lineStr.slice(wordTimeStamp.length);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextWordTimeStampIndex = lineStr.search(wordTimestampRegex);
|
||||
const text =
|
||||
nextWordTimeStampIndex === -1
|
||||
? lineStr
|
||||
: lineStr.slice(0, nextWordTimeStampIndex);
|
||||
lineItems.push(text);
|
||||
lineStr = lineStr.slice(text.length);
|
||||
}
|
||||
|
||||
const words: LyricWord[] = [];
|
||||
lineItems.forEach((item, index) => {
|
||||
if (typeof item === "number") return;
|
||||
const startTime = lineItems[index - 1] ?? lineStartTime;
|
||||
const endTime = lineItems[index + 1] ?? startTime;
|
||||
if (typeof startTime !== "number" || typeof endTime !== "number") return;
|
||||
if (item.startsWith(" ") && words[words.length - 1]?.word.trim())
|
||||
words.push(createWord({ word: " " }));
|
||||
words.push(createWord({ word: item.trim(), startTime, endTime }));
|
||||
if (item.endsWith(" ")) words.push(createWord({ word: " " }));
|
||||
});
|
||||
|
||||
const lineEndTime = words[words.length - 1]?.endTime ?? lineStartTime;
|
||||
lyricLines.push(
|
||||
createLine({
|
||||
startTime: lineStartTime,
|
||||
endTime: lineEndTime,
|
||||
words,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lyricLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 LRC A2 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns LRC A2 格式的字符串
|
||||
*/
|
||||
export function stringifyLrcA2(lines: LyricLine[]): string {
|
||||
return lines
|
||||
.map((line) => {
|
||||
const normalizedLineStartTime = normalizeTimestamp(line.startTime);
|
||||
if (line.words.length === 0)
|
||||
return `[${formatTime(normalizedLineStartTime)}]`;
|
||||
const normalizedWords: {
|
||||
word: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
}[] = [];
|
||||
line.words.forEach((w) => {
|
||||
if (!w.word.trim() && normalizedWords.length) {
|
||||
normalizedWords[normalizedWords.length - 1].word += w.word;
|
||||
return;
|
||||
}
|
||||
normalizedWords.push({
|
||||
word: w.word,
|
||||
startTime: normalizeTimestamp(w.startTime),
|
||||
endTime: normalizeTimestamp(w.endTime),
|
||||
});
|
||||
});
|
||||
const lineItems: (number | string)[] = normalizedWords.flatMap((w) => [
|
||||
w.startTime,
|
||||
w.word,
|
||||
]);
|
||||
lineItems.push(normalizedWords[normalizedWords.length - 1].endTime);
|
||||
return (
|
||||
`[${formatTime(normalizedLineStartTime)}]` +
|
||||
lineItems
|
||||
.map((item) =>
|
||||
typeof item === "number" ? `<${formatTime(item)}>` : item,
|
||||
)
|
||||
.join("")
|
||||
);
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
68
amll-local/packages/lyric/src/formats/lyl.ts
Normal file
68
amll-local/packages/lyric/src/formats/lyl.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* @fileoverview Lyricify Lines(LYL)格式解析与生成。
|
||||
* 该格式为行级结构,每行使用 `[start,end]` 表示时间区间。
|
||||
*
|
||||
* 格式示例:
|
||||
* [type:LyricifyLines]
|
||||
* [54260,57380]Stop and stare
|
||||
* [57380,62840]I think I'm moving but I go nowhere
|
||||
*/
|
||||
import type { LyricLine } from "../types";
|
||||
import { createLine, createWord, normalizeTimestamp } from "../utils";
|
||||
|
||||
/**
|
||||
* 解析 LYL 格式的歌词字符串
|
||||
* @param lyl 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseLyl(lyl: string): LyricLine[] {
|
||||
const lines = lyl
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const lyricLines: LyricLine[] = [];
|
||||
const timeRegex = /^\[(\d+),(\d+)\](.*)$/;
|
||||
const bgRegex = /^[((](.+)[))]$/;
|
||||
|
||||
for (const lineStr of lines) {
|
||||
if (lineStr === "[type:LyricifyLines]") continue;
|
||||
|
||||
const timeMatch = lineStr.match(timeRegex);
|
||||
if (!timeMatch) continue;
|
||||
|
||||
const [, startStr, endStr, text] = timeMatch;
|
||||
const startTime = Number(startStr);
|
||||
const endTime = Number(endStr);
|
||||
|
||||
const backgroundMatch = text.match(bgRegex);
|
||||
const isBG = Boolean(backgroundMatch);
|
||||
const textContent = (backgroundMatch ? backgroundMatch[1] : text).trim();
|
||||
if (!textContent) continue;
|
||||
|
||||
lyricLines.push(
|
||||
createLine({
|
||||
startTime,
|
||||
endTime,
|
||||
isBG,
|
||||
words: [createWord({ word: textContent, startTime, endTime })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return lyricLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 LYL 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns LYL 格式的字符串
|
||||
*/
|
||||
export function stringifyLyl(lines: LyricLine[]): string {
|
||||
const header = "[type:LyricifyLines]";
|
||||
const body = lines.map((line) => {
|
||||
const text = line.words.map((w) => w.word).join("");
|
||||
const printText = line.isBG ? `(${text})` : text;
|
||||
return `[${normalizeTimestamp(line.startTime)},${normalizeTimestamp(line.endTime)}]${printText}`;
|
||||
});
|
||||
return [header, ...body].join("\n");
|
||||
}
|
||||
145
amll-local/packages/lyric/src/formats/lys.ts
Normal file
145
amll-local/packages/lyric/src/formats/lys.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* @fileoverview Lyricify Syllable(LYS)格式解析与生成。
|
||||
* 支持词级时间戳、背景人声与对唱属性。每行以属性位 `[prop]` 开头,后续为 `文本(start,duration)` 序列。
|
||||
*
|
||||
* 属性位说明:
|
||||
* 0: 未设置
|
||||
* 1: 左对齐
|
||||
* 2: 右对齐(对唱)
|
||||
* 3: 非背景,未设置对齐
|
||||
* 4: 非背景,左对齐
|
||||
* 5: 非背景,右对齐(对唱)
|
||||
* 6: 背景,未设置对齐
|
||||
* 7: 背景,左对齐
|
||||
* 8: 背景,右对齐(对唱)
|
||||
*
|
||||
* 格式示例:
|
||||
* [0]Lately (358,1336)I've (1694,487)been, (2181,673)I've (2854,268)been (3122,280)losing (3402,345)sleep(3747,1186)
|
||||
* [0]Dreaming (5245,696)about (5941,471)the (6412,306)things (6718,458)that (7176,292)we (7468,511)could (7979,393)be(8372,737)
|
||||
*/
|
||||
import type { LyricLine, LyricWord } from "../types";
|
||||
import {
|
||||
createLine,
|
||||
createWord,
|
||||
normalizeDuration,
|
||||
normalizeTimestamp,
|
||||
} from "../utils";
|
||||
|
||||
/**
|
||||
* 解析 LYS 格式中的属性值
|
||||
* @param prop 属性值
|
||||
* @returns 对唱与背景标志位
|
||||
*/
|
||||
function parseProp(prop: number): {
|
||||
isDuet: boolean | undefined;
|
||||
isBG: boolean | undefined;
|
||||
} {
|
||||
if (prop < 0 || prop > 8) prop = 0;
|
||||
return {
|
||||
isDuet: prop % 3 === 0 ? undefined : prop % 3 === 2,
|
||||
isBG: prop <= 2 ? undefined : prop >= 6,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 LYS 格式的歌词字符串
|
||||
* @param lys 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseLys(lys: string): LyricLine[] {
|
||||
const lines = lys
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
const lyricLines: LyricLine[] = [];
|
||||
const propRegex = /^\[(\d+)\]/;
|
||||
const wordRegex = /(.*?)\((\d+),(\d+)\)/g;
|
||||
|
||||
for (const lineStr of lines) {
|
||||
const propMatch = lineStr.match(propRegex);
|
||||
if (!propMatch) continue;
|
||||
|
||||
const [, propStr] = propMatch;
|
||||
const content = lineStr.slice(propMatch[0].length);
|
||||
const words: LyricWord[] = [];
|
||||
const props = parseProp(Number(propStr));
|
||||
|
||||
for (const match of content.matchAll(wordRegex)) {
|
||||
const [, rawWord, startStr, durStr] = match;
|
||||
const startTime = Number(startStr);
|
||||
const duration = Number(durStr);
|
||||
const endTime = startTime + duration;
|
||||
const wordText = rawWord;
|
||||
words.push(createWord({ word: wordText, startTime, endTime }));
|
||||
}
|
||||
|
||||
const lineStartTime = words[0]?.startTime ?? 0;
|
||||
const lineEndTime = words[words.length - 1]?.endTime ?? 0;
|
||||
if (!words.length) continue;
|
||||
|
||||
if (props.isBG === undefined)
|
||||
props.isBG =
|
||||
words.length > 0 &&
|
||||
/^[((]/.test(words[0].word) &&
|
||||
/[))]$/.test(words[words.length - 1].word);
|
||||
|
||||
if (props.isBG && words.length) {
|
||||
words[0].word = words[0].word.replace(/^[((]/, "");
|
||||
words[words.length - 1].word = words[words.length - 1].word.replace(
|
||||
/[))]$/,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
lyricLines.push(
|
||||
createLine({
|
||||
startTime: lineStartTime,
|
||||
endTime: lineEndTime,
|
||||
isDuet: !!props.isDuet,
|
||||
isBG: props.isBG,
|
||||
words,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lyricLines;
|
||||
}
|
||||
|
||||
function makeProp(line: LyricLine): number {
|
||||
let prop = 0;
|
||||
prop += line.isDuet ? 2 : 1;
|
||||
prop += line.isBG ? 6 : 3;
|
||||
return prop;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 LYS 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns LYS 格式的字符串
|
||||
*/
|
||||
export function stringifyLys(lines: LyricLine[]): string {
|
||||
return lines
|
||||
.map((line) => {
|
||||
const prop = makeProp(line);
|
||||
const printWords: {
|
||||
startTime: number;
|
||||
duration: number;
|
||||
word: string;
|
||||
}[] = [];
|
||||
line.words.forEach((w) => {
|
||||
if (w.word.trim() || !printWords.length)
|
||||
printWords.push({
|
||||
word: w.word,
|
||||
startTime: normalizeTimestamp(w.startTime),
|
||||
duration: normalizeDuration(
|
||||
normalizeTimestamp(w.endTime) - normalizeTimestamp(w.startTime),
|
||||
),
|
||||
});
|
||||
else printWords[printWords.length - 1].word += w.word;
|
||||
});
|
||||
const wordsStr = printWords
|
||||
.map((w) => `${w.word}(${w.startTime},${w.duration})`)
|
||||
.join("");
|
||||
return `[${prop}]${wordsStr}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
120
amll-local/packages/lyric/src/formats/qrc.ts
Normal file
120
amll-local/packages/lyric/src/formats/qrc.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* @fileoverview QRC(QQ 音乐逐词歌词)格式解析与生成。
|
||||
* 行开头为 [startTime,duration],每个词为 word(startTime,duration)
|
||||
*
|
||||
* 格式示例:
|
||||
* [190871,1984]For (190871,361)the (191232,172)first (191404,376)time(191780,1075)
|
||||
* [193459,4198]What's (193459,412)past (193871,574)is (194445,506)past(194951,2706)
|
||||
*/
|
||||
import type { LyricLine, LyricWord } from "../types";
|
||||
import {
|
||||
createLine,
|
||||
createWord,
|
||||
normalizeDuration,
|
||||
normalizeTimestamp,
|
||||
} from "../utils";
|
||||
|
||||
/**
|
||||
* 解析 QRC 格式的歌词字符串
|
||||
* @param qrc 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseQrc(qrc: string): LyricLine[] {
|
||||
const wordPattern = /(.*?)\((\d+),(\d+)\)/g;
|
||||
const linePattern = /^\[(\d+),(\d+)\]/;
|
||||
|
||||
const lines = qrc
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
|
||||
return lines
|
||||
.map((lineStr) => {
|
||||
const lineMatch = lineStr.match(linePattern);
|
||||
if (!lineMatch) return null;
|
||||
const [linePrefix, lineStartStr, lineDurStr] = lineMatch;
|
||||
|
||||
const lineStart = Number(lineStartStr);
|
||||
const lineDuration = Number(lineDurStr);
|
||||
|
||||
const words: LyricWord[] = [];
|
||||
const lineContent = lineStr.slice(linePrefix.length).trim();
|
||||
if (!lineContent) return null;
|
||||
|
||||
for (const wordMatch of lineContent.matchAll(wordPattern)) {
|
||||
const [, wordText, wordStartStr, wordDurStr] = wordMatch;
|
||||
const wordStart = Number(wordStartStr);
|
||||
const wordDur = Number(wordDurStr);
|
||||
|
||||
words.push(
|
||||
createWord({
|
||||
word: wordText,
|
||||
startTime: wordStart,
|
||||
endTime: wordStart + wordDur,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const isBG =
|
||||
words.length > 0 &&
|
||||
/^[((]/.test(words[0].word) &&
|
||||
/[))]$/.test(words[words.length - 1].word);
|
||||
|
||||
if (isBG) {
|
||||
words[0].word = words[0].word.replace(/^[((]/, "");
|
||||
words[words.length - 1].word = words[words.length - 1].word.replace(
|
||||
/[))]$/,
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
return createLine({
|
||||
startTime: lineStart,
|
||||
endTime: lineStart + lineDuration,
|
||||
words,
|
||||
isBG,
|
||||
});
|
||||
})
|
||||
.filter((line) => line !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 QRC 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns QRC 格式的字符串
|
||||
*/
|
||||
export function stringifyQrc(lines: LyricLine[]): string {
|
||||
return lines
|
||||
.map((line) => {
|
||||
const lineStart = normalizeTimestamp(line.startTime);
|
||||
const lineEnd = normalizeTimestamp(line.endTime);
|
||||
const lineDuration = normalizeDuration(lineEnd - lineStart);
|
||||
|
||||
const lineWords: string[] = [];
|
||||
for (const [
|
||||
index,
|
||||
{ word, startTime, endTime },
|
||||
] of line.words.entries()) {
|
||||
if (!word.trim() && lineWords.length) {
|
||||
lineWords[lineWords.length - 1] += word;
|
||||
continue;
|
||||
}
|
||||
let printedWord = word;
|
||||
if (line.isBG) {
|
||||
if (index === 0) printedWord = `(${printedWord}`;
|
||||
if (index === line.words.length - 1) printedWord += ")";
|
||||
}
|
||||
const normalizedWordStart = normalizeTimestamp(startTime);
|
||||
const normalizedWordEnd = normalizeTimestamp(endTime);
|
||||
const wordDuration = normalizeDuration(
|
||||
normalizedWordEnd - normalizedWordStart,
|
||||
);
|
||||
lineWords.push(
|
||||
`${printedWord}(${normalizedWordStart},${wordDuration})`,
|
||||
);
|
||||
}
|
||||
|
||||
return `[${lineStart},${lineDuration}]${lineWords.join("")}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
22
amll-local/packages/lyric/src/formats/ttml.ts
Normal file
22
amll-local/packages/lyric/src/formats/ttml.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
exportTTML,
|
||||
parseTTML as parseTTMLPacked,
|
||||
} from "@applemusic-like-lyrics/ttml";
|
||||
import type { TTMLLyric } from "../types";
|
||||
|
||||
/**
|
||||
* 解析 TTML 格式(包含 AMLL 特有属性信息)的歌词字符串
|
||||
* @param ttmlText 歌词字符串
|
||||
* @returns 成功解析出来的 TTML 歌词对象
|
||||
*/
|
||||
export function parseTTML(ttmlText: string): TTMLLyric {
|
||||
return parseTTMLPacked(ttmlText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 TTML 格式(包含 AMLL 特有属性信息)的歌词字符串
|
||||
* @param ttmlLyric TTML 歌词对象
|
||||
*/
|
||||
export function stringifyTTML(ttmlLyric: TTMLLyric): string {
|
||||
return exportTTML(ttmlLyric);
|
||||
}
|
||||
140
amll-local/packages/lyric/src/formats/yrc.ts
Normal file
140
amll-local/packages/lyric/src/formats/yrc.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @fileoverview YRC(网易云音乐逐词歌词)格式解析与生成。
|
||||
* 行开头为 [startTime,duration],每个词为 (startTime,duration,0)word
|
||||
*
|
||||
* 格式示例:
|
||||
* [190871,1984](190871,361,0)For (191232,172,0)the (191404,376,0)first (191780,1075,0)time
|
||||
* [193459,4198](193459,412,0)What's (193871,574,0)past (194445,506,0)is (194951,2706,0)past
|
||||
*/
|
||||
import type { LyricLine, LyricWord } from "../types";
|
||||
import {
|
||||
createLine,
|
||||
createWord,
|
||||
normalizeDuration,
|
||||
normalizeTimestamp,
|
||||
} from "../utils";
|
||||
|
||||
const beginParenPattern = /^[((]/;
|
||||
const endParenPattern = /[))]$/;
|
||||
function checkIsBG(words: LyricWord[]): boolean {
|
||||
return (
|
||||
words.length > 0 &&
|
||||
beginParenPattern.test(words[0].word) &&
|
||||
endParenPattern.test(words[words.length - 1].word)
|
||||
);
|
||||
}
|
||||
function trimBGParentheses(words: LyricWord[]): void {
|
||||
words[0].word = words[0].word.slice(1);
|
||||
words[words.length - 1].word = words[words.length - 1].word.slice(0, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 YRC 格式的歌词字符串
|
||||
* @param yrc 歌词字符串
|
||||
* @returns 成功解析出来的歌词
|
||||
*/
|
||||
export function parseYrc(yrc: string): LyricLine[] {
|
||||
const wordPattern = /^(.*?)\((\d+),(\d+),0\)/;
|
||||
const linePattern = /^\[(\d+),(\d+)\]/;
|
||||
|
||||
const lines = yrc
|
||||
.split(/\r?\n/)
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.length > 0);
|
||||
|
||||
return lines
|
||||
.map((lineStr) => {
|
||||
const lineMatch = lineStr.match(linePattern);
|
||||
if (!lineMatch) return null;
|
||||
const [linePrefix, lineStartStr, lineDurStr] = lineMatch;
|
||||
const lineStart = Number(lineStartStr);
|
||||
const lineDuration = Number(lineDurStr);
|
||||
|
||||
const words: LyricWord[] = [];
|
||||
let lineContent = lineStr.slice(linePrefix.length).trim();
|
||||
if (!lineContent) return null;
|
||||
|
||||
let lastStart = -1;
|
||||
let lastEnd = -1;
|
||||
while (true) {
|
||||
const wordMatch = lineContent.match(wordPattern);
|
||||
if (!wordMatch) break;
|
||||
const [fullMatch, lastText, wordStartStr, wordDurStr] = wordMatch;
|
||||
if (lastText && lastStart !== -1)
|
||||
words.push(
|
||||
createWord({
|
||||
word: lastText,
|
||||
startTime: lastStart,
|
||||
endTime: lastEnd,
|
||||
}),
|
||||
);
|
||||
const wordStart = Number(wordStartStr);
|
||||
const wordDur = Number(wordDurStr);
|
||||
const wordEnd = wordStart + wordDur;
|
||||
[lastStart, lastEnd] = [wordStart, wordEnd];
|
||||
lineContent = lineContent.slice(fullMatch.length);
|
||||
}
|
||||
if (lastStart !== -1 && lineContent)
|
||||
words.push(
|
||||
createWord({
|
||||
word: lineContent,
|
||||
startTime: lastStart,
|
||||
endTime: lastEnd,
|
||||
}),
|
||||
);
|
||||
|
||||
const isBG = checkIsBG(words);
|
||||
if (isBG) trimBGParentheses(words);
|
||||
return createLine({
|
||||
startTime: lineStart,
|
||||
endTime: lineStart + lineDuration,
|
||||
words,
|
||||
isBG,
|
||||
});
|
||||
})
|
||||
.filter((line): line is LyricLine => line !== null);
|
||||
}
|
||||
|
||||
function makeParenthesesFull(text: string): string {
|
||||
return text.replace(/\(/g, "(").replace(/\)/g, ")");
|
||||
}
|
||||
|
||||
/**
|
||||
* 将歌词数组转换为 YRC 格式的字符串
|
||||
* @param lines 歌词数组
|
||||
* @returns YRC 格式的字符串
|
||||
*/
|
||||
export function stringifyYrc(lines: LyricLine[]): string {
|
||||
return lines
|
||||
.map((line) => {
|
||||
const lineStart = normalizeTimestamp(line.startTime);
|
||||
const lineEnd = normalizeTimestamp(line.endTime);
|
||||
const lineDuration = normalizeDuration(lineEnd - lineStart);
|
||||
|
||||
const lineWords: string[] = [];
|
||||
for (const [
|
||||
index,
|
||||
{ word, startTime, endTime },
|
||||
] of line.words.entries()) {
|
||||
if (!word.trim() && lineWords.length) {
|
||||
lineWords[lineWords.length - 1] += word;
|
||||
continue;
|
||||
}
|
||||
let printedWord = makeParenthesesFull(word);
|
||||
if (line.isBG) {
|
||||
if (index === 0) printedWord = `(${printedWord}`;
|
||||
if (index === line.words.length - 1) printedWord += ")";
|
||||
}
|
||||
const normalizedWordStart = normalizeTimestamp(startTime);
|
||||
const normalizedWordEnd = normalizeTimestamp(endTime);
|
||||
const wordDuration = normalizeDuration(
|
||||
normalizedWordEnd - normalizedWordStart,
|
||||
);
|
||||
lineWords.push(
|
||||
`(${normalizedWordStart},${wordDuration},0)${printedWord}`,
|
||||
);
|
||||
}
|
||||
return `[${lineStart},${lineDuration}]${lineWords.join("")}`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
Reference in New Issue
Block a user