Compare commits
No commits in common. "57de9f426f6fe9edb62b569f16d0d9957942401a" and "5988c8335cfcdb0de2acfff048158ad17a07d5e0" have entirely different histories.
57de9f426f
...
5988c8335c
@ -1,2 +0,0 @@
|
|||||||
[install.scopes]
|
|
||||||
"@jsr" = "https://npm.jsr.io"
|
|
@ -30,13 +30,11 @@
|
|||||||
"svelte-check": "^3.7.1",
|
"svelte-check": "^3.7.1",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"vitest": "^2.1.4",
|
"vitest": "^1.6.0",
|
||||||
"@types/bun": "^1.1.6",
|
"@types/bun": "^1.1.6",
|
||||||
"concurrently": "^9.0.1",
|
"concurrently": "^9.0.1",
|
||||||
"cross-env": "^7.0.3"
|
"cross-env": "^7.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
"trustedDependencies": [
|
}
|
||||||
"svelte-preprocess"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@ -46,6 +46,7 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
canvas {
|
canvas {
|
||||||
position: relative;
|
position: relative;
|
||||||
@ -54,6 +55,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: .45s;
|
transition: .45s;
|
||||||
filter: saturate(1.2);
|
filter: brightness(0.8);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -12,9 +12,8 @@
|
|||||||
{#if hasLyrics}
|
{#if hasLyrics}
|
||||||
<img
|
<img
|
||||||
class="absolute shadow-md select-none z-10 object-cover rounded-lg md:rounded-2xl max-md:h-20 max-xl:h-32 max-xl:top-6 md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] xl:max-w-[37vw]
|
class="absolute shadow-md select-none z-10 object-cover rounded-lg md:rounded-2xl max-md:h-20 max-xl:h-32 max-xl:top-6 md:max-h-[calc(94vh-20rem)] xl:w-auto max-w-[90%] xl:max-w-[37vw]
|
||||||
md:bottom-[21rem] left-6 md:left-[calc(7vw-1rem)] lg:left-[calc(12vw-1rem)] xl:translate-x-[-50%] xl:left-[25vw]"
|
md:bottom-80 left-6 md:left-[calc(7vw-1rem)] lg:left-[calc(12vw-1rem)] xl:translate-x-[-50%] xl:left-[25vw]"
|
||||||
src={path}
|
src={path}
|
||||||
width="1200"
|
|
||||||
alt="封面"
|
alt="封面"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
|
@ -78,7 +78,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={'absolute select-none bottom-12 h-60 w-[86vw] left-[7vw] z-10 ' +
|
class={'absolute select-none bottom-2 h-60 w-[86vw] left-[7vw] z-10 ' +
|
||||||
(hasLyrics
|
(hasLyrics
|
||||||
? 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[7vw]'
|
? 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[7vw]'
|
||||||
: 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]')}
|
: 'lg:w-[76vw] lg:left-[12vw] xl:w-[37vw] xl:left-[31.5vw]')}
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import createSpring from '@core/graphics/spring';
|
import createSpring from '@core/graphics/spring';
|
||||||
import type { LyricWord, ScriptItem } from '@core/lyrics/type';
|
import type { ScriptItem } from '@core/lyrics/type';
|
||||||
import type { LyricPos } from './type';
|
import type { LyricPos } from './type';
|
||||||
import type { Spring } from '@core/graphics/spring/spring';
|
import type { Spring } from '@core/graphics/spring/spring';
|
||||||
|
|
||||||
@ -91,7 +91,7 @@
|
|||||||
} else {
|
} else {
|
||||||
blurRadius = Math.min(offset * blurRatio, 16);
|
blurRadius = Math.min(offset * blurRatio, 16);
|
||||||
}
|
}
|
||||||
if (scrolling) blurRadius = 0;
|
if (scrolling) blurRadius=0;
|
||||||
ref.style.filter = `blur(${blurRadius}px)`;
|
ref.style.filter = `blur(${blurRadius}px)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -134,53 +134,6 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getRef = () => ref;
|
export const getRef = () => ref;
|
||||||
|
|
||||||
// Calculate if the current character should be highlighted based on progress
|
|
||||||
const isCharacterHighlighted = (line: ScriptItem, word: LyricWord, charIndex: number, progress: number) => {
|
|
||||||
const charProgress = getCharacterProgress(word, charIndex);
|
|
||||||
return line.start <= progress &&
|
|
||||||
progress <= line.end &&
|
|
||||||
progress > charProgress;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get the progress timing for a specific character in a word
|
|
||||||
const getCharacterProgress = (word: LyricWord, charIndex: number) => {
|
|
||||||
const { startTime, endTime } = word;
|
|
||||||
const wordDuration = endTime - startTime;
|
|
||||||
return wordDuration * (charIndex / word.word.length) + startTime;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate the transition duration for a character
|
|
||||||
const getTransitionDuration = (word: LyricWord, charIndex: number) => {
|
|
||||||
const { startTime, endTime } = word;
|
|
||||||
const wordDuration = endTime - startTime;
|
|
||||||
const charDuration = wordDuration * ((charIndex + 1) / word.word.length);
|
|
||||||
|
|
||||||
// If duration is less than 0.6s, we'll use CSS class with fixed duration
|
|
||||||
if (charDuration < 0.6) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, calculate custom duration
|
|
||||||
return charDuration / 1.6;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate the CSS classes for a character
|
|
||||||
const getCharacterClasses = (line: ScriptItem, word: LyricWord, charIndex: number, progress: number) => {
|
|
||||||
const baseClasses = 'inline-block';
|
|
||||||
const opacityClass = isCharacterHighlighted(line, word, charIndex, progress)
|
|
||||||
? 'opacity-100 text-glow'
|
|
||||||
: 'opacity-35';
|
|
||||||
|
|
||||||
return `${baseClasses} ${opacityClass}`.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Generate the style string for a character
|
|
||||||
const getCharacterStyle = (line: ScriptItem, word: LyricWord, charIndex: number, progress: number) => {
|
|
||||||
const duration = getTransitionDuration(word, charIndex);
|
|
||||||
const progressAtCurrentLine = progress <= line.end;
|
|
||||||
return (duration && progressAtCurrentLine) ? `transition-duration: ${duration}s;` : 'transition-duration: 200ms;';
|
|
||||||
};
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
@ -197,28 +150,30 @@
|
|||||||
on:touchstart={() => {
|
on:touchstart={() => {
|
||||||
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
|
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
|
||||||
}}
|
}}
|
||||||
style="transform: translate3d({positionX}px, {positionY}px, 0);
|
style="transform: translate3d({positionX}px, {positionY}px, 0); transition-property: text-shadow;
|
||||||
transform-origin: center left; font-family: LyricFont, sans-serif;"
|
transition-duration: 0.36s; transition-timing-function: ease-out;
|
||||||
|
transform-origin: center left;"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
bind:this={clickMask}
|
bind:this={clickMask}
|
||||||
class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-3rem)] h-full
|
class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-3rem)] h-full
|
||||||
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)] z-[100]"
|
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)] z-[100] "
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
{#if debugMode}
|
{#if debugMode}
|
||||||
<span class="text-white text-lg absolute -translate-y-7">
|
<span class="text-lg absolute -translate-y-7">
|
||||||
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if line.words !== undefined && line.words.length > 0}
|
{#if line.words !== undefined && line.words.length > 0}
|
||||||
<span class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 `}>
|
<span
|
||||||
|
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 `}
|
||||||
|
>
|
||||||
{#each line.words as word}
|
{#each line.words as word}
|
||||||
{#if word.word}
|
{#if word.word}
|
||||||
{#each word.word.split('') as chr, i}
|
{#each word.word.split("") as chr, i}
|
||||||
<span
|
<span
|
||||||
class={getCharacterClasses(line, word, i, progress)}
|
class={(line.start <= progress && progress <= line.end && progress > (word.endTime - word.startTime) * ((i)/word.word.length) + word.startTime ? "opacity-100 text-glow" : "opacity-35") + " inline-block duration-300"}
|
||||||
style={getCharacterStyle(line, word, i, progress)}
|
|
||||||
>
|
>
|
||||||
{chr}
|
{chr}
|
||||||
</span>
|
</span>
|
||||||
@ -228,8 +183,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span
|
<span
|
||||||
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 duration-200
|
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 duration-200 ${line.start <= progress && progress <= line.end ? "opacity-100 text-glow" : "opacity-35"}`}
|
||||||
${line.start <= progress && progress <= line.end ? 'opacity-100 text-glow' : 'opacity-35'}`}
|
|
||||||
>
|
>
|
||||||
{line.text}
|
{line.text}
|
||||||
</span>
|
</span>
|
||||||
@ -243,11 +197,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.text-glow {
|
.text-glow {
|
||||||
text-shadow: 0 0 3px #ffffff2c,
|
text-shadow:
|
||||||
0 0 6px #ffffff2c,
|
0 0 3px #ffffff2c,
|
||||||
0 15px 30px rgba(0, 0, 0, 0.11),
|
0 0 6px #ffffff2c,
|
||||||
0 5px 15px rgba(0, 0, 0, 0.08);
|
0 15px 30px rgba(0, 0, 0, 0.11),
|
||||||
|
0 5px 15px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { type ScriptItem, type LyricData } from '@alikia/aqualyrics';
|
import type { LrcJsonData, ScriptItem } from '@core/lyrics/type';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import LyricLine from './lyricLine.svelte';
|
import LyricLine from './lyricLine.svelte';
|
||||||
import createLyricsSearcher from '@core/lyrics/lyricSearcher';
|
import createLyricsSearcher from '@core/lyrics/lyricSearcher';
|
||||||
@ -15,7 +15,7 @@
|
|||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
export let originalLyrics: LyricData;
|
export let originalLyrics: LrcJsonData;
|
||||||
export let progress: number;
|
export let progress: number;
|
||||||
export let player: HTMLAudioElement | null;
|
export let player: HTMLAudioElement | null;
|
||||||
|
|
||||||
@ -243,32 +243,13 @@
|
|||||||
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
player.play();
|
player.play();
|
||||||
}
|
}
|
||||||
|
|
||||||
let lastFPSTime = performance.now();
|
|
||||||
let frameCount = 0;
|
|
||||||
let fps = 0;
|
|
||||||
|
|
||||||
function calculateFPS(t: number) {
|
|
||||||
// 计算时间差
|
|
||||||
const deltaTime = t - lastFPSTime;
|
|
||||||
frameCount ++;
|
|
||||||
if (frameCount % 5 == 0) {
|
|
||||||
fps = 1000 / deltaTime;
|
|
||||||
}
|
|
||||||
lastFPSTime = t;
|
|
||||||
// 请求下一帧
|
|
||||||
requestAnimationFrame(calculateFPS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 开始检测帧率
|
|
||||||
requestAnimationFrame(calculateFPS);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:keydown={onKeyDown} />
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
{#if debugMode}
|
{#if debugMode}
|
||||||
<span class="text-white text-lg absolute z-50 px-2 py-0.5 m-2 rounded-3xl bg-white bg-opacity-20 backdrop-blur-lg right-0 font-mono">
|
<span class="text-lg absolute">
|
||||||
{fps.toFixed(1)}fps, progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex}
|
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
@ -47,6 +47,7 @@ export class Spring {
|
|||||||
return (
|
return (
|
||||||
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
|
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
|
||||||
this.getV(this.currentTime) < 0.01 &&
|
this.getV(this.currentTime) < 0.01 &&
|
||||||
|
this.getV2(this.currentTime) < 0.01 &&
|
||||||
this.queueParams === undefined &&
|
this.queueParams === undefined &&
|
||||||
this.queuePosition === undefined
|
this.queuePosition === undefined
|
||||||
);
|
);
|
||||||
|
20
packages/core/lyrics/LRCtoAMLL.ts
Normal file
20
packages/core/lyrics/LRCtoAMLL.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import type { LyricLine } from '@applemusic-like-lyrics/core';
|
||||||
|
import type { ScriptItem } from './LRCparser';
|
||||||
|
|
||||||
|
export default function mapLRCtoAMLL(line: ScriptItem, i: number, lines: ScriptItem[]): LyricLine {
|
||||||
|
return {
|
||||||
|
words: [
|
||||||
|
{
|
||||||
|
word: line.text,
|
||||||
|
startTime: line.start * 1000,
|
||||||
|
endTime: line.end * 1000
|
||||||
|
}
|
||||||
|
],
|
||||||
|
startTime: line.start * 1000,
|
||||||
|
endTime: line.end * 1000,
|
||||||
|
translatedLyric: line.translation ?? "",
|
||||||
|
romanLyric: '',
|
||||||
|
isBG: false,
|
||||||
|
isDuet: false
|
||||||
|
};
|
||||||
|
}
|
10
packages/core/lyrics/lrc/type.d.ts
vendored
10
packages/core/lyrics/lrc/type.d.ts
vendored
@ -1,3 +1,13 @@
|
|||||||
|
import type { ScriptWordsItem } from "../type";
|
||||||
|
|
||||||
|
export interface ParserScriptItem {
|
||||||
|
start: number;
|
||||||
|
text: string;
|
||||||
|
words?: ScriptWordsItem[];
|
||||||
|
translation?: string;
|
||||||
|
singer?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IDTag {
|
export interface IDTag {
|
||||||
[key: string]: string;
|
[key: string]: string;
|
||||||
}
|
}
|
@ -2,13 +2,14 @@ import type { LrcJsonData } from '../type';
|
|||||||
import { parseTTML as ttmlParser } from './parser';
|
import { parseTTML as ttmlParser } from './parser';
|
||||||
import type { LyricLine } from './ttml-types';
|
import type { LyricLine } from './ttml-types';
|
||||||
|
|
||||||
|
export * from './writer';
|
||||||
export type * from './ttml-types';
|
export type * from './ttml-types';
|
||||||
|
|
||||||
export function parseTTML(text: string) {
|
export function parseTTML(text: string) {
|
||||||
let lyrics: LrcJsonData;
|
let lyrics: LrcJsonData;
|
||||||
const lyricLines = ttmlParser(text).lyricLines;
|
const lyricLines = ttmlParser(text).lyricLines;
|
||||||
lyrics = {
|
lyrics = {
|
||||||
scripts: lyricLines.map((value: LyricLine, _index: number, _array: LyricLine[]) => {
|
scripts: lyricLines.map((value: LyricLine, index: number, array: LyricLine[]) => {
|
||||||
let words = value.words.length == 0 ? undefined : value.words;
|
let words = value.words.length == 0 ? undefined : value.words;
|
||||||
if (words) {
|
if (words) {
|
||||||
words = words.map((word) => {
|
words = words.map((word) => {
|
||||||
|
254
packages/core/lyrics/ttml/writer.ts
Normal file
254
packages/core/lyrics/ttml/writer.ts
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
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("code");
|
||||||
|
|
||||||
|
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(
|
||||||
|
[
|
||||||
|
'<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">',
|
||||||
|
' <xsl:strip-space elements="*"/>',
|
||||||
|
' <xsl:template match="para[content-style][not(text())]">',
|
||||||
|
' <xsl:value-of select="normalize-space(.)"/>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:template match="node()|@*">',
|
||||||
|
' <xsl:copy><xsl:apply-templates select="node()|@*"/></xsl:copy>',
|
||||||
|
" </xsl:template>",
|
||||||
|
' <xsl:output indent="yes"/>',
|
||||||
|
"</xsl:stylesheet>",
|
||||||
|
].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);
|
||||||
|
}
|
21
packages/core/lyrics/type.d.ts
vendored
21
packages/core/lyrics/type.d.ts
vendored
@ -1,11 +1,8 @@
|
|||||||
export interface ScriptItem{
|
import type { ParserScriptItem } from "./lrc/type";
|
||||||
|
|
||||||
|
export interface ScriptItem extends ParserScriptItem {
|
||||||
end: number;
|
end: number;
|
||||||
chorus?: string;
|
chorus?: string;
|
||||||
start: number;
|
|
||||||
text: string;
|
|
||||||
words?: LyricWord[];
|
|
||||||
translation?: string;
|
|
||||||
singer?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LyricWord {
|
export interface LyricWord {
|
||||||
@ -15,7 +12,7 @@ export interface LyricWord {
|
|||||||
emptyBeat?: number;
|
emptyBeat?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LyricMetadata {
|
export interface LrcMetaData {
|
||||||
ar?: string;
|
ar?: string;
|
||||||
ti?: string;
|
ti?: string;
|
||||||
al?: string;
|
al?: string;
|
||||||
@ -26,8 +23,14 @@ export interface LyricMetadata {
|
|||||||
ve?: string;
|
ve?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LyricData extends LyricMetadata {
|
export interface ParsedLrc extends LrcMetaData {
|
||||||
scripts?: ScriptItem[];
|
scripts?: ParserScriptItem[];
|
||||||
|
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LrcJsonData extends LrcMetaData {
|
||||||
|
scripts?: ScriptItem[];
|
||||||
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
@ -23,10 +23,10 @@
|
|||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite": "5.4.6",
|
"vite": "5.4.6",
|
||||||
"vite-plugin-wasm": "^3.3.0"
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
|
|
||||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||||
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
|
||||||
import formatDuration from '@core/utils/formatDuration';
|
|
||||||
|
|
||||||
describe('formatDuration test', () => {
|
|
||||||
it('converts 120 seconds to "2:00"', () => {
|
|
||||||
expect(formatDuration(120)).toBe('2:00');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts 119.935429 seconds to "1:59"', () => {
|
|
||||||
expect(formatDuration(119.935429)).toBe('1:59');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts 185 seconds to "3:05"', () => {
|
|
||||||
expect(formatDuration(185)).toBe('3:05');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts 601 seconds to "10:01"', () => {
|
|
||||||
expect(formatDuration(601)).toBe('10:01');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('converts 3601 seconds to "1:00:01"', () => {
|
|
||||||
expect(formatDuration(3601)).toBe('1:00:01');
|
|
||||||
});
|
|
||||||
});
|
|
@ -1,7 +1,6 @@
|
|||||||
{
|
{
|
||||||
"extends": ["../../tsconfig.base.json"],
|
"extends": ["../../tsconfig.base.json"],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2019",
|
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@core/*": ["./*"]
|
"@core/*": ["./*"]
|
||||||
@ -14,7 +13,6 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"types": ["bun"],
|
"types": ["bun"]
|
||||||
"moduleResolution": "bundler"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import windowStateManager from 'electron-window-state';
|
|||||||
import { app, BrowserWindow, ipcMain } from 'electron';
|
import { app, BrowserWindow, ipcMain } from 'electron';
|
||||||
import contextMenu from 'electron-context-menu';
|
import contextMenu from 'electron-context-menu';
|
||||||
import serve from 'electron-serve';
|
import serve from 'electron-serve';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
require('electron-reloader')(module);
|
require('electron-reloader')(module);
|
||||||
|
@ -1,5 +1,28 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { safePath } from '@core/server/safePath.js';
|
import formatDuration from '$lib/utils/formatDuration.js';
|
||||||
|
import { safePath } from '$lib/server/safePath';
|
||||||
|
|
||||||
|
describe('formatDuration test', () => {
|
||||||
|
it('converts 120 seconds to "2:00"', () => {
|
||||||
|
expect(formatDuration(120)).toBe('2:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 119.935429 seconds to "1:59"', () => {
|
||||||
|
expect(formatDuration(119.935429)).toBe('1:59');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 185 seconds to "3:05"', () => {
|
||||||
|
expect(formatDuration(185)).toBe('3:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 601 seconds to "10:01"', () => {
|
||||||
|
expect(formatDuration(601)).toBe('10:01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 3601 seconds to "1:00:01"', () => {
|
||||||
|
expect(formatDuration(3601)).toBe('1:00:01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('safePath test', () => {
|
describe('safePath test', () => {
|
||||||
const base = "data/subdir";
|
const base = "data/subdir";
|
@ -81,46 +81,10 @@
|
|||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-cover`, function (err, file) {
|
localforage.getItem(`${audioId}-cover`, function (err, file) {
|
||||||
if (file) {
|
if (file) {
|
||||||
const img = new Image();
|
const path = URL.createObjectURL(file as File);
|
||||||
img.src = URL.createObjectURL(file as File);
|
coverPath.set(path);
|
||||||
|
|
||||||
img.onload = function () {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// 计算新的宽度和高度,确保宽度至少为1200px
|
|
||||||
let newWidth = img.width;
|
|
||||||
let newHeight = img.height;
|
|
||||||
|
|
||||||
console.log(newWidth)
|
|
||||||
|
|
||||||
if (newWidth < 1200) {
|
|
||||||
newWidth = 1200;
|
|
||||||
newHeight = (img.height * 1200) / img.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = newWidth;
|
|
||||||
canvas.height = newHeight;
|
|
||||||
|
|
||||||
// 绘制放大后的图片到canvas
|
|
||||||
ctx!.drawImage(img, 0, 0, newWidth, newHeight);
|
|
||||||
|
|
||||||
// 将canvas内容转换为Blob
|
|
||||||
canvas.toBlob(function (blob) {
|
|
||||||
const path = URL.createObjectURL(blob!);
|
|
||||||
coverPath.set(path);
|
|
||||||
}, 'image/jpeg'); // 你可以根据需要更改图片格式
|
|
||||||
|
|
||||||
prepared.push('cover');
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = function () {
|
|
||||||
console.error('Failed to load image');
|
|
||||||
prepared.push('cover');
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
prepared.push('cover');
|
|
||||||
}
|
}
|
||||||
|
prepared.push('cover');
|
||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||||
if (audioPlayer === null) return;
|
if (audioPlayer === null) return;
|
||||||
@ -247,7 +211,7 @@
|
|||||||
{hasLyrics}
|
{hasLyrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer} />
|
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
|
||||||
|
|
||||||
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
|
<!-- <Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" /> -->
|
||||||
|
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { parseLRC } from '@core/lyrics/lrc/parser';
|
import { parseLRC } from '../../packages/core/lyrics/lrc/parser';
|
||||||
|
|
||||||
describe('LRC parser test', () => {
|
describe('LRC parser test', () => {
|
||||||
const test01Buffer = fs.readFileSync('./packages/core/test/lyrics/resources/test-01.lrc');
|
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');
|
||||||
const test01Text = test01Buffer.toString('utf-8');
|
const test01Text = test01Buffer.toString('utf-8');
|
||||||
const test03Buffer = fs.readFileSync('./packages/core/test/lyrics/resources/test-03.lrc');
|
const test02Buffer = fs.readFileSync('./src/test/resources/test-02.lrc');
|
||||||
|
const test02Text = test02Buffer.toString('utf-8');
|
||||||
|
const test03Buffer = fs.readFileSync('./src/test/resources/test-03.lrc');
|
||||||
const test03Text = test03Buffer.toString('utf-8');
|
const test03Text = test03Buffer.toString('utf-8');
|
||||||
|
|
||||||
const lf_alternatives = ['\n', '\r\n', '\r'];
|
const lf_alternatives = ['\n', '\r\n', '\r'];
|
||||||
@ -24,6 +26,17 @@ describe('LRC parser test', () => {
|
|||||||
expect(result.scripts!![1].start).toBe(49000 + 588);
|
expect(result.scripts!![1].start).toBe(49000 + 588);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
it('Parses test-02.lrc', () => {
|
||||||
|
const result = parseLRC(test02Text, { wordDiv: ' ', strict: true });
|
||||||
|
|
||||||
|
expect(result.ti).toBe("Somebody to Love");
|
||||||
|
expect(result.ar).toBe("Jefferson Airplane");
|
||||||
|
expect(result.scripts!!.length).toBe(3);
|
||||||
|
expect(result.scripts!![0].text).toBe("When the truth is found to be lies");
|
||||||
|
expect(result.scripts!![0].start).toBe(0);
|
||||||
|
expect(result.scripts!![0].words!![1].beginIndex).toBe("[00:00.00] <00:00.04> When <00:00.16> the".indexOf("the"));
|
||||||
|
expect(result.scripts!![0].words!![1].start).toBe(160);
|
||||||
|
});
|
||||||
it('Parses test-03.lrc', () => {
|
it('Parses test-03.lrc', () => {
|
||||||
const result = parseLRC(test03Text, { wordDiv: ' ', strict: true });
|
const result = parseLRC(test03Text, { wordDiv: ' ', strict: true });
|
||||||
expect(result.scripts!![5].text).toBe("བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ།");
|
expect(result.scripts!![5].text).toBe("བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ།");
|
||||||
@ -33,6 +46,18 @@ describe('LRC parser test', () => {
|
|||||||
expect(result.scripts!![11].singer).toBeUndefined();
|
expect(result.scripts!![11].singer).toBeUndefined();
|
||||||
expect(result.scripts!![11].translation).toBe("我们在此相聚");
|
expect(result.scripts!![11].translation).toBe("我们在此相聚");
|
||||||
});
|
});
|
||||||
|
it('Rejects some invalid LRCs', () => {
|
||||||
|
const cases = [
|
||||||
|
"[<00:00.00>] <00:00.04> When <00:00.16> the",
|
||||||
|
"[00:00.00] <00:00.04> <When> <00:00.16> the",
|
||||||
|
"[00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
"<00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
"<1:00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
]
|
||||||
|
for (const c of cases) {
|
||||||
|
expect(() => parseLRC(c, { strict: true })).toThrow();
|
||||||
|
}
|
||||||
|
})
|
||||||
it('Accepts some weird but parsable LRCs', () => {
|
it('Accepts some weird but parsable LRCs', () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
"[ti: []]",
|
"[ti: []]",
|
||||||
@ -49,4 +74,14 @@ describe('LRC parser test', () => {
|
|||||||
expect(() => parseLRC(c, { strict: false })).not.toThrow();
|
expect(() => parseLRC(c, { strict: false })).not.toThrow();
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
it('Parses a legacy LRC', () => {
|
||||||
|
const result = parseLRC(test02Text, { wordDiv: ' ', strict: true, legacy: true });
|
||||||
|
|
||||||
|
expect(result.ti).toBe("Somebody to Love");
|
||||||
|
expect(result.ar).toBe("Jefferson Airplane");
|
||||||
|
expect(result.scripts!!.length).toBe(3);
|
||||||
|
expect(result.scripts!![1].text).toBe("<00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies");
|
||||||
|
expect(result.scripts!![1].start).toBe(6000 + 470);
|
||||||
|
result.scripts!!.forEach((s) => expect(s.words).not.toBeDefined());
|
||||||
|
});
|
||||||
});
|
});
|
9
packages/electron/src/test/resources/test-02.lrc
Normal file
9
packages/electron/src/test/resources/test-02.lrc
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[ti: Somebody to Love]
|
||||||
|
[ar: Jefferson Airplane]
|
||||||
|
[al: Surrealistic Pillow]
|
||||||
|
[lr: Lyricists of that song]
|
||||||
|
[length: 2:58]
|
||||||
|
|
||||||
|
[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies
|
||||||
|
[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies
|
||||||
|
[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> love
|
@ -12,44 +12,43 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/svelte": "^4.0.2",
|
"@iconify/svelte": "^4.0.2",
|
||||||
"@sveltejs/adapter-auto": "^3.3.1",
|
"@sveltejs/adapter-auto": "^3.2.0",
|
||||||
"@sveltejs/adapter-node": "^5.2.9",
|
"@sveltejs/adapter-node": "^5.0.1",
|
||||||
"@sveltejs/kit": "^2.8.1",
|
"@sveltejs/kit": "^2.5.9",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||||
"@types/eslint": "^8.56.12",
|
"@types/eslint": "^8.56.10",
|
||||||
"@types/node": "^20.17.6",
|
"@types/node": "^20.14.10",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.57.1",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.46.0",
|
"eslint-plugin-svelte": "^2.39.0",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-svelte": "^3.2.8",
|
"prettier-plugin-svelte": "^3.2.3",
|
||||||
"svelte": "^4.2.19",
|
"svelte": "^4.2.19",
|
||||||
"svelte-check": "^3.8.6",
|
"svelte-check": "^3.7.1",
|
||||||
"tailwindcss": "^3.4.15",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.4.5",
|
||||||
"vite": "5.4.6",
|
"vite": "5.4.6",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"vitest": "^1.6.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alikia/aqualyrics": "npm:@jsr/alikia__aqualyrics",
|
|
||||||
"@applemusic-like-lyrics/core": "^0.1.3",
|
"@applemusic-like-lyrics/core": "^0.1.3",
|
||||||
"@applemusic-like-lyrics/lyric": "^0.2.4",
|
"@applemusic-like-lyrics/lyric": "^0.2.2",
|
||||||
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
"@esbuild-plugins/node-globals-polyfill": "^0.2.3",
|
||||||
"@rollup/plugin-commonjs": "^28.0.1",
|
"@rollup/plugin-commonjs": "^28.0.1",
|
||||||
"@rollup/plugin-node-resolve": "^15.3.0",
|
"@rollup/plugin-node-resolve": "^15.3.0",
|
||||||
"@types/bun": "^1.1.13",
|
"@types/bun": "^1.1.6",
|
||||||
"bezier-easing": "^2.1.0",
|
"bezier-easing": "^2.1.0",
|
||||||
"jotai": "^2.10.2",
|
"jotai": "^2.8.0",
|
||||||
"jotai-svelte": "^0.0.2",
|
"jotai-svelte": "^0.0.2",
|
||||||
"jss": "^10.10.0",
|
"jss": "^10.10.0",
|
||||||
"jss-preset-default": "^10.10.0",
|
"jss-preset-default": "^10.10.0",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
"lrc-parser-ts": "^1.0.3",
|
"lrc-parser-ts": "^1.0.3",
|
||||||
"music-metadata-browser": "^2.5.11",
|
"music-metadata-browser": "^2.5.10",
|
||||||
"node-cache": "^5.1.2",
|
"node-cache": "^5.1.2",
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
|
4105
packages/web/pnpm-lock.yaml
Normal file
4105
packages/web/pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load Diff
@ -28,13 +28,7 @@ h2 {
|
|||||||
|
|
||||||
body,
|
body,
|
||||||
html {
|
html {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overscroll-behavior: none;
|
overscroll-behavior: none;
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'LyricFont';
|
|
||||||
src: url('/font.otf') format('opentype');
|
|
||||||
font-weight: 600; /* Semibold weight */
|
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
<!-- <link rel="icon" href="%sveltekit.assets%/favicon.png" /> -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>AquaVox</title>
|
<title>AquaVox</title>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
|
47
packages/web/src/index.test.js
Normal file
47
packages/web/src/index.test.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import formatDuration from '$lib/utils/formatDuration.js';
|
||||||
|
import { safePath } from '$lib/server/safePath';
|
||||||
|
|
||||||
|
describe('formatDuration test', () => {
|
||||||
|
it('converts 120 seconds to "2:00"', () => {
|
||||||
|
expect(formatDuration(120)).toBe('2:00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 119.935429 seconds to "1:59"', () => {
|
||||||
|
expect(formatDuration(119.935429)).toBe('1:59');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 185 seconds to "3:05"', () => {
|
||||||
|
expect(formatDuration(185)).toBe('3:05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 601 seconds to "10:01"', () => {
|
||||||
|
expect(formatDuration(601)).toBe('10:01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts 3601 seconds to "1:00:01"', () => {
|
||||||
|
expect(formatDuration(3601)).toBe('1:00:01');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safePath test', () => {
|
||||||
|
const base = "data/subdir";
|
||||||
|
it('rejects empty string', () => {
|
||||||
|
expect(safePath('', { base })).toBe(null);
|
||||||
|
});
|
||||||
|
it('accepts a regular path', () => {
|
||||||
|
expect(safePath('subsubdir/file.txt', { base })).toBe('data/subdir/subsubdir/file.txt');
|
||||||
|
});
|
||||||
|
it('rejects path with ..', () => {
|
||||||
|
expect(safePath('../file.txt', { base })).toBe(null);
|
||||||
|
});
|
||||||
|
it('accepts path with .', () => {
|
||||||
|
expect(safePath('./file.txt', { base })).toBe('data/subdir/file.txt');
|
||||||
|
});
|
||||||
|
it('accepts path traversal within base', () => {
|
||||||
|
expect(safePath('subsubdir/../file.txt', { base })).toBe('data/subdir/file.txt');
|
||||||
|
});
|
||||||
|
it('rejects path with subdir if noSubDir is true', () => {
|
||||||
|
expect(safePath('subsubdir/file.txt', { base, noSubDir: true })).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
@ -1,9 +1,8 @@
|
|||||||
import { safePath } from '@core/server/safePath';
|
import { safePath } from '$lib/server/safePath';
|
||||||
import { getCurrentFormattedDateTime } from '@core/utils/songUpdateTime';
|
import { getCurrentFormattedDateTime } from '$lib/utils/songUpdateTime';
|
||||||
import { json, error } from '@sveltejs/kit';
|
import { json, error } from '@sveltejs/kit';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import type { RequestHandler } from './$types';
|
import type { RequestHandler } from './$types';
|
||||||
import type { MusicMetadata } from '@core/server/database/musicInfo';
|
|
||||||
|
|
||||||
export const GET: RequestHandler = async ({ params }) => {
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
const filePath = safePath(`${params.id}.json`, { base: './data/song' });
|
||||||
|
@ -7,12 +7,13 @@
|
|||||||
import extractFileName from '@core/utils/extractFileName';
|
import extractFileName from '@core/utils/extractFileName';
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import { type LyricData } from "@alikia/aqualyrics";
|
import lrcParser from '@core/lyrics/lrc/parser';
|
||||||
|
import type { LrcJsonData } from '@core/lyrics/type';
|
||||||
import userAdjustingProgress from '@core/state/userAdjustingProgress';
|
import userAdjustingProgress from '@core/state/userAdjustingProgress';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import progressBarRaw from '@core/state/progressBarRaw';
|
import progressBarRaw from '@core/state/progressBarRaw';
|
||||||
import { parseTTML, parseLRC } from '@alikia/aqualyrics';
|
import { parseTTML } from '@core/lyrics/ttml';
|
||||||
import NewLyrics from '@core/components/lyrics/newLyrics.svelte';
|
import NewLyrics from '@core/components/lyrics/newLyrics.svelte';
|
||||||
|
|
||||||
const audioId = $page.params.id;
|
const audioId = $page.params.id;
|
||||||
@ -26,7 +27,7 @@
|
|||||||
let paused: boolean = true;
|
let paused: boolean = true;
|
||||||
let launched = false;
|
let launched = false;
|
||||||
let prepared: string[] = [];
|
let prepared: string[] = [];
|
||||||
let originalLyrics: LyricData;
|
let originalLyrics: LrcJsonData;
|
||||||
let lyricsText: string[] = [];
|
let lyricsText: string[] = [];
|
||||||
let hasLyrics: boolean;
|
let hasLyrics: boolean;
|
||||||
const coverPath = writable('');
|
const coverPath = writable('');
|
||||||
@ -80,46 +81,10 @@
|
|||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-cover`, function (err, file) {
|
localforage.getItem(`${audioId}-cover`, function (err, file) {
|
||||||
if (file) {
|
if (file) {
|
||||||
const img = new Image();
|
const path = URL.createObjectURL(file as File);
|
||||||
img.src = URL.createObjectURL(file as File);
|
coverPath.set(path);
|
||||||
|
|
||||||
img.onload = function () {
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
|
|
||||||
// 计算新的宽度和高度,确保宽度至少为1200px
|
|
||||||
let newWidth = img.width;
|
|
||||||
let newHeight = img.height;
|
|
||||||
|
|
||||||
console.log(newWidth)
|
|
||||||
|
|
||||||
if (newWidth < 1200) {
|
|
||||||
newWidth = 1200;
|
|
||||||
newHeight = (img.height * 1200) / img.width;
|
|
||||||
}
|
|
||||||
|
|
||||||
canvas.width = newWidth;
|
|
||||||
canvas.height = newHeight;
|
|
||||||
|
|
||||||
// 绘制放大后的图片到canvas
|
|
||||||
ctx!.drawImage(img, 0, 0, newWidth, newHeight);
|
|
||||||
|
|
||||||
// 将canvas内容转换为Blob
|
|
||||||
canvas.toBlob(function (blob) {
|
|
||||||
const path = URL.createObjectURL(blob!);
|
|
||||||
coverPath.set(path);
|
|
||||||
}, 'image/jpeg'); // 你可以根据需要更改图片格式
|
|
||||||
|
|
||||||
prepared.push('cover');
|
|
||||||
};
|
|
||||||
|
|
||||||
img.onerror = function () {
|
|
||||||
console.error('Failed to load image');
|
|
||||||
prepared.push('cover');
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
prepared.push('cover');
|
|
||||||
}
|
}
|
||||||
|
prepared.push('cover');
|
||||||
});
|
});
|
||||||
localforage.getItem(`${audioId}-file`, function (err, file) {
|
localforage.getItem(`${audioId}-file`, function (err, file) {
|
||||||
if (audioPlayer === null) return;
|
if (audioPlayer === null) return;
|
||||||
@ -138,13 +103,12 @@
|
|||||||
f.text().then((lr) => {
|
f.text().then((lr) => {
|
||||||
if (f.name.endsWith('.ttml')) {
|
if (f.name.endsWith('.ttml')) {
|
||||||
originalLyrics = parseTTML(lr);
|
originalLyrics = parseTTML(lr);
|
||||||
console.log(originalLyrics);
|
|
||||||
for (const line of originalLyrics.scripts!) {
|
for (const line of originalLyrics.scripts!) {
|
||||||
lyricsText.push(line.text);
|
lyricsText.push(line.text);
|
||||||
}
|
}
|
||||||
hasLyrics = true;
|
hasLyrics = true;
|
||||||
} else if (f.name.endsWith('.lrc')) {
|
} else if (f.name.endsWith('.lrc')) {
|
||||||
originalLyrics = parseLRC(lr);
|
originalLyrics = lrcParser(lr);
|
||||||
if (!originalLyrics.scripts) return;
|
if (!originalLyrics.scripts) return;
|
||||||
for (const line of originalLyrics.scripts) {
|
for (const line of originalLyrics.scripts) {
|
||||||
lyricsText.push(line.text);
|
lyricsText.push(line.text);
|
||||||
|
87
packages/web/src/test/lrcParser.test.ts
Normal file
87
packages/web/src/test/lrcParser.test.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { parseLRC } from '../../packages/core/lyrics/lrc/parser';
|
||||||
|
|
||||||
|
describe('LRC parser test', () => {
|
||||||
|
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');
|
||||||
|
const test01Text = test01Buffer.toString('utf-8');
|
||||||
|
const test02Buffer = fs.readFileSync('./src/test/resources/test-02.lrc');
|
||||||
|
const test02Text = test02Buffer.toString('utf-8');
|
||||||
|
const test03Buffer = fs.readFileSync('./src/test/resources/test-03.lrc');
|
||||||
|
const test03Text = test03Buffer.toString('utf-8');
|
||||||
|
|
||||||
|
const lf_alternatives = ['\n', '\r\n', '\r'];
|
||||||
|
|
||||||
|
it('Parses test-01.lrc', () => {
|
||||||
|
for (const lf of lf_alternatives) {
|
||||||
|
const text = test01Text.replaceAll('\n', lf);
|
||||||
|
|
||||||
|
const result = parseLRC(text, { wordDiv: '', strict: true });
|
||||||
|
|
||||||
|
expect(result.ar).toBe("洛天依");
|
||||||
|
expect(result.ti).toBe("中华少女·终");
|
||||||
|
expect(result.al).toBe("中华少女");
|
||||||
|
expect(result["tool"]).toBe("歌词滚动姬 https://lrc-maker.github.io");
|
||||||
|
expect(result.scripts!![1].text).toBe("因果与恩怨牵杂等谁来诊断");
|
||||||
|
expect(result.scripts!![1].start).toBe(49000 + 588);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Parses test-02.lrc', () => {
|
||||||
|
const result = parseLRC(test02Text, { wordDiv: ' ', strict: true });
|
||||||
|
|
||||||
|
expect(result.ti).toBe("Somebody to Love");
|
||||||
|
expect(result.ar).toBe("Jefferson Airplane");
|
||||||
|
expect(result.scripts!!.length).toBe(3);
|
||||||
|
expect(result.scripts!![0].text).toBe("When the truth is found to be lies");
|
||||||
|
expect(result.scripts!![0].start).toBe(0);
|
||||||
|
expect(result.scripts!![0].words!![1].beginIndex).toBe("[00:00.00] <00:00.04> When <00:00.16> the".indexOf("the"));
|
||||||
|
expect(result.scripts!![0].words!![1].start).toBe(160);
|
||||||
|
});
|
||||||
|
it('Parses test-03.lrc', () => {
|
||||||
|
const result = parseLRC(test03Text, { wordDiv: ' ', strict: true });
|
||||||
|
expect(result.scripts!![5].text).toBe("བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ།");
|
||||||
|
expect(result.scripts!![5].translation).toBe("在舞池里舞一舞");
|
||||||
|
expect(result.scripts!![6].text).toBe("祝祷转过千年 五色经幡飘飞");
|
||||||
|
expect(result.scripts!![6].singer).toBe("a");
|
||||||
|
expect(result.scripts!![11].singer).toBeUndefined();
|
||||||
|
expect(result.scripts!![11].translation).toBe("我们在此相聚");
|
||||||
|
});
|
||||||
|
it('Rejects some invalid LRCs', () => {
|
||||||
|
const cases = [
|
||||||
|
"[<00:00.00>] <00:00.04> When <00:00.16> the",
|
||||||
|
"[00:00.00] <00:00.04> <When> <00:00.16> the",
|
||||||
|
"[00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
"<00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
"<1:00:00.00> <00:00.04> When <00:00.16> the",
|
||||||
|
]
|
||||||
|
for (const c of cases) {
|
||||||
|
expect(() => parseLRC(c, { strict: true })).toThrow();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Accepts some weird but parsable LRCs', () => {
|
||||||
|
const cases = [
|
||||||
|
"[ti: []]",
|
||||||
|
"[ar: [<]]",
|
||||||
|
"[ar: <ar>]",
|
||||||
|
"[ar: a b c]",
|
||||||
|
"[00:00.00] <00:00.04> When the <00:00.16> the",
|
||||||
|
"[00:00.00] [00:00.04] When [00:00.16] the",
|
||||||
|
"[00:00.0000000] <00:00.04> When <00:00.16> the",
|
||||||
|
"[00:00.00] <00:00.04> [When] <00:00.16> the",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
expect(() => parseLRC(c, { strict: false })).not.toThrow();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('Parses a legacy LRC', () => {
|
||||||
|
const result = parseLRC(test02Text, { wordDiv: ' ', strict: true, legacy: true });
|
||||||
|
|
||||||
|
expect(result.ti).toBe("Somebody to Love");
|
||||||
|
expect(result.ar).toBe("Jefferson Airplane");
|
||||||
|
expect(result.scripts!!.length).toBe(3);
|
||||||
|
expect(result.scripts!![1].text).toBe("<00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies");
|
||||||
|
expect(result.scripts!![1].start).toBe(6000 + 470);
|
||||||
|
result.scripts!!.forEach((s) => expect(s.words).not.toBeDefined());
|
||||||
|
});
|
||||||
|
});
|
56
packages/web/src/test/resources/test-01.lrc
Normal file
56
packages/web/src/test/resources/test-01.lrc
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
[ti: 中华少女·终]
|
||||||
|
[ar: 洛天依]
|
||||||
|
[al: 中华少女]
|
||||||
|
[tool: 歌词滚动姬 https://lrc-maker.github.io]
|
||||||
|
[00:46.706] 我想要仗剑天涯却陷入纷乱
|
||||||
|
[00:49.588] 因果与恩怨牵杂等谁来诊断
|
||||||
|
[00:52.284] 暗箭在身后是否该回身看
|
||||||
|
[00:55.073] 人心有了鬼心房便要过鬼门关
|
||||||
|
[00:57.875] 早已茫然染了谁的血的这抹长衫
|
||||||
|
[01:00.702] 独木桥上独目瞧的人一夫当关
|
||||||
|
[01:03.581] 复仇或是诅咒缠在身上的宿命
|
||||||
|
[01:06.591] 棋子在棋盘被固定移动不停转
|
||||||
|
[01:09.241] 仇恨与仇恨周而复始往返
|
||||||
|
[01:12.586] 酒楼深胭脂一点分隔光暗
|
||||||
|
[01:15.205] 数求问天涯海角血债偿还
|
||||||
|
[01:18.015] 终是神念迷茫只做旁观
|
||||||
|
[01:21.087] 是非恩怨三生纠葛轮转
|
||||||
|
[01:23.709] 回望从前苦笑将杯酒斟满
|
||||||
|
[01:26.573] 那时明月今日仍旧皎洁
|
||||||
|
[01:29.115] 只叹换拨人看
|
||||||
|
[01:31.024] 你可是这样的少年
|
||||||
|
[01:33.971] 梦想着穿越回从前
|
||||||
|
[01:36.554] 弦月下着青衫抚长剑
|
||||||
|
[01:42.341] 风起时以血绘长卷
|
||||||
|
[01:45.276] 三寸剑只手撼江山
|
||||||
|
[01:47.838] 拂衣去逍遥天地间
|
||||||
|
[01:52.991]
|
||||||
|
[02:16.707] 黄藓绿斑苔痕将岁月扒谱
|
||||||
|
[02:20.077] 望眼欲穿你何时寄来家书
|
||||||
|
[02:22.788] 踱步间院落飞絮聚散化作愁字
|
||||||
|
[02:25.601] 当泪水成河能否凫上一位游子
|
||||||
|
[02:28.050] 当庭间嫣红轻轻闻着雨声
|
||||||
|
[02:30.841] 电闪雷鸣院中青翠摇曳倚风
|
||||||
|
[02:33.362] 青丝落指尖模糊记忆更为清晰
|
||||||
|
[02:36.334] 那一朵英姿遮挡于一抹旌旗飘
|
||||||
|
[02:39.511] 厮杀与厮杀周而复始招摇
|
||||||
|
[02:42.576] 血渍滑落于枪尖映照宵小
|
||||||
|
[02:45.726] 城池下红莲飞溅绽放照耀
|
||||||
|
[02:48.509] 碧落黄泉再无叨扰
|
||||||
|
[02:51.338] 北风呼啸三生等待轮转
|
||||||
|
[02:53.660] 山崖古道思绪被光阴晕染
|
||||||
|
[02:56.895] 那时明月今日仍旧皎洁
|
||||||
|
[02:59.293] 只叹孤身人看
|
||||||
|
[03:01.335] 你可是这样的少年
|
||||||
|
[03:04.377] 梦想着穿越回从前
|
||||||
|
[03:06.924] 北风里铁衣冷槊光寒
|
||||||
|
[03:12.607] 一朝去大小三百战
|
||||||
|
[03:15.623] 岁月欺万里定江山
|
||||||
|
[03:18.126] 再与她同游天地间
|
||||||
|
[03:24.356] 说书人或许会留恋
|
||||||
|
[03:27.057] 但故事毕竟有终点
|
||||||
|
[03:29.590] 最好的惊堂木是时间
|
||||||
|
[03:35.157] 就让我合上这书卷
|
||||||
|
[03:38.242] 愿那些梦中的玩伴
|
||||||
|
[03:40.857] 梦醒后仍然是少年
|
||||||
|
[03:46.139]
|
9
packages/web/src/test/resources/test-02.lrc
Normal file
9
packages/web/src/test/resources/test-02.lrc
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
[ti: Somebody to Love]
|
||||||
|
[ar: Jefferson Airplane]
|
||||||
|
[al: Surrealistic Pillow]
|
||||||
|
[lr: Lyricists of that song]
|
||||||
|
[length: 2:58]
|
||||||
|
|
||||||
|
[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies
|
||||||
|
[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies
|
||||||
|
[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> love
|
77
packages/web/src/test/resources/test-03.lrc
Normal file
77
packages/web/src/test/resources/test-03.lrc
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
[ti: 雪山之眼]
|
||||||
|
[ar: 洛天依 & 旦增益西]
|
||||||
|
[al: 游四方]
|
||||||
|
[tool: 歌词滚动姬 https://lrc-maker.github.io]
|
||||||
|
[length: 04:17.400]
|
||||||
|
[00:34.280] 浸透了经卷 记忆的呼喊
|
||||||
|
[00:37.800] 雪珠滚落山巅 栽下一个春天
|
||||||
|
[00:47.390] 松石敲响玲珑清脆的银花
|
||||||
|
[00:51.600] 穿过玛瑙的红霞
|
||||||
|
[00:54.430] 在她眼中结编 亘久诗篇
|
||||||
|
[01:05.440] a: བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ། | 在舞池里舞一舞
|
||||||
|
[01:08.780] a: 祝祷转过千年 五色经幡飘飞
|
||||||
|
[01:12.040] 奏起悠扬巴叶 任岁月拨弦
|
||||||
|
[01:19.130] གཞས་ར་འདི་ལ་གཞས་གཅིག་བཏང་། 我在歌坛献首歌
|
||||||
|
[01:22.330] 宫殿 塔尖 彩绘 日月 同辉
|
||||||
|
[01:25.810] 那层厚重壁垒化身 蝉翼一片
|
||||||
|
[01:29.110] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། | 我们在此相聚
|
||||||
|
[01:30.790] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:32.510] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:34.120] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:35.920] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[01:37.630] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:39.350] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:41.050] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:42.740] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[01:44.630] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:46.280] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:48.010] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:49.600] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[01:51.380] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[01:53.070] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[01:54.820] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[01:58.580] སྔོན་དང་པོ་གྲུབ་ཐོབ་ཐང་སྟོང་རྒྱལ་པོས་མཛད་པའི་མཛད་ཚུལ་དུ། དང་པོ་རྔོན་པའི་ས་སྦྱངས་ས་འདུལ། གཉིས་པ་རྒྱ་ལུའི་བྱིན་འབེབས། གསུམ་པ་ལྷ་མོའི་གླུ་གར་སོགས་རིན་ཆེན་གསུང་མགུར་གཞུང་བཟང་མང་པོ་འདུག་སྟེ། དེ་ཡང་མ་ཉུང་གི་ཚིག་ལ་དུམ་མཚམས་གཅིག་ཞུས་པ་བྱུང་བ་ཡིན་པ་ལགས་སོ། 如祖师唐东杰布所著,一有温巴净地,二有甲鲁祈福,三有仙女歌舞,所著繁多,在此简略献之。
|
||||||
|
[02:24.240] 浸透了经卷 记忆的呼喊
|
||||||
|
[02:27.450] 雪珠滚落山巅 栽下一个春天
|
||||||
|
[02:37.090] 松石敲响玲珑清脆的银花
|
||||||
|
[02:41.280] 穿过玛瑙的红霞
|
||||||
|
[02:44.010] 在她眼中结编 亘久诗篇
|
||||||
|
[02:55.250] བྲོ་ར་འདི་ལ་བྲོ་ཅིག་འཁྲབ། 在舞池里舞一舞
|
||||||
|
[02:58.410] 祝祷转过千年 五色经幡飘飞
|
||||||
|
[03:01.750] 奏起悠扬巴叶 任岁月拨弦
|
||||||
|
[03:08.840] གཞས་ར་འདི་ལ་གཞས་གཅིག་བཏང་། 我在歌坛献首歌
|
||||||
|
[03:12.050] 宫殿 塔尖 彩绘 日月 同辉
|
||||||
|
[03:15.400] 那层厚重壁垒化身 蝉翼一片
|
||||||
|
[03:18.850] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:20.480] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:22.210] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:23.910] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:25.662] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:27.391] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:29.096] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:30.789] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:32.496] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:34.175] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:35.876] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:37.606] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:39.290] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:41.030] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:42.679] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:44.455] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:46.176] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:47.910] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:49.625] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:51.293] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:53.005] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[03:54.742] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[03:56.479] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[03:58.159] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[03:59.859] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[04:01.548] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[04:03.312] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[04:05.026] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[04:06.721] ང་ཚོ་འདི་ལ་འཛོམས་འཛོམས། 我们在此相聚
|
||||||
|
[04:08.479] གཏན་དུ་འཛོམས་རྒྱུ་བྱུང་ན། 希望可以常聚
|
||||||
|
[04:10.175] གཏན་དུ་འཛོམས་པའི་མི་ལ། 在此相聚的人们
|
||||||
|
[04:11.923] སྙུན་གཞི་གོད་ཆགས་མ་གཏོང༌། 祝愿平安富足
|
||||||
|
[04:17.400]
|
Binary file not shown.
Loading…
Reference in New Issue
Block a user