forked from miao-moe/QZMusic_PC
fork(fix): Clone AMLL 并修复 BUG
- 将AMLL Clone到本以地进行修复和优化(emm虽然这很不优雅但是暂时无时间做子模块和Fork) - 修复在当前播放歌词行不可见的视口Seek会出现滚动偏移的问题
This commit is contained in:
127
amll-local/packages/ttml/src/constants.ts
Normal file
127
amll-local/packages/ttml/src/constants.ts
Normal 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,
|
||||
}
|
||||
712
amll-local/packages/ttml/src/generator.ts
Normal file
712
amll-local/packages/ttml/src/generator.ts
Normal 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}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
amll-local/packages/ttml/src/index.ts
Normal file
41
amll-local/packages/ttml/src/index.ts
Normal 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);
|
||||
}
|
||||
1076
amll-local/packages/ttml/src/parser.ts
Normal file
1076
amll-local/packages/ttml/src/parser.ts
Normal file
File diff suppressed because it is too large
Load Diff
119
amll-local/packages/ttml/src/types/amll.ts
Normal file
119
amll-local/packages/ttml/src/types/amll.ts
Normal 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;
|
||||
}
|
||||
79
amll-local/packages/ttml/src/types/dom.ts
Normal file
79
amll-local/packages/ttml/src/types/dom.ts
Normal 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;
|
||||
}
|
||||
334
amll-local/packages/ttml/src/types/index.ts
Normal file
334
amll-local/packages/ttml/src/types/index.ts
Normal 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,还会出现 v3,v4 等指代每个演唱者,以及 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[];
|
||||
}
|
||||
440
amll-local/packages/ttml/src/utils/amll-converter.ts
Normal file
440
amll-local/packages/ttml/src/utils/amll-converter.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user