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