Compare commits

...

3 Commits
main ... 2.1.0

Author SHA1 Message Date
0c315972c1
ref: project structure, write basic type for lyrics layout 2024-10-22 00:22:17 +08:00
d34ac176c0
ref: started refactoring the dynamic lyrics
add: initialization of lyric lines
2024-10-20 23:14:02 +08:00
alikia2x
ba31bc4b98 improve: better lyric effect & performance
add: support for TTML
2024-08-03 21:34:39 +08:00
19 changed files with 1067 additions and 145 deletions

3
.gitignore vendored
View File

@ -8,4 +8,5 @@ node_modules
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
data/pending
data/pending
.vscode

View File

@ -0,0 +1,117 @@
<script lang="ts">
import createSpring from '$lib/graphics/spring';
import type { ScriptItem } from '$lib/lyrics/type';
import type { LyricPos } from './type';
import type { Spring } from '$lib/graphics/spring/spring';
export let line: ScriptItem;
export let index: number;
export let debugMode: Boolean;
export let initPos: LyricPos;
let ref: HTMLDivElement;
let time = 0;
let positionX: number = 0;
let positionY: number = 0;
let lastPosX: number | undefined = undefined;
let lastPosY: number | undefined = undefined;
let lastUpdateY: number | undefined = undefined;
let lastUpdateX: number | undefined = undefined;
let springY: Spring | undefined = undefined;
let springX: Spring | undefined = undefined;
let isCurrentLyric = false;
$: {
if (initPos) {
positionX = initPos.x;
positionY = initPos.y;
}
}
function updateY(timestamp: number) {
if (lastUpdateY === undefined) {
lastUpdateY = timestamp;
}
if (springY === undefined) return;
time = (timestamp - lastUpdateY) / 1000;
springY.update(time);
positionY = springY.getCurrentPosition();
if (!springY.arrived()) {
requestAnimationFrame(updateY);
}
lastUpdateY = timestamp;
}
function updateX(timestamp: number) {
if (lastUpdateX === undefined) {
lastUpdateX = timestamp;
}
if (springX === undefined) return;
time = (timestamp - lastUpdateX) / 1000;
springX.update(time);
positionX = springX.getCurrentPosition();
if (!springX.arrived()) {
requestAnimationFrame(updateX);
}
lastUpdateX = timestamp;
}
/**
* Set the x position of the element, **with no animation**
* @param {number} pos - X offset, in pixels
*/
export const setX = (pos: number) => {
positionX = pos;
};
/**
* Set the y position of the element, **with no animation**
* @param {number} pos - Y offset, in pixels
*/
export const setY = (pos: number) => {
positionY = pos;
};
export const setCurrent = (isCurrent: boolean) => {
isCurrentLyric = isCurrent;
};
export const update = (pos: LyricPos, delay: number = 0) => {
if (lastPosX === undefined) {
lastPosX = pos.x;
}
if (lastPosY === undefined) {
lastPosY = pos.y;
}
springY = createSpring(lastPosY, pos.y, 0.12, 0.7, delay);
springX = createSpring(lastPosX, pos.x, 0.12, 0.7, delay);
requestAnimationFrame(updateY);
requestAnimationFrame(updateX);
};
export const getInfo = () => {
return {
x: positionX,
y: positionY,
isCurrent: isCurrentLyric
};
};
export const init = (pos: LyricPos) => {
lastPosX = pos.x;
lastPosY = pos.y;
positionX = pos.x;
positionY = pos.y;
};
export const getRef = () => ref;
</script>
<div style="transform: translateY({positionY}px) translateX({positionX}px);" class="absolute z-50" bind:this={ref}>
{#if debugMode}
<span class="text-lg absolute -translate-y-7">Line idx: {index}</span>
{/if}
<span class={`text-white text-5xl font-semibold text-shadow-lg ${!isCurrentLyric && 'opacity-50'} `}>
{line.text}
</span>
</div>

View File

@ -2,10 +2,11 @@
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
import progressBarRaw from '$lib/state/progressBarRaw';
import type { LrcJsonData } from '$lib/lyrics/parser';
import type { LrcJsonData } from '$lib/lyrics/type';
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[];
@ -19,15 +20,13 @@
let showTranslation = false;
if (localStorage.getItem('debugMode') == null) {
localStorage.setItem('debugMode', 'false');
}
else {
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === "true";
} else {
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
}
if (localStorage.getItem('showTranslation') == null) {
localStorage.setItem('showTranslation', 'false');
}
else {
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === "true";
} else {
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === 'true';
}
let currentLyricIndex = -1;
let currentPositionIndex = -1;
@ -46,18 +45,16 @@
$: refs = _refs.filter(Boolean);
$: getLyricIndex = createLyricsSearcher(originalLyrics);
// handle KeyDown event
function onKeyDown(e: KeyboardEvent) {
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
debugMode = !debugMode;
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
}
else if (e.key === 't') {
} else if (e.key === 't') {
showTranslation = !showTranslation;
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
setTimeout(() => {
scrollToLyric(refs[currentPositionIndex])
scrollToLyric(refs[currentPositionIndex]);
}, 50);
}
}
@ -66,7 +63,7 @@
function extractTranslateValue(s: string): string | null {
const regex = /translateY\((-?\d*px)\)/;
let arr = regex.exec(s);
return arr==null ? null : arr[1];
return arr == null ? null : arr[1];
}
// Helper function to get CSS class for a lyric based on its index and progress
@ -79,7 +76,7 @@
// Function to move the lyrics up smoothly
async function moveToNextLine(h: number) {
console.debug(new Date().getTime() , 'moveToNextLine', h);
console.debug(new Date().getTime(), 'moveToNextLine', h);
// the line that's going to process (like a pointer)
// by default, it's "the next line" after the lift
let processingLineIndex = currentPositionIndex + 2;
@ -87,21 +84,24 @@
// modify translateY of all lines in viewport one by one to lift them up
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,
lyric.style.transition = `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);
const twoLinesAhead = refs[i - 2];
if (lyricsContainer && twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break;
if (
lyricsContainer &&
twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height
)
break;
}
if (refs.length - processingLineIndex < 3) {
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);
@ -200,9 +200,13 @@
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,15 +214,30 @@
})();
}
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(){
async function lyricsUpdate() {
if (
currentPositionIndex < 0 ||
currentPositionIndex === currentAnimationIndex ||
$userAdjustingProgress === true ||
scrolling
) return;
)
return;
const currentLyric = refs[currentPositionIndex];
const currentLyricRect = currentLyric.getBoundingClientRect();
@ -228,18 +247,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);
@ -247,8 +268,7 @@
currentAnimationIndex = currentPositionIndex;
}
nextUpdate.subscribe(lyricsUpdate)
nextUpdate.subscribe(lyricsUpdate);
// Process while user is adjusting progress
userAdjustingProgress.subscribe((adjusting) => {
@ -282,63 +302,77 @@
});
function lyricClick(lyricIndex: number) {
if (player===null || originalLyrics.scripts === undefined) return;
if (player === null || originalLyrics.scripts === undefined) return;
player.currentTime = originalLyrics.scripts[lyricIndex].start;
player.play()
player.play();
}
</script>
<svelte:window on:keydown={onKeyDown} />
{#if debugMode && lyricsContainer}
<div
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
px-2 rounded-xl text-white">
<p>
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
AnimationIndex:{currentAnimationIndex}
NextUpdate: {$nextUpdate}
Progress: {progress.toFixed(2)}
scrollPosition: {lyricsContainer.scrollTop}
</p>
</div>
{/if}
<div {...$$restProps}>
{#if debugMode && lyricsContainer}
<div
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
px-2 rounded-xl text-white"
>
<p>
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
AnimationIndex:{currentAnimationIndex}
NextUpdate: {$nextUpdate}
Progress: {progress.toFixed(2)}
scrollPosition: {lyricsContainer.scrollTop}
</p>
</div>
{/if}
{#if lyrics && originalLyrics && originalLyrics.scripts}
<div
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
{#if lyrics && originalLyrics && originalLyrics.scripts}
<div
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
bind:this={lyricsContainer}
on:scroll={scrollHandler}
>
{#each lyrics as lyric, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div bind:this={_refs[i]} class="relative h-fit text-shadow-lg" on:click={() => {lyricClick(i)}} >
{#if debugMode && refs[i] && refs[i].style !== undefined}
<span class="previous-lyric !text-lg !absolute !-translate-y-12">{i} &nbsp;
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
tY: {extractTranslateValue(refs[i].style.transform)}
top: {Math.round(refs[i].getBoundingClientRect().top)}px
</span>
{/if}
<p class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}>
{#if originalLyrics.scripts[i].singer}
<span class="singer">{originalLyrics.scripts[i].singer}</span>
bind:this={lyricsContainer}
on:scroll={scrollHandler}
>
{#each lyrics as lyric, i}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
bind:this={_refs[i]}
class="relative h-fit text-shadow-lg"
on:click={() => {
lyricClick(i);
}}
>
{#if debugMode && refs[i] && refs[i].style !== undefined}
<span class="previous-lyric !text-lg !absolute !-translate-y-12"
>{i} &nbsp;
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
tY: {extractTranslateValue(refs[i].style.transform)}
top: {Math.round(refs[i].getBoundingClientRect().top)}px
</span>
{/if}
{lyric}
</p>
{#if originalLyrics.scripts[i].translation && showTranslation}
<div class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}>{originalLyrics.scripts[i].translation}</div>
{/if}
</div>
{/each}
<div class="relative w-full h-[50rem]"></div>
</div>
{/if}
<p
class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}
>
{#if originalLyrics.scripts[i].singer}
<span class="singer">{originalLyrics.scripts[i].singer}</span>
{/if}
{lyric}
</p>
{#if originalLyrics.scripts[i].translation && showTranslation}
<div
class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}
>
{originalLyrics.scripts[i].translation}
</div>
{/if}
</div>
{/each}
<div class="relative w-full h-[50rem]"></div>
</div>
{/if}
</div>
<!--suppress CssUnusedSymbol -->
<style>
@ -347,17 +381,17 @@
--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 {
mask-image: linear-gradient(
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 5rem),
rgba(0, 0, 0, 0) 100%
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 1) 2rem,
rgba(0, 0, 0, 1) calc(100% - 5rem),
rgba(0, 0, 0, 0) 100%
);
}
@ -367,7 +401,7 @@
bottom: 50%;
transform: translateY(calc(50%)) translateX(-3rem);
padding: 0.1rem 0.4rem;
background: rgba(255,255,255,.15);
background: rgba(255, 255, 255, 0.15);
border-radius: 0.4rem;
font-size: 1.5rem;
line-height: 2rem;

View File

@ -0,0 +1,85 @@
<script lang="ts">
import type { LrcJsonData } from '$lib/lyrics/type';
import { onMount } from 'svelte';
import type { ScriptItem } from '$lib/lyrics/type';
import LyricLine from './lyricLine.svelte';
import type { LyricPos } from './type';
// Props
export let originalLyrics: LrcJsonData;
export let progress: number;
export let player: HTMLAudioElement | null;
// States
let lyricLines: ScriptItem[] = [];
let lyricExists = false;
let lyricsContainer: HTMLDivElement | null;
let debugMode = false;
let showTranslation = false;
// Exlpaination:
// The hot module reloading makes the each lyric component position to be re-initialized,
// which causes the lyrics are all at {x: 0, y: 0},
// instead of the value calculated in the initLyricComponents.
// So, we need to store the initial position of each lyric component and restore it.
let lyricComponentInitPos = Array<LyricPos>(lyricLines.length).fill({ x: 0, y: 0 });
// References to lyric elements
let lyricElements: HTMLDivElement[] = [];
let lyricComponents: LyricLine[] = [];
function initLyricComponents() {
let cumulativeHeight = 0;
const marginY = 48;
for (let i = 0; i < lyricLines.length; i++) {
const c = lyricComponents[i];
lyricElements.push(c.getRef());
const e = lyricElements[i];
const elementHeight = e.getBoundingClientRect().height;
const elementTargetTop = cumulativeHeight;
cumulativeHeight += elementHeight + marginY;
lyricComponentInitPos[i] = { x: 0, y: elementTargetTop };
}
}
$: {
if (originalLyrics && originalLyrics.scripts) {
lyricExists = true;
lyricLines = originalLyrics.scripts!;
}
}
$: {
if (lyricComponents.length > 0) {
initLyricComponents();
}
}
onMount(() => {
// Initialize
if (localStorage.getItem('debugMode') == null) {
localStorage.setItem('debugMode', 'false');
} else {
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
}
});
</script>
{#if originalLyrics && originalLyrics.scripts}
<div
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
text-left no-scrollbar overflow-y-auto z-[1] pt-16"
bind:this={lyricsContainer}
>
{#each lyricLines as lyric, i}
<LyricLine
line={lyric}
index={i}
bind:this={lyricComponents[i]}
{debugMode}
initPos={lyricComponentInitPos[i]}
/>
{/each}
<div class="relative w-full h-[50rem]"></div>
</div>
{/if}

10
src/lib/components/lyrics/type.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
export interface LyricPos {
x: number;
y: number;
}
export interface LyricLayout {
pos: LyricPos;
blur: number;
scale: number;
}

View File

@ -0,0 +1,8 @@
export function derivative(f: (x: number) => number) {
const h = 0.001;
return (x: number) => (f(x + h) - f(x - h)) / (2 * h);
}
export function getVelocity(f: (t: number) => number): (t: number) => number {
return derivative(f);
}

View File

@ -0,0 +1,17 @@
import { Spring } from './spring';
export default function createSpring(
from: number,
to: number,
bounce: number,
duration: number,
delaySeconds: number = 0,
) {
const mass = 1;
const stiffness = Math.pow((Math.PI * 2) / duration, 2);
const damping = bounce >= 0 ? ((1 - bounce) * (4 * Math.PI)) / duration : ((1 + bounce) * (4 * Math.PI)) / duration;
const spring = new Spring(from);
spring.updateParams({ mass, stiffness, damping });
spring.setTargetPosition(to, delaySeconds);
return spring;
}

View File

@ -0,0 +1,158 @@
import { getVelocity } from "./derivative";
/** MIT License github.com/pushkine/ */
export interface SpringParams {
mass: number; // = 1.0
damping: number; // = 10.0
stiffness: number; // = 100.0
soft: boolean; // = false
}
type seconds = number;
export class Spring {
private currentPosition = 0;
private targetPosition = 0;
private currentTime = 0;
private params: Partial<SpringParams> = {};
private currentSolver: (t: seconds) => number;
private getV: (t: seconds) => number;
private getV2: (t: seconds) => number;
private queueParams:
| (Partial<SpringParams> & {
time: number;
})
| undefined;
private queuePosition:
| {
time: number;
position: number;
}
| undefined;
constructor(currentPosition = 0) {
this.targetPosition = currentPosition;
this.currentPosition = this.targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
private resetSolver() {
const curV = this.getV(this.currentTime);
this.currentTime = 0;
this.currentSolver = solveSpring(
this.currentPosition,
curV,
this.targetPosition,
0,
this.params,
);
this.getV = getVelocity(this.currentSolver);
this.getV2 = getVelocity(this.getV);
}
arrived() {
return (
Math.abs(this.targetPosition - this.currentPosition) < 0.01 &&
this.getV(this.currentTime) < 0.01 &&
this.getV2(this.currentTime) < 0.01 &&
this.queueParams === undefined &&
this.queuePosition === undefined
);
}
setPosition(targetPosition: number) {
this.targetPosition = targetPosition;
this.currentPosition = targetPosition;
this.currentSolver = () => this.targetPosition;
this.getV = () => 0;
this.getV2 = () => 0;
}
update(delta = 0) {
this.currentTime += delta;
this.currentPosition = this.currentSolver(this.currentTime);
if (this.queueParams) {
this.queueParams.time -= delta;
if (this.queueParams.time <= 0) {
this.updateParams({
...this.queueParams,
});
}
}
if (this.queuePosition) {
this.queuePosition.time -= delta;
if (this.queuePosition.time <= 0) {
this.setTargetPosition(this.queuePosition.position);
}
}
if (this.arrived()) {
this.setPosition(this.targetPosition);
}
}
updateParams(params: Partial<SpringParams>, delay = 0) {
if (delay > 0) {
this.queueParams = {
...(this.queuePosition ?? {}),
...params,
time: delay,
};
} else {
this.queuePosition = undefined;
this.params = {
...this.params,
...params,
};
this.resetSolver();
}
}
setTargetPosition(targetPosition: number, delay = 0) {
if (delay > 0) {
this.queuePosition = {
...(this.queuePosition ?? {}),
position: targetPosition,
time: delay,
};
} else {
this.queuePosition = undefined;
this.targetPosition = targetPosition;
this.resetSolver();
}
}
getCurrentPosition() {
return this.currentPosition;
}
}
function solveSpring(
from: number,
velocity: number,
to: number,
delay: seconds = 0,
params?: Partial<SpringParams>,
): (t: seconds) => number {
const soft = params?.soft ?? false;
const stiffness = params?.stiffness ?? 100;
const damping = params?.damping ?? 10;
const mass = params?.mass ?? 1;
const delta = to - from;
if (soft || 1.0 <= damping / (2.0 * Math.sqrt(stiffness * mass))) {
const angular_frequency = -Math.sqrt(stiffness / mass);
const leftover = -angular_frequency * delta - velocity;
return (t: seconds) => {
t -= delay;
if (t < 0) return from;
return to - (delta + t * leftover) * Math.E ** (t * angular_frequency);
};
}
const damping_frequency = Math.sqrt(4.0 * mass * stiffness - damping ** 2.0);
const leftover =
(damping * delta - 2.0 * mass * velocity) / damping_frequency;
const dfm = (0.5 * damping_frequency) / mass;
const dm = -(0.5 * damping) / mass;
return (t: seconds) => {
t -= delay;
if (t < 0) return from;
return (
to -
(Math.cos(t * dfm) * delta + Math.sin(t * dfm) * leftover) *
Math.E ** (t * dm)
);
};
}

View File

View File

@ -16,6 +16,8 @@ import {
tok,
type Token
} from 'typescript-parsec';
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
import type { IDTag } from './type';
interface ParserScriptItem {
@ -26,45 +28,6 @@ interface ParserScriptItem {
singer?: string;
}
export interface ScriptItem extends ParserScriptItem {
end: number;
chorus?: string;
}
export interface ScriptWordsItem {
start: number;
end: number;
beginIndex: number;
endIndex: number;
}
export interface LrcMetaData {
ar?: string;
ti?: string;
al?: string;
au?: string;
length?: string;
offset?: string;
tool?: string;
ve?: string;
}
export interface ParsedLrc extends LrcMetaData {
scripts?: ParserScriptItem[];
[key: string]: any;
}
export interface LrcJsonData extends LrcMetaData {
scripts?: ScriptItem[];
[key: string]: any;
}
interface IDTag {
[key: string]: string;
}
function convertTimeToMs({
mins,
secs,

11
src/lib/lyrics/lrc/type.d.ts vendored Normal file
View File

@ -0,0 +1,11 @@
export interface ParserScriptItem {
start: number;
text: string;
words?: ScriptWordsItem[];
translation?: string;
singer?: string;
}
export interface IDTag {
[key: string]: string;
}

View File

@ -0,0 +1,21 @@
import type { LrcJsonData } from '$lib/lyrics/type';
import { parseTTML as ttmlParser } from './parser';
import type { LyricLine } from './ttml-types';
export * from './writer';
export type * from './ttml-types';
export function parseTTML(text: string) {
let lyrics: LrcJsonData;
const lyricLines = ttmlParser(text).lyricLines;
lyrics = {
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
};
})
};
return lyrics;
}

View File

@ -0,0 +1,169 @@
/**
* @fileoverview
* Parser for TTML lyrics.
* Used to parse lyrics files from Apple Music,
* and extended to support translation and pronounciation of text.
* @see https://www.w3.org/TR/2018/REC-ttml1-20181108/
*/
import type {
LyricLine,
LyricWord,
TTMLLyric,
TTMLMetadata,
} from "./ttml-types";
const timeRegexp =
/^(((?<hour>[0-9]+):)?(?<min>[0-9]+):)?(?<sec>[0-9]+([.:]([0-9]+))?)/;
function parseTimespan(timeSpan: string): number {
const matches = timeRegexp.exec(timeSpan);
if (matches) {
const hour = Number(matches.groups?.hour || "0");
const min = Number(matches.groups?.min || "0");
const sec = Number(matches.groups?.sec.replace(/:/, ".") || "0");
return Math.floor((hour * 3600 + min * 60 + sec) * 1000);
}
throw new TypeError(`Failed to parse time stamp:${timeSpan}`);
}
export function parseTTML(ttmlText: string): TTMLLyric {
const domParser = new DOMParser();
const ttmlDoc: XMLDocument = domParser.parseFromString(
ttmlText,
"application/xml",
);
let mainAgentId = "v1";
const metadata: TTMLMetadata[] = [];
for (const meta of ttmlDoc.querySelectorAll("meta")) {
if (meta.tagName === "amll:meta") {
const key = meta.getAttribute("key");
if (key) {
const value = meta.getAttribute("value");
if (value) {
const existing = metadata.find((m) => m.key === key);
if (existing) {
existing.value.push(value);
} else {
metadata.push({
key,
value: [value],
});
}
}
}
}
}
for (const agent of ttmlDoc.querySelectorAll("ttm\\:agent")) {
if (agent.getAttribute("type") === "person") {
const id = agent.getAttribute("xml:id");
if (id) {
mainAgentId = id;
}
}
}
const lyricLines: LyricLine[] = [];
function parseParseLine(lineEl: Element, isBG = false, isDuet = false) {
const line: LyricLine = {
words: [],
translatedLyric: "",
romanLyric: "",
isBG,
isDuet:
!!lineEl.getAttribute("ttm:agent") &&
lineEl.getAttribute("ttm:agent") !== mainAgentId,
startTime: 0,
endTime: 0,
};
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,
};
}

View File

@ -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;
}

View 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);
}

36
src/lib/lyrics/type.d.ts vendored Normal file
View File

@ -0,0 +1,36 @@
import type { ParserScriptItem } from "./lrc/type";
export interface ScriptItem extends ParserScriptItem {
end: number;
chorus?: string;
}
export interface ScriptWordsItem {
start: number;
end: number;
beginIndex: number;
endIndex: number;
}
export interface LrcMetaData {
ar?: string;
ti?: string;
al?: string;
au?: string;
length?: string;
offset?: string;
tool?: string;
ve?: string;
}
export interface ParsedLrc extends LrcMetaData {
scripts?: ParserScriptItem[];
[key: string]: any;
}
export interface LrcJsonData extends LrcMetaData {
scripts?: ScriptItem[];
[key: string]: any;
}

View File

@ -16,7 +16,7 @@
<div class="w-full flex my-3">
<h2>歌词文件</h2>
<FileSelector accept=".lrc" class="ml-auto top-2 relative" />
<FileSelector accept=".lrc, .ttml" class="ml-auto top-2 relative" />
</div>
<FileList />

View File

@ -4,15 +4,18 @@
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 Lyrics from '$lib/components/lyrics/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 lrcParser from '$lib/lyrics/lrc/parser';
import type { LrcJsonData } from '$lib/lyrics/type';
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
import type { IAudioMetadata } from 'music-metadata-browser';
import { onMount } from 'svelte';
import progressBarRaw from '$lib/state/progressBarRaw';
import { parseTTML, type LyricLine } from '$lib/lyrics/ttml';
import NewLyrics from '$lib/components/lyrics/newLyrics.svelte';
const audioId = $page.params.id;
let audioPlayer: HTMLAudioElement | null = null;
@ -44,26 +47,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 +88,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 +102,18 @@
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')) {
originalLyrics = parseTTML(lr);
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 +121,7 @@
}
function playAudio() {
if (audioPlayer===null) return;
if (audioPlayer === null) return;
if (audioPlayer.duration) {
duration = audioPlayer.duration;
}
@ -158,17 +169,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 +212,20 @@
{hasLyrics}
/>
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer}/>
<NewLyrics {originalLyrics} progress={currentProgress} player={audioPlayer}/>
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} player={audioPlayer} class="hidden" />
<audio
bind:this={audioPlayer}
controls
style="display: none"
on:play={() => {
if (audioPlayer===null) return;
if (audioPlayer === null) return;
paused = audioPlayer.paused;
}}
on:pause={() => {
if (audioPlayer===null) return;
if (audioPlayer === null) return;
paused = audioPlayer.paused;
}}
on:ended={() => {

View File

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import fs from 'fs';
import { parseLRC } from '$lib/lyrics/parser';
import { parseLRC } from '$lib/lyrics/lrc/parser';
describe('LRC parser test', () => {
const test01Buffer = fs.readFileSync('./src/test/resources/test-01.lrc');