diff --git a/bun.lockb b/bun.lockb index 383e354..98d6a6f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 773c30a..3850db9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aquavox", - "version": "1.15.0", + "version": "2.0.0", "private": false, "scripts": { "dev": "vite dev", @@ -34,15 +34,27 @@ "tailwindcss": "^3.4.3", "typescript": "^5.4.5", "vite": "^5.2.11", + "vite-plugin-wasm": "^3.3.0", "vitest": "^1.6.0" }, "type": "module", "dependencies": { + "@applemusic-like-lyrics/core": "^0.1.3", + "@applemusic-like-lyrics/lyric": "^0.2.2", "@esbuild-plugins/node-globals-polyfill": "^0.2.3", + "@pixi/app": "^7.4.2", + "@pixi/core": "^7.4.2", + "@pixi/display": "^7.4.2", + "@pixi/filter-blur": "^7.4.2", + "@pixi/filter-bulge-pinch": "^5.1.1", + "@pixi/filter-color-matrix": "^7.4.2", + "@pixi/sprite": "^7.4.2", "@types/bun": "^1.1.6", "bezier-easing": "^2.1.0", "jotai": "^2.8.0", "jotai-svelte": "^0.0.2", + "jss": "^10.10.0", + "jss-preset-default": "^10.10.0", "localforage": "^1.10.0", "lrc-parser-ts": "^1.0.3", "music-metadata-browser": "^2.5.10", diff --git a/src/lib/components/newLyrics.svelte b/src/lib/components/newLyrics.svelte new file mode 100644 index 0000000..bded294 --- /dev/null +++ b/src/lib/components/newLyrics.svelte @@ -0,0 +1,132 @@ + + +
diff --git a/src/lib/lyrics/mapLyric.ts b/src/lib/lyrics/mapLyric.ts new file mode 100644 index 0000000..13f96b2 --- /dev/null +++ b/src/lib/lyrics/mapLyric.ts @@ -0,0 +1,18 @@ +import type { LyricLine } from "@applemusic-like-lyrics/core"; +import { + type LyricLine as RawLyricLine, + parseLrc, + parseYrc, + parseLys, + parseQrc, +} from "@applemusic-like-lyrics/lyric"; + +export const mapLyric = (line: RawLyricLine, i: number, lines: RawLyricLine[]): LyricLine => ({ + words: line.words, + startTime: line.words[0]?.startTime ?? 0, + endTime: line.words[line.words.length - 1]?.endTime ?? Infinity, + translatedLyric: '', + romanLyric: '', + isBG: false, + isDuet: false +}); 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..192174c 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..514b51e 100644 --- a/src/routes/play/[id]/+page.svelte +++ b/src/routes/play/[id]/+page.svelte @@ -4,15 +4,19 @@ import Background from '$lib/components/background.svelte'; import Cover from '$lib/components/cover.svelte'; import InteractiveBox from '$lib/components/interactiveBox.svelte'; - import Lyrics from '$lib/components/lyrics.svelte'; import extractFileName from '$lib/extractFileName'; import localforage from 'localforage'; import { writable } from 'svelte/store'; - import lrcParser, { type LrcJsonData } from '$lib/lyrics/parser'; import userAdjustingProgress from '$lib/state/userAdjustingProgress'; import type { IAudioMetadata } from 'music-metadata-browser'; - import { onMount } from 'svelte'; + import { onDestroy, onMount } from 'svelte'; import progressBarRaw from '$lib/state/progressBarRaw'; + import { parseTTML, type TTMLLyric } from '$lib/ttml'; + import type { LyricLine, LyricLineMouseEvent, LyricPlayer } from '@applemusic-like-lyrics/core'; + import NewLyrics from '$lib/components/newLyrics.svelte'; + import { LyricPlayer as CoreLyricPlayer } from '@applemusic-like-lyrics/core'; + import { parseLrc } from '@applemusic-like-lyrics/lyric'; + import { mapLyric } from '$lib/lyrics/mapLyric'; const audioId = $page.params.id; let audioPlayer: HTMLAudioElement | null = null; @@ -25,9 +29,9 @@ let paused: boolean = true; let launched = false; let prepared: string[] = []; - let originalLyrics: LrcJsonData; - let lyricsText: string[] = []; + let lyricLines: LyricLine[]; let hasLyrics: boolean; + let lyricPlayer: LyricPlayer = new CoreLyricPlayer(); const coverPath = writable(''); let mainInterval: ReturnType; @@ -44,26 +48,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 +89,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 +103,26 @@ 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')) { + lyricLines = parseTTML(lr).lyricLines; + hasLyrics = true; + } else if (f.name.endsWith('.lrc')) { + lyricLines = parseLrc(lr).map((line, i, lines) => ({ + words: [ + { + word: line.words[0]?.word ?? '', + startTime: line.words[0]?.startTime ?? 0, + endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity + } + ], + startTime: line.words[0]?.startTime ?? 0, + endTime: lines[i + 1]?.words?.[0]?.startTime ?? Infinity, + translatedLyric: '', + romanLyric: '', + isBG: false, + isDuet: false + })); + hasLyrics = true; } }); } @@ -110,7 +130,7 @@ } function playAudio() { - if (audioPlayer===null) return; + if (audioPlayer === null) return; if (audioPlayer.duration) { duration = audioPlayer.duration; } @@ -140,6 +160,7 @@ if (audioPlayer) { audioPlayer.currentTime = duration * progress; currentProgress = duration * progress; + lyricPlayer.calcLayout(false, true); } } @@ -158,17 +179,19 @@ $: { 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; + }); + onDestroy(() => { + if (audioPlayer === null) return; + }); $: { if (audioPlayer) { @@ -177,7 +200,10 @@ } } - $: hasLyrics = !!originalLyrics; + function onLyricLineClick(e: LyricLineMouseEvent) { + lyricPlayer.resetScroll(); + adjustProgress(lyricLines[e.lineIndex].startTime / 1000 / duration); + } readDB(); @@ -202,18 +228,24 @@ {hasLyrics} /> - +