forked from miao-moe/QZMusic_PC
fork(fix): Clone AMLL 并修复 BUG
- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork) - 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user