173 lines
4.5 KiB
TypeScript
173 lines
4.5 KiB
TypeScript
/**
|
|
* @fileoverview
|
|
* Parser for TTML lyrics.
|
|
* Used to parse lyrics files from Apple Music,
|
|
* and extended to support translation and pronounciation of text.
|
|
* @see https://www.w3.org/TR/2018/REC-ttml1-20181108/
|
|
*/
|
|
|
|
import type {
|
|
LyricLine,
|
|
LyricWord,
|
|
TTMLLyric,
|
|
TTMLMetadata,
|
|
} from "./ttml-types";
|
|
|
|
const timeRegexp =
|
|
/^(((?<hour>[0-9]+):)?(?<min>[0-9]+):)?(?<sec>[0-9]+([.:]([0-9]+))?)/;
|
|
function parseTimespan(timeSpan: string): number {
|
|
const matches = timeRegexp.exec(timeSpan);
|
|
if (matches) {
|
|
const hour = Number(matches.groups?.hour || "0");
|
|
const min = Number(matches.groups?.min || "0");
|
|
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
|
|
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
|
|
}
|
|
throw new TypeError(`Failed to parse time stamp:${timeSpan}`);
|
|
}
|
|
|
|
export function parseTTML(ttmlText: string): TTMLLyric {
|
|
const domParser = new DOMParser();
|
|
const ttmlDoc: XMLDocument = domParser.parseFromString(
|
|
ttmlText,
|
|
"application/xml",
|
|
);
|
|
|
|
let mainAgentId = "v1";
|
|
|
|
const metadata: TTMLMetadata[] = [];
|
|
for (const meta of ttmlDoc.querySelectorAll("meta")) {
|
|
if (meta.tagName === "amll:meta") {
|
|
const key = meta.getAttribute("key");
|
|
if (key) {
|
|
const value = meta.getAttribute("value");
|
|
if (value) {
|
|
const existing = metadata.find((m) => m.key === key);
|
|
if (existing) {
|
|
existing.value.push(value);
|
|
} else {
|
|
metadata.push({
|
|
key,
|
|
value: [value],
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const agent of ttmlDoc.querySelectorAll("ttm\\:agent")) {
|
|
if (agent.getAttribute("type") === "person") {
|
|
const id = agent.getAttribute("xml:id");
|
|
if (id) {
|
|
mainAgentId = id;
|
|
}
|
|
}
|
|
}
|
|
|
|
const lyricLines: LyricLine[] = [];
|
|
|
|
function parseParseLine(lineEl: Element, isBG = false, isDuet = false) {
|
|
const line: LyricLine = {
|
|
words: [],
|
|
translatedLyric: "",
|
|
romanLyric: "",
|
|
isBG,
|
|
isDuet:
|
|
!!lineEl.getAttribute("ttm:agent") &&
|
|
lineEl.getAttribute("ttm:agent") !== mainAgentId,
|
|
startTime: 0,
|
|
endTime: 0,
|
|
singer: ""
|
|
};
|
|
if (isBG) line.isDuet = isDuet;
|
|
let haveBg = false;
|
|
|
|
for (const wordNode of lineEl.childNodes) {
|
|
if (wordNode.nodeType === Node.TEXT_NODE) {
|
|
line.words?.push({
|
|
word: wordNode.textContent ?? "",
|
|
startTime: 0,
|
|
endTime: 0,
|
|
});
|
|
} else if (wordNode.nodeType === Node.ELEMENT_NODE) {
|
|
const wordEl = wordNode as Element;
|
|
const role = wordEl.getAttribute("ttm:role");
|
|
|
|
if (wordEl.nodeName === "span" && role) {
|
|
if (role === "x-bg") {
|
|
parseParseLine(wordEl, true, line.isDuet);
|
|
haveBg = true;
|
|
} else if (role === "x-translation") {
|
|
line.translatedLyric = wordEl.innerHTML;
|
|
} else if (role === "x-roman") {
|
|
line.romanLyric = wordEl.innerHTML;
|
|
} else if (role === "x-singer") {
|
|
line.singer = wordEl.innerHTML;
|
|
}
|
|
} else if (wordEl.hasAttribute("begin") && wordEl.hasAttribute("end")) {
|
|
const word: LyricWord = {
|
|
word: wordNode.textContent ?? "",
|
|
startTime: parseTimespan(wordEl.getAttribute("begin") ?? ""),
|
|
endTime: parseTimespan(wordEl.getAttribute("end") ?? ""),
|
|
};
|
|
const emptyBeat = wordEl.getAttribute("amll:empty-beat");
|
|
if (emptyBeat) {
|
|
word.emptyBeat = Number(emptyBeat);
|
|
}
|
|
line.words.push(word);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (line.isBG) {
|
|
const firstWord = line.words?.[0];
|
|
if (firstWord?.word.startsWith("(")) {
|
|
firstWord.word = firstWord.word.substring(1);
|
|
if (firstWord.word.length === 0) {
|
|
line.words.shift();
|
|
}
|
|
}
|
|
|
|
const lastWord = line.words?.[line.words.length - 1];
|
|
if (lastWord?.word.endsWith(")")) {
|
|
lastWord.word = lastWord.word.substring(0, lastWord.word.length - 1);
|
|
if (lastWord.word.length === 0) {
|
|
line.words.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
const startTime = lineEl.getAttribute("begin");
|
|
const endTime = lineEl.getAttribute("end");
|
|
if (startTime && endTime) {
|
|
line.startTime = parseTimespan(startTime);
|
|
line.endTime = parseTimespan(endTime);
|
|
} else {
|
|
line.startTime = line.words
|
|
.filter((v) => v.word.trim().length > 0)
|
|
.reduce((pv, cv) => Math.min(pv, cv.startTime), Infinity);
|
|
line.endTime = line.words
|
|
.filter((v) => v.word.trim().length > 0)
|
|
.reduce((pv, cv) => Math.max(pv, cv.endTime), 0);
|
|
}
|
|
|
|
if (haveBg) {
|
|
const bgLine = lyricLines.pop();
|
|
lyricLines.push(line);
|
|
if (bgLine) lyricLines.push(bgLine);
|
|
} else {
|
|
lyricLines.push(line);
|
|
}
|
|
}
|
|
|
|
for (const lineEl of ttmlDoc.querySelectorAll("body p[begin][end]")) {
|
|
parseParseLine(lineEl);
|
|
}
|
|
|
|
return {
|
|
metadata,
|
|
lyricLines: lyricLines,
|
|
};
|
|
}
|