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,51 @@
## 1.0.1 (2026-05-17)
### Patch Changes
- **fix:** 更正 stringifyLrcA2 函数大小写错误 ([#535](https://github.com/amll-dev/applemusic-like-lyrics/pull/535))
`stringifylrcA2` 更正为 `stringifyLrcA2`。我们保留了兼容旧版本拼写的接口,但其将在未来版本中移除。
- **chore:** 更正 package.json 协议声明 ([#534](https://github.com/amll-dev/applemusic-like-lyrics/pull/534))
仓库根目录的 LICENSE 文件为 AGPL v3.0 协议,但是 package.json 中的 `license` 字段为 `GPL-3.0`。经与原开发者确认package.json 中的 `license` 字段有误。仓库与其所有产出的 npm 包均应为 AGPL v3 only 协议SPDX: `AGPL-3.0-only`。因此,更正各包 `package.json``license` 字段为 `AGPL-3.0-only`
### Updated Dependencies
- Updated `@applemusic-like-lyrics/ttml` to `1.0.1`
### Contributors
- Linho [@Linho1219](https://github.com/Linho1219)
# 1.0.0 (2026-04-23)
### Updated Dependencies
- Updated `@applemusic-like-lyrics/ttml` to `1.0.0`
## 1.0.0-alpha.0 (2026-04-14)
### Major Changes
- **refactor:** 使用 TS 重写歌词正反解逻辑 ([56fd547c](https://github.com/amll-dev/applemusic-like-lyrics/commit/56fd547c))
### Minor Changes
- **refactor:** 重构 TTML 解析和生成器 ([#471](https://github.com/amll-dev/applemusic-like-lyrics/pull/471))
### Patch Changes
- **fix:** 修复接驳错误导致的 ttml 输出对象结构问题 ([3418d391](https://github.com/amll-dev/applemusic-like-lyrics/commit/3418d391))
- **fix:** 修复 lyric 包函数导出名误修改 ([1a81fcd1](https://github.com/amll-dev/applemusic-like-lyrics/commit/1a81fcd1))
- **fix:** 修复 lyric 包在 isolatedDeclarations 下的类型问题 ([d2060cd9](https://github.com/amll-dev/applemusic-like-lyrics/commit/d2060cd9))
- **chore:** 更换工具链 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476))
### Updated Dependencies
- Updated `@applemusic-like-lyrics/ttml` to `1.0.0-alpha.0`
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
- Linho [@Linho1219](https://github.com/Linho1219)

View File

@@ -0,0 +1,70 @@
# Lyric parser/writer for AMLL
[English](./README.md) / 简体中文
此为基于 TypeScript 的重构版本 Lyrics 包,文档未完成。
以下是原文档:
---
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
![AMLL-Lyric](https://img.shields.io/badge/Lyric-%23FB8C84?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)
[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/lyric)](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Flyric)](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
一个 AMLL 的歌词解析/生成模块,使用纯 TypeScript 编写。
本模块由于只着重于歌词内容,所以会丢弃一切和歌词无关的信息,如需获取一个歌词文件中的详细信息(例如歌手)请考虑使用其他框架。
歌词格式支持表:
| 源格式\目标格式 | 解析自身格式 | LyRiC 格式 `.lrc` | ESLyric 逐词歌词格式 `.lrc` | 网易云音乐逐词歌词格式 `.yrc` | QQ 音乐逐词歌词格式 `.qrc` | Lyricify Syllable 逐词歌词格式 `.lys` | TTML 歌词格式 `.ttml` | ASS 字幕格式 `.ass` |
| ------------------------------------- | ------------ | ----------------- | --------------------------- | ----------------------------- | -------------------------- | ------------------------------------- | --------------------- | ------------------- |
| LyRiC 格式 `.lrc` | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ESLyric 逐词歌词格式 `.lrc` | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
| 网易云音乐逐词歌词格式 `.yrc` | ✅ | ✅ [^1] | ✅ [^1] | | ✅ | ✅ | ✅ | ✅ |
| QQ 音乐逐词歌词格式 `.qrc` | ✅ | ✅ [^1] | ✅ [^1] | ✅ | | ✅ | ✅ | ✅ |
| Lyricify Syllable 逐词歌词格式 `.lys` | ✅ | ✅ [^1] | ✅ [^1] | ✅ [^2] | ✅ [^2] | | ✅ | ✅ |
| TTML 歌词格式 `.ttml` | ✅ | ✅ [^1] | ✅ [^1] | ✅ [^2] | ✅ [^2] | ✅ [^3] | | ✅ |
| ASS 字幕格式 `.ass` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
[^1]: 会丢失逐词时间数据、演唱属性(背景人声,对唱人声)和 AMLL 元数据
[^2]: 会丢失演唱属性(背景人声,对唱人声)和 AMLL 元数据
[^3]: 会丢失 AMLL 元数据
## 与 Core 歌词组件一起使用
在和二者合用的时候,需要注意**两者的歌词行结构并不完全相同**,需要进行诸如(以 LyRiC 举例)下面的方式进行转换方可被歌词组件正确解析:
```typescript
import { parseLrc } from "@applemusic-like-lyrics/lyric";
const lines = parseLrc("[00:00.00]test");
const converted = lines.map((line, i, lines) => ({
words: [
{
word: line.words[0]?.word ?? "",
startTime: line.words[0]?.startTime ?? 0,
endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity,
},
],
startTime: line.words[0]?.startTime ?? 0,
endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity,
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
}));
// 此时就可以将 converted 传给 LyricPlayer 了
```
推荐使用 TypeScript这样可以更方便地查错。
## 构建
```shell
wasm-pack build --target bundler --release --scope applemusic-like-lyrics
```

View File

@@ -0,0 +1,70 @@
# Lyric parser/writer for AMLL
English / [简体中文](./README-CN.md)
This is a refactored version of the original lyrics pack. Docs not finished yet.
Below is the original docs.
---
> Warning: This is a personal project and is still under development. There may still be many issues, so please do not use it directly in production environments!
![AMLL-Lyric](https://img.shields.io/badge/Lyric-%23FB8C84?label=Apple%20Music-like%20Lyrics&labelColor=%23FB5C74)
[![npm](https://img.shields.io/npm/dt/%40applemusic-like-lyrics/lyric)](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
[![npm](https://img.shields.io/npm/v/%40applemusic-like-lyrics%2Flyric)](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
A lyric parsing/generation module for AMLL, written entirely in TypeScript.
Since this module focuses only on lyric content, it discards all information unrelated to lyrics. If you need to get detailed information from a lyric file (such as artist), please consider using other frameworks.
Lyric format support table:
| Source FormatTarget Format | Parse Own Format | LyRiC Format `.lrc` | ESLyric Word-by-word Format `.lrc` | NetEase Cloud Music Word-by-word Format `.yrc` | QQ Music Word-by-word Format `.qrc` | Lyricify Syllable Word-by-word Format `.lys` | TTML Lyric Format `.ttml` | ASS Subtitle Format `.ass` |
| ---------------------------------------------- | ---------------- | ------------------- | ---------------------------------- | ---------------------------------------------- | ----------------------------------- | -------------------------------------------- | ------------------------- | -------------------------- |
| LyRiC Format `.lrc` | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| ESLyric Word-by-word Format `.lrc` | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ |
| NetEase Cloud Music Word-by-word Format `.yrc` | ✅ | ✅ [^1] | ✅ [^1] | | ✅ | ✅ | ✅ | ✅ |
| QQ Music Word-by-word Format `.qrc` | ✅ | ✅ [^1] | ✅ [^1] | ✅ | | ✅ | ✅ | ✅ |
| Lyricify Syllable Word-by-word Format `.lys` | ✅ | ✅ [^1] | ✅ [^1] | ✅ [^2] | ✅ [^2] | | ✅ | ✅ |
| TTML Lyric Format `.ttml` | ✅ | ✅ [^1] | ✅ [^1] | ✅ [^2] | ✅ [^2] | ✅ [^3] | | ✅ |
| ASS Subtitle Format `.ass` | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | |
[^1]: Will lose word-by-word timing data, vocal attributes (background vocals, duet vocals) and AMLL metadata
[^2]: Will lose vocal attributes (background vocals, duet vocals) and AMLL metadata
[^3]: Will lose AMLL metadata
## Using with Core Lyric Component
When using them together, note that **the lyric line structures of the two are not exactly the same**. You need to convert them in a way like the following example (using LyRiC as an example) for the lyric component to parse correctly:
```typescript
import { parseLrc } from "@applemusic-like-lyrics/lyric";
const lines = parseLrc("[00:00.00]test");
const converted = lines.map((line, i, lines) => ({
words: [
{
word: line.words[0]?.word ?? "",
startTime: line.words[0]?.startTime ?? 0,
endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity,
},
],
startTime: line.words[0]?.startTime ?? 0,
endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity,
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
}));
// Now you can pass converted to LyricPlayer
```
Using TypeScript is recommended as it makes it easier to detect errors.
## Building
```shell
wasm-pack build --target bundler --release --scope applemusic-like-lyrics
```

View File

@@ -0,0 +1,69 @@
{
"name": "@applemusic-like-lyrics/lyric",
"version": "1.0.1",
"description": "一个歌词解析/生成模块,着重于歌词内容解析,支持多种格式",
"repository": {
"url": "https://github.com/amll-dev/applemusic-like-lyrics.git",
"directory": "packages/lyric",
"type": "git"
},
"license": "AGPL-3.0-only",
"nx": {
"tags": [
"library"
],
"targets": {
"test": {
"executor": "nx:run-commands",
"options": {
"command": "vitest test",
"cwd": "packages/lyric"
}
},
"nx-release-publish": {
"executor": "@nx/js:release-publish",
"dependsOn": []
}
}
},
"type": "module",
"files": [
"dist"
],
"main": "./dist/amll-lyric.cjs",
"module": "./dist/amll-lyric.mjs",
"types": "./dist/amll-lyric.d.mts",
"exports": {
"import": {
"types": "./dist/amll-lyric.d.mts",
"default": "./dist/amll-lyric.mjs"
},
"require": {
"types": "./dist/amll-lyric.d.cts",
"default": "./dist/amll-lyric.cjs"
}
},
"scripts": {
"build:docs": "typedoc --plugin typedoc-plugin-markdown --out docs src/index.ts",
"typecheck": "tsgo -b",
"build-only": "tsdown",
"build": "run-p typecheck \"build-only {@}\" --",
"build:dev": "tsdown",
"fmt": "biome format --write ./src",
"dev": "vite dev",
"test": "vitest test",
"test:watch": "vitest test --watch",
"test:coverage": "vitest test --coverage"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/node": "^25.8.0",
"@types/pako": "^2.0.4",
"tsdown": "^0.22.0",
"vitest": "^4.1.6"
},
"dependencies": {
"@applemusic-like-lyrics/ttml": "file:../ttml",
"pako": "^2.1.0"
}
}

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

View File

@@ -0,0 +1,26 @@
export { stringifyAss } from "./formats/ass";
export { decryptQrcHex, encryptQrcHex } from "./formats/eqrc";
export { parseEslrc, stringifyEslrc } from "./formats/eslrc";
export { parseLqe, stringifyLqe } from "./formats/lqe";
export { parseLrc, stringifyLrc } from "./formats/lrc";
export { parseLrcA2, stringifyLrcA2 } from "./formats/lrca2";
export { parseLyl, stringifyLyl } from "./formats/lyl";
export { parseLys, stringifyLys } from "./formats/lys";
export { parseQrc, stringifyQrc } from "./formats/qrc";
export { parseTTML, stringifyTTML } from "./formats/ttml";
export { parseYrc, stringifyYrc } from "./formats/yrc";
import { stringifyLrcA2 } from "./formats/lrca2";
/**
* {@link stringifyLrcA2} 的别名。
*
* @deprecated 此为兼容旧版本拼写错误的接口,请改用 `stringifyLrcA2`。此接口将在未来版本中移除。
*/
export function stringifylrcA2(
...args: Parameters<typeof stringifyLrcA2>
): ReturnType<typeof stringifyLrcA2> {
return stringifyLrcA2(...args);
}
export type { LyricLine, LyricWord, TTMLLyric } from "./types";

View File

@@ -0,0 +1,69 @@
/**
* 一个歌词单词
*/
export interface LyricWord {
/** 单词的起始时间 */
startTime: number;
/** 单词的结束时间 */
endTime: number;
/** 单词 */
word: string;
/** 单词的音译 */
romanWord?: string;
}
/**
* 一行歌词,存储多个单词
* 如果是 LyRiC 等只能表达一行歌词的格式,则会将整行当做一个单词存储起来
*/
export interface LyricLine {
/**
* 该行的所有单词
* 如果是 LyRiC 等只能表达一行歌词的格式,这里就只会有一个单词
*/
words: LyricWord[];
/**
* 该行的翻译
*/
translatedLyric: string;
/**
* 该行的音译
*/
romanLyric: string;
/**
* 该行是否为背景歌词行
* 此选项只有作为 Lyricify Syllable 文件格式导入导出时才有意义
*/
isBG: boolean;
/**
* 该行是否为对唱歌词行(即歌词行靠右对齐)
* 此选项只有作为 Lyricify Syllable 文件格式导入导出时才有意义
*/
isDuet: boolean;
/**
* 该行的开始时间
*
* **并不总是等于第一个单词的开始时间**
*/
startTime: number;
/**
* 该行的结束时间
*
* **并不总是等于最后一个单词的开始时间**
*/
endTime: number;
}
/**
* 一个 TTML 歌词行对象,存储了歌词行信息和 AMLL 元数据信息
*/
export interface TTMLLyric {
/**
* TTML 中存储的歌词行信息
*/
lines: LyricLine[];
/**
* 一个元数据表,以 `[键, 值数组]` 的形式存储
*/
metadata: [string, string[]][];
}

View File

@@ -0,0 +1,75 @@
import type { LyricLine, LyricWord } from "./types";
export const createLine = (line: Partial<LyricLine>): LyricLine => ({
words: [],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
startTime: 0,
endTime: 0,
...line,
});
export const createWord = (word: Partial<LyricWord>): LyricWord => ({
startTime: 0,
endTime: 0,
word: "",
...word,
});
export const parseTime = (time: string): number =>
Math.round(
time
.split(":")
.map(Number)
.reverse()
.reduce((acc, cur, idx) => acc + cur * 60 ** idx, 0) * 1000,
);
export const formatTime = (ms: number): string => {
const min = Math.floor(ms / 60000)
.toString()
.padStart(2, "0");
const sec = Math.floor((ms % 60000) / 1000)
.toString()
.padStart(2, "0");
const msPart = Math.round(ms % 1000)
.toString()
.padStart(3, "0");
return `${min}:${sec}.${msPart}`;
};
export const normalizeTimestamp = (ms: number): number => {
if (!Number.isFinite(ms) || ms < 0) return 0;
return ms;
};
export const normalizeDuration = (duration: number): number => {
if (!Number.isFinite(duration) || duration < 0) return 0;
return duration;
};
export const MAX_LRC_TIMESTAMP = 60_039_999; // 999:99.999
export const clampTimestamp = (
ms: number,
max: number = MAX_LRC_TIMESTAMP,
): number => Math.min(max, normalizeTimestamp(ms));
/**
* Returns consecutive pairs from the given iterable.
*
* Example: `0, 1, 2, 3` -> `[0, 1], [1, 2], [2, 3]`
*/
export function* pairwise<T>(
iterable: Iterable<T>,
): Generator<[T, T], void, unknown> {
let prev: T | undefined;
let hasPrev = false;
for (const curr of iterable) {
if (hasPrev) yield [prev as T, curr];
prev = curr;
hasPrev = true;
}
}

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { stringifyAss } from "../src/formats/ass";
describe("ass", () => {
it("stringifies basic timed words into dialogue with k tags", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [
{ startTime: 1000, endTime: 1200, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1300, endTime: 1500, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[Script Info]\n[Events]\nFormats: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\nDialogue: 0,0:00:01.00, 0:00:01.50, Default, v1,0,0,0,,{\\k20}Hello {\\k10}{\\k20}World\n",
);
});
it("stringifies duet/bg speaker and trans/roman lines", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [
{ startTime: 1000, endTime: 1500, word: "Hello", romanWord: "" },
],
translatedLyric: "你好",
romanLyric: "ni hao",
isBG: true,
isDuet: true,
},
]);
expect(result).toContain(
"Dialogue: 0,0:00:01.00, 0:00:01.50, Default, v2-bg,0,0,0,,{\\k50}Hello",
);
expect(result).toContain(
"Dialogue: 0,0:00:01.00, 0:00:01.50, Default, v2-bg-trans,0,0,0,,你好",
);
expect(result).toContain(
"Dialogue: 0,0:00:01.00, 0:00:01.50, Default, v2-bg-roman,0,0,0,,ni hao",
);
});
it("skips lines without any valid timed words", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [{ startTime: 0, endTime: 0, word: "NoTime", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[Script Info]\n[Events]\nFormats: Marked, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n",
);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyAss([
{
startTime: 0,
endTime: 0,
words: [
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
word: "Bad",
romanWord: "",
},
{ startTime: -1, endTime: 100, word: "Ok", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toContain(
"Dialogue: 0,0:00:00.00, 0:00:00.10, Default, v1,0,0,0,,Bad{\\k10}Ok",
);
});
});

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,56 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { decryptQrcHex, encryptQrcHex } from "../src/formats/eqrc";
import { parseQrc } from "../src/formats/qrc";
function decodeXmlEntities(text: string): string {
return text
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&#10;/g, "\n")
.replace(/&#13;/g, "\r");
}
function extractQrcPayload(xmlLike: string): string {
const match = xmlLike.match(/<!\[CDATA\[([\s\S]*?)\]\]>/);
if (match?.[1]) return match[1].trim();
const attrMatch = xmlLike.match(/LyricContent="([^"]*)"/);
if (!attrMatch?.[1]) return "";
return decodeXmlEntities(attrMatch[1]).trim();
}
describe("eqrc", () => {
it("decrypts rust sample hex with stable output", () => {
const hexPath = resolve(__dirname, "eqrc.hex");
const hex = readFileSync(hexPath, "utf8").trim();
const decrypted = decryptQrcHex(hex);
const sha256 = createHash("sha256").update(decrypted).digest("hex");
const qrcText = extractQrcPayload(decrypted);
const lines = parseQrc(qrcText);
expect(hex.length).toBe(7136);
expect(decrypted.length).toBe(7188);
expect(sha256).toBe(
"f3bf2f3b5af01e9f21c5fc49e5ed9ab59370faa530f62b60a38df1881ea31f6a",
);
expect(decrypted).toContain("<QrcInfos>");
expect(decrypted).toContain('<LyricInfo LyricCount="1">');
expect(qrcText.length).toBeGreaterThan(0);
expect(lines.length).toBeGreaterThan(0);
});
it("keeps decrypt/encrypt/decrypt text stable for rust sample", () => {
const hexPath = resolve(__dirname, "eqrc.hex");
const hex = readFileSync(hexPath, "utf8").trim();
const decrypted = decryptQrcHex(hex);
const encryptedAgain = encryptQrcHex(decrypted);
const decryptedAgain = decryptQrcHex(encryptedAgain);
expect(decryptedAgain).toBe(decrypted);
});
});

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import { parseEslrc, stringifyEslrc } from "../src/formats/eslrc";
describe("eslrc", () => {
it("parses basic word-ended-timestamp lines", () => {
const lines = parseEslrc(
"[00:10.82]Test[00:10.97] Word[00:12.62]\n[00:12.62]Next[00:13.20] line[00:14.10]",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(10820);
expect(lines[0].endTime).toBe(12620);
expect(lines[0].words[0].word).toBe("Test");
expect(lines[0].words[0].startTime).toBe(10820);
expect(lines[0].words[0].endTime).toBe(10970);
expect(lines[0].words[1].word).toBe(" Word");
expect(lines[0].words[1].startTime).toBe(10970);
expect(lines[0].words[1].endTime).toBe(12620);
});
it("handles CRLF and ignores malformed lines", () => {
const lines = parseEslrc(
"[00:10.82]Ok[00:11.00]\r\nno timestamp\r\n[00:12.00]Broken no end\r\n[00:12.00]AlsoBroken[invalid]\r\n[00:13.00]Fine[00:13.50]",
);
expect(lines).toHaveLength(2);
expect(lines[0].words.map((w) => w.word).join("")).toBe("Ok");
expect(lines[1].words.map((w) => w.word).join("")).toBe("Fine");
});
it("sorts parsed lines by first word timestamp", () => {
const lines = parseEslrc(
"[00:20.00]Second[00:21.00]\n[00:10.00]First[00:11.00]",
);
expect(lines).toHaveLength(2);
expect(lines[0].words.map((w) => w.word).join("")).toBe("First");
expect(lines[1].words.map((w) => w.word).join("")).toBe("Second");
});
it("clamps parsed timestamps to max lrc time", () => {
const lines = parseEslrc("[999:99.999]Max[1000:40.000]");
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(60039999);
expect(lines[0].endTime).toBe(60039999);
expect(lines[0].words[0].startTime).toBe(60039999);
expect(lines[0].words[0].endTime).toBe(60039999);
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseEslrc(
"[00:01.000] \n[00:02.000]\n[00:10.82]Test[00:10.97] Word[00:12.62]\n \n[00:12.62]Next[00:13.20] line[00:14.10]\n \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Test");
expect(lines[1].words[0].word).toBe("Next");
});
it("stringifies to expected eslrc text", () => {
const result = stringifyEslrc([
{
startTime: 0,
endTime: 0,
words: [
{ startTime: 10820, endTime: 10970, word: "Test", romanWord: "" },
{ startTime: 10970, endTime: 12620, word: " Word", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:10.820]Test[00:10.970] Word[00:12.620]");
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyEslrc([
{
startTime: 0,
endTime: 0,
words: [
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
{ startTime: -1, endTime: -2, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:00.000]Hello[00:00.000]World[00:00.000]");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[00:10.82]Test[00:10.97] Word[00:12.62]\n[00:12.62]Next[00:13.20] line[00:14.10]";
const first = parseEslrc(input);
const text = stringifyEslrc(first);
const second = parseEslrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from "vitest";
import { parseLrc, stringifyLrc } from "../src/formats/lrc";
import { MAX_LRC_TIMESTAMP } from "../src/utils";
import { timeStampsTestCases } from "./timestampcase.fixture";
describe("lrc", () => {
it("parses basic timestamped lines", () => {
const lines = parseLrc("[00:01.120]Hello\n[00:03.000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("handles CRLF line breaks correctly", () => {
const lines = parseLrc("[00:01.120]Hello\r\n[00:03.000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("parses multiple timestamps for the same line", () => {
const lines = parseLrc("[00:01.120][00:02.000]Hello");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Hello");
});
it("ignores lines without timestamps", () => {
const lines = parseLrc(
"This is a line without timestamp\n[ar: Artist]\n[00:01.120]Hello\n# This is a comment\n{ some: 'metadata' }\n\n[00:03.000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLrc(
"[00:01.120]Hello\n[invalid]Bad line\n[xx:yy.zzz]Bad line\n[-1:00.000]Bad line\n[NaN:NaN]Bad line\n[00:03.000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].words[0].word).toBe("World");
});
it("sorts lines by timestamp and sets end times correctly", () => {
const lines = parseLrc("[00:03.000]World\n[00:01.120][00:05.000]Hello");
expect(lines).toHaveLength(3);
expect(lines[0].startTime).toBe(1120);
expect(lines[0].endTime).toBe(3000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(5000);
expect(lines[1].words[0].word).toBe("World");
expect(lines[2].startTime).toBe(5000);
expect(lines[2].endTime).toBe(MAX_LRC_TIMESTAMP);
expect(lines[2].words[0].word).toBe("Hello");
});
it("parses all kinds of timestamps", () => {
const input = timeStampsTestCases
.map(([ts, ms]) => `[${ts}]Should be ${ts} = ${ms} ms`)
.join("\n");
const lines = parseLrc(input);
expect(lines).toHaveLength(timeStampsTestCases.length);
lines.forEach((line, i) => {
const [ts, ms] = timeStampsTestCases[i];
expect(line.words[0].word).toBe(`Should be ${ts} = ${ms} ms`);
expect(line.startTime).toBe(ms);
});
});
it("identifies background lines with parentheses", () => {
const lines = parseLrc(
"[00:01.120](Hello)\n[00:03.000]Hi\n[00:03.000]World",
);
expect(lines).toHaveLength(3);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].isBG).toBe(true);
expect(lines[1].words).toHaveLength(1);
expect(lines[1].words[0].word).toBe("Hi");
expect(lines[2].isBG).toBe(false);
expect(lines[2].words).toHaveLength(1);
expect(lines[2].words[0].word).toBe("World");
});
it("trims whitespace from lines and ignore empty lines, while preserving end times", () => {
const lines = parseLrc(
"[00:00.000]\n[00:01.000] \n[00:01.120] Hello \n[00:02.333]\n[00:03.000] World \n[00:05.000] \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].endTime).toBe(2333);
expect(lines[1].words[0].word).toBe("World");
expect(lines[1].endTime).toBe(5000);
});
it("stringifies background lines with parentheses and normal lines without", () => {
const lines = [
{
startTime: 1120,
endTime: 3000,
words: [
{ startTime: 1120, endTime: 3000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
{
startTime: 3000,
endTime: 3000,
words: [
{ startTime: 3000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
];
const result = stringifyLrc(lines);
expect(result).toBe("[00:01.120](Hello)\n[00:03.000]World");
});
it("stringifies lines to expected lrc text", () => {
const result = stringifyLrc([
{
startTime: 1120,
endTime: 3000,
words: [
{ startTime: 1120, endTime: 3000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1120, endTime: 3000, word: "world!", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:01.120]Hello world!");
});
it("normalizes invalid startTime when stringifying", () => {
const baseLine = {
endTime: 3000,
words: [{ startTime: 0, endTime: 0, word: "Hello", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
};
expect(stringifyLrc([{ ...baseLine, startTime: -1 }])).toBe(
"[00:00.000]Hello",
);
expect(stringifyLrc([{ ...baseLine, startTime: Number.NaN }])).toBe(
"[00:00.000]Hello",
);
expect(
stringifyLrc([{ ...baseLine, startTime: Number.POSITIVE_INFINITY }]),
).toBe("[00:00.000]Hello");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[00:01.120]Hello\n[00:03.000](World)";
const first = parseLrc(input);
const text = stringifyLrc(first);
const second = parseLrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,150 @@
import { describe, expect, it } from "vitest";
import { parseLrcA2, stringifyLrcA2 } from "../src/formats/lrca2";
import { timeStampsTestCases } from "./timestampcase.fixture";
describe("lrca2", () => {
it("parses basic word-timestamped line", () => {
const lines = parseLrcA2(
"[00:01.000]<00:01.000>Hello <00:01.500>World<00:02.000>",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].words[0].startTime).toBe(1000);
expect(lines[0].words[0].endTime).toBe(1500);
expect(lines[0].words[1].word).toBe(" ");
expect(lines[0].words[2].word).toBe("World");
expect(lines[0].words[2].startTime).toBe(1500);
expect(lines[0].words[2].endTime).toBe(2000);
});
it("handles CRLF and ignores non-lyric lines", () => {
const lines = parseLrcA2(
"[ar: Artist]\r\n#comment\r\n{meta:true}\r\n[00:01.000]<00:01.000>Hello<00:02.000>",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLrcA2(
"[00:01.000]<00:01.000>Hello<00:02.000>\n[invalid]<00:03.000>Bad<00:04.000>\n[-1:00.000]<00:03.000>Bad<00:04.000>\n[NaN:NaN]<00:03.000>Bad<00:04.000>\n[00:03.000]<00:03.000>World<00:04.000>",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words.map((w) => w.word).join("")).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(4000);
expect(lines[1].words.map((w) => w.word).join("")).toBe("World");
});
it("parses all kinds of valid line timestamps", () => {
const input = timeStampsTestCases
.map(([ts]) => `[${ts}]<${ts}>Word<${ts}>`)
.join("\n");
const lines = parseLrcA2(input);
expect(lines).toHaveLength(timeStampsTestCases.length);
lines.forEach((line, i) => {
const [, ms] = timeStampsTestCases[i];
expect(line.startTime).toBe(ms);
});
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseLrcA2(
"[00:00.000] \n[00:01.000]<00:01.000>Hello<00:02.000>\n \n\n[00:03.000]<00:03.000>World<00:04.000>\n \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].words[0].word).toBe("World");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyLrcA2([
{
startTime: 1000,
endTime: 3000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 2000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[00:01.000]<00:01.000>Hello <00:02.000>World<00:03.000>",
);
});
it("stringifies empty-word line as bare line timestamp", () => {
const result = stringifyLrcA2([
{
startTime: 1000,
endTime: 1000,
words: [],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:01.000]");
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyLrcA2([
{
startTime: Number.NaN,
endTime: 0,
words: [
{
startTime: -1,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[00:00.000]<00:00.000>Hello<00:00.000>");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[00:01.000]<00:01.000>Hello <00:01.500>World<00:02.000>\n[00:03.000]<00:03.000>Again<00:03.500>";
const first = parseLrcA2(input);
const text = stringifyLrcA2(first);
const second = parseLrcA2(text);
expect(second).toEqual(first);
});
it("keeps exactly one space between adjacent words in '<time> word <time> word' pattern", () => {
const input = "[00:01.000]<00:01.000> word <00:01.500> word<00:02.000>";
const output = stringifyLrcA2(parseLrcA2(input));
expect(output).toBe(
"[00:01.000]<00:01.000>word <00:01.500>word<00:02.000>",
);
expect(output).not.toContain(" ");
});
});

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "vitest";
import { parseLyl, stringifyLyl } from "../src/formats/lyl";
describe("lyl", () => {
it("parses basic line-timestamped lines", () => {
const lines = parseLyl("[1000,2000]Hello\n[3000,4000]World");
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(4000);
expect(lines[1].words[0].word).toBe("World");
});
it("handles CRLF and ignores non-lyric lines", () => {
const lines = parseLyl(
"[type:LyricifyLines]\r\n#comment\r\n{meta:true}\r\nno timestamp\r\n[1000,2000]Hello",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores lines with bad timestamps", () => {
const lines = parseLyl(
"[1000,2000]Hello\n[invalid,2000]Bad\n[-1,2000]Bad\n[NaN,NaN]Bad\n[3000,4000]World",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].startTime).toBe(3000);
expect(lines[1].endTime).toBe(4000);
expect(lines[1].words[0].word).toBe("World");
});
it("identifies background lines with parentheses", () => {
const lines = parseLyl(
"[1000,2000](Hello)\n[3000,4000]Hi\n[5000,6000]World",
);
expect(lines).toHaveLength(3);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].isBG).toBe(true);
expect(lines[1].words[0].word).toBe("Hi");
expect(lines[2].isBG).toBe(false);
expect(lines[2].words[0].word).toBe("World");
});
it("trims whitespace from lines and ignore empty lines", () => {
const lines = parseLyl(
"[0,500]\n[600,1000] \n[1000,2000] Hello \n\n[3000,4000] World \n[5000,6000] \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].words[0].word).toBe("World");
});
it("stringifies with header and bg markers", () => {
const result = stringifyLyl([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
{
startTime: 3000,
endTime: 4000,
words: [
{ startTime: 3000, endTime: 4000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe(
"[type:LyricifyLines]\n[1000,2000](Hello)\n[3000,4000]World",
);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyLyl([
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
words: [{ startTime: 0, endTime: 0, word: "Hello", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
{
startTime: -1,
endTime: -2,
words: [{ startTime: 0, endTime: 0, word: "World", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[type:LyricifyLines]\n[0,0]Hello\n[0,0]World");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[1000,2000]Hello\n[3000,4000](World)";
const first = parseLyl(input);
const text = stringifyLyl(first);
const second = parseLyl(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,136 @@
import { describe, expect, it } from "vitest";
import { parseLys, stringifyLys } from "../src/formats/lys";
describe("lys", () => {
it("parses basic word-timestamped line", () => {
const lines = parseLys("[0]Hello(1000,500) World(1500,500)");
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].isBG).toBe(false);
expect(lines[0].isDuet).toBe(false);
expect(lines[0].words.map((w) => w.word)).toEqual(["Hello", " World"]);
});
it("parses bg + duet flags from prop and strips bg wrappers", () => {
const lines = parseLys("[8](Hello(1000,500)World(1500,500))");
expect(lines).toHaveLength(1);
expect(lines[0].isBG).toBe(true);
expect(lines[0].isDuet).toBe(true);
expect(lines[0].words.map((w) => w.word).join("")).toBe("HelloWorld");
});
it("handles CRLF and ignores lines without valid prop prefix", () => {
const lines = parseLys(
"no prop\r\n#comment\r\n{meta:true}\r\n[4]Hello(1000,500)World(1500,500)",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
});
it("ignores lines with bad word timestamps", () => {
const lines = parseLys(
"[0]Hello(1000,500)\n[0]Bad(a,b)\n[0]AlsoBad(1000,-10)\n[0]World(2000,500)",
);
expect(lines).toHaveLength(2);
expect(lines[0].words.map((w) => w.word).join("")).toBe("Hello");
expect(lines[1].words.map((w) => w.word).join("")).toBe("World");
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseLys(
"[0] \n[3]\n[0]Hello(1000,500) World(1500,500)\n \n\n[0]Next(2000,500) line(2500,500)\n \n",
);
expect(lines).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[1].words[0].word).toBe("Next");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyLys([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 1500, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 1500, endTime: 2000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[4]Hello (1000,500)World(1500,500)");
});
it("stringifies props according to duet/background presence", () => {
const result = stringifyLys([
{
startTime: 0,
endTime: 0,
words: [{ startTime: 1000, endTime: 1500, word: "A", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: true,
},
{
startTime: 0,
endTime: 0,
words: [{ startTime: 2000, endTime: 2500, word: "B", romanWord: "" }],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[8]A(1000,500)\n[4]B(2000,500)");
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyLys([
{
startTime: 0,
endTime: 0,
words: [
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
{
startTime: -1,
endTime: -2,
word: "World",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[4]Hello(0,0)World(0,0)");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input = "[4]Hello(1000,500) World(1500,500)\n[8](Again(3000,500))";
const first = parseLys(input);
const text = stringifyLys(first);
const second = parseLys(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { parseQrc, stringifyQrc } from "../src/formats/qrc";
describe("qrc", () => {
it("parses basic word-timestamped line", () => {
const lines = parseQrc(
"[1000,1000]Hello(1000,500)World(1500,500)\n[3000,1000]Again(3000,1000)",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].words[0].startTime).toBe(1000);
expect(lines[0].words[0].endTime).toBe(1500);
expect(lines[0].words[1].word).toBe("World");
expect(lines[0].words[1].startTime).toBe(1500);
expect(lines[0].words[1].endTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Again");
});
it("handles CRLF and ignores lines without valid line timestamp", () => {
const lines = parseQrc(
"no timestamp\r\n[invalid,100]Bad(0,100)\r\n[-1,100]Bad(0,100)\r\n[1000,1000]Hello(1000,1000)",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseQrc(
"[0,20] \n[0,20]\n[0,20]Test(10,10)\n \n\n[0,20] \n",
);
expect(lines).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Test");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyQrc([
{
startTime: 1000,
endTime: 3000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 2000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[1000,2000]Hello(1000,1000) World(2000,1000)");
});
it("stringifies bg lines with full-width wrapping parentheses", () => {
const result = stringifyQrc([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
]);
expect(result).toBe("[1000,1000]Hello(1000,1000)");
});
it("parses and keeps bg wrapper lines stable", () => {
const input = "[1000,1000]Hello,(1000,1000) world(2000,2000)";
const lines = parseQrc(input);
expect(lines).toHaveLength(1);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello,");
expect(lines[0].words[1].word).toBe(" world");
expect(stringifyQrc(lines)).toBe(input);
});
it("keeps parenthesized fragments in plain word text", () => {
const lines = parseQrc("[0,20]A(x)B(0,10) C(10,10)");
expect(lines).toHaveLength(1);
expect(lines[0].words.map((w) => w.word)).toEqual(["A(x)B", " C"]);
expect(lines[0].words[0].startTime).toBe(0);
expect(lines[0].words[0].endTime).toBe(10);
expect(lines[0].words[1].startTime).toBe(10);
expect(lines[0].words[1].endTime).toBe(20);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyQrc([
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
words: [
{
startTime: -1,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[0,0]Hello(0,0)");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[1000,1000]Hello (1000,500)World(1500,500)\n[3000,1000]Again(3000,1000)";
const first = parseQrc(input);
const text = stringifyQrc(first);
const second = parseQrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,24 @@
const timeStampsTestCasesReg: [string, number][] = [
["1", 1_000],
["2.1", 2_100],
["3.12", 3_120],
["4.123", 4_123],
["5.1234", 5_123],
["6.12354", 6_124],
["7.123456", 7_123],
["8.00100000", 8_001],
["61", 61_000],
["2:01", 2 * 60_000 + 1_000],
["3:1", 3 * 60_000 + 1_000],
["4:1.1", 4 * 60_000 + 1_100],
["5:1.12", 5 * 60_000 + 1_120],
["06:1.12", 6 * 60_000 + 1_120],
["07:01.012", 7 * 60_000 + 1_012],
["61:21", 61 * 60_000 + 21_000],
["1:2:3.123", 1 * 3600_000 + 2 * 60_000 + 3_123],
["01:05:03.123", 1 * 3600_000 + 5 * 60_000 + 3_123],
];
export const timeStampsTestCases = timeStampsTestCasesReg.toSorted(
([, t1], [, t2]) => t1 - t2,
) as readonly [string, number][];

View File

@@ -0,0 +1,134 @@
import { describe, expect, it } from "vitest";
import { parseYrc, stringifyYrc } from "../src/formats/yrc";
describe("yrc", () => {
it("parses basic word-timestamped line", () => {
const lines = parseYrc(
"[1000,1000](1000,500,0)Hello(1500,500,0)World\n[3000,1000](3000,1000,0)Again",
);
expect(lines).toHaveLength(2);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
expect(lines[0].words[0].startTime).toBe(1000);
expect(lines[0].words[0].endTime).toBe(1500);
expect(lines[0].words[1].word).toBe("World");
expect(lines[0].words[1].startTime).toBe(1500);
expect(lines[0].words[1].endTime).toBe(2000);
expect(lines[1].words[0].word).toBe("Again");
});
it("handles CRLF and ignores lines without valid line timestamp", () => {
const lines = parseYrc(
"no timestamp\r\n[invalid,100](0,100,0)Bad\r\n[-1,100](0,100,0)Bad\r\n[1000,1000](1000,1000,0)Hello",
);
expect(lines).toHaveLength(1);
expect(lines[0].startTime).toBe(1000);
expect(lines[0].endTime).toBe(2000);
expect(lines[0].words[0].word).toBe("Hello");
});
it("ignores empty lines and lines with only whitespace", () => {
const lines = parseYrc(
"[0,20] \n[0,20]\n[0,20](10,10,0)Test\n \n\n[0,20] \n",
);
expect(lines).toHaveLength(1);
expect(lines[0].words[0].word).toBe("Test");
});
it("stringifies words and preserves spaces", () => {
const result = stringifyYrc([
{
startTime: 1000,
endTime: 3000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
{ startTime: 0, endTime: 0, word: " ", romanWord: "" },
{ startTime: 2000, endTime: 3000, word: "World", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[1000,2000](1000,1000,0)Hello (2000,1000,0)World");
});
it("stringifies bg lines with full-width wrapping parentheses", () => {
const result = stringifyYrc([
{
startTime: 1000,
endTime: 2000,
words: [
{ startTime: 1000, endTime: 2000, word: "Hello", romanWord: "" },
],
translatedLyric: "",
romanLyric: "",
isBG: true,
isDuet: false,
},
]);
expect(result).toBe("[1000,1000](1000,1000,0)Hello");
});
it("parses and keeps bg wrapper lines stable", () => {
const input = "[1000,1000](1000,1000,0)Hello, (2000,2000,0)world";
const lines = parseYrc(input);
expect(lines).toHaveLength(1);
expect(lines[0].isBG).toBe(true);
expect(lines[0].words).toHaveLength(2);
expect(lines[0].words[0].word).toBe("Hello, ");
expect(lines[0].words[1].word).toBe("world");
expect(stringifyYrc(lines)).toBe(input);
});
it("keeps parenthesized fragments in plain word text", () => {
const lines = parseYrc("[0,20](0,10,0)A(x)(10,10,0) B");
expect(lines).toHaveLength(1);
expect(lines[0].words.map((w) => w.word)).toEqual(["A(x)", " B"]);
expect(lines[0].words[0].startTime).toBe(0);
expect(lines[0].words[0].endTime).toBe(10);
expect(lines[0].words[1].startTime).toBe(10);
expect(lines[0].words[1].endTime).toBe(20);
});
it("normalizes invalid timestamps when stringifying", () => {
const result = stringifyYrc([
{
startTime: Number.NaN,
endTime: Number.POSITIVE_INFINITY,
words: [
{
startTime: -1,
endTime: Number.POSITIVE_INFINITY,
word: "Hello",
romanWord: "",
},
],
translatedLyric: "",
romanLyric: "",
isBG: false,
isDuet: false,
},
]);
expect(result).toBe("[0,0](0,0,0)Hello");
});
it("keeps parse -> stringify -> parse stable for content and timing", () => {
const input =
"[1000,1000](1000,500,0)Hello (1500,500,0)World\n[3000,1000](3000,1000,0)Again";
const first = parseYrc(input);
const text = stringifyYrc(first);
const second = parseYrc(text);
expect(second).toEqual(first);
});
});

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.test.json" }
]
}

View File

@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"include": ["test/**/*.ts", "tests/**/*.ts"],
"compilerOptions": {
"types": ["node", "vitest"],
"isolatedDeclarations": false,
"declaration": false
}
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from "tsdown";
import { baseConfig } from "../../tsdown.base.ts";
export default defineConfig({
...baseConfig,
entry: { "amll-lyric": "./src/index.ts" },
dts: { tsconfig: "./tsconfig.app.json" },
});