fork(fix): Clone AMLL 并修复 BUG

- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork)
- 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
lqtmcstudio
2026-06-07 00:02:14 +08:00
parent 783d2c3dee
commit 72f4510dc8
458 changed files with 86075 additions and 1665 deletions

View 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`;
}

View 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,
];

View 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;
}

View 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);
}

View 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("");
}

View 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");
}

View File

@@ -0,0 +1,140 @@
/**
* @fileoverview Lyricify Quick ExportLQE格式解析与生成。
* 该格式本质是组合格式:`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");
}

View 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");
}

View 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");
}

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview Lyricify LinesLYL格式解析与生成。
* 该格式为行级结构,每行使用 `[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");
}

View File

@@ -0,0 +1,145 @@
/**
* @fileoverview Lyricify SyllableLYS格式解析与生成。
* 支持词级时间戳、背景人声与对唱属性。每行以属性位 `[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");
}

View File

@@ -0,0 +1,120 @@
/**
* @fileoverview QRCQQ 音乐逐词歌词)格式解析与生成。
* 行开头为 [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");
}

View 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);
}

View 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");
}