fork(fix): Clone AMLL 并修复 BUG

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

View File

@@ -0,0 +1,45 @@
## 1.0.1 (2026-05-17)
### Patch Changes
- **fix:** 修复因仅匹配起始时间导致的逐字音译错位 ([#533](https://github.com/amll-dev/applemusic-like-lyrics/pull/533))
- **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`
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
- Linho [@Linho1219](https://github.com/Linho1219)
# 1.0.0 (2026-04-23)
### Minor Changes
- **chore(ttml):** 同步上游 ttml 包的更新 ([#489](https://github.com/amll-dev/applemusic-like-lyrics/pull/489))
https://github.com/apoint123/ttml-processor/compare/86d7dd66a461c8fcd7e5a6b09b6d60daabee752e..af6d35a2404e4b2a85fa36086a39e333c7c3ba07
### Patch Changes
- **feat:** 识别同名 songPart 但不同 div 的情况 ([#490](https://github.com/amll-dev/applemusic-like-lyrics/pull/490))
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
## 1.0.0-alpha.0 (2026-04-14)
### Major Changes
- **refactor:** 重构 TTML 解析和生成器 ([#471](https://github.com/amll-dev/applemusic-like-lyrics/pull/471))
### Patch Changes
- **chore:** 更换工具链 ([#476](https://github.com/amll-dev/applemusic-like-lyrics/pull/476))
### Contributors
- apoint123 [@apoint123](https://github.com/apoint123)
- Linho [@Linho1219](https://github.com/Linho1219)

View File

@@ -0,0 +1,7 @@
# Lyric parser/writer for AMLL
[English](./README.md) / 简体中文
> 警告:此为个人项目,且尚未完成开发,可能仍有大量问题,所以请勿直接用于生产环境!
一个 AMLL 的 TTML 格式解析/导出模块,使用纯 TypeScript 编写。

View File

@@ -0,0 +1,7 @@
# Lyric parser/writer for AMLL
English / [简体中文](./README-CN.md)
> 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!
A TTML format parser/exporter module for AMLL, written in pure TypeScript.

View File

@@ -0,0 +1,58 @@
{
"name": "@applemusic-like-lyrics/ttml",
"version": "1.0.1",
"description": "AMLL 的 TTML 歌词文件序列化和反序列化模块",
"repository": {
"url": "https://github.com/amll-dev/applemusic-like-lyrics.git",
"directory": "packages/ttml",
"type": "git"
},
"license": "AGPL-3.0-only",
"nx": {
"tags": [
"library"
],
"targets": {
"nx-release-publish": {
"executor": "@nx/js:release-publish",
"dependsOn": []
}
}
},
"type": "module",
"files": [
"dist"
],
"main": "./dist/amll-ttml.cjs",
"module": "./dist/amll-ttml.mjs",
"types": "./dist/amll-ttml.d.mts",
"exports": {
"import": {
"types": "./dist/amll-ttml.d.mts",
"default": "./dist/amll-ttml.mjs"
},
"require": {
"types": "./dist/amll-ttml.d.cts",
"default": "./dist/amll-ttml.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",
"test": "vitest test",
"test:watch": "vitest test --watch",
"test:coverage": "vitest test --coverage"
},
"devDependencies": {
"@biomejs/biome": "^2.4.8",
"@types/node": "^25.5.2",
"@xmldom/xmldom": "^0.9.10",
"tsdown": "^0.22.0",
"typedoc": "^0.28.18",
"vitest": "^4.1.6"
}
}

View File

@@ -0,0 +1,127 @@
/**
* 一些常量定义
* @module constants
* @internal
*/
export const NS = {
TT: "http://www.w3.org/ns/ttml",
TTM: "http://www.w3.org/ns/ttml#metadata",
ITUNES: "http://music.apple.com/lyric-ttml-internal",
AMLL: "http://www.example.com/ns/amll",
XML: "http://www.w3.org/XML/1998/namespace",
XMLNS: "http://www.w3.org/2000/xmlns/",
ITUNES_INTERNAL: "http://music.apple.com/lyric-ttml-internal",
TTS: "http://www.w3.org/ns/ttml#styling",
} as const;
export const Elements = {
TT: "tt",
Head: "head",
Body: "body",
Div: "div",
P: "p",
Span: "span",
Title: "title",
Name: "name",
Meta: "meta",
ITunesMetadata: "iTunesMetadata",
TTMLMetadata: "metadata",
Songwriters: "songwriters",
Songwriter: "songwriter",
Translation: "translation",
Translations: "translations",
Transliteration: "transliteration",
Transliterations: "transliterations",
Text: "text",
ParserError: "parsererror",
Agent: "agent",
} as const;
export const Attributes = {
Timing: "timing",
Id: "id",
Key: "key",
Value: "value",
Lang: "lang",
For: "for",
SongPart: "songPart",
SongPartKebab: "song-part",
Begin: "begin",
End: "end",
Role: "role",
Type: "type",
Dur: "dur",
Xmlns: "xmlns",
Ruby: "ruby",
Obscene: "obscene",
EmptyBeat: "empty-beat",
} as const;
export const QualifiedAttributes = {
ITunesTiming: "itunes:timing",
ITunesPart: "itunes:songPart",
ITunesKey: "itunes:key",
TTMAgent: "ttm:agent",
TTMRole: "ttm:role",
TTMName: "ttm:name",
AmllMeta: "amll:meta",
AmllObscene: "amll:obscene",
AmllEmptyBeat: "amll:empty-beat",
XmlLang: "xml:lang",
XmlId: "xml:id",
XmlnsTtm: "xmlns:ttm",
XmlnsTts: "xmlns:tts",
XmlnsItunes: "xmlns:itunes",
XmlnsAmll: "xmlns:amll",
TtsRuby: "tts:ruby",
} as const;
export const Values = {
Word: "Word",
Line: "Line",
MimeXML: "application/xml",
MusicName: "musicName",
Artists: "artists",
Album: "album",
ISRC: "isrc",
TTMLAuthorGithub: "ttmlAuthorGithub",
TTMLAuthorGithubLogin: "ttmlAuthorGithubLogin",
NCMMusicId: "ncmMusicId",
QQMusicId: "qqMusicId",
SpotifyId: "spotifyId",
AppleMusicId: "appleMusicId",
RoleBg: "x-bg",
RoleTranslation: "x-translation",
RoleRoman: "x-roman",
Group: "group",
Person: "person",
Other: "other",
Full: "full",
AgentGroup: "v1000",
AgentDefault: "v1",
AgentDefaultDuet: "v2",
RubyContainer: "container",
RubyBase: "base",
RubyTextContainer: "textContainer",
RubyText: "text",
True: "true",
TimingMode: "timingMode",
Language: "language",
} as const;
// 为了兼容 nodejs 环境而重新定义
export enum NodeType {
ELEMENT_NODE = 1,
ATTRIBUTE_NODE = 2,
TEXT_NODE = 3,
CDATA_SECTION_NODE = 4,
ENTITY_REFERENCE_NODE = 5,
ENTITY_NODE = 6,
PROCESSING_INSTRUCTION_NODE = 7,
COMMENT_NODE = 8,
DOCUMENT_NODE = 9,
DOCUMENT_TYPE_NODE = 10,
DOCUMENT_FRAGMENT_NODE = 11,
NOTATION_NODE = 12,
}

View File

@@ -0,0 +1,712 @@
/**
* 核心的 TTML 生成器实现
* @module generator
*/
import {
Attributes,
Elements,
NS,
QualifiedAttributes,
Values,
} from "./constants";
import type {
Agent,
GeneratorOptions,
LyricBase,
SubLyricContent,
Syllable,
TTMLResult,
} from "./types";
/**
* TTML 歌词生成器类
*
* 用于将内部的 {@link TTMLResult} 数据结构序列化为 AMLL 项目使用的 TTML 字符串
* @see https://github.com/amll-dev/amll-ttml-db/wiki/%E6%A0%BC%E5%BC%8F%E8%A7%84%E8%8C%83
*/
export class TTMLGenerator {
private domImpl: DOMImplementation;
private options: GeneratorOptions;
private xmlSerializer: XMLSerializer;
private doc!: Document;
private timingMode: "Word" | "Line" = "Line";
/**
* 构造一个 TTML 生成器实例
*
* @param options 生成器配置选项
*
* 在 Node.js 环境下必须注入 `domImplementation` 和 `xmlSerializer` 实例(例如用 `@xmldom/xmldom` 等)
*/
constructor(options: GeneratorOptions = {}) {
this.options = options;
if (this.options.domImplementation) {
this.domImpl = this.options.domImplementation as DOMImplementation;
} else if (typeof document !== "undefined" && document.implementation) {
this.domImpl = document.implementation;
} else {
throw new Error(
"No DOMImplementation found. If you are running in Node.js, please inject via options (e.g., using @xmldom/xmldom in Node.js).",
);
}
if (this.options.xmlSerializer) {
this.xmlSerializer = this.options.xmlSerializer;
} else if (typeof XMLSerializer !== "undefined") {
this.xmlSerializer = new XMLSerializer();
} else {
throw new Error(
"No XMLSerializer found. If you are running in Node.js, please inject via options (e.g., using @xmldom/xmldom in Node.js).",
);
}
}
/**
* 生成 TTML 字符串的静态便捷方法
* @param result 包含元数据和歌词行的 TTML 数据结构
* @param options 生成器配置选项,用于注入 DOM 依赖及自定义部分生成行为
* @returns 序列化后的 TTML 字符串
*/
public static generate(
result: TTMLResult,
options?: GeneratorOptions,
): string {
const instance = new TTMLGenerator(options);
return instance.generate(result);
}
/**
* 生成 TTML 字符串
* @param result 包含元数据和歌词行的 TTML 数据结构
* @returns 序列化后的 TTML 字符串
*/
public generate(result: TTMLResult): string {
this.doc = this.domImpl.createDocument(NS.TT, Elements.TT, null);
this.timingMode = result.metadata.timingMode || "Line";
// 允许调用者自定义所有行的 ID但是如果只自定义了部分行 ID为了避免 ID 乱序,我们直接忽略残缺行 ID
const allLinesHaveId = result.lines.every(
(line) => typeof line.id === "string" && line.id.trim() !== "",
);
result.lines.forEach((line, index) => {
if (!allLinesHaveId) {
line.id = `L${index + 1}`;
}
// 未提供 agentId默认所有行的 agentId 均为默认 v1
if (!line.agentId) {
line.agentId = Values.AgentDefault;
}
});
const root = this.doc.documentElement;
this.setupRootAttributes(root, result);
const head = this.buildHead(result);
root.appendChild(head);
const body = this.buildBody(result);
root.appendChild(body);
const xmlStr = this.xmlSerializer.serializeToString(this.doc);
return xmlStr;
}
private setupRootAttributes(root: Element, result: TTMLResult) {
root.setAttributeNS(NS.XMLNS, QualifiedAttributes.XmlnsAmll, NS.AMLL);
root.setAttributeNS(NS.XMLNS, QualifiedAttributes.XmlnsItunes, NS.ITUNES);
root.setAttributeNS(NS.XMLNS, QualifiedAttributes.XmlnsTtm, NS.TTM);
root.setAttributeNS(NS.XMLNS, QualifiedAttributes.XmlnsTts, NS.TTS);
if (result.metadata.language) {
root.setAttributeNS(
NS.XML,
QualifiedAttributes.XmlLang,
result.metadata.language,
);
}
if (result.metadata.timingMode) {
root.setAttributeNS(
NS.ITUNES,
QualifiedAttributes.ITunesTiming,
result.metadata.timingMode,
);
} else {
root.setAttributeNS(NS.ITUNES, QualifiedAttributes.ITunesTiming, "Word");
}
}
private isLyricBase(
content: LyricBase | SubLyricContent,
): content is LyricBase {
return "startTime" in content;
}
private isWordByWord(words?: Syllable[]): boolean {
if (!words || words.length === 0) return false;
if (this.timingMode === "Word") return true;
return true;
}
private shouldMoveToSidecar(content: SubLyricContent): boolean {
if (content.words && content.words.length > 0) return true;
return !!this.options.useSidecar;
}
private buildHead(result: TTMLResult): Element {
const head = this.doc.createElement(Elements.Head);
const metadata = this.doc.createElement(Elements.TTMLMetadata);
const meta = result.metadata;
let agentsToGenerate: Agent[] = [];
if (meta.agents && Object.keys(meta.agents).length > 0) {
agentsToGenerate = Object.values(meta.agents);
} else {
const uniqueAgentIds = new Set<string>();
result.lines.forEach((line) => {
if (line.agentId) {
uniqueAgentIds.add(line.agentId);
}
});
uniqueAgentIds.forEach((id) => {
agentsToGenerate.push({
id,
type: id === Values.AgentGroup ? Values.Group : Values.Person,
});
});
}
agentsToGenerate.forEach((agent) => {
const { id, name, type: agentType } = agent;
const agentEl = this.doc.createElementNS(
NS.TTM,
QualifiedAttributes.TTMAgent,
);
const type =
agentType || (id === Values.AgentGroup ? Values.Group : Values.Person);
agentEl.setAttribute(Attributes.Type, type);
agentEl.setAttribute(QualifiedAttributes.XmlId, id);
if (name) {
const nameEl = this.doc.createElementNS(
NS.TTM,
QualifiedAttributes.TTMName,
);
nameEl.setAttribute(Attributes.Type, Values.Full);
nameEl.textContent = name;
agentEl.appendChild(nameEl);
}
metadata.appendChild(agentEl);
});
this.buildITunesMetadata(metadata, result);
const addAmllMeta = (key: string, value: string) => {
const el = this.doc.createElementNS(
NS.AMLL,
QualifiedAttributes.AmllMeta,
);
el.setAttribute(Attributes.Key, key);
el.setAttribute(Attributes.Value, value);
metadata.appendChild(el);
};
meta.title?.forEach((v) => {
addAmllMeta(Values.MusicName, v);
});
meta.artist?.forEach((v) => {
addAmllMeta(Values.Artists, v);
});
meta.album?.forEach((v) => {
addAmllMeta(Values.Album, v);
});
if (result.metadata.platformIds) {
Object.entries(result.metadata.platformIds).forEach(([key, values]) => {
values?.forEach((v) => {
addAmllMeta(key, v);
});
});
}
meta.isrc?.forEach((v) => {
addAmllMeta(Values.ISRC, v);
});
meta.authorIds?.forEach((v) => {
addAmllMeta(Values.TTMLAuthorGithub, v);
});
meta.authorNames?.forEach((v) => {
addAmllMeta(Values.TTMLAuthorGithubLogin, v);
});
if (meta.rawProperties) {
Object.entries(meta.rawProperties).forEach(([key, values]) => {
values?.forEach((v) => {
addAmllMeta(key, v);
});
});
}
head.appendChild(metadata);
return head;
}
private buildITunesMetadata(metadataEl: Element, result: TTMLResult) {
const iTunesMeta = this.doc.createElement(Elements.ITunesMetadata);
iTunesMeta.setAttribute(Attributes.Xmlns, NS.ITUNES_INTERNAL);
let hasContent = false;
const translationsMap = new Map<
string | undefined,
Array<{ id: string; main?: SubLyricContent; bg?: SubLyricContent }>
>();
const romansMap = new Map<
string | undefined,
Array<{ id: string; main?: SubLyricContent; bg?: SubLyricContent }>
>();
for (const line of result.lines) {
const pairedTrans = this.pairSubContents(
line.translations,
line.backgroundVocal?.translations,
);
for (const pair of pairedTrans) {
if (
(pair.main && this.shouldMoveToSidecar(pair.main)) ||
(pair.bg && this.shouldMoveToSidecar(pair.bg))
) {
if (!translationsMap.has(pair.lang))
translationsMap.set(pair.lang, []);
translationsMap.get(pair.lang)?.push({
// biome-ignore lint/style/noNonNullAssertion: generate 方法已验证行 id 一定有
id: line.id!,
main: pair.main,
bg: pair.bg,
});
}
}
const pairedRomans = this.pairSubContents(
line.romanizations,
line.backgroundVocal?.romanizations,
);
for (const pair of pairedRomans) {
if (
(pair.main && this.shouldMoveToSidecar(pair.main)) ||
(pair.bg && this.shouldMoveToSidecar(pair.bg))
) {
if (!romansMap.has(pair.lang)) romansMap.set(pair.lang, []);
romansMap.get(pair.lang)?.push({
// biome-ignore lint/style/noNonNullAssertion: generate 方法已验证行 id 一定有
id: line.id!,
main: pair.main,
bg: pair.bg,
});
}
}
}
if (translationsMap.size > 0) {
const container = this.doc.createElement(Elements.Translations);
for (const [lang, items] of translationsMap) {
const transEl = this.doc.createElement(Elements.Translation);
if (lang) {
transEl.setAttribute(QualifiedAttributes.XmlLang, lang);
}
items.forEach((item) => {
const textEl = this.doc.createElement(Elements.Text);
textEl.setAttribute(Attributes.For, item.id);
if (item.main) {
this.appendContentToElement(textEl, item.main);
}
if (item.bg) {
this.appendBackgroundVocal(textEl, item.bg);
}
transEl.appendChild(textEl);
});
container.appendChild(transEl);
}
iTunesMeta.appendChild(container);
hasContent = true;
}
if (romansMap.size > 0) {
const container = this.doc.createElement(Elements.Transliterations);
for (const [lang, items] of romansMap) {
const transEl = this.doc.createElement(Elements.Transliteration);
if (lang) {
transEl.setAttribute(QualifiedAttributes.XmlLang, lang);
}
items.forEach((item) => {
const textEl = this.doc.createElement(Elements.Text);
textEl.setAttribute(Attributes.For, item.id);
if (item.main) {
this.appendContentToElement(textEl, item.main);
}
if (item.bg) {
this.appendBackgroundVocal(textEl, item.bg);
}
transEl.appendChild(textEl);
});
container.appendChild(transEl);
}
iTunesMeta.appendChild(container);
hasContent = true;
}
if (result.metadata.songwriters && result.metadata.songwriters.length > 0) {
const container = this.doc.createElement(Elements.Songwriters);
result.metadata.songwriters.forEach((name) => {
const sw = this.doc.createElement(Elements.Songwriter);
sw.textContent = name;
container.appendChild(sw);
});
iTunesMeta.appendChild(container);
hasContent = true;
}
if (hasContent) {
metadataEl.appendChild(iTunesMeta);
}
}
private buildBody(result: TTMLResult): Element {
const body = this.doc.createElement(Elements.Body);
const lines = result.lines;
const lastTime =
lines.length > 0 ? Math.max(...lines.map((l) => l.endTime)) : 0;
body.setAttribute(Attributes.Dur, this.formatTime(lastTime));
let currentDiv: Element | null = null;
let currentSongPart: string | undefined;
let currentBlockIndex: number | undefined;
let currentSectionEndTime = 0;
const finalizeCurrentDiv = () => {
if (currentDiv && currentSectionEndTime > 0) {
currentDiv.setAttribute(
Attributes.End,
this.formatTime(currentSectionEndTime),
);
if (currentSongPart) {
currentDiv.setAttributeNS(
NS.ITUNES,
QualifiedAttributes.ITunesPart,
currentSongPart,
);
}
}
};
for (const line of lines) {
if (
line.songPart !== currentSongPart ||
line.blockIndex !== currentBlockIndex ||
!currentDiv
) {
finalizeCurrentDiv();
currentSongPart = line.songPart;
currentBlockIndex = line.blockIndex;
currentSectionEndTime = 0;
currentDiv = this.doc.createElement(Elements.Div);
currentDiv.setAttribute(
Attributes.Begin,
this.formatTime(line.startTime),
);
body.appendChild(currentDiv);
}
if (line.endTime > currentSectionEndTime) {
currentSectionEndTime = line.endTime;
}
const p = this.doc.createElement(Elements.P);
p.setAttribute(Attributes.Begin, this.formatTime(line.startTime));
p.setAttribute(Attributes.End, this.formatTime(line.endTime));
// biome-ignore lint/style/noNonNullAssertion: generate 方法已验证行 id 一定有
p.setAttributeNS(NS.ITUNES, QualifiedAttributes.ITunesKey, line.id!);
if (line.agentId) {
p.setAttributeNS(NS.TTM, QualifiedAttributes.TTMAgent, line.agentId);
}
this.appendContentToElement(p, line);
currentDiv.appendChild(p);
}
finalizeCurrentDiv();
return body;
}
private pairSubContents(
mainList?: SubLyricContent[],
bgList?: SubLyricContent[],
): Array<{ lang?: string; main?: SubLyricContent; bg?: SubLyricContent }> {
const map = new Map<
string | undefined,
{ lang?: string; main?: SubLyricContent; bg?: SubLyricContent }
>();
const getEntry = (lang?: string) => {
let entry = map.get(lang);
if (!entry) {
entry = { lang };
map.set(lang, entry);
}
return entry;
};
mainList?.forEach((item) => {
getEntry(item.language).main = item;
});
bgList?.forEach((item) => {
getEntry(item.language).bg = item;
});
return Array.from(map.values());
}
private appendContentToElement(
element: Element,
content: LyricBase | SubLyricContent,
isBackground: boolean = false,
) {
if (this.isWordByWord(content.words) && content.words) {
this.appendWords(element, content.words, isBackground);
} else {
let text = content.text || "";
if (isBackground) {
text = `(${text})`;
}
element.textContent = text;
}
if (this.isLyricBase(content)) {
this.appendSubLyrics(element, content);
}
if ("backgroundVocal" in content && content.backgroundVocal) {
this.appendBackgroundVocal(element, content.backgroundVocal);
}
}
private appendWords(
element: Element,
words: Syllable[],
isBackground: boolean,
) {
words.forEach((syllable, index) => {
let text = syllable.text;
if (isBackground) {
if (index === 0) text = `(${text}`;
if (index === words.length - 1) text = `${text})`;
}
if (syllable.ruby && syllable.ruby.length > 0) {
this.appendRubySyllable(element, syllable, text);
} else {
this.appendNormalSyllable(element, syllable, text);
}
if (syllable.endsWithSpace) {
const spaceNode = this.doc.createTextNode(" ");
element.appendChild(spaceNode);
}
});
}
private appendRubySyllable(
element: Element,
syllable: Syllable,
text: string,
) {
const containerSpan = this.doc.createElement(Elements.Span);
containerSpan.setAttributeNS(
NS.TTS,
QualifiedAttributes.TtsRuby,
Values.RubyContainer,
);
if (syllable.obscene) {
containerSpan.setAttributeNS(
NS.AMLL,
QualifiedAttributes.AmllObscene,
Values.True,
);
}
if (syllable.emptyBeat !== undefined) {
containerSpan.setAttributeNS(
NS.AMLL,
QualifiedAttributes.AmllEmptyBeat,
syllable.emptyBeat.toString(),
);
}
const baseSpan = this.doc.createElement(Elements.Span);
baseSpan.setAttributeNS(
NS.TTS,
QualifiedAttributes.TtsRuby,
Values.RubyBase,
);
baseSpan.textContent = text;
containerSpan.appendChild(baseSpan);
const textContainerSpan = this.doc.createElement(Elements.Span);
textContainerSpan.setAttributeNS(
NS.TTS,
QualifiedAttributes.TtsRuby,
Values.RubyTextContainer,
);
syllable.ruby?.forEach((rt) => {
const rtSpan = this.doc.createElement(Elements.Span);
rtSpan.setAttributeNS(
NS.TTS,
QualifiedAttributes.TtsRuby,
Values.RubyText,
);
rtSpan.setAttribute(Attributes.Begin, this.formatTime(rt.startTime));
rtSpan.setAttribute(Attributes.End, this.formatTime(rt.endTime));
rtSpan.textContent = rt.text;
textContainerSpan.appendChild(rtSpan);
});
containerSpan.appendChild(textContainerSpan);
element.appendChild(containerSpan);
}
private appendNormalSyllable(
element: Element,
syllable: Syllable,
text: string,
) {
const span = this.doc.createElement(Elements.Span);
span.setAttribute(Attributes.Begin, this.formatTime(syllable.startTime));
span.setAttribute(Attributes.End, this.formatTime(syllable.endTime));
if (syllable.obscene) {
span.setAttributeNS(
NS.AMLL,
QualifiedAttributes.AmllObscene,
Values.True,
);
}
if (syllable.emptyBeat !== undefined) {
span.setAttributeNS(
NS.AMLL,
QualifiedAttributes.AmllEmptyBeat,
syllable.emptyBeat.toString(),
);
}
span.textContent = text;
element.appendChild(span);
}
private appendSubLyrics(element: Element, content: LyricBase) {
if (content.translations) {
content.translations.forEach((trans) => {
if (!this.shouldMoveToSidecar(trans)) {
const span = this.doc.createElement(Elements.Span);
span.setAttributeNS(
NS.TTM,
QualifiedAttributes.TTMRole,
Values.RoleTranslation,
);
if (trans.language) {
span.setAttributeNS(
NS.XML,
QualifiedAttributes.XmlLang,
trans.language,
);
}
this.appendContentToElement(span, trans);
element.appendChild(span);
}
});
}
if (content.romanizations) {
content.romanizations.forEach((roman) => {
if (!this.shouldMoveToSidecar(roman)) {
const span = this.doc.createElement(Elements.Span);
span.setAttributeNS(
NS.TTM,
QualifiedAttributes.TTMRole,
Values.RoleRoman,
);
if (roman.language) {
span.setAttributeNS(
NS.XML,
QualifiedAttributes.XmlLang,
roman.language,
);
}
this.appendContentToElement(span, roman);
element.appendChild(span);
}
});
}
}
private appendBackgroundVocal(
element: Element,
bg: LyricBase | SubLyricContent,
) {
const bgSpan = this.doc.createElement(Elements.Span);
bgSpan.setAttributeNS(NS.TTM, QualifiedAttributes.TTMRole, Values.RoleBg);
if (this.isLyricBase(bg)) {
if (bg.startTime > 0 && bg.endTime > 0) {
bgSpan.setAttribute(Attributes.Begin, this.formatTime(bg.startTime));
bgSpan.setAttribute(Attributes.End, this.formatTime(bg.endTime));
}
}
this.appendContentToElement(bgSpan, bg, true);
element.appendChild(bgSpan);
}
private formatTime(ms: number): string {
if (ms < 0) ms = 0;
const totalSeconds = Math.floor(ms / 1000);
const milliseconds = ms % 1000;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const fff = milliseconds.toString().padStart(3, "0");
if (minutes > 0) {
const ss = seconds.toString().padStart(2, "0");
return `${minutes}:${ss}.${fff}`;
} else {
return `${seconds}.${fff}`;
}
}
}

View File

@@ -0,0 +1,41 @@
export * from "./generator";
export * from "./parser";
export type * from "./types";
export * from "./utils/amll-converter";
import { TTMLGenerator } from "./generator";
import { TTMLParser } from "./parser";
import type { AmllLyricResult, TTMLResult } from "./types";
import { toAmllLyrics, toTTMLResult } from "./utils/amll-converter";
/**
* 将 TTML 格式的 XML 字符串解析为 {@link AmllLyricResult} 对象的便捷方法
*
* 若需要原始的、内容更丰富的 {@link TTMLResult} 结构,建议直接使用 {@link TTMLParser} 类
*
* @remarks 默认使用全局的 `DOMParser`,若为 Nodejs 环境,必须使用
* {@link TTMLParser} 类注入 `DOMParser` 实现,例如 `@xmldom/xmldom`
* @param ttmlText 符合 TTML 规范的 XML 字符串
* @returns 解析后的 {@link AmllLyricResult} 对象,包含歌词行列表和元数据
* @throws 如果没有全局的 `DOMParser`,抛出错误
*/
export function parseTTML(ttmlText: string): AmllLyricResult {
const result = TTMLParser.parse(ttmlText);
return toAmllLyrics(result);
}
/**
* 将 {@link AmllLyricResult} 对象序列化为 TTML 格式的 XML 字符串的便捷方法
*
* 若需要自定义生成选项,建议直接使用 {@link TTMLParser} 类
* @remarks 默认使用全局的 `document.implementation` 和 `XMLSerializer`,若为 Nodejs 环境,
* 必须使用 {@link TTMLGenerator} 类注入 `domImplementation` 和 `xmlSerializer`,例如 `@xmldom/xmldom`
* @param ttmlLyric 包含歌词行列表和元数据的 {@link AmllLyricResult} 对象
* @returns 序列化后的 TTML XML 字符串
* @throws 如果没有全局的 `DOMImplementation` 和 `XMLSerializer`,抛出错误
*/
export function exportTTML(ttmlLyric: AmllLyricResult): string {
const result = toTTMLResult(ttmlLyric.lines, ttmlLyric.metadata);
const generator = new TTMLGenerator();
return generator.generate(result);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,119 @@
/**
* AMLL 所使用的较简单的数据结构
* @module amll-types
*/
/**
* 一个歌词单词
*/
export interface AmllLyricWord extends LyricWordBase {
/** 单词的音译内容 */
romanWord?: string;
/** 单词内容是否包含冒犯性的不雅用语 */
obscene?: boolean;
/** 单词的空拍数量,一般只用于方便歌词打轴 */
emptyBeat?: number;
/** 单词的注音内容 */
ruby?: LyricWordBase[];
}
/** 一个歌词单词 */
export interface LyricWordBase {
/** 单词的起始时间,单位为毫秒 */
startTime: number;
/** 单词的结束时间,单位为毫秒 */
endTime: number;
/** 单词内容 */
word: string;
}
/**
* 一行歌词,存储多个单词
*/
export interface AmllLyricLine {
/**
* 该行的所有单词
*/
words: AmllLyricWord[];
/**
* 该行的翻译
*/
translatedLyric: string;
/**
* 该行的音译
*/
romanLyric: string;
/**
* 该行是否为背景歌词行
*/
isBG: boolean;
/**
* 该行是否为对唱歌词行(即歌词行靠右对齐)
*/
isDuet: boolean;
/**
* 该行的开始时间
*
* **并不总是等于第一个单词的开始时间**
*/
startTime: number;
/**
* 该行的结束时间
*
* **并不总是等于最后一个单词的开始时间**
*/
endTime: number;
}
/**
* 一个元数据,以 `[键, 值数组]` 的形式存储
*/
export type AmllMetadata = [string, string[]];
/**
* 一个 TTML 歌词对象,存储了歌词行信息和 AMLL 元数据信息
*/
export interface AmllLyricResult {
/**
* TTML 中存储的歌词行信息
*/
lines: AmllLyricLine[];
/**
* 一个元数据表,以 `[键, 值数组]` 的形式存储
*/
metadata: AmllMetadata[];
}
/**
* 解析器生成的原始 TTML 数据结构转换为 AMLL 的数据结构时的配置选项
*/
export interface TTMLToAmllOptions {
/**
* 提取翻译时的首选目标语言 (如 `"zh-Hans"`)
*
* 未提供或找不到指定的目标语言代码时提取第一个翻译
*/
translationLanguage?: string;
/**
* 提取音译时的首选目标语言 (如 `"ja-Latn"`)
*
* 未提供或找不到指定的目标语言代码时提取第一个音译
*/
romanizationLanguage?: string;
}
/**
* AMLL 简单的数据结构转换为解析器内部复杂的 TTML 数据结构时的配置选项
*/
export interface AmllToTTMLOptions {
/**
* 翻译的目标语言代码
* @default "zh-Hans"
*/
translationLanguage?: string;
/**
* 音译的目标语言代码
* @default undefined
*/
romanizationLanguage?: string;
}

View File

@@ -0,0 +1,79 @@
/**
* 为了兼容 xmldom 和原生 dom 而定义的最小化类型
* @module dom-types
* @internal
*/
export interface MinimalNode {
readonly nodeType: number;
textContent: string | null;
readonly childNodes: ArrayLike<MinimalNode> | Iterable<MinimalNode>;
readonly localName?: string | null;
readonly tagName?: string;
readonly nodeName?: string;
}
export interface MinimalAttribute {
localName?: string | null;
nodeName: string;
value: string;
}
export interface MinimalElement extends MinimalNode {
readonly localName: string | null;
readonly tagName: string;
readonly attributes: ArrayLike<MinimalAttribute> | Iterable<MinimalAttribute>;
getAttribute(name: string): string | null;
getAttributeNS(namespace: string | null, localName: string): string | null;
hasAttributes(): boolean;
setAttribute(name: string, value: string): void;
setAttributeNS(
namespace: string | null,
qualifiedName: string,
value: string,
): void;
getElementsByTagName(
name: string,
): ArrayLike<MinimalElement> | Iterable<MinimalElement>;
getElementsByTagNameNS(
namespaceURI: string,
localName: string,
): ArrayLike<MinimalElement> | Iterable<MinimalElement>;
appendChild(node: MinimalNode): MinimalNode;
}
export interface MinimalDocument extends MinimalNode {
readonly documentElement: MinimalElement | null;
getElementsByTagName(
name: string,
): ArrayLike<MinimalElement> | Iterable<MinimalElement>;
createElement(tagName: string): MinimalElement;
createElementNS(
namespaceURI: string | null,
qualifiedName: string,
): MinimalElement;
createTextNode(data: string): MinimalNode;
}
export interface MinimalDOMParser {
parseFromString(
string: string,
type: DOMParserSupportedType | string,
): MinimalDocument;
}
export interface MinimalDOMImplementation {
createDocument(
namespaceURI: string | null,
qualifiedName: string | null,
doctype?: MinimalNode | null,
): MinimalDocument;
}
export interface MinimalXMLSerializer {
serializeToString(root: MinimalNode): string;
}

View File

@@ -0,0 +1,334 @@
/**
* 解析器所使用的较复杂的数据结构
* @module ttml-types
*/
import type {
MinimalDOMImplementation,
MinimalDOMParser,
MinimalXMLSerializer,
} from "./dom";
export type * from "./amll";
/**
* 解析器配置选项
*/
export interface TTMLParserOptions {
/**
* 注入的 DOMParser 实例
* - 浏览器环境: 可忽略,默认使用 window.DOMParser
* - Node.js 环境: 必须传入 (例如: `new (require('@xmldom/xmldom').DOMParser)()`)
*/
domParser?: MinimalDOMParser;
}
/**
* 生成器配置选项
*/
export interface GeneratorOptions {
/**
* 注入的 DOMImplementation 实例
* - 浏览器: 可忽略,默认使用 document.implementation
* - Node.js 环境: 必须传入 (例如: `new (require('@xmldom/xmldom').DOMImplementation)()`)
*/
domImplementation?: MinimalDOMImplementation;
/**
* 注入的 XMLSerializer 实例
* - 浏览器: 可忽略,默认使用 new XMLSerializer()
* - Node.js 环境: 必须传入 (例如: `new (require('@xmldom/xmldom').XMLSerializer)()`)
*/
xmlSerializer?: MinimalXMLSerializer;
/**
* 对于逐行翻译/音译,是否将其放入 Head (Apple Music 风格)
*
* 注意逐字翻译/音译将始终强制放入 Head无论此值如何
* @default false
*/
useSidecar?: boolean;
}
/**
* 翻译/音译的内容
*/
export interface SubLyricContent {
/**
* 该内容的 BCP-47 语言代码
*/
language?: string;
/**
* 完整文本
*/
text: string;
/**
* 逐字音节信息
*/
words?: Syllable[];
}
/**
* 背景人声内容
*/
export type BackgroundVocal = Omit<LyricBase, "backgroundVocal">;
/**
* 基础歌词内容
*/
export interface LyricBase {
/**
* 完整的文本内容
* - 如果是逐字歌词,这里是所有字拼接后的结果
*/
text: string;
/**
* 开始时间,单位毫秒
*/
startTime: number;
/**
* 结束时间,单位毫秒
*/
endTime: number;
/**
* 逐字音节信息
*
* 如果数组为空或未定义,一般就是逐行歌词
*/
words?: Syllable[];
/**
* 翻译内容
*/
translations?: SubLyricContent[];
/**
* 音译内容
*/
romanizations?: SubLyricContent[];
/**
* 背景人声内容
*/
backgroundVocal?: BackgroundVocal;
}
/**
* 一个主歌词行
*/
export interface LyricLine extends LyricBase {
/**
* 行 ID
*
* 例如 "L1", "L2"...
*/
id?: string;
/**
* 演唱者 ID
*
* 可用于在 metadata.agents 中查找具体名字
*/
agentId?: string;
/**
* 歌曲结构组成
*
* 例如: "Verse", "Chorus", "Intro", "Outro"
*/
songPart?: string;
/**
* 所属的递增区块索引
*
* 用于区分连续出现但属于不同 div 的同名 songPart
*/
blockIndex?: number;
}
/**
* Ruby 标注的单个注音音节
*/
export interface RubyTag {
/**
* 注音文本内容
*/
text: string;
/**
* 该注音的开始时间,单位毫秒
*/
startTime: number;
/**
* 该注音的结束时间,单位毫秒
*/
endTime: number;
}
/**
* 一个歌词音节
*/
export interface Syllable {
/**
* 该音节的内容
* - 如果是普通音节,为常规歌词文本
* - 如果是 Ruby 标注,这里对应 ruby 的基文本,通常为汉字
*/
text: string;
/**
* 该音节的开始时间,单位毫秒
* - 如果是 Ruby 标注,此值为第一个 RubyTag 的 startTime
*/
startTime: number;
/**
* 该音节的结束时间,单位毫秒
* - 如果是 Ruby 标注,此值为最后一个 RubyTag 的 endTime
*/
endTime: number;
/**
* 该音节后面是否应该跟着一个空格
*
* 注意必须根据此标志在歌词后面添加空格text 中不应包含空格
*/
endsWithSpace?: boolean;
/**
* Ruby 标注信息
*
* 如果存在此属性,说明该音节是一个 Ruby 容器
*/
ruby?: RubyTag[];
/**
* 单词内容是否包含冒犯性的不雅用语
*/
obscene?: boolean;
/**
* 单词的空拍数量,一般只用于方便歌词打轴
*/
emptyBeat?: number;
}
/**
* 演唱者信息结构
*/
export interface Agent {
/**
* 演唱者的 ID
*
* 如果是 AMLL 的 TTML只有 v1 和 v2 分别指代非对唱和对唱。
* 如果是 Apple Music 的 TTML还会出现 v3v4 等指代每个演唱者,以及 v1000 用于指代合唱。
*/
id: string;
/**
* 演唱者名称
*/
name?: string;
/**
* 演唱者类型
*
* 通常为 "person", "group", "other",也有可能是其他字符串
*/
type?: string;
}
/**
* 元数据中的各个平台 ID
*/
export type PlatformId =
| "ncmMusicId"
| "qqMusicId"
| "spotifyId"
| "appleMusicId";
/**
* TTML 歌词的元数据内容
*/
export interface TTMLMetadata {
/**
* 歌词主语言代码 (BCP-47)
*/
language?: string;
/**
* 计时模式
*/
timingMode?: "Word" | "Line";
/**
* 歌曲创作者列表
*/
songwriters?: string[];
/**
* 歌曲标题列表
*/
title?: string[];
/**
* 艺术家名称列表
*/
artist?: string[];
/**
* 专辑名称列表
*/
album?: string[];
/**
* ISRC 号码列表
*/
isrc?: string[];
/**
* 歌词作者 GitHub 数字 ID 列表
*/
authorIds?: string[];
/**
* 歌词作者 GitHub 用户名列表
*/
authorNames?: string[];
/**
* 演唱者映射表
*/
agents?: Record<string, Agent>;
/**
* 平台关联 ID
*/
platformIds?: Partial<Record<PlatformId, string[]>>;
/**
* 其他原始的自定义属性
*/
rawProperties?: Record<string, string[]>;
}
/**
* 解析器返回的结果对象
*/
export interface TTMLResult {
/**
* TTML 歌词的元数据内容
*/
metadata: TTMLMetadata;
/**
* 所有的歌词行
*/
lines: LyricLine[];
}

View File

@@ -0,0 +1,440 @@
/**
* 包含解析器内部的复杂数据结构和 AMLL 简单的数据结构的互转功能的模块
* @module amll-converter
*/
import { Elements, Values } from "../constants";
import type {
AmllLyricLine,
AmllLyricResult,
AmllLyricWord,
AmllMetadata,
AmllToTTMLOptions,
LyricBase,
LyricLine,
Syllable,
TTMLMetadata,
TTMLResult,
TTMLToAmllOptions,
} from "../types";
/**
* 将本解析器复杂的数据结构降级为 AMLL 所使用的较简单的数据结构
*/
export function toAmllLyrics(
result: TTMLResult,
options?: TTMLToAmllOptions,
): AmllLyricResult {
const amllLines: AmllLyricLine[] = [];
const convertToAmllLine = (
source: LyricBase,
isBG: boolean,
isDuet: boolean,
): AmllLyricLine => {
let amllWords: AmllLyricWord[] = [];
if (source.words && source.words.length > 0) {
amllWords = source.words.map((w) => {
const amllWord: AmllLyricWord = {
startTime: w.startTime,
endTime: w.endTime,
word: w.text + (w.endsWithSpace ? " " : ""),
romanWord: "",
obscene: w.obscene,
emptyBeat: w.emptyBeat,
};
if (w.ruby && w.ruby.length > 0) {
amllWord.ruby = w.ruby.map((r) => ({
startTime: r.startTime,
endTime: r.endTime,
word: r.text,
}));
}
return amllWord;
});
} else {
amllWords = [
{
startTime: source.startTime,
endTime: source.endTime,
word: source.text,
romanWord: "",
},
];
}
let transText = "";
if (source.translations && source.translations.length > 0) {
const targetTrans =
(options?.translationLanguage &&
source.translations.find(
(t) => t.language === options.translationLanguage,
)) ||
source.translations[0];
transText = targetTrans.text;
}
let romanText = "";
let romanWords: Syllable[] | undefined;
if (source.romanizations && source.romanizations.length > 0) {
const targetRoman =
(options?.romanizationLanguage &&
source.romanizations.find(
(r) => r.language === options.romanizationLanguage,
)) ||
source.romanizations[0];
romanWords = targetRoman.words;
if (!romanWords || romanWords.length === 0) {
romanText = targetRoman.text;
}
}
if (romanWords && amllWords.length > 0) {
alignRomanization(amllWords, romanWords);
}
return {
words: amllWords,
translatedLyric: transText,
romanLyric: romanText,
isBG: isBG,
isDuet: isDuet,
startTime: source.startTime,
endTime: source.endTime,
};
};
let lastPersonAgentId: string | null = null;
let lastPersonIsDuet: boolean = false;
for (const line of result.lines) {
const agentId = line.agentId || Values.AgentDefault;
const agent = result.metadata.agents?.[agentId];
const isGroup = agent?.type === Values.Group;
const isOther = agent?.type === Values.Other;
let currentIsDuet = false;
// Apple Music 风格的对唱识别逻辑
if (isGroup) {
// 合唱始终非对唱,且不影响其他 agent type 的交替计算
currentIsDuet = false;
} else {
if (lastPersonAgentId === null) {
// 如果第一次遇到的演唱者类型是 Other强制为对唱否则非对唱
currentIsDuet = !!isOther;
lastPersonAgentId = agentId;
lastPersonIsDuet = currentIsDuet;
} else if (lastPersonAgentId === agentId) {
// 与上一个非 Group 演唱者相同,保持对唱状态
currentIsDuet = lastPersonIsDuet;
} else {
// 与上一个非 Group 演唱者不同,翻转对唱侧
currentIsDuet = !lastPersonIsDuet;
lastPersonAgentId = agentId;
lastPersonIsDuet = currentIsDuet;
}
}
const amllMain = convertToAmllLine(line, false, currentIsDuet);
amllLines.push(amllMain);
if (line.backgroundVocal) {
const simpleBg = convertToAmllLine(
line.backgroundVocal,
true,
currentIsDuet,
);
amllLines.push(simpleBg);
}
}
const amllMetadata: [string, string[]][] = [];
const meta = result.metadata;
if (meta.title) amllMetadata.push([Values.MusicName, meta.title]);
if (meta.artist) amllMetadata.push([Values.Artists, meta.artist]);
if (meta.album) amllMetadata.push([Values.Album, meta.album]);
if (meta.isrc) amllMetadata.push([Values.ISRC, meta.isrc]);
if (meta.authorIds)
amllMetadata.push([Values.TTMLAuthorGithub, meta.authorIds]);
if (meta.authorNames)
amllMetadata.push([Values.TTMLAuthorGithubLogin, meta.authorNames]);
if (meta.language) amllMetadata.push([Values.Language, [meta.language]]);
if (meta.timingMode)
amllMetadata.push([Values.TimingMode, [meta.timingMode]]);
if (meta.songwriters)
amllMetadata.push([Elements.Songwriters, meta.songwriters]);
if (meta.platformIds) {
if (meta.platformIds.ncmMusicId)
amllMetadata.push([Values.NCMMusicId, meta.platformIds.ncmMusicId]);
if (meta.platformIds.qqMusicId)
amllMetadata.push([Values.QQMusicId, meta.platformIds.qqMusicId]);
if (meta.platformIds.spotifyId)
amllMetadata.push([Values.SpotifyId, meta.platformIds.spotifyId]);
if (meta.platformIds.appleMusicId)
amllMetadata.push([Values.AppleMusicId, meta.platformIds.appleMusicId]);
}
if (meta.rawProperties) {
for (const [key, value] of Object.entries(meta.rawProperties)) {
amllMetadata.push([key, value]);
}
}
return {
lines: amllLines,
metadata: amllMetadata,
};
}
function alignRomanization(amllWords: AmllLyricWord[], romanWords: Syllable[]) {
let romanSearchStartIndex = 0;
/** 交并比阈值,至少有 10% 的面积重合 */
const MIN_IOU_THRESHOLD = 0.1;
/** 快速通道,优先匹配时间戳完全相同的主歌词和音译音节,同时避免浮点数误差 */
const FAST_TRACK_TOLERANCE_MS = 2;
for (let i = 0; i < amllWords.length; i++) {
const main = amllWords[i];
const mainEndTime = main.endTime;
let maxIou = 0;
let bestMatchIndex = -1;
let isFastTrackMatched = false;
let j = romanSearchStartIndex;
while (j < romanWords.length) {
const sub = romanWords[j];
if (Math.abs(main.startTime - sub.startTime) <= FAST_TRACK_TOLERANCE_MS) {
main.romanWord = sub.text;
romanSearchStartIndex = j + 1;
isFastTrackMatched = true;
break;
}
const subEndTime = sub.endTime;
// 交集
const overlapStart = Math.max(main.startTime, sub.startTime);
const overlapEnd = Math.min(mainEndTime, subEndTime);
const intersection = Math.max(0, overlapEnd - overlapStart);
if (intersection > 0) {
// 并集
const unionStart = Math.min(main.startTime, sub.startTime);
const unionEnd = Math.max(mainEndTime, subEndTime);
const unionDuration = Math.max(1, unionEnd - unionStart);
const iou = intersection / unionDuration;
if (iou > maxIou) {
maxIou = iou;
bestMatchIndex = j;
}
}
if (sub.startTime >= mainEndTime) {
break;
}
j++;
}
if (
!isFastTrackMatched &&
bestMatchIndex !== -1 &&
maxIou >= MIN_IOU_THRESHOLD
) {
main.romanWord = romanWords[bestMatchIndex].text;
romanSearchStartIndex = bestMatchIndex + 1;
}
}
}
/**
* 将 AMLL 格式的歌词和元数据转换为 TTMLResult 结构
*/
export function toTTMLResult(
amllLines: AmllLyricLine[],
amllMetadata: AmllMetadata[],
options: AmllToTTMLOptions = {},
): TTMLResult {
const opts = {
translationLanguage: "zh-Hans",
...options,
};
const metadata: TTMLMetadata = {
agents: {
[Values.AgentDefault]: { id: Values.AgentDefault },
[Values.AgentDefaultDuet]: { id: Values.AgentDefaultDuet },
},
};
for (const entry of amllMetadata) {
const [key, value] = entry;
if (!value || value.length === 0) continue;
switch (key) {
case Values.MusicName:
metadata.title = value;
break;
case Values.Artists:
metadata.artist = value;
break;
case Values.Album:
metadata.album = value;
break;
case Values.ISRC:
metadata.isrc = value;
break;
case Values.TTMLAuthorGithub:
metadata.authorIds = value;
break;
case Values.TTMLAuthorGithubLogin:
metadata.authorNames = value;
break;
case Values.NCMMusicId:
case Values.QQMusicId:
case Values.SpotifyId:
case Values.AppleMusicId:
if (!metadata.platformIds) {
metadata.platformIds = {};
}
metadata.platformIds[key] = value;
break;
default:
if (!metadata.rawProperties) {
metadata.rawProperties = {};
}
metadata.rawProperties[key] = value;
break;
}
}
const resultLines: LyricLine[] = [];
let currentMainLine: LyricLine | null = null;
for (const amllLine of amllLines) {
const { mainSyllables, romanSyllables, fullText, romanText } =
convertWords(amllLine);
const lyricBase: LyricBase = {
startTime: amllLine.startTime,
endTime: amllLine.endTime,
text: fullText,
words: mainSyllables,
};
if (amllLine.translatedLyric) {
lyricBase.translations = [
{
language: opts.translationLanguage,
text: amllLine.translatedLyric,
},
];
}
if (amllLine.romanLyric || romanSyllables.length > 0) {
lyricBase.romanizations = [
{
language: opts.romanizationLanguage,
text: amllLine.romanLyric || romanText,
words: romanSyllables.length > 0 ? romanSyllables : undefined,
},
];
}
if (amllLine.isBG) {
if (currentMainLine && !currentMainLine.backgroundVocal) {
currentMainLine.backgroundVocal = lyricBase;
} else {
const inheritedAgentId = currentMainLine
? currentMainLine.agentId
: Values.AgentDefault;
const promotedLine: LyricLine = {
agentId: inheritedAgentId,
...lyricBase,
};
resultLines.push(promotedLine);
}
} else {
const agentId = amllLine.isDuet
? Values.AgentDefaultDuet
: Values.AgentDefault;
const lyricLine: LyricLine = {
agentId,
...lyricBase,
};
resultLines.push(lyricLine);
currentMainLine = lyricLine;
}
}
return {
metadata: metadata,
lines: resultLines,
};
}
function convertWords(amllLine: AmllLyricLine) {
const mainSyllables: Syllable[] = [];
const romanSyllables: Syllable[] = [];
for (const word of amllLine.words) {
const rawText = word.word;
const trimmedText = rawText.trimEnd();
const hasSpace = rawText !== trimmedText;
const syllable: Syllable = {
text: trimmedText,
startTime: word.startTime,
endTime: word.endTime,
endsWithSpace: hasSpace,
obscene: word.obscene,
emptyBeat: word.emptyBeat,
};
if (word.ruby && word.ruby.length > 0) {
syllable.ruby = word.ruby.map((r) => ({
startTime: r.startTime,
endTime: r.endTime,
text: r.word,
}));
}
mainSyllables.push(syllable);
if (word.romanWord) {
romanSyllables.push({
text: word.romanWord.trim(), // AMLL 那边的实现已经总是 trim 各个逐字音译音节了
startTime: word.startTime,
endTime: word.endTime,
});
}
}
const fullText = amllLine.words.map((w) => w.word).join("");
const romanText =
romanSyllables.length > 0
? romanSyllables
.map((s) => s.text + (s.endsWithSpace ? " " : ""))
.join("")
: "";
return { mainSyllables, romanSyllables, fullText, romanText };
}

View File

@@ -0,0 +1,5 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`TTML Generator - toTTMLResult > generates TTMLResult from AMLL data and serialize it to XML 1`] = `"<tt xmlns:amll="http://www.example.com/ns/amll" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" xmlns:tts="http://www.w3.org/ns/ttml#styling" itunes:timing="Word" xmlns="http://www.w3.org/ns/ttml"><head><metadata><ttm:agent type="person" xml:id="v1"/><ttm:agent type="person" xml:id="v2"/><iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"><transliterations><transliteration xml:lang="zh-Latn"><text for="L1"><span begin="1.000" end="2.000">ni</span><span begin="2.000" end="3.000">hao</span><span ttm:role="x-bg"><span begin="3.000" end="4.000">(shi</span><span begin="4.000" end="5.000">jie)</span></span></text></transliteration></transliterations></iTunesMetadata><amll:meta key="musicName" value="Test Song"/><amll:meta key="artists" value="Artist A"/><amll:meta key="artists" value="Artist B"/></metadata></head><body dur="3.000"><div begin="1.000" end="3.000"><p begin="1.000" end="3.000" itunes:key="L1" ttm:agent="v1"><span begin="1.000" end="2.000">你</span><span begin="2.000" end="3.000">好</span><span ttm:role="x-translation" xml:lang="en">你好</span><span ttm:role="x-bg" begin="3.000" end="5.000"><span begin="3.000" end="4.000">(世</span><span begin="4.000" end="5.000">界)</span><span ttm:role="x-translation" xml:lang="en">世界</span></span></p></div></body></tt>"`;
exports[`TTML Generator Integration > matches the XML snapshot 1`] = `"<tt xmlns:amll="http://www.example.com/ns/amll" xmlns:itunes="http://music.apple.com/lyric-ttml-internal" xmlns:ttm="http://www.w3.org/ns/ttml#metadata" xmlns:tts="http://www.w3.org/ns/ttml#styling" xml:lang="ja" itunes:timing="Word" xmlns="http://www.w3.org/ns/ttml"><head><metadata><ttm:agent type="person" xml:id="v1"><ttm:name type="full">Vocalist A (Taro)</ttm:name></ttm:agent><ttm:agent type="person" xml:id="v2"><ttm:name type="full">Vocalist B (Hanako)</ttm:name></ttm:agent><ttm:agent type="group" xml:id="v1000"><ttm:name type="full">Chorus Group</ttm:name></ttm:agent><iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal"><transliterations><transliteration xml:lang="ja-Latn"><text for="L1"><span begin="10.000" end="10.500">Ko</span><span begin="10.500" end="10.800">re</span> <span begin="10.800" end="11.000">wa</span> <span begin="11.200" end="11.800">tesuto</span></text><text for="L2"><span begin="15.000" end="15.800">Futatsume</span> <span begin="16.000" end="16.500">no</span> <span begin="16.500" end="17.000">rain</span></text><text for="L3"><span begin="20.000" end="21.500">Kōrasu</span> <span begin="21.500" end="22.000">desu</span><span ttm:role="x-bg"><span begin="22.500" end="23.800">(haikei)</span></span></text></transliteration></transliterations><songwriters><songwriter>作曲者1号</songwriter><songwriter>作曲者2号</songwriter></songwriters></iTunesMetadata><amll:meta key="musicName" value="Complex Test Song"/><amll:meta key="musicName" value="複雑なテストソング"/><amll:meta key="artists" value="Vocalist A (Taro)"/><amll:meta key="artists" value="Vocalist B (Hanako)"/><amll:meta key="album" value="AMLL Parser Test Suite"/><amll:meta key="ncmMusicId" value="123456789"/><amll:meta key="qqMusicId" value="987654321"/><amll:meta key="spotifyId" value="abc123xyz"/><amll:meta key="appleMusicId" value="999888777"/><amll:meta key="isrc" value="JPXX02500001"/><amll:meta key="ttmlAuthorGithub" value="10001"/><amll:meta key="ttmlAuthorGithubLogin" value="TestUser"/></metadata></head><body dur="25.000"><div begin="10.000" end="17.000" itunes:songPart="Verse"><p begin="10.000" end="12.000" itunes:key="L1" ttm:agent="v1"><span begin="10.000" end="10.500" amll:obscene="true">これ</span><span begin="10.500" end="10.800">は</span> <span begin="11.200" end="11.800" amll:empty-beat="5">テスト</span><span ttm:role="x-translation" xml:lang="en-US">This is the first line (Vocalist A)</span><span ttm:role="x-translation" xml:lang="zh-Hans-CN">这是第一行歌词 (演唱者A)</span></p><p begin="15.000" end="17.000" itunes:key="L2" ttm:agent="v2"><span begin="15.000" end="15.800">二つ目</span> <span begin="16.000" end="16.500">の</span> <span begin="16.500" end="17.000">ライン</span><span ttm:role="x-translation" xml:lang="en-US">This is the second line (Vocalist B)</span><span ttm:role="x-translation" xml:lang="zh-Hans-CN">这是第二行歌词 (演唱者B)</span></p></div><div begin="20.000" end="25.000" itunes:songPart="Chorus"><p begin="20.000" end="25.000" itunes:key="L3" ttm:agent="v1000"><span begin="20.000" end="21.500">コーラス</span> <span begin="21.500" end="22.000">です</span><span ttm:role="x-translation" xml:lang="en-US">This is the chorus line</span><span ttm:role="x-translation" xml:lang="zh-Hans-CN">这是合唱部分</span><span ttm:role="x-bg" begin="22.500" end="23.800"><span begin="22.500" end="23.800">(背景)</span><span ttm:role="x-translation" xml:lang="en">Background</span><span ttm:role="x-translation" xml:lang="en-US">With background</span><span ttm:role="x-translation" xml:lang="zh-Hans-CN">带背景音</span><span ttm:role="x-roman" xml:lang="ja-Latn">haikei</span></span></p></div></body></tt>"`;

View File

@@ -0,0 +1,161 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`toAmllLyrics Conversion > computes Apple Music TTML with v2000 other agent correctly in duet alignment 1`] = `
[
"[ 0.36s] 右 [main] : Baby, you've gone far away",
"[ 5.83s] 右 [main] : You've gone far away",
"[ 10.35s] 右 [main] : Baby, you've gone far away",
"[ 15.81s] 右 [main] : You've gone far away",
"[ 20.40s] 左 [main] : 於是茶就這樣冷了",
"[ 25.36s] 左 [main] : 於是天就這樣亮了",
"[ 30.36s] 左 [main] : 於是你就這樣離開了",
"[ 35.38s] 左 [main] : 於是我終於醒了",
"[ 40.35s] 左 [main] : 於是你我從此遠了",
"[ 45.36s] 左 [main] : 於是路從此分岔了",
"[ 50.38s] 左 [main] : 你的臉從此就陌生了",
"[ 54.11s] 左 [main] : Whoa, whoa",
"[ 55.18s] 左 [main] : 雖然我有一點不捨",
"[ 60.37s] 左 [main] : 可是時間不可能停下",
"[ 63.21s] 左 [main] : 不能停下 不會停下",
"[ 65.68s] 左 [main] : 世界一直在變幻著",
"[ 68.20s] 左 [main] : 你也變了 我也變了",
"[ 70.70s] 左 [main] : 過去都已經過去了",
"[ 73.80s] 左 [main] : 既然回不去了 我還在煩惱什麼",
"[ 79.14s] 左 [main] : Oh, whoa",
"[ 80.37s] 左 [main] : 於是告訴自己 不要哭",
"[ 83.20s] 左 [main] : 我不要哭 我不能哭",
"[ 85.69s] 左 [main] : 往前方的路 走一步",
"[ 88.17s] 左 [main] : 再走一步 就會幸福",
"[ 90.69s] 左 [main] : 成長要學會獨處",
"[ 93.81s] 左 [main] : Oh, whoa",
"[ 95.28s] 左 [main] : 雖然有一點孤獨",
"[100.40s] 右 [main] : Baby, you've gone far away",
"[102.52s] 左 [main] : Therefore, I think I should follow, boy",
"[105.72s] 右 [main] : When you've gone far away",
"[107.50s] 左 [main] : Therefore, maybe I'm about to go, yeah, because",
"[110.37s] 右 [main] : Baby, you've gone far away",
"[112.57s] 左 [main] : I don't know, boy, I don't really know why, why",
"[115.68s] 右 [main] : That you've gone far away",
"[117.49s] 左 [main] : Therefore, therefore, I'm now so alone",
"[120.38s] 左 [main] : 於是花一個人種了",
"[125.38s] 左 [main] : 於是夢一個人做了",
"[130.36s] 左 [main] : 於是痛一個人扛下了",
"[133.82s] 左 [main] : Oh, whoa",
"[135.37s] 左 [main] : 快樂一個人笑著",
"[140.35s] 左 [main] : 可是時間不可能停下",
"[143.22s] 左 [main] : 不能停下 不會停下",
"[145.67s] 左 [main] : 世界一直在變幻著",
"[148.21s] 左 [main] : 你也變了 我也變了",
"[150.69s] 左 [main] : 過去都已經過去了",
"[153.76s] 左 [main] : 既然回不去了 我還在煩惱什麼",
"[159.14s] 左 [main] : Oh, whoa",
"[160.39s] 左 [main] : 於是告訴自己 不要哭",
"[163.21s] 左 [main] : 我不要哭 我不能哭",
"[165.70s] 左 [main] : 往前方的路 走一步",
"[168.18s] 左 [main] : 再走一步 就會幸福",
"[170.69s] 左 [main] : 成長要學會獨處",
"[173.82s] 左 [main] : Oh, whoa",
"[175.26s] 左 [main] : 雖然有一點孤獨",
"[180.38s] 右 [main] : Baby, you've gone far away",
"[183.17s] 左 [main] : Ooh, ooh-ooh",
"[185.38s] 右 [main] : Oh, when you've gone far away",
"[187.57s] 左 [main] : Baby, you've gone",
"[189.43s] 左 [main] : Oh, you've gone far away",
"[190.40s] 右 [main] : Baby, you've gone far away",
"[192.57s] 左 [main] : Oh-oh, oh-whoa",
"[195.38s] 右 [main] : Oh, when you've gone far away",
"[196.96s] 左 [main] : Baby, you've gone far away",
"[199.64s] 左 [main] : You've gone far away",
"[200.41s] 右 [main] : Baby, you've gone far away",
"[204.15s] 左 [main] : Oh-ooh",
"[205.38s] 右 [main] : Oh, that you've gone far away",
"[206.32s] 左 [main] : Oh-oh-oh-ooh, oh-oh-oh-ooh",
"[210.39s] 左 [main] : Baby, you've gone far away",
"[213.84s] 左 [main] : Oh, oh",
"[215.58s] 左 [main] : When you've gone far away",
]
`;
exports[`toAmllLyrics Conversion > computes left-right duet layout in Apple Music style with multiple singers correctly in duet alignment 1`] = `
[
"[ 1.15s] 左 [main] : We don't talk about Bruno, no, no, no",
"[ 5.84s] 左 [main] : We don't talk about Bruno, but",
"[ 10.52s] 左 [main] : It was my wedding day",
"[ 11.72s] 左 [bg] : It was our wedding day",
"[ 12.79s] 左 [main] : We were getting ready and there wasn't a cloud in the sky",
"[ 16.94s] 左 [bg] : No clouds allowed in the sky",
"[ 19.26s] 左 [main] : Bruno walks in with a mischievous grin",
"[ 23.12s] 左 [bg] : Thunder",
"[ 24.26s] 左 [main] : You telling this story or am I?",
"[ 26.61s] 左 [bg] : I'm sorry, mi vida, go on",
"[ 28.49s] 左 [main] : Bruno says, "It looks like rain"",
"[ 31.39s] 左 [bg] : Why did he tell us?",
"[ 32.98s] 左 [main] : In doing so, he floods my brain",
"[ 35.71s] 左 [bg] : Abuela, get the umbrellas",
"[ 37.89s] 左 [main] : Married in a hurricane",
"[ 40.66s] 左 [bg] : What a joyous day, but anyway",
"[ 43.13s] 左 [main] : We don't talk about Bruno, no, no, no",
"[ 47.80s] 左 [main] : We don't talk about Bruno",
"[ 51.33s] 右 [main] : Hey, grew to live in fear of Bruno stuttering or stumbling",
"[ 54.20s] 右 [main] : I can always hear him sort of muttering and mumbling",
"[ 56.53s] 右 [main] : I associate him with the sound of falling sand",
"[ 59.42s] 右 [bg] : Ch-ch-ch",
"[ 61.23s] 右 [main] : It's a heavy lift with a gift so humbling",
"[ 63.53s] 右 [main] : Always left Abuela and the family fumbling",
"[ 65.85s] 右 [main] : Grappling with prophecies they couldn't understand",
"[ 69.04s] 右 [main] : Do you understand?",
"[ 70.41s] 左 [main] : Seven-foot frame, rats along his back",
"[ 74.60s] 左 [main] : When he calls your name, it all fades to black",
"[ 79.25s] 左 [main] : Yeah, he sees your dreams and feasts on your screams",
"[ 83.34s] 左 [bg] : Hey",
"[ 85.06s] 左 [main] : We don't talk about Bruno, no, no, no",
"[ 89.71s] 左 [main] : We don't talk about Bruno",
"[ 94.08s] 右 [main] : He told me my fish would die, the next day, dead",
"[ 97.64s] 右 [bg] : No, no",
"[ 98.79s] 左 [main] : He told me I'd grow a gut and just like he said",
"[102.27s] 左 [bg] : No, no",
"[102.38s] 右 [main] : He said that all my hair would disappear, now look at my head",
"[106.99s] 右 [bg] : No, no",
"[108.15s] 左 [main] : Your fate is sealed when your prophecy is read",
"[112.50s] 左 [main] : He told me that the life of my dreams",
"[116.33s] 左 [main] : Would be promised, and someday be mine",
"[121.78s] 左 [main] : He told me that my power would grow",
"[125.54s] 左 [main] : Like the grapes that thrive on the vine",
"[129.04s] 左 [bg] : Óye, Mariano's on his way",
"[131.05s] 右 [main] : He told me that the man of my dreams",
"[134.85s] 右 [main] : Would be just out of reach",
"[137.81s] 右 [main] : Betrothed to another",
"[140.72s] 右 [main] : It's like I hear him now",
"[142.15s] 左 [main] : Hey sis, I want not a sound out of you",
"[145.38s] 右 [main] : It's like I can hear him now",
"[148.30s] 右 [main] : I can hear him now",
"[149.75s] 左 [main] : Um, Bruno",
"[152.32s] 左 [main] : Yeah, about that Bruno",
"[154.23s] 左 [main] : I really need to know about Bruno",
"[156.36s] 左 [main] : Gimme the truth and the whole truth, Bruno",
"[158.42s] 左 [bg] : Isabela, your boyfriend's here",
"[161.75s] 左 [main] : Time for dinner",
"[163.73s] 右 [main] : Seven-foot frame, rats along his back",
"[164.33s] 右 [bg] : It was my wedding day, it was our wedding day",
"[166.63s] 右 [main] : When he calls your name, it all fades to black",
"[166.63s] 右 [bg] : We were getting ready and there wasn't a cloud in the sky",
"[170.70s] 右 [main] : No clouds allowed in the sky",
"[172.48s] 右 [main] : Yeah, he sees your dreams and feasts on your screams",
"[173.07s] 右 [bg] : Bruno walks in with a mischievous grin",
"[176.81s] 右 [main] : You telling this story or am I?",
"[176.81s] 右 [bg] : Thunder",
"[180.35s] 左 [main] : Óye, Mariano's on his way",
"[182.37s] 右 [main] : Bruno says, "It looks like rain"",
"[182.46s] 右 [bg] : He told me that the man of my dreams would be just out of reach",
"[185.21s] 左 [main] : Why did he tell us?",
"[186.74s] 右 [main] : In doing so, he floods my brain",
"[188.57s] 右 [bg] : Betrothed to another, another",
"[191.10s] 右 [main] : Married in a hurricane",
"[191.10s] 右 [bg] : And I'm fine, and I'm fine, and I'm fine, I'm fine",
"[196.05s] 左 [main] : He's here",
"[196.94s] 左 [main] : Don't talk about Bruno",
"[198.97s] 左 [main] : Why did I talk about Bruno?",
"[201.60s] 左 [main] : Not a word about Bruno",
"[203.22s] 左 [main] : I never shoulda brought up Bruno",
]
`;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<tt xmlns="http://www.w3.org/ns/ttml"
xmlns:ttm="http://www.w3.org/ns/ttml#metadata"
xmlns:itunes="http://itunes.apple.com/lyric-ttml-extensions"
xmlns:amll="http://www.example.com/ns/amll"
xml:lang="ja"
itunes:timing="Word">
<head>
<metadata>
<ttm:title>Complex Test Song</ttm:title>
<ttm:agent type="person" xml:id="v1">
<ttm:name type="full">Vocalist A (Taro)</ttm:name>
</ttm:agent>
<ttm:agent type="person" xml:id="v2">
<ttm:name type="full">Vocalist B (Hanako)</ttm:name>
</ttm:agent>
<ttm:agent type="group" xml:id="v1000">
<ttm:name type="full">Chorus Group</ttm:name>
</ttm:agent>
<amll:meta key="musicName" value="複雑なテストソング" />
<amll:meta key="artists" value="Vocalist A (Taro)" />
<amll:meta key="artists" value="Vocalist B (Hanako)" />
<amll:meta key="album" value="AMLL Parser Test Suite" />
<amll:meta key="isrc" value="JPXX02500001" />
<amll:meta key="ncmMusicId" value="123456789" />
<amll:meta key="qqMusicId" value="987654321" />
<amll:meta key="spotifyId" value="abc123xyz" />
<amll:meta key="appleMusicId" value="999888777" />
<amll:meta key="ttmlAuthorGithub" value="10001" />
<amll:meta key="ttmlAuthorGithubLogin" value="TestUser" />
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
<songwriters>
<songwriter>作曲者1号</songwriter>
<songwriter>作曲者2号</songwriter>
</songwriters>
<translations>
<translation type="subtitle" xml:lang="en-US">
<text for="L1">This is the first line (Vocalist A)</text>
<text for="L2">This is the second line (Vocalist B)</text>
<text for="L3"> This is the chorus line <span ttm:role="x-bg">(With
background)</span>
</text>
</translation>
<translation type="subtitle" xml:lang="zh-Hans-CN">
<text for="L1">这是第一行歌词 (演唱者A)</text>
<text for="L2">这是第二行歌词 (演唱者B)</text>
<text for="L3"> 这是合唱部分 <span ttm:role="x-bg">(带背景音)</span>
</text>
</translation>
</translations>
<transliterations>
<transliteration xml:lang="ja-Latn">
<text for="L1">
<span begin="00:10.000" end="00:10.500">Ko</span>
<span begin="00:10.500" end="00:10.800">re </span>
<span begin="00:10.800" end="00:11.000">wa </span>
<span begin="00:11.200" end="00:11.800">tesuto</span>
</text>
<text for="L2">
<span begin="00:15.000" end="00:15.800">Futatsume </span>
<span begin="00:16.000" end="00:16.500">no </span>
<span begin="00:16.500" end="00:17.000">rain</span>
</text>
<text for="L3">
<span begin="00:20.000" end="00:21.500">Kōrasu </span>
<span begin="00:21.500" end="00:22.000">desu</span>
<span ttm:role="x-bg">
<span begin="00:22.500" end="00:23.800">(haikei)</span>
</span>
</text>
</transliteration>
</transliterations>
</iTunesMetadata>
</metadata>
</head>
<body dur="00:30.000">
<div begin="00:08.000" end="00:18.000" itunes:song-part="Verse">
<p begin="00:10.000" end="00:12.000" itunes:key="L1" ttm:agent="v1">
<span begin="00:10.000" end="00:10.500" amll:obscene="true">これ</span>
<span begin="00:10.500" end="00:10.800">は </span>
<span begin="00:11.200" end="00:11.800" amll:empty-beat="5">テスト</span>
</p>
<p begin="00:15.000" end="00:17.000" itunes:key="L2" ttm:agent="v2">
<span begin="00:15.000" end="00:15.800">二つ目 </span>
<span begin="00:16.000" end="00:16.500">の </span>
<span begin="00:16.500" end="00:17.000">ライン</span>
</p>
</div>
<div begin="00:19.000" end="00:30.000" itunes:song-part="Chorus">
<p begin="00:20.000" end="00:25.000" itunes:key="L3" ttm:agent="v1000">
<span begin="00:20.000" end="00:21.500">コーラス </span>
<span begin="00:21.500" end="00:22.000">です</span>
<span ttm:role="x-bg" begin="00:22.500" end="00:23.800" ttm:agent="v1">
<span begin="00:22.500" end="00:23.800">(背景)</span>
<span ttm:role="x-translation" xml:lang="en">Background</span>
<span ttm:role="x-roman" xml:lang="ja-Latn">haikei</span>
</span>
</p>
</div>
</body>
</tt>

View File

@@ -0,0 +1,32 @@
<tt xmlns="http://www.w3.org/ns/ttml"
xmlns:ttm="http://www.w3.org/ns/ttml#metadata"
xmlns:tts="http://www.w3.org/ns/ttml#styling"
xmlns:itunes="http://music.apple.com/lyric-ttml-internal"
itunes:timing="Word"
xml:lang="ja">
<head>
<metadata>
<ttm:agent type="person" xml:id="v1" />
</metadata>
</head>
<body dur="28.000">
<div begin="27.000" end="28.000">
<p begin="27.000" end="28.000" itunes:key="L1" ttm:agent="v1">
<span begin="27.000" end="27.500">これは</span>
<span tts:ruby="container">
<span tts:ruby="base">所</span>
<span tts:ruby="textContainer">
<span tts:ruby="text" begin="27.690" end="27.820">しょ</span>
</span>
</span>
<span tts:ruby="container">
<span tts:ruby="base">詮</span>
<span tts:ruby="textContainer">
<span tts:ruby="text" begin="27.820" end="27.880">せ</span>
<span tts:ruby="text" begin="27.880" end="27.950">ん</span>
</span>
</span>
</p>
</div>
</body>
</tt>

View File

@@ -0,0 +1,748 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DOMImplementation, DOMParser, XMLSerializer } from "@xmldom/xmldom";
import { beforeAll, describe, expect, it } from "vitest";
import type { AmllLyricLine, AmllMetadata, TTMLResult } from "../src/index";
import { TTMLGenerator, TTMLParser, toTTMLResult } from "../src/index";
const XML = readFileSync(
join(import.meta.dirname, "fixtures", "complex-test-song.ttml"),
"utf-8",
);
describe("TTML Generator Integration", () => {
let parser: TTMLParser;
let generator: TTMLGenerator;
let originalResult: TTMLResult;
let generatedXML: string;
let parsedGeneratedResult: TTMLResult;
beforeAll(() => {
parser = new TTMLParser({ domParser: new DOMParser() });
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
originalResult = parser.parse(XML);
generatedXML = generator.generate(originalResult);
parsedGeneratedResult = parser.parse(generatedXML);
});
it("generates an XML string", () => {
expect(generatedXML).toBeDefined();
expect(typeof generatedXML).toBe("string");
expect(generatedXML.length).toBeGreaterThan(0);
expect(generatedXML).toContain("<tt");
expect(generatedXML).toContain("</tt>");
});
it("matches the XML snapshot", () => {
expect(generatedXML).toMatchSnapshot();
});
it("preserves metadata after round-trip generation", () => {
expect(parsedGeneratedResult.metadata.language).toBe(
originalResult.metadata.language,
);
expect(parsedGeneratedResult.metadata.timingMode).toBe(
originalResult.metadata.timingMode,
);
expect(parsedGeneratedResult.metadata.title).toEqual(
originalResult.metadata.title,
);
expect(parsedGeneratedResult.metadata.artist).toEqual(
originalResult.metadata.artist,
);
expect(parsedGeneratedResult.metadata.album).toEqual(
originalResult.metadata.album,
);
expect(parsedGeneratedResult.metadata.isrc).toEqual(
originalResult.metadata.isrc,
);
expect(parsedGeneratedResult.metadata.platformIds).toEqual(
originalResult.metadata.platformIds,
);
expect(parsedGeneratedResult.metadata.authorIds).toEqual(
originalResult.metadata.authorIds,
);
expect(parsedGeneratedResult.metadata.authorNames).toEqual(
originalResult.metadata.authorNames,
);
expect(parsedGeneratedResult.metadata.songwriters).toEqual(
originalResult.metadata.songwriters,
);
expect(parsedGeneratedResult.metadata.agents).toEqual(
originalResult.metadata.agents,
);
});
it("preserves line count after round-trip generation", () => {
expect(parsedGeneratedResult.lines.length).toBe(
originalResult.lines.length,
);
});
it("preserves line content after round-trip generation", () => {
for (let i = 0; i < originalResult.lines.length; i++) {
const originalLine = originalResult.lines[i];
const generatedLine = parsedGeneratedResult.lines[i];
expect(generatedLine.id).toBe(originalLine.id);
expect(generatedLine.startTime).toBe(originalLine.startTime);
expect(generatedLine.endTime).toBe(originalLine.endTime);
expect(generatedLine.agentId).toBe(originalLine.agentId);
expect(generatedLine.songPart).toBe(originalLine.songPart);
expect(generatedLine.text).toBe(originalLine.text);
expect(generatedLine.words?.length).toBe(originalLine.words?.length);
if (originalLine.words && generatedLine.words) {
for (let j = 0; j < originalLine.words.length; j++) {
expect(generatedLine.words[j].text).toBe(originalLine.words[j].text);
expect(generatedLine.words[j].startTime).toBe(
originalLine.words[j].startTime,
);
expect(generatedLine.words[j].endTime).toBe(
originalLine.words[j].endTime,
);
}
}
expect(generatedLine.translations?.length).toBe(
originalLine.translations?.length,
);
if (originalLine.translations && generatedLine.translations) {
for (let j = 0; j < originalLine.translations.length; j++) {
expect(generatedLine.translations[j].language).toBe(
originalLine.translations[j].language,
);
expect(generatedLine.translations[j].text).toBe(
originalLine.translations[j].text,
);
}
}
expect(generatedLine.romanizations?.length).toBe(
originalLine.romanizations?.length,
);
if (originalLine.romanizations && generatedLine.romanizations) {
for (let j = 0; j < originalLine.romanizations.length; j++) {
expect(generatedLine.romanizations[j].language).toBe(
originalLine.romanizations[j].language,
);
expect(generatedLine.romanizations[j].text).toBe(
originalLine.romanizations[j].text,
);
}
}
}
});
it("generates separated sidecar nodes for mismatched languages between main and background", () => {
const mockResult: TTMLResult = {
metadata: { timingMode: "Line" },
lines: [
{
id: "L1",
startTime: 0,
endTime: 1000,
text: "Main",
translations: [{ language: "en", text: "Main English" }],
backgroundVocal: {
startTime: 0,
endTime: 1000,
text: "(Bg)",
translations: [{ language: "es", text: "Bg Spanish" }],
},
},
],
};
const generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
useSidecar: true,
});
const xml = generator.generate(mockResult);
const doc = new DOMParser().parseFromString(xml, "application/xml");
const translations = Array.from(doc.getElementsByTagName("translation"));
const enTrans = translations.find(
(el) => el.getAttribute("xml:lang") === "en",
);
const esTrans = translations.find(
(el) => el.getAttribute("xml:lang") === "es",
);
expect(enTrans).toBeDefined();
expect(esTrans).toBeDefined();
expect(enTrans?.textContent).toContain("Main English");
expect(enTrans?.getElementsByTagName("span").length).toBe(0);
expect(esTrans?.textContent).toContain("Bg Spanish");
expect(esTrans?.textContent).not.toContain("Main English");
const esSpans = esTrans?.getElementsByTagName("span");
expect(esSpans?.length).toBe(1);
expect(esSpans?.[0].getAttribute("ttm:role")).toBe("x-bg");
});
it("ensures inline translations and background vocals do not deeply nest each other", () => {
const mockResult: TTMLResult = {
metadata: { timingMode: "Line" },
lines: [
{
id: "L1",
startTime: 0,
endTime: 1000,
text: "Main Lyric",
translations: [{ language: "en", text: "Main Trans" }],
backgroundVocal: {
startTime: 0,
endTime: 1000,
text: "(Bg Lyric)",
translations: [{ language: "en", text: "Bg Trans" }],
},
},
],
};
const generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
useSidecar: false,
});
const xmlStr = generator.generate(mockResult);
const doc = new DOMParser().parseFromString(xmlStr, "application/xml");
const pNode = doc.getElementsByTagName("p")[0];
const childSpans = Array.from(pNode.childNodes).filter(
(n) => n.nodeType === 1 && n.nodeName.toLowerCase() === "span",
) as unknown as Element[];
const mainTransSpan = childSpans.find(
(span) => span.getAttribute("ttm:role") === "x-translation",
);
const bgSpan = childSpans.find(
(span) => span.getAttribute("ttm:role") === "x-bg",
);
expect(mainTransSpan).toBeDefined();
expect(mainTransSpan?.textContent).toBe("Main Trans");
expect(bgSpan).toBeDefined();
expect(bgSpan?.textContent).toContain("Bg Lyric");
if (!bgSpan) throw new Error();
const nestedTransSpans = Array.from(bgSpan.getElementsByTagName("span"));
expect(nestedTransSpans).toHaveLength(1);
expect(nestedTransSpans[0].getAttribute("ttm:role")).toBe("x-translation");
expect(nestedTransSpans[0].textContent).toBe("Bg Trans");
});
});
describe("TTML Generator - toTTMLResult", () => {
let generator: TTMLGenerator;
let parser: TTMLParser;
beforeAll(() => {
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
parser = new TTMLParser({ domParser: new DOMParser() });
});
it("generates TTMLResult from AMLL data and serialize it to XML", () => {
const amllMetadata: AmllMetadata[] = [
["musicName", ["Test Song"]],
["artists", ["Artist A", "Artist B"]],
];
const amllLines: AmllLyricLine[] = [
{
startTime: 1000,
endTime: 3000,
isBG: false,
isDuet: false,
translatedLyric: "你好",
romanLyric: "ni hao",
words: [
{ startTime: 1000, endTime: 2000, word: "你", romanWord: "ni" },
{ startTime: 2000, endTime: 3000, word: "好", romanWord: "hao" },
],
},
{
startTime: 3000,
endTime: 5000,
isBG: true,
isDuet: false,
translatedLyric: "世界",
romanLyric: "shi jie",
words: [
{ startTime: 3000, endTime: 4000, word: "世", romanWord: "shi" },
{ startTime: 4000, endTime: 5000, word: "界", romanWord: "jie" },
],
},
];
const ttmlResult = toTTMLResult(amllLines, amllMetadata, {
translationLanguage: "en",
romanizationLanguage: "zh-Latn",
});
expect(ttmlResult.metadata.title).toEqual(["Test Song"]);
expect(ttmlResult.metadata.artist).toEqual(["Artist A", "Artist B"]);
expect(ttmlResult.lines.length).toBe(1);
expect(ttmlResult.lines[0].backgroundVocal).toBeDefined();
const xml = generator.generate(ttmlResult);
expect(xml).toContain("<tt");
expect(xml).toContain("Test Song");
expect(xml).toContain("Artist A");
expect(xml).toMatchSnapshot();
const parsed = parser.parse(xml);
expect(parsed.metadata.title).toEqual(["Test Song"]);
expect(parsed.lines.length).toBe(1);
expect(parsed.lines[0].text).toBe("你好");
expect(parsed.lines[0].backgroundVocal?.text).toBe("世界");
});
});
describe("TTML Generator - Line ID Generation", () => {
let generator: TTMLGenerator;
beforeAll(() => {
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
});
const createMockResult = (
lines: Partial<TTMLResult["lines"][0]>[],
): TTMLResult => ({
metadata: { agents: { v1: { id: "v1" } } },
lines: lines as TTMLResult["lines"],
});
it("auto-generates line IDs from L1 when all IDs are missing", () => {
const result = createMockResult([
{ startTime: 0, endTime: 1000, text: "Line 1" },
{ startTime: 1000, endTime: 2000, text: "Line 2" },
]);
const xml = generator.generate(result);
expect(xml).toContain('itunes:key="L1"');
expect(xml).toContain('itunes:key="L2"');
});
it("regenerates all line IDs when only some IDs are provided", () => {
const result = createMockResult([
{ id: "Custom1", startTime: 0, endTime: 1000, text: "Line 1" },
{ startTime: 1000, endTime: 2000, text: "Line 2" },
{ id: "Custom3", startTime: 2000, endTime: 3000, text: "Line 3" },
]);
const xml = generator.generate(result);
expect(xml).not.toContain('"Custom1"');
expect(xml).not.toContain('"Custom3"');
expect(xml).toContain('itunes:key="L1"');
expect(xml).toContain('itunes:key="L2"');
expect(xml).toContain('itunes:key="L3"');
});
it("keeps existing line IDs when all valid IDs are provided", () => {
const result = createMockResult([
{ id: "Custom1", startTime: 0, endTime: 1000, text: "Line 1" },
{ id: "Custom2", startTime: 1000, endTime: 2000, text: "Line 2" },
]);
const xml = generator.generate(result);
expect(xml).toContain('itunes:key="Custom1"');
expect(xml).toContain('itunes:key="Custom2"');
expect(xml).not.toContain('itunes:key="L1"');
});
});
describe("TTML Generator - Agent Inference and Completion", () => {
let generator: TTMLGenerator;
beforeAll(() => {
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
});
it("infers and generate default v1 when meta.agents and line agentId are missing", () => {
const result: TTMLResult = {
metadata: {},
lines: [
{ startTime: 0, endTime: 1000, text: "Line 1" },
{ startTime: 1000, endTime: 2000, text: "Line 2" },
],
};
const xml = generator.generate(result);
expect(xml).toContain('<ttm:agent type="person" xml:id="v1"');
const pTagMatches = xml.match(/ttm:agent="v1"/g);
expect(pTagMatches?.length).toBe(2);
});
it("infers unique agents from line agentIds when meta.agents is missing", () => {
const result: TTMLResult = {
metadata: {},
lines: [
{ agentId: "v1", startTime: 0, endTime: 1000, text: "Line 1" },
{ agentId: "v2", startTime: 1000, endTime: 2000, text: "Line 2" },
{ agentId: "v1", startTime: 2000, endTime: 3000, text: "Line 3" },
],
};
const xml = generator.generate(result);
const v1AgentDeclMatches = xml.match(
/<ttm:agent type="person" xml:id="v1"/g,
);
const v2AgentDeclMatches = xml.match(
/<ttm:agent type="person" xml:id="v2"/g,
);
expect(v1AgentDeclMatches?.length).toBe(1);
expect(v2AgentDeclMatches?.length).toBe(1);
});
it("uses provided meta.agents without auto-inference", () => {
const result: TTMLResult = {
metadata: {
agents: {
v3: { id: "v3", name: "Custom Singer", type: "person" },
},
},
lines: [
{ agentId: "v1", startTime: 0, endTime: 1000, text: "Line 1" },
{ agentId: "v2", startTime: 1000, endTime: 2000, text: "Line 2" },
],
};
const xml = generator.generate(result);
expect(xml).toContain('xml:id="v3"');
expect(xml).toContain("Custom Singer");
expect(xml).not.toContain('<ttm:agent type="person" xml:id="v1"');
expect(xml).not.toContain('<ttm:agent type="person" xml:id="v2"');
expect(xml).toContain(
'<p begin="0.000" end="1.000" itunes:key="L1" ttm:agent="v1">',
);
expect(xml).toContain(
'<p begin="1.000" end="2.000" itunes:key="L2" ttm:agent="v2">',
);
});
it("infers v1000 as a group agent", () => {
const result: TTMLResult = {
metadata: {},
lines: [
{
agentId: "v1",
startTime: 0,
endTime: 1000,
text: "Line 1",
},
{
agentId: "v1000",
startTime: 1000,
endTime: 2000,
text: "Chorus Line",
},
],
};
const xml = generator.generate(result);
expect(xml).toContain('<ttm:agent type="person" xml:id="v1"');
expect(xml).toContain('<ttm:agent type="group" xml:id="v1000"');
expect(xml).toContain(
'<p begin="0.000" end="1.000" itunes:key="L1" ttm:agent="v1">',
);
expect(xml).toContain(
'<p begin="1.000" end="2.000" itunes:key="L2" ttm:agent="v1000">',
);
});
});
describe("TTML Generator - Ruby Generation", () => {
let generator: TTMLGenerator;
let parser: TTMLParser;
beforeAll(() => {
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
parser = new TTMLParser({ domParser: new DOMParser() });
});
it("generates ruby XML with tts namespace and four-level nesting for round-trip", () => {
const rubyResult: TTMLResult = {
metadata: {
title: ["Ruby Generation Test"],
},
lines: [
{
id: "L1",
startTime: 27000,
endTime: 28000,
text: "これは所詮",
words: [
{ text: "これは", startTime: 27000, endTime: 27500 },
{
text: "所",
startTime: 27690,
endTime: 27820,
ruby: [{ text: "しょ", startTime: 27690, endTime: 27820 }],
},
{
text: "詮",
startTime: 27820,
endTime: 27950,
ruby: [
{ text: "せ", startTime: 27820, endTime: 27880 },
{ text: "ん", startTime: 27880, endTime: 27950 },
],
},
],
},
],
};
const xml = generator.generate(rubyResult);
expect(xml).toContain('xmlns:tts="http://www.w3.org/ns/ttml#styling"');
expect(xml).toContain('tts:ruby="container"');
expect(xml).toContain('tts:ruby="base"');
expect(xml).toContain('tts:ruby="textContainer"');
expect(xml).toContain('tts:ruby="text"');
expect(xml).toContain('begin="27.000" end="27.500">これは</span>');
const parsedResult = parser.parse(xml);
const parsedLine = parsedResult.lines[0];
expect(parsedLine.text).toBe("これは所詮");
expect(parsedLine.words).toBeDefined();
expect(parsedLine.words).toHaveLength(3);
const rubyWord1 = parsedLine.words?.[1];
expect(rubyWord1?.text).toBe("所");
expect(rubyWord1?.ruby).toHaveLength(1);
expect(rubyWord1?.ruby?.[0]).toMatchObject({
text: "しょ",
startTime: 27690,
endTime: 27820,
});
const rubyWord2 = parsedLine.words?.[2];
expect(rubyWord2?.text).toBe("詮");
expect(rubyWord2?.ruby).toHaveLength(2);
expect(rubyWord2?.ruby?.[0]).toMatchObject({
text: "せ",
startTime: 27820,
endTime: 27880,
});
expect(rubyWord2?.ruby?.[1]).toMatchObject({
text: "ん",
startTime: 27880,
endTime: 27950,
});
});
});
describe("TTML Generator - Obscene words", () => {
let generator: TTMLGenerator;
let parser: TTMLParser;
beforeAll(() => {
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
parser = new TTMLParser({ domParser: new DOMParser() });
});
it("injects amll:obscene in generated XML and supports round-trip", () => {
const result: TTMLResult = {
metadata: {},
lines: [
{
id: "L1",
startTime: 0,
endTime: 3000,
text: "bad word rubyBad",
words: [
{ text: "bad", startTime: 0, endTime: 1000, obscene: true },
{
text: "word",
startTime: 1000,
endTime: 2000,
endsWithSpace: true,
},
{
text: "rubyBad",
startTime: 2000,
endTime: 3000,
obscene: true,
ruby: [{ text: "rb", startTime: 2000, endTime: 3000 }],
},
],
},
],
};
const xml = generator.generate(result);
expect(xml).toContain('amll:obscene="true">bad</span>');
expect(xml).not.toContain('amll:obscene="true">word</span>');
expect(xml).toContain('tts:ruby="container" amll:obscene="true"');
const parsed = parser.parse(xml);
const parsedWords = parsed.lines[0].words;
expect(parsedWords).toBeDefined();
expect(parsedWords).toHaveLength(3);
expect(parsedWords?.[0].obscene).toBe(true);
expect(parsedWords?.[1].obscene).toBeUndefined();
expect(parsedWords?.[2].obscene).toBe(true);
});
it("restores obscene from AMLL fallback structure", () => {
const amllLines: AmllLyricLine[] = [
{
startTime: 0,
endTime: 1000,
isBG: false,
isDuet: false,
translatedLyric: "",
romanLyric: "",
words: [
{
startTime: 0,
endTime: 500,
word: "bad ",
romanWord: "",
obscene: true,
},
{ startTime: 500, endTime: 1000, word: "word", romanWord: "" },
],
},
];
const ttmlResult = toTTMLResult(amllLines, []);
const words = ttmlResult.lines[0].words;
expect(words).toBeDefined();
expect(words).toHaveLength(2);
expect(words?.[0].text).toBe("bad");
expect(words?.[0].endsWithSpace).toBe(true);
expect(words?.[0].obscene).toBe(true);
expect(words?.[1].text).toBe("word");
expect(words?.[1].endsWithSpace).toBe(false);
expect(words?.[1].obscene).toBeUndefined();
});
});
describe("TTML Generator - Empty Beat", () => {
let generator: TTMLGenerator;
let parser: TTMLParser;
beforeAll(() => {
generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
});
parser = new TTMLParser({ domParser: new DOMParser() });
});
it("injects amll:empty-beat in generated XML and support round-trip", () => {
const result: TTMLResult = {
metadata: {},
lines: [
{
id: "L1",
startTime: 0,
endTime: 2000,
text: "wait word",
words: [
{ text: "wait", startTime: 0, endTime: 1000, emptyBeat: 4 },
{ text: "word", startTime: 1000, endTime: 2000 },
],
},
],
};
const xml = generator.generate(result);
expect(xml).toContain('amll:empty-beat="4">wait</span>');
expect(xml.match(/amll:empty-beat/g)?.length).toBe(1);
const parsed = parser.parse(xml);
const parsedWords = parsed.lines[0].words;
expect(parsedWords).toBeDefined();
expect(parsedWords).toHaveLength(2);
expect(parsedWords?.[0].emptyBeat).toBe(4);
expect(parsedWords?.[1].emptyBeat).toBeUndefined();
});
it("restores emptyBeat from AMLL fallback structure", () => {
const amllLines: AmllLyricLine[] = [
{
startTime: 0,
endTime: 1000,
isBG: false,
isDuet: false,
translatedLyric: "",
romanLyric: "",
words: [
{
startTime: 0,
endTime: 500,
word: "wait ",
romanWord: "",
emptyBeat: 8,
},
{ startTime: 500, endTime: 1000, word: "word", romanWord: "" },
],
},
];
const ttmlResult = toTTMLResult(amllLines, []);
const words = ttmlResult.lines[0].words;
expect(words).toBeDefined();
expect(words).toHaveLength(2);
expect(words?.[0].text).toBe("wait");
expect(words?.[0].emptyBeat).toBe(8);
expect(words?.[1].text).toBe("word");
expect(words?.[1].emptyBeat).toBeUndefined();
});
});

View File

@@ -0,0 +1,883 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { DOMImplementation, DOMParser, XMLSerializer } from "@xmldom/xmldom";
import { beforeAll, describe, expect, it } from "vitest";
import type { AmllLyricLine, SubLyricContent, TTMLResult } from "../src/index";
import {
TTMLGenerator,
TTMLParser,
toAmllLyrics,
toTTMLResult,
} from "../src/index";
const XML = readFileSync(
join(import.meta.dirname, "fixtures", "complex-test-song.ttml"),
"utf-8",
);
const RUBY_XML = readFileSync(
join(import.meta.dirname, "fixtures", "ruby-test-song.ttml"),
"utf-8",
);
describe("TTML Integration Test", () => {
let parser: TTMLParser;
let result: TTMLResult;
beforeAll(() => {
parser = new TTMLParser({ domParser: new DOMParser() });
result = parser.parse(XML);
});
const getLine = (id: string) => {
const line = result.lines.find((l) => l.id === id);
if (!line) throw new Error(`找不到 ID 为 ${id} 的歌词行`);
return line;
};
const getTranslation = (
item: { translations?: SubLyricContent[] },
lang: string,
) => {
const trans = item.translations?.find((t) => t.language === lang);
if (!trans) throw new Error(`未找到语言为 ${lang} 的翻译`);
return trans;
};
const getRomanization = (
item: { romanizations?: SubLyricContent[] },
lang: string,
) => {
const roman = item.romanizations?.find((r) => r.language === lang);
if (!roman) throw new Error(`未找到语言为 ${lang} 的音译`);
return roman;
};
it("parses global language and timing mode", () => {
expect(result.metadata.language).toBe("ja");
expect(result.metadata.timingMode).toBe("Word");
expect(result.metadata.title).toHaveLength(2);
expect(result.metadata.title).toEqual([
"Complex Test Song",
"複雑なテストソング",
]);
});
it("parses platform IDs", () => {
expect(result.metadata.platformIds?.ncmMusicId).toContain("123456789");
expect(result.metadata.platformIds?.qqMusicId).toContain("987654321");
expect(result.metadata.platformIds?.spotifyId).toContain("abc123xyz");
expect(result.metadata.platformIds?.appleMusicId).toContain("999888777");
});
it("parses artists list", () => {
expect(result.metadata.artist).toHaveLength(2);
expect(result.metadata.artist).toContain("Vocalist A (Taro)");
expect(result.metadata.artist).toContain("Vocalist B (Hanako)");
});
it("builds an agent map", () => {
expect(result.metadata.agents?.v1?.name).toBe("Vocalist A (Taro)");
expect(result.metadata.agents?.v1000?.name).toBe("Chorus Group");
});
it("parses songwriters list", () => {
expect(result.metadata.songwriters).toBeInstanceOf(Array);
expect(result.metadata.songwriters).toHaveLength(2);
expect(result.metadata.songwriters).toContain("作曲者1号");
expect(result.metadata.songwriters).toContain("作曲者2号");
});
it("parses ISRC", () => {
expect(result.metadata.isrc).toBeInstanceOf(Array);
expect(result.metadata.isrc).toContain("JPXX02500001");
});
it("parses verse and agent in L1", () => {
const l1 = getLine("L1");
expect(l1.songPart).toBe("Verse");
expect(l1.agentId).toBe("v1");
});
it("merges translations from Head in L1", () => {
const l1 = getLine("L1");
const transEn = getTranslation(l1, "en-US");
const transZh = getTranslation(l1, "zh-Hans-CN");
expect(transEn.text).toBe("This is the first line (Vocalist A)");
expect(transZh.text).toBe("这是第一行歌词 (演唱者A)");
});
it("merges word-level romanization from Head", () => {
const l1 = getLine("L1");
const roman = getRomanization(l1, "ja-Latn");
expect(roman.words).toBeInstanceOf(Array);
expect(roman.words).toMatchObject([
{ text: "Ko", startTime: 10000, endTime: 10500, endsWithSpace: false },
{ text: "re", startTime: 10500, endTime: 10800, endsWithSpace: true },
{ text: "wa", startTime: 10800, endTime: 11000, endsWithSpace: true },
{
text: "tesuto",
startTime: 11200,
endTime: 11800,
endsWithSpace: false,
},
]);
});
it("handles explicit whitespace spans in L1", () => {
const l1 = getLine("L1");
expect(l1.words).toMatchObject([
{ text: "これ" },
{ text: "は", endsWithSpace: true },
{ text: "テスト" },
]);
});
it("handles complex background vocal nesting in L3", () => {
const l3 = getLine("L3");
expect(l3.songPart).toBe("Chorus");
expect(l3.agentId).toBe("v1000");
expect(l3.text).toContain("コーラス です");
expect(l3.backgroundVocal).toBeDefined();
const bg = l3.backgroundVocal;
if (!bg) throw new Error("背景人声数组中未找到数据");
expect(bg.text).toBe("背景");
const transEn = getTranslation(bg, "en");
expect(transEn.text).toBe("Background");
const roman = getRomanization(bg, "ja-Latn");
expect(roman.text).toBe("haikei");
});
it("keeps both inline Body translation (en) and Head translation (en-US) in L3", () => {
const l3 = getLine("L3");
const bg = l3.backgroundVocal;
if (!bg) throw new Error("背景人声数组中未找到数据");
const transEn = getTranslation(bg, "en");
expect(transEn.text).toBe("Background");
const transEnUS = getTranslation(bg, "en-US");
expect(transEnUS.text).toBe("With background");
});
it("parses all lyric lines", () => {
expect(result.lines).toBeInstanceOf(Array);
expect(result.lines).toHaveLength(3);
const lineIds = result.lines.map((l) => l.id);
expect(lineIds).toContain("L1");
expect(lineIds).toContain("L2");
expect(lineIds).toContain("L3");
});
it("parses the second line in L2", () => {
const l2 = getLine("L2");
expect(l2.songPart).toBe("Verse");
expect(l2.agentId).toBe("v2");
expect(l2.text).toContain("二つ目");
expect(l2.text).toContain("の");
expect(l2.text).toContain("ライン");
});
it("parses word-level timings in L2", () => {
const l2 = getLine("L2");
expect(l2.words).toMatchObject([
{ text: "二つ目", startTime: 15000, endTime: 15800, endsWithSpace: true },
{ text: "の", startTime: 16000, endTime: 16500, endsWithSpace: true },
{ text: "ライン", startTime: 16500, endTime: 17000 },
]);
});
it("validates time ranges for all lines", () => {
const l1 = getLine("L1");
expect(l1.startTime).toBe(10000);
expect(l1.endTime).toBe(12000);
const l2 = getLine("L2");
expect(l2.startTime).toBe(15000);
expect(l2.endTime).toBe(17000);
const l3 = getLine("L3");
expect(l3.startTime).toBe(20000);
expect(l3.endTime).toBe(25000);
});
it("validates word-level timing accuracy in L1", () => {
const l1 = getLine("L1");
expect(l1.words).toMatchObject([
{ startTime: 10000, endTime: 10500 },
{ startTime: 10500, endTime: 10800 },
{ startTime: 11200, endTime: 11800 },
]);
});
it("parses album metadata", () => {
expect(result.metadata.album).toBeInstanceOf(Array);
expect(result.metadata.album).toHaveLength(1);
expect(result.metadata.album?.[0]).toBe("AMLL Parser Test Suite");
});
it("parses author metadata", () => {
expect(result.metadata.authorIds).toBeInstanceOf(Array);
expect(result.metadata.authorIds).toHaveLength(1);
expect(result.metadata.authorIds?.[0]).toBe("10001");
expect(result.metadata.authorNames).toBeInstanceOf(Array);
expect(result.metadata.authorNames).toHaveLength(1);
expect(result.metadata.authorNames?.[0]).toBe("TestUser");
});
it("merges translations and romanization in L2", () => {
const l2 = getLine("L2");
const transEn = getTranslation(l2, "en-US");
const transZh = getTranslation(l2, "zh-Hans-CN");
expect(transEn.text).toBe("This is the second line (Vocalist B)");
expect(transZh.text).toBe("这是第二行歌词 (演唱者B)");
const roman = getRomanization(l2, "ja-Latn");
expect(roman.words).toBeInstanceOf(Array);
expect(roman.words).toHaveLength(3);
});
it("parses word-level romanization timings in L2", () => {
const l2 = getLine("L2");
const roman = getRomanization(l2, "ja-Latn");
expect(roman.words).toMatchObject([
{
text: "Futatsume",
startTime: 15000,
endTime: 15800,
endsWithSpace: true,
},
{ text: "no", startTime: 16000, endTime: 16500, endsWithSpace: true },
{ text: "rain", startTime: 16500, endTime: 17000 },
]);
});
it("parses word-level timings for main lyrics in L3", () => {
const l3 = getLine("L3");
expect(l3.words).toMatchObject([
{
text: "コーラス",
startTime: 20000,
endTime: 21500,
endsWithSpace: true,
},
{ text: "です", startTime: 21500, endTime: 22000 },
]);
});
it("parses background vocal timing and words in L3", () => {
const l3 = getLine("L3");
const bg = l3.backgroundVocal;
if (!bg) throw new Error("未找到背景人声");
expect(bg.startTime).toBe(22500);
expect(bg.endTime).toBe(23800);
expect(bg.words).toMatchObject([
{ text: "背景", startTime: 22500, endTime: 23800 },
]);
});
it("parses word-level timings for background romanization in L3", () => {
const l3 = getLine("L3");
const bg = l3.backgroundVocal;
if (!bg) throw new Error("未找到背景人声");
const roman = bg.romanizations?.find(
(r) => r.language === "ja-Latn" && r.words && r.words.length > 0,
);
if (!roman) throw new Error("未找到包含字级别数据的 ja-Latn 音译");
expect(roman.words).toMatchObject([
{ text: "haikei", startTime: 22500, endTime: 23800 },
]);
});
it("keeps both inline Body romanization and Head sidecar word-level romanization in L3", () => {
const l3 = getLine("L3");
const bg = l3.backgroundVocal;
if (!bg) throw new Error("未找到背景人声");
const jaRomans =
bg.romanizations?.filter((r) => r.language === "ja-Latn") || [];
expect(jaRomans.length).toBeGreaterThanOrEqual(2);
const inlineRoman = jaRomans.find((r) => !r.words || r.words.length === 0);
expect(inlineRoman?.text).toBe("haikei");
const sidecarRoman = jaRomans.find((r) => r.words && r.words.length > 0);
expect(sidecarRoman?.words).toMatchObject([
{ text: "haikei", startTime: 22500, endTime: 23800 },
]);
});
it("parses background role markers in translations in L3", () => {
const l3 = getLine("L3");
const transEn = getTranslation(l3, "en-US");
expect(transEn.text).toContain("This is the chorus line");
const transZh = getTranslation(l3, "zh-Hans-CN");
expect(transZh.text).toContain("这是合唱部分");
});
it("flattens background vocal data in L3 translations", () => {
const l3 = getLine("L3");
const mainTranslation = getTranslation(l3, "en-US");
expect("backgroundVocal" in mainTranslation).toBe(false);
const bg = l3.backgroundVocal;
expect(bg).toBeDefined();
if (!bg) throw new Error();
const bgTranslation = getTranslation(bg, "en-US");
expect(bgTranslation).toBeDefined();
expect(bgTranslation.text).toBe("With background");
});
it("composes full text correctly", () => {
expect(getLine("L1").text).toBe("これは テスト");
expect(getLine("L2").text).toBe("二つ目 の ライン");
expect(getLine("L3").text).toBe("コーラス です");
});
it("maps all vocalists correctly", () => {
expect(result.metadata.agents).toBeDefined();
expect(Object.keys(result.metadata.agents ?? {})).toHaveLength(3);
expect(result.metadata.agents?.v1?.name).toBe("Vocalist A (Taro)");
expect(result.metadata.agents?.v2?.name).toBe("Vocalist B (Hanako)");
expect(result.metadata.agents?.v1000?.name).toBe("Chorus Group");
});
it("parses merged romanized text", () => {
const l1 = getLine("L1");
const roman = getRomanization(l1, "ja-Latn");
expect(roman.text).toBe("Kore wa tesuto");
});
it("parses merged translation text", () => {
const l1 = getLine("L1");
expect(getTranslation(l1, "en-US").text).toBe(
"This is the first line (Vocalist A)",
);
expect(getTranslation(l1, "zh-Hans-CN").text).toBe(
"这是第一行歌词 (演唱者A)",
);
});
it("parses obscene marker on regular syllables (amll:obscene) in L1", () => {
const l1 = getLine("L1");
expect(l1.words).toBeDefined();
expect(l1.words?.[0].text).toBe("これ");
expect(l1.words?.[0].obscene).toBe(true);
expect(l1.words?.[1].text).toBe("は");
expect(l1.words?.[1].obscene).toBeUndefined();
});
it("parses empty-beat marker on regular syllables (amll:empty-beat) in L1", () => {
const l1 = getLine("L1");
expect(l1.words).toBeDefined();
expect(l1.words?.[2].text).toBe("テスト");
expect(l1.words?.[2].emptyBeat).toBe(5);
expect(l1.words?.[1].text).toBe("は");
expect(l1.words?.[1].emptyBeat).toBeUndefined();
});
it("ensures all timings are valid numbers", () => {
for (const line of result.lines) {
expect(typeof line.startTime).toBe("number");
expect(typeof line.endTime).toBe("number");
expect(line.startTime).toBeGreaterThanOrEqual(0);
expect(line.endTime).toBeGreaterThan(line.startTime);
line.words?.forEach((word) => {
expect(typeof word.startTime).toBe("number");
expect(typeof word.endTime).toBe("number");
expect(word.startTime).toBeGreaterThanOrEqual(0);
expect(word.endTime).toBeGreaterThanOrEqual(word.startTime);
});
if (line.backgroundVocal) {
expect(typeof line.backgroundVocal.startTime).toBe("number");
expect(typeof line.backgroundVocal.endTime).toBe("number");
expect(line.backgroundVocal.startTime).toBeGreaterThanOrEqual(0);
expect(line.backgroundVocal.endTime).toBeGreaterThan(
line.backgroundVocal.startTime,
);
}
}
});
it("ensures all text fields are valid strings", () => {
for (const line of result.lines) {
expect(typeof line.text).toBe("string");
expect(line.text.length).toBeGreaterThan(0);
expect(typeof line.id).toBe("string");
expect(line.id?.length).toBeGreaterThan(0);
line.words?.forEach((word) => {
expect(typeof word.text).toBe("string");
expect(word.text.length).toBeGreaterThan(0);
});
}
});
it("preserve the data structure in Parse -> Generate -> Parse round-trip", () => {
const originalResult = parser.parse(XML);
const generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
useSidecar: false,
});
const generatedXML = generator.generate(originalResult);
const roundTripParser = new TTMLParser({ domParser: new DOMParser() });
const roundTripResult = roundTripParser.parse(generatedXML);
expect(roundTripResult).toEqual(originalResult);
});
it("handles translation containing ONLY background vocals", () => {
const xml = `
<tt xmlns="http://www.w3.org/ns/ttml"
xmlns:ttm="http://www.w3.org/ns/ttml#metadata"
xmlns:itunes="http://music.apple.com/lyric-ttml-internal">
<body>
<div>
<p begin="0s" end="1s" itunes:key="L1">
Main Lyric
<span ttm:role="x-bg">(Bg Lyric)</span>
<span ttm:role="x-translation" xml:lang="en">
<span ttm:role="x-bg">Only Bg Translation</span>
</span>
</p>
</div>
</body>
</tt>
`;
const customParser = new TTMLParser({ domParser: new DOMParser() });
const res = customParser.parse(xml);
const l1 = res.lines[0];
expect(l1).toMatchObject({
translations: undefined,
backgroundVocal: {
translations: [
{
text: "Only Bg Translation",
language: "en",
},
],
},
});
});
});
describe("toAmllLyrics Conversion", () => {
let parser: TTMLParser;
let result: TTMLResult;
let amllLines: AmllLyricLine[];
beforeAll(() => {
parser = new TTMLParser({ domParser: new DOMParser() });
result = parser.parse(XML);
amllLines = toAmllLyrics(result).lines;
});
it("converts to a flattened array", () => {
expect(amllLines).toBeInstanceOf(Array);
expect(amllLines).toHaveLength(4);
});
it("is sorted correctly", () => {
for (let i = 0; i < amllLines.length - 1; i++) {
expect(amllLines[i].startTime).toBeLessThanOrEqual(
amllLines[i + 1].startTime,
);
}
});
it("preserves word alignment for L1", () => {
const l1 = amllLines[0];
expect(l1.words).toMatchObject([
{ romanWord: "Ko" },
{ romanWord: "re" },
{ romanWord: "tesuto" },
]);
});
it("does not align romanization to nearby punctuation with different end time", () => {
const xml = `
<tt xmlns="http://www.w3.org/ns/ttml"
xmlns:itunes="http://music.apple.com/lyric-ttml-internal"
xmlns:ttm="http://www.w3.org/ns/ttml#metadata">
<head>
<iTunesMetadata xmlns="http://music.apple.com/lyric-ttml-internal">
<transliterations>
<transliteration xml:lang="ja-Latn">
<text for="L26">
<span begin="2:00.250" end="2:00.690" xmlns="http://www.w3.org/ns/ttml">ko</span>
<span begin="2:00.695" end="2:00.870" xmlns="http://www.w3.org/ns/ttml">u</span>
<span begin="2:01.070" end="2:01.470" xmlns="http://www.w3.org/ns/ttml">ki</span>
</text>
</transliteration>
</transliterations>
</iTunesMetadata>
</head>
<body>
<div>
<p begin="1:57.890" end="2:03.860" itunes:key="L26" ttm:agent="v1">
<span begin="2:00.250" end="2:00.690">処</span>
<span begin="2:00.690" end="2:00.695">、</span>
<span begin="2:00.695" end="2:00.870">浮</span>
<span begin="2:01.070" end="2:01.470">き</span>
</p>
</div>
</body>
</tt>
`;
const customParser = new TTMLParser({ domParser: new DOMParser() });
const lines = toAmllLyrics(customParser.parse(xml)).lines;
expect(lines[0].words).toMatchObject([
{ word: "処", romanWord: "ko" },
{ word: "、", romanWord: "" },
{ word: "浮", romanWord: "u" },
{ word: "き", romanWord: "ki" },
]);
});
it("handles duet flags", () => {
expect(amllLines[0].isDuet).toBe(false);
expect(amllLines[1].isDuet).toBe(true);
expect(amllLines[2].isDuet).toBe(false);
});
it("sets the isBG flag", () => {
const bgLine = amllLines[3];
expect(bgLine.isBG).toBe(true);
expect(bgLine.translatedLyric).toBe("Background");
expect(bgLine.romanLyric).toBe("haikei");
});
it("passes through obscene to AmllLyricWord", () => {
const l1 = amllLines[0];
expect(l1.words[0].word).toBe("これ");
expect(l1.words[0].obscene).toBe(true);
expect(l1.words[1].word).toBe("は ");
expect(l1.words[1].obscene).toBeUndefined();
});
it("passes through emptyBeat to AmllLyricWord", () => {
const l1 = amllLines[0];
expect(l1.words[2].word).toBe("テスト");
expect(l1.words[2].emptyBeat).toBe(5);
expect(l1.words[1].word).toBe("は ");
expect(l1.words[1].emptyBeat).toBeUndefined();
});
const toLayoutSnapshot = (lines: AmllLyricLine[]) =>
lines.map((line) => {
const time = (line.startTime / 1000).toFixed(2).padStart(6, " ");
const position = line.isDuet ? "右" : "左";
const typeMark = line.isBG ? "[bg]" : "[main]";
const text = line.words
.map((w) => w.word)
.join("")
.trim();
return `[${time}s] ${position} ${typeMark} : ${text}`;
});
it.each([
[
"left-right duet layout in Apple Music style with multiple singers",
"apple-music-duet.ttml",
],
["Apple Music TTML with v2000 other agent", "apple-music-other-duet.ttml"],
])("computes %s correctly in duet alignment", (_, fixture) => {
const xml = readFileSync(
join(import.meta.dirname, "fixtures", fixture),
"utf-8",
);
const lines = toAmllLyrics(parser.parse(xml)).lines;
expect(toLayoutSnapshot(lines)).toMatchSnapshot();
});
it("converts Syllable.ruby to AmllLyricWord.ruby", () => {
const mockRubyResult: TTMLResult = {
metadata: {},
lines: [
{
startTime: 0,
endTime: 1000,
text: "所詮",
words: [
{
text: "所",
startTime: 0,
endTime: 500,
ruby: [{ text: "しょ", startTime: 0, endTime: 500 }],
},
{
text: "詮",
startTime: 500,
endTime: 1000,
ruby: [
{ text: "せ", startTime: 500, endTime: 750 },
{ text: "ん", startTime: 750, endTime: 1000 },
],
},
],
},
],
};
const lines = toAmllLyrics(mockRubyResult).lines;
expect(lines[0].words[0].ruby).toBeDefined();
expect(lines[0].words[0].ruby).toMatchObject([
{ word: "しょ", startTime: 0, endTime: 500 },
]);
expect(lines[0].words[1].ruby).toBeDefined();
expect(lines[0].words[1].ruby).toMatchObject([
{ word: "せ", startTime: 500, endTime: 750 },
{ word: "ん", startTime: 750, endTime: 1000 },
]);
});
});
describe("TTML Ruby Integration Test", () => {
let parser: TTMLParser;
let result: TTMLResult;
beforeAll(() => {
parser = new TTMLParser({ domParser: new DOMParser() });
result = parser.parse(RUBY_XML);
});
it("parses full line text including ruby base text", () => {
const l1 = result.lines.find((l) => l.id === "L1");
expect(l1).toBeDefined();
expect(l1?.text).toBe("これは所詮");
});
it("extracts ruby containers as standalone syllables with inferred timings", () => {
const l1 = result.lines.find((l) => l.id === "L1");
const words = l1?.words;
expect(words).toBeDefined();
expect(words).toHaveLength(3);
expect(words?.[0].text).toBe("これは");
expect(words?.[0].startTime).toBe(27000);
expect(words?.[1].text).toBe("所");
expect(words?.[1].startTime).toBe(27690);
expect(words?.[1].endTime).toBe(27820);
expect(words?.[2].text).toBe("詮");
expect(words?.[2].startTime).toBe(27820);
expect(words?.[2].endTime).toBe(27950);
});
it("extracts ruby annotation arrays (RubyTags)", () => {
const l1 = result.lines.find((l) => l.id === "L1");
const words = l1?.words;
const ruby1 = words?.[1].ruby;
expect(ruby1).toBeDefined();
expect(ruby1).toHaveLength(1);
expect(ruby1?.[0]).toMatchObject({
text: "しょ",
startTime: 27690,
endTime: 27820,
});
const ruby2 = words?.[2].ruby;
expect(ruby2).toBeDefined();
expect(ruby2).toHaveLength(2);
expect(ruby2?.[0]).toMatchObject({
text: "せ",
startTime: 27820,
endTime: 27880,
});
expect(ruby2?.[1]).toMatchObject({
text: "ん",
startTime: 27880,
endTime: 27950,
});
});
it("excludes ruby from regular syllables", () => {
const l1 = result.lines.find((l) => l.id === "L1");
const words = l1?.words;
expect(words?.[0].ruby).toBeUndefined();
});
});
describe("toTTMLResult Conversion", () => {
it("converts AmllLyricWord.ruby to Syllable.ruby", () => {
const mockAmllLines: AmllLyricLine[] = [
{
startTime: 0,
endTime: 1000,
isBG: false,
isDuet: false,
translatedLyric: "",
romanLyric: "",
words: [
{
word: "所",
startTime: 0,
endTime: 500,
ruby: [{ word: "しょ", startTime: 0, endTime: 500 }],
},
{
word: "詮",
startTime: 500,
endTime: 1000,
ruby: [
{ word: "せ", startTime: 500, endTime: 750 },
{ word: "ん", startTime: 750, endTime: 1000 },
],
},
],
},
];
const result = toTTMLResult(mockAmllLines, []);
const words = result.lines[0].words;
expect(words).toBeDefined();
expect(words?.[0].ruby).toBeDefined();
expect(words?.[0].ruby).toMatchObject([
{ text: "しょ", startTime: 0, endTime: 500 },
]);
expect(words?.[1].ruby).toBeDefined();
expect(words?.[1].ruby).toMatchObject([
{ text: "せ", startTime: 500, endTime: 750 },
{ text: "ん", startTime: 750, endTime: 1000 },
]);
});
});
describe("TTML BlockIndex Boundary Tests", () => {
let parser: TTMLParser;
let result: TTMLResult;
beforeAll(() => {
parser = new TTMLParser({ domParser: new DOMParser() });
const xml = readFileSync(
join(import.meta.dirname, "fixtures", "apple-music-duet.ttml"),
"utf-8",
);
result = parser.parse(xml);
});
it("assigns an incremental blockIndex to parsed lines", () => {
let previousBlockIndex = 0;
for (const line of result.lines) {
expect(line.blockIndex).toBeDefined();
expect(line.blockIndex).toBeTypeOf("number");
expect(line.blockIndex).toBeGreaterThanOrEqual(previousBlockIndex);
if (line.blockIndex !== undefined) {
previousBlockIndex = line.blockIndex;
}
}
});
it("preserves continuous block boundaries in Generator round-trip", () => {
const generator = new TTMLGenerator({
domImplementation: new DOMImplementation(),
xmlSerializer: new XMLSerializer(),
useSidecar: true,
});
const generatedXML = generator.generate(result);
const roundTripParser = new TTMLParser({ domParser: new DOMParser() });
const roundTripResult = roundTripParser.parse(generatedXML);
const originalBlocks = new Set(result.lines.map((l) => l.blockIndex));
const roundTripBlocks = new Set(
roundTripResult.lines.map((l) => l.blockIndex),
);
expect(roundTripBlocks.size).toBe(originalBlocks.size);
expect(roundTripResult.lines.map((l) => l.blockIndex)).toEqual(
result.lines.map((l) => l.blockIndex),
);
});
it("distinguishes adjacent containers with the same songPart using exact blockIndex assertions", () => {
const getLine = (id: string) => {
const line = result.lines.find((l) => l.id === id);
if (!line) throw new Error(`找不到 ID 为 ${id} 的歌词行`);
return line;
};
expect(getLine("L18").songPart).toBe("Verse");
expect(getLine("L18").blockIndex).toBe(4);
expect(getLine("L19").songPart).toBe("Verse");
expect(getLine("L19").blockIndex).toBe(5);
expect(getLine("L27").songPart).toBe("Verse");
expect(getLine("L27").blockIndex).toBe(7);
expect(getLine("L28").songPart).toBe("Verse");
expect(getLine("L28").blockIndex).toBe(8);
expect(getLine("L31").songPart).toBe("Verse");
expect(getLine("L31").blockIndex).toBe(8);
expect(getLine("L32").songPart).toBe("Verse");
expect(getLine("L32").blockIndex).toBe(9);
expect(getLine("L38").songPart).toBe("Verse");
expect(getLine("L38").blockIndex).toBe(9);
expect(getLine("L39").songPart).toBe("Verse");
expect(getLine("L39").blockIndex).toBe(10);
expect(getLine("L43").songPart).toBe("Verse");
expect(getLine("L43").blockIndex).toBe(10);
expect(getLine("L44").songPart).toBe("Verse");
expect(getLine("L44").blockIndex).toBe(11);
expect(getLine("L48").songPart).toBe("Verse");
expect(getLine("L48").blockIndex).toBe(11);
expect(getLine("L49").songPart).toBe("Verse");
expect(getLine("L49").blockIndex).toBe(12);
});
});

View File

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

View File

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

View File

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

View File

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