forked from miao-moe/QZMusic_PC
fork(fix): Clone AMLL 并修复 BUG
- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork) - 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
51
amll-local/packages/lyric/CHANGELOG.md
Normal file
51
amll-local/packages/lyric/CHANGELOG.md
Normal 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)
|
||||
70
amll-local/packages/lyric/README-CN.md
Normal file
70
amll-local/packages/lyric/README-CN.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Lyric parser/writer for AMLL
|
||||
|
||||
[English](./README.md) / 简体中文
|
||||
|
||||
此为基于 TypeScript 的重构版本 Lyrics 包,文档未完成。
|
||||
|
||||
以下是原文档:
|
||||
|
||||
---
|
||||
|
||||
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
|
||||
|
||||

|
||||
[](https://www.npmjs.com/package/@applemusic-like-lyrics/lyric)
|
||||
[](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
|
||||
```
|
||||
70
amll-local/packages/lyric/README.md
Normal file
70
amll-local/packages/lyric/README.md
Normal 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!
|
||||
|
||||

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