feature: support word-highlight lyrics

This commit is contained in:
alikia2x (寒寒) 2024-10-29 01:43:23 +08:00
parent f50ff5c588
commit f939472329
Signed by: alikia2x
GPG Key ID: 56209E0CCD8420C6
6 changed files with 59 additions and 307 deletions

View File

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
<option name="SCRIPT_TEXT" value="bun dev" />
<configuration default="false" name="Web Dev Server" type="ShConfigurationType" focusToolWindowBeforeRun="true">
<option name="SCRIPT_TEXT" value="bun web:dev" />
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
<option name="SCRIPT_PATH" value="" />
<option name="SCRIPT_OPTIONS" value="" />

View File

@ -8,6 +8,7 @@
export let index: number;
export let debugMode: Boolean;
export let lyricClick: Function;
export let progress: number;
let ref: HTMLDivElement;
let clickMask: HTMLSpanElement;
@ -121,25 +122,25 @@
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
style="transform: translate3d({positionX}px, {positionY}px, 0); transition-property: opacity, text-shadow;
transition-duration: 0.36s; transition-timing-function: ease-out; opacity: {opacity};
transform-origin: center left;"
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
bind:this={ref}
on:touchstart={() => {
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
on:click={() => {
lyricClick(index);
}}
on:touchend={() => {
clickMask.style.backgroundColor = 'transparent';
}}
on:click={() => {
lyricClick(index);
on:touchstart={() => {
clickMask.style.backgroundColor = 'rgba(255,255,255,.3)';
}}
style="transform: translate3d({positionX}px, {positionY}px, 0); transition-property: text-shadow;
transition-duration: 0.36s; transition-timing-function: ease-out;
transform-origin: center left;"
>
<span
bind:this={clickMask}
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)]"
bind:this={clickMask}
>
</span>
{#if debugMode}
@ -147,11 +148,30 @@
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
</span>
{/if}
<span
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4`}
>
{line.text}
</span>
{#if line.words}
<span
class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold mr-4 `}
>
{#each line.words as word}
{#if word.word}
{#each word.word.split("") as chr, i}
<span
class={(line.start <= progress && progress <= line.end && progress > (word.endTime - word.startTime) * ((i)/word.word.length) + word.startTime ? "opacity-100" : "opacity-35") + " inline-block duration-300"}
>
{chr}
</span>
{/each}
{/if}
{/each}
</span>
{:else}
<span
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" : "opacity-35"}`}
>
{line.text}
</span>
{/if}
{#if line.translation}
<br />
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300 text-white`}>

View File

@ -203,21 +203,21 @@
lastProgress = progress;
}
$: {
for (let i = 0; i < lyricElements.length; i++) {
const s = originalLyrics.scripts![i].start;
const t = originalLyrics.scripts![i].end;
// Explain:
// The `currentLyricIndex` is also used for locating & layout computing,
// so when the current progress is in the interlude between two lyrics,
// `currentLyricIndex` still needs to have a valid value to ensure that
// the style and scrolling position are calculated correctly.
// But in that situation, the “current lyric index” does not exist.
const isCurrent = i == currentLyricIndex && s <= progress && progress <= t;
const currentLyricComponent = lyricComponents[i];
currentLyricComponent.setCurrent(isCurrent);
}
}
// $: {
// for (let i = 0; i < lyricElements.length; i++) {
// const s = originalLyrics.scripts![i].start;
// const t = originalLyrics.scripts![i].end;
// // Explain:
// // The `currentLyricIndex` is also used for locating & layout computing,
// // so when the current progress is in the interlude between two lyrics,
// // `currentLyricIndex` still needs to have a valid value to ensure that
// // the style and scrolling position are calculated correctly.
// // But in that situation, the “current lyric index” does not exist.
// const isCurrent = i == currentLyricIndex && s <= progress && progress <= t;
// const currentLyricComponent = lyricComponents[i];
// currentLyricComponent.setCurrent(isCurrent);
// }
// }
onMount(() => {
// Initialize
@ -263,7 +263,7 @@
bind:this={lyricsContainer}
>
{#each lyricLines as lyric, i}
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} />
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} {progress} />
{/each}
</div>
{/if}

View File

@ -1,270 +0,0 @@
import {
alt_sc,
apply,
buildLexer,
expectEOF,
fail,
kleft,
kmid,
kright,
opt_sc,
type Parser,
rep,
rep_sc,
seq,
str,
tok,
type Token
} from 'typescript-parsec';
import type { LrcJsonData, ParsedLrc, ScriptItem, ScriptWordsItem } from '../type';
import type { IDTag } from './type';
interface ParserScriptItem {
start: number;
text: string;
words?: ScriptWordsItem[];
translation?: string;
singer?: string;
}
function convertTimeToMs({
mins,
secs,
decimals
}: {
mins?: number | string;
secs?: number | string;
decimals?: string;
}) {
let result = 0;
if (mins) {
result += Number(mins) * 60 * 1000;
}
if (secs) {
result += Number(secs) * 1000;
}
if (decimals) {
const denom = Math.pow(10, decimals.length);
result += Number(decimals) / (denom / 1000);
}
return result;
}
const digit = Array.from({ length: 10 }, (_, i) => apply(str(i.toString()), (_) => i)).reduce(
(acc, cur) => alt_sc(cur, acc),
fail('no alternatives')
);
const numStr = apply(rep_sc(digit), (r) => r.join(''));
const num = apply(numStr, (r) => parseInt(r));
const alpha = alt_sc(
Array.from({ length: 26 }, (_, i) =>
apply(str(String.fromCharCode('a'.charCodeAt(0) + i)), (_) => String.fromCharCode('a'.charCodeAt(0) + i))
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives')),
Array.from({ length: 26 }, (_, i) =>
apply(str(String.fromCharCode('A'.charCodeAt(0) + i)), (_) => String.fromCharCode('A'.charCodeAt(0) + i))
).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'))
);
const alphaStr = apply(rep(alpha), (r) => r.join(''));
function spaces<K>(): Parser<K, Token<K>[]> {
return rep_sc(str(' '));
}
const unicodeStr = rep(tok('char'));
function trimmed<K, T>(p: Parser<K, Token<T>[]>): Parser<K, Token<T>[]> {
return apply(p, (r) => {
while (r.length > 0 && r[0].text.trim() === '') {
r.shift();
}
while (r.length > 0 && r[r.length - 1].text.trim() === '') {
r.pop();
}
return r;
});
}
function padded<K, T>(p: Parser<K, T>): Parser<K, T> {
return kmid(spaces(), p, spaces());
}
function anythingTyped(types: string[]) {
return types.map((t) => tok(t)).reduce((acc, cur) => alt_sc(cur, acc), fail('no alternatives'));
}
function lrcTimestamp<K, T>(delim: [Parser<K, Token<T>>, Parser<K, Token<T>>]) {
const innerTS = alt_sc(
apply(seq(num, str(':'), num, str('.'), numStr), (r) =>
convertTimeToMs({ mins: r[0], secs: r[2], decimals: r[4] })
),
apply(seq(num, str('.'), numStr), (r) => convertTimeToMs({ secs: r[0], decimals: r[2] })),
apply(seq(num, str(':'), num), (r) => convertTimeToMs({ mins: r[0], secs: r[2] })),
apply(num, (r) => convertTimeToMs({ secs: r }))
);
return kmid(delim[0], innerTS, delim[1]);
}
const squareTS = lrcTimestamp([tok('['), tok(']')]);
const angleTS = lrcTimestamp([tok('<'), tok('>')]);
const lrcTag = apply(
seq(
tok('['),
alphaStr,
str(':'),
tokenParserToText(trimmed(rep(anythingTyped(['char', '[', ']', '<', '>'])))),
tok(']')
),
(r) => ({
[r[1]]: r[3]
})
);
function joinTokens<T>(tokens: Token<T>[]) {
return tokens.map((t) => t.text).join('');
}
function tokenParserToText<K, T>(p: Parser<K, Token<T>> | Parser<K, Token<T>[]>): Parser<K, string> {
return apply(p, (r: Token<T> | Token<T>[]) => {
if (Array.isArray(r)) {
return joinTokens(r);
}
return r.text;
});
}
const singerIndicator = kleft(tok('char'), str(':'));
const translateParser = kright(tok('|'), unicodeStr);
function lrcLine(
wordDiv = ' ', legacy = false
): Parser<unknown, ['script_item', ParserScriptItem] | ['lrc_tag', IDTag] | ['comment', string] | ['empty', null]> {
return alt_sc(
legacy ? apply(seq(squareTS, trimmed(rep_sc(anythingTyped(['char', '[', ']', '<', '>'])))), (r) =>
['script_item', { start: r[0], text: joinTokens(r[1]) } as ParserScriptItem] // TODO: Complete this
) : apply(
seq(
squareTS,
opt_sc(padded(singerIndicator)),
rep_sc(
seq(
opt_sc(angleTS),
trimmed(rep_sc(anythingTyped(['char', '[', ']'])))
)
),
opt_sc(trimmed(translateParser))
), (r) => {
const start = r[0];
const singerPart = r[1];
const mainPart = r[2];
const translatePart = r[3];
const text = mainPart
.map((s) => joinTokens(s[1]))
.filter((s) => s.trim().length > 0)
.join(wordDiv);
const words = mainPart
.filter((s) => joinTokens(s[1]).trim().length > 0)
.map((s) => {
const wordBegin = s[0];
const word = s[1];
let ret: Partial<ScriptWordsItem> = { start: wordBegin };
if (word[0]) {
ret.beginIndex = word[0].pos.columnBegin - 1;
}
if (word[word.length - 1]) {
ret.endIndex = word[word.length - 1].pos.columnEnd;
}
return ret as ScriptWordsItem; // TODO: Complete this
});
const singer = singerPart?.text;
const translation = translatePart === undefined ? undefined : joinTokens(translatePart);
return ['script_item', { start, text, words, singer, translation } as ParserScriptItem];
}),
apply(lrcTag, (r) => ['lrc_tag', r as IDTag]),
apply(seq(spaces(), str('#'), unicodeStr), (cmt) => ['comment', cmt[2].join('')] as const),
apply(spaces(), (_) => ['empty', null] as const)
);
}
export function dumpToken<T>(t: Token<T> | undefined): string {
if (t === undefined) {
return '<EOF>';
}
return '`' + t.text + '` -> ' + dumpToken(t.next);
}
export function parseLRC(
input: string,
{ wordDiv, strict, legacy }: { wordDiv?: string; strict?: boolean; legacy?: boolean } = {}
): ParsedLrc {
const tokenizer = buildLexer([
[true, /^\[/gu, '['],
[true, /^\]/gu, ']'],
[true, /^</gu, '<'],
[true, /^>/gu, '>'],
[true, /^\|/gu, '|'],
[true, /^./gu, 'char']
]);
const lines = input
.split(/\r\n|\r|\n/gu)
.filter((line) => line.trim().length > 0)
.map((line) => tokenizer.parse(line));
return lines
.map((line) => {
const res = expectEOF(lrcLine(wordDiv, legacy).parse(line));
if (!res.successful) {
if (strict) {
throw new Error('Failed to parse full line: ' + dumpToken(line));
} else {
console.error('Failed to parse full line: ' + dumpToken(line));
}
return null;
}
return res.candidates[0].result;
})
.filter((r) => r !== null)
.reduce((acc, cur) => {
switch (cur[0]) {
case 'lrc_tag':
Object.assign(acc, cur[1]);
return acc;
case 'script_item':
acc.scripts = acc.scripts || [];
acc.scripts.push(cur[1]);
return acc;
default:
return acc;
}
}, {} as ParsedLrc);
}
export default function lrcParser(lrc: string): LrcJsonData {
const parsedLrc = parseLRC(lrc, { wordDiv: '', strict: false });
if (parsedLrc.scripts === undefined) {
return parsedLrc as LrcJsonData;
}
let finalLrc: LrcJsonData = parsedLrc as LrcJsonData;
let lyrics: ScriptItem[] = [];
let i = 0;
while (i < parsedLrc.scripts.length - 1) {
let lyricLine = parsedLrc.scripts[i] as ScriptItem;
lyricLine.start/=1000;
lyricLine.end = parsedLrc.scripts[i+1].start / 1000;
if (parsedLrc.scripts[i+1].text.trim() === "") {
i+=2;
} else i++;
if (lyricLine.text.trim() !== "") {
lyrics.push(lyricLine);
}
}
finalLrc.scripts = lyrics;
return finalLrc;
}

View File

@ -1,3 +1,5 @@
import type { ScriptWordsItem } from "../type";
export interface ParserScriptItem {
start: number;
text: string;

View File

@ -5,11 +5,11 @@ export interface ScriptItem extends ParserScriptItem {
chorus?: string;
}
export interface ScriptWordsItem {
start: number;
end: number;
beginIndex: number;
endIndex: number;
export interface LyricWord {
startTime: number;
endTime: number;
word: string;
emptyBeat?: number;
}
export interface LrcMetaData {