diff --git a/src/lib/components/lyrics.svelte b/src/lib/components/lyrics.svelte index d74354e..2c030ce 100644 --- a/src/lib/components/lyrics.svelte +++ b/src/lib/components/lyrics.svelte @@ -6,6 +6,7 @@ import progressBarSlideValue from '$lib/state/progressBarSlideValue'; import nextUpdate from '$lib/state/nextUpdate'; import truncate from '$lib/truncate'; + import { blur } from 'svelte/transition'; // Component input properties export let lyrics: string[]; @@ -88,7 +89,7 @@ for (let i = processingLineIndex; i < refs.length; i++) { const lyric = refs[i]; lyric.style.transition = - `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, + `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; lyric.style.transform = `translateY(${-h}px)`; processingLineIndex = i; @@ -101,7 +102,7 @@ for (let i = processingLineIndex; i < refs.length; i++) { const lyric = refs[i]; lyric.style.transition = - 'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; + 'transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease'; lyric.style.transform = `translateY(${-h}px)`; processingLineIndex = i; await sleep(75); @@ -199,10 +200,14 @@ const currentLyric = refs[currentPositionIndex]; if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return; - - for (let i = 0; i < scripts.length; i++) { + + for (let i = 0; i < refs.length; i++) { const offset = Math.abs(i - currentPositionIndex); - const blurRadius = Math.min(offset * 0.96, 16); + let blurRadius = Math.min(offset * 1.25, 16); + const rect = refs[i].getBoundingClientRect(); + if (rect.top + rect.height < 0 || rect.top > lyricsContainer.getBoundingClientRect().height) { + blurRadius = 0; + } if (refs[i]) { refs[i].style.filter = `blur(${blurRadius}px)`; } @@ -210,6 +215,21 @@ })(); } + function getViewportRange() { + let min = 0; + let max = 0; + for (let i = 0; i < refs.length; i++) { + const element = refs[i]; + if (element.getBoundingClientRect().top < 0) { + min = i; + } + else if (element.getBoundingClientRect().bottom < 0) { + max = i; + return [min, max]; + } + } + } + // Main function that control's lyrics update during playing // triggered by nextUpdate's update async function lyricsUpdate(){ @@ -228,18 +248,20 @@ const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity); // prepare current line - currentLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, + currentLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; currentLyric.style.transform = `translateY(${-offsetHeight}px)`; + // prepare past lines for (let i = currentPositionIndex - 1; i >= 0; i--) { - refs[i].style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, + refs[i].style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; refs[i].style.transform = `translateY(${-offsetHeight}px)`; } + await sleep(75); if (currentPositionIndex + 1 < refs.length) { const nextLyric = refs[currentPositionIndex + 1]; - nextLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, + nextLyric.style.transition = `transform .5s cubic-bezier(.16,.02,.38,.98), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease`; nextLyric.style.transform = `translateY(${-offsetHeight}px)`; await moveToNextLine(offsetHeight); @@ -347,9 +369,9 @@ --lyric-mobile-line-height: 2.4rem; --lyric-mobile-margin: 1.5rem 0; --lyric-mobile-font-weight: 600; - --lyric-desktop-font-size: 3.5rem; + --lyric-desktop-font-size: 3rem; --lyric-desktop-line-height: 4.5rem; - --lyric-desktop-margin: 1.75rem 0; + --lyric-desktop-margin: 2.75rem 0; } .lyrics { diff --git a/src/lib/ttml/index.ts b/src/lib/ttml/index.ts new file mode 100644 index 0000000..79fec1c --- /dev/null +++ b/src/lib/ttml/index.ts @@ -0,0 +1,3 @@ +export * from "./parser"; +export * from "./writer"; +export type * from "./ttml-types"; diff --git a/src/lib/ttml/parser.ts b/src/lib/ttml/parser.ts new file mode 100644 index 0000000..3cdb8eb --- /dev/null +++ b/src/lib/ttml/parser.ts @@ -0,0 +1,168 @@ +/** + * @fileoverview + * 解析 TTML 歌词文档到歌词数组的解析器 + * 用于解析从 Apple Music 来的歌词文件,且扩展并支持翻译和音译文本。 + * @see https://www.w3.org/TR/2018/REC-ttml1-20181108/ + */ + +import type { + LyricLine, + LyricWord, + TTMLLyric, + TTMLMetadata, +} from "./ttml-types"; + +const timeRegexp = + /^(((?[0-9]+):)?(?[0-9]+):)?(?[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(`时间戳字符串解析失败:${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, + }; + 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 (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, + }; +} diff --git a/src/lib/ttml/ttml-types.ts b/src/lib/ttml/ttml-types.ts new file mode 100644 index 0000000..644a870 --- /dev/null +++ b/src/lib/ttml/ttml-types.ts @@ -0,0 +1,26 @@ +export interface TTMLMetadata { + key: string; + value: string[]; +} + +export interface TTMLLyric { + metadata: TTMLMetadata[]; + lyricLines: LyricLine[]; +} + +export interface LyricWord { + startTime: number; + endTime: number; + word: string; + emptyBeat?: number; +} + +export interface LyricLine { + words: LyricWord[]; + translatedLyric: string; + romanLyric: string; + isBG: boolean; + isDuet: boolean; + startTime: number; + endTime: number; +} diff --git a/src/lib/ttml/writer.ts b/src/lib/ttml/writer.ts new file mode 100644 index 0000000..30668a1 --- /dev/null +++ b/src/lib/ttml/writer.ts @@ -0,0 +1,260 @@ +/** + * @fileoverview + * 用于将内部歌词数组对象导出成 TTML 格式的模块 + * 但是可能会有信息会丢失 + */ + +import type { LyricLine, LyricWord, TTMLLyric } from "./ttml-types"; + +function msToTimestamp(timeMS: number): string { + let time = timeMS; + if (!Number.isSafeInteger(time) || time < 0) { + return "00:00.000"; + } + if (time === Infinity) { + return "99:99.999"; + } + time = time / 1000; + const secs = time % 60; + time = (time - secs) / 60; + const mins = time % 60; + const hrs = (time - mins) / 60; + + const h = hrs.toString().padStart(2, "0"); + const m = mins.toString().padStart(2, "0"); + const s = secs.toFixed(3).padStart(6, "0"); + + if (hrs > 0) { + return `${h}:${m}:${s}`; + } + return `${m}:${s}`; +} + +export function exportTTML(ttmlLyric: TTMLLyric, pretty = false): string { + const params: LyricLine[][] = []; + const lyric = ttmlLyric.lyricLines; + + let tmp: LyricLine[] = []; + for (const line of lyric) { + if (line.words.length === 0 && tmp.length > 0) { + params.push(tmp); + tmp = []; + } else { + tmp.push(line); + } + } + + if (tmp.length > 0) { + params.push(tmp); + } + + const doc = new Document(); + + function createWordElement(word: LyricWord): Element { + const span = doc.createElement("span"); + span.setAttribute("begin", msToTimestamp(word.startTime)); + span.setAttribute("end", msToTimestamp(word.endTime)); + if (word.emptyBeat) { + span.setAttribute("amll:empty-beat", `${word.emptyBeat}`); + } + span.appendChild(doc.createTextNode(word.word)); + return span; + } + + const ttRoot = doc.createElement("tt"); + + ttRoot.setAttribute("xmlns", "http://www.w3.org/ns/ttml"); + ttRoot.setAttribute("xmlns:ttm", "http://www.w3.org/ns/ttml#metadata"); + ttRoot.setAttribute("xmlns:amll", "http://www.example.com/ns/amll"); + ttRoot.setAttribute( + "xmlns:itunes", + "http://music.apple.com/lyric-ttml-internal", + ); + + doc.appendChild(ttRoot); + + const head = doc.createElement("head"); + + ttRoot.appendChild(head); + + const body = doc.createElement("body"); + const hasOtherPerson = !!lyric.find((v) => v.isDuet); + + const metadataEl = doc.createElement("metadata"); + const mainPersonAgent = doc.createElement("ttm:agent"); + mainPersonAgent.setAttribute("type", "person"); + mainPersonAgent.setAttribute("xml:id", "v1"); + + metadataEl.appendChild(mainPersonAgent); + + if (hasOtherPerson) { + const otherPersonAgent = doc.createElement("ttm:agent"); + otherPersonAgent.setAttribute("type", "other"); + otherPersonAgent.setAttribute("xml:id", "v2"); + + metadataEl.appendChild(otherPersonAgent); + } + + for (const metadata of ttmlLyric.metadata) { + for (const value of metadata.value) { + const metaEl = doc.createElement("amll:meta"); + metaEl.setAttribute("key", metadata.key); + metaEl.setAttribute("value", value); + metadataEl.appendChild(metaEl); + } + } + + head.appendChild(metadataEl); + + let i = 0; + + const guessDuration = lyric[lyric.length - 1]?.endTime ?? 0; + body.setAttribute("dur", msToTimestamp(guessDuration)); + + for (const param of params) { + const paramDiv = doc.createElement("div"); + const beginTime = param[0]?.startTime ?? 0; + const endTime = param[param.length - 1]?.endTime ?? 0; + + paramDiv.setAttribute("begin", msToTimestamp(beginTime)); + paramDiv.setAttribute("end", msToTimestamp(endTime)); + + for (let lineIndex = 0; lineIndex < param.length; lineIndex++) { + const line = param[lineIndex]; + const lineP = doc.createElement("p"); + const beginTime = line.startTime ?? 0; + const endTime = line.endTime; + + lineP.setAttribute("begin", msToTimestamp(beginTime)); + lineP.setAttribute("end", msToTimestamp(endTime)); + + lineP.setAttribute("ttm:agent", line.isDuet ? "v2" : "v1"); + lineP.setAttribute("itunes:key", `L${++i}`); + + if (line.words.length > 1) { + let beginTime = Infinity; + let endTime = 0; + for (const word of line.words) { + if (word.word.trim().length === 0) { + lineP.appendChild(doc.createTextNode(word.word)); + } else { + const span = createWordElement(word); + lineP.appendChild(span); + beginTime = Math.min(beginTime, word.startTime); + endTime = Math.max(endTime, word.endTime); + } + } + lineP.setAttribute("begin", msToTimestamp(line.startTime)); + lineP.setAttribute("end", msToTimestamp(line.endTime)); + } else if (line.words.length === 1) { + const word = line.words[0]; + lineP.appendChild(doc.createTextNode(word.word)); + lineP.setAttribute("begin", msToTimestamp(word.startTime)); + lineP.setAttribute("end", msToTimestamp(word.endTime)); + } + + const nextLine = param[lineIndex + 1]; + if (nextLine?.isBG) { + lineIndex++; + const bgLine = nextLine; + const bgLineSpan = doc.createElement("span"); + bgLineSpan.setAttribute("ttm:role", "x-bg"); + + if (bgLine.words.length > 1) { + let beginTime = Infinity; + let endTime = 0; + for ( + let wordIndex = 0; + wordIndex < bgLine.words.length; + wordIndex++ + ) { + const word = bgLine.words[wordIndex]; + if (word.word.trim().length === 0) { + bgLineSpan.appendChild(doc.createTextNode(word.word)); + } else { + const span = createWordElement(word); + if (wordIndex === 0) { + span.prepend(doc.createTextNode("(")); + } else if (wordIndex === bgLine.words.length - 1) { + span.appendChild(doc.createTextNode(")")); + } + bgLineSpan.appendChild(span); + beginTime = Math.min(beginTime, word.startTime); + endTime = Math.max(endTime, word.endTime); + } + } + bgLineSpan.setAttribute("begin", msToTimestamp(beginTime)); + bgLineSpan.setAttribute("end", msToTimestamp(endTime)); + } else if (bgLine.words.length === 1) { + const word = bgLine.words[0]; + bgLineSpan.appendChild(doc.createTextNode(`(${word.word})`)); + bgLineSpan.setAttribute("begin", msToTimestamp(word.startTime)); + bgLineSpan.setAttribute("end", msToTimestamp(word.endTime)); + } + + if (bgLine.translatedLyric) { + const span = doc.createElement("span"); + span.setAttribute("ttm:role", "x-translation"); + span.setAttribute("xml:lang", "zh-CN"); + span.appendChild(doc.createTextNode(bgLine.translatedLyric)); + bgLineSpan.appendChild(span); + } + + if (bgLine.romanLyric) { + const span = doc.createElement("span"); + span.setAttribute("ttm:role", "x-roman"); + span.appendChild(doc.createTextNode(bgLine.romanLyric)); + bgLineSpan.appendChild(span); + } + + lineP.appendChild(bgLineSpan); + } + + if (line.translatedLyric) { + const span = doc.createElement("span"); + span.setAttribute("ttm:role", "x-translation"); + span.setAttribute("xml:lang", "zh-CN"); + span.appendChild(doc.createTextNode(line.translatedLyric)); + lineP.appendChild(span); + } + + if (line.romanLyric) { + const span = doc.createElement("span"); + span.setAttribute("ttm:role", "x-roman"); + span.appendChild(doc.createTextNode(line.romanLyric)); + lineP.appendChild(span); + } + + paramDiv.appendChild(lineP); + } + + body.appendChild(paramDiv); + } + + ttRoot.appendChild(body); + + if (pretty) { + const xsltDoc = new DOMParser().parseFromString( + [ + '', + ' ', + ' ', + ' ', + " ", + ' ', + ' ', + " ", + ' ', + "", + ].join("\n"), + "application/xml", + ); + + const xsltProcessor = new XSLTProcessor(); + xsltProcessor.importStylesheet(xsltDoc); + const resultDoc = xsltProcessor.transformToDocument(doc); + + return new XMLSerializer().serializeToString(resultDoc); + } + return new XMLSerializer().serializeToString(doc); +} diff --git a/src/routes/import/[id]/lyric/+page.svelte b/src/routes/import/[id]/lyric/+page.svelte index a52a1f8..196597f 100644 --- a/src/routes/import/[id]/lyric/+page.svelte +++ b/src/routes/import/[id]/lyric/+page.svelte @@ -16,7 +16,7 @@

歌词文件

- +
diff --git a/src/routes/play/[id]/+page.svelte b/src/routes/play/[id]/+page.svelte index b7558eb..20408ad 100644 --- a/src/routes/play/[id]/+page.svelte +++ b/src/routes/play/[id]/+page.svelte @@ -13,6 +13,7 @@ import type { IAudioMetadata } from 'music-metadata-browser'; import { onMount } from 'svelte'; import progressBarRaw from '$lib/state/progressBarRaw'; + import { parseTTML, type LyricLine, type LyricWord } from '$lib/ttml'; const audioId = $page.params.id; let audioPlayer: HTMLAudioElement | null = null; @@ -44,26 +45,26 @@ ] }); ms.setActionHandler('play', function () { - if (audioPlayer===null) return; + if (audioPlayer === null) return; audioPlayer.play(); paused = false; }); ms.setActionHandler('pause', function () { - if (audioPlayer===null) return; + if (audioPlayer === null) return; audioPlayer.pause(); paused = true; }); ms.setActionHandler('seekbackward', function () { - if (audioPlayer===null) return; + if (audioPlayer === null) return; if (audioPlayer.currentTime > 4) { audioPlayer.currentTime = 0; } }); ms.setActionHandler('previoustrack', function () { - if (audioPlayer===null) return; + if (audioPlayer === null) return; if (audioPlayer.currentTime > 4) { audioPlayer.currentTime = 0; } @@ -85,7 +86,7 @@ prepared.push('cover'); }); localforage.getItem(`${audioId}-file`, function (err, file) { - if (audioPlayer===null) return; + if (audioPlayer === null) return; if (file) { const f = file as File; audioFile = f; @@ -99,10 +100,28 @@ if (file) { const f = file as File; f.text().then((lr) => { - originalLyrics = lrcParser(lr); - if (!originalLyrics.scripts) return; - for (const line of originalLyrics.scripts) { - lyricsText.push(line.text); + if (f.name.endsWith('.ttml')) { + const lyricLines = parseTTML(lr).lyricLines; + originalLyrics = { + scripts: lyricLines.map((value: LyricLine, index: number, array: LyricLine[]) => { + return { + text: value.words.map(word => word.word).join(''), + start: value.startTime / 1000, + end: value.endTime / 1000, + translation: value.translatedLyric || undefined + }; + }) + }; + for (const line of originalLyrics.scripts!) { + lyricsText.push(line.text); + } + hasLyrics = true; + } else if (f.name.endsWith('.lrc')) { + originalLyrics = lrcParser(lr); + if (!originalLyrics.scripts) return; + for (const line of originalLyrics.scripts) { + lyricsText.push(line.text); + } } }); } @@ -110,7 +129,7 @@ } function playAudio() { - if (audioPlayer===null) return; + if (audioPlayer === null) return; if (audioPlayer.duration) { duration = audioPlayer.duration; } @@ -158,17 +177,16 @@ $: { clearInterval(mainInterval); mainInterval = setInterval(() => { - if (audioPlayer===null) return; - if ($userAdjustingProgress === false) - currentProgress = audioPlayer.currentTime; + if (audioPlayer === null) return; + if ($userAdjustingProgress === false) currentProgress = audioPlayer.currentTime; progressBarRaw.set(audioPlayer.currentTime); }, 50); } - onMount(() => { - if (audioPlayer===null) return; - audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1; - }); + onMount(() => { + if (audioPlayer === null) return; + audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1; + }); $: { if (audioPlayer) { @@ -202,18 +220,18 @@ {hasLyrics} /> - +