Merge pull request #66 from meowarex/dev

Syllable Lyrics <3
This commit is contained in:
Meow Meow
2026-02-20 23:06:14 +11:00
committed by GitHub
2 changed files with 202 additions and 77 deletions
+134 -55
View File
@@ -1162,11 +1162,6 @@ const createStickyLyricsDropdown = (): void => {
const style = Number(raw); const style = Number(raw);
if (style === settings.lyricsStyle) return; if (style === settings.lyricsStyle) return;
if (style === 2) {
trace.msg.log("Syllables are coming very soon");
return;
}
settings.lyricsStyle = style; settings.lyricsStyle = style;
for (const b of segButtons) b.classList.remove("rl-seg-active"); for (const b of segButtons) b.classList.remove("rl-seg-active");
btn.classList.add("rl-seg-active"); btn.classList.add("rl-seg-active");
@@ -1314,6 +1309,7 @@ interface WordEntry {
el: HTMLSpanElement; el: HTMLSpanElement;
start: number; // ms start: number; // ms
end: number; // ms end: number; // ms
duration: number; // ms
} }
interface LineEntry { interface LineEntry {
@@ -1571,12 +1567,10 @@ const buildWordSpans = (): {
}); });
const lineWords: WordEntry[] = []; const lineWords: WordEntry[] = [];
const syllabus = apiLine.syllabus;
const isSylMode = settings.lyricsStyle === 2;
for (const syl of apiLine.syllabus) { const WORD_SPAN_STYLE: Record<string, string> = {
const wordSpan = document.createElement("span");
wordSpan.className = "rl-wbw-word";
wordSpan.textContent = syl.text.trimEnd();
forceStyle(wordSpan, {
display: "inline", display: "inline",
float: "none", float: "none",
flex: "none", flex: "none",
@@ -1584,30 +1578,61 @@ const buildWordSpans = (): {
padding: "0", padding: "0",
"word-spacing": "normal", "word-spacing": "normal",
"letter-spacing": "normal", "letter-spacing": "normal",
}); };
if (syl.isBackground) {
wordSpan.classList.add("rl-wbw-bg");
}
const seekTimeMs = syl.time; const makeSpan = (text: string, seekMs: number, bg: boolean): HTMLSpanElement => {
wordSpan.addEventListener("click", () => { const span = document.createElement("span");
PlayState.seek(seekTimeMs / 1000); span.className = "rl-wbw-word";
span.textContent = text;
forceStyle(span, WORD_SPAN_STYLE);
if (bg) span.classList.add("rl-wbw-bg");
span.addEventListener("click", () => {
PlayState.seek(seekMs / 1000);
if (!PlayState.playing) PlayState.play(); if (!PlayState.playing) PlayState.play();
resync(); resync();
}); });
return span;
lineDiv.appendChild(wordSpan);
// insert text spacebar between words (most reliable inline spacing)
lineDiv.appendChild(document.createTextNode(" "));
const wordEntry: WordEntry = {
el: wordSpan,
start: syl.time,
end: syl.time + syl.duration,
}; };
lineWords.push(wordEntry);
words.push(wordEntry); // Group syllables into words: trailing whitespace in syl.text marks a word boundary
const wordGroups: number[][] = [];
let currentGroup: number[] = [];
for (let si = 0; si < syllabus.length; si++) {
currentGroup.push(si);
const isWordEnd = syllabus[si].text !== syllabus[si].text.trimEnd() || si === syllabus.length - 1;
if (isWordEnd) {
wordGroups.push(currentGroup);
currentGroup = [];
}
}
for (const group of wordGroups) {
if (isSylMode) {
// Syllable mode: separate span per syllable, no space within same word
for (const si of group) {
const syl = syllabus[si];
const span = makeSpan(syl.text.trimEnd(), syl.time, syl.isBackground);
lineDiv.appendChild(span);
const entry: WordEntry = { el: span, start: syl.time, end: syl.time + syl.duration, duration: syl.duration };
lineWords.push(entry);
words.push(entry);
}
} else {
// Word mode: merge syllables into one span
const mergedText = group.map(si => syllabus[si].text.trimEnd()).join("");
const first = syllabus[group[0]];
const last = syllabus[group[group.length - 1]];
const start = first.time;
const end = last.time + last.duration;
const bg = first.isBackground;
const span = makeSpan(mergedText, start, bg);
lineDiv.appendChild(span);
const entry: WordEntry = { el: span, start, end, duration: end - start };
lineWords.push(entry);
words.push(entry);
}
// Space between words (not between syllables of the same word)
lineDiv.appendChild(document.createTextNode(" "));
} }
wbwContainer.appendChild(lineDiv); wbwContainer.appendChild(lineDiv);
@@ -1624,6 +1649,21 @@ const buildWordSpans = (): {
} }
} }
// insert spacers between lines with large timing gaps (instrumental breaks)
for (let i = 0; i < lines.length - 1; i++) {
const gap = lines[i + 1].startMs - lines[i].endMs;
if (gap > 2500) {
const spacer = document.createElement("div");
spacer.className = "rl-wbw-spacer";
forceStyle(spacer, {
display: "block",
height: "2rem",
margin: "0 0 1rem 0",
});
lines[i].el.after(spacer);
}
}
// match lines to tidal spans by index // match lines to tidal spans by index
const tidalSpans = Array.from( const tidalSpans = Array.from(
innerDiv.querySelectorAll('span[data-test="lyrics-line"]'), innerDiv.querySelectorAll('span[data-test="lyrics-line"]'),
@@ -1870,11 +1910,20 @@ const startTickLoop = (): void => {
console.log("[RL-Syllable] Tick loop started"); console.log("[RL-Syllable] Tick loop started");
let lastLogTime = 0; let lastLogTime = 0;
let lastTickMs = -1;
tickLoopUnload = safeInterval(unloads, () => { tickLoopUnload = safeInterval(unloads, () => {
if (!isActive || lines.length === 0) return; if (!isActive || lines.length === 0) return;
const nowMs = getPlaybackMs(); const nowMs = getPlaybackMs();
const isSyl = settings.lyricsStyle === 2;
const CLS_ACTIVE = isSyl ? "rl-syl-active" : "rl-wbw-active";
const CLS_FINISHED = isSyl ? "rl-syl-finished" : "rl-wbw-finished";
// scrub/seek detection: time went backward or jumped forward significantly
const timeDelta = nowMs - lastTickMs;
const didScrub = lastTickMs >= 0 && (timeDelta < -100 || timeDelta > 1000);
lastTickMs = nowMs;
// remove data-current from tidals hidden spans // remove data-current from tidals hidden spans
const tidalCurrentSpans = document.querySelectorAll( const tidalCurrentSpans = document.querySelectorAll(
@@ -1889,23 +1938,49 @@ const startTickLoop = (): void => {
console.log(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`); console.log(`[RL-Syllable] Playback | ${nowMs.toFixed(0)} ms`);
} }
// find active line // find active line (-1 if before all lyrics or in instrumental)
let newLineIdx = activeLineIdx; let newLineIdx = -1;
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i]; const nextStart = lines[i + 1]?.startMs ?? Number.MAX_SAFE_INTEGER;
const nextLine = lines[i + 1]; const effectiveEnd = Math.min(nextStart, lines[i].endMs + 2500);
if (nowMs >= lines[i].startMs && nowMs < effectiveEnd) {
// Line is active until the next line start
const lineEnd = nextLine ? nextLine.startMs : Number.MAX_SAFE_INTEGER;
if (nowMs >= line.startMs && nowMs < lineEnd) {
newLineIdx = i; newLineIdx = i;
break; break;
} }
} }
// Scroll to new line and set active/inactive + Hook scroll // single pass to set correct state for all words (scrub or seek)
if (didScrub) {
for (let li = 0; li < lines.length; li++) {
for (const w of lines[li].words) {
if (li < newLineIdx) {
w.el.classList.remove(CLS_ACTIVE);
if (isSyl) w.el.style.animation = "";
if (!w.el.classList.contains(CLS_FINISHED)) w.el.classList.add(CLS_FINISHED);
} else {
w.el.classList.remove(CLS_ACTIVE, CLS_FINISHED);
if (isSyl) w.el.style.animation = "";
}
}
}
activeWordEl = null;
if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
lines[activeLineIdx].el.removeAttribute("data-current");
}
activeLineIdx = -1;
console.log(`[RL-Syllable] Scrub detected (${timeDelta > 0 ? "+" : ""}${timeDelta.toFixed(0)} ms) → resync`);
}
// Deactivate line when entering instrumental
if (newLineIdx === -1 && activeLineIdx >= 0 && activeLineIdx < lines.length) {
lines[activeLineIdx].el.classList.remove("rl-wbw-line-active");
lines[activeLineIdx].el.removeAttribute("data-current");
activeLineIdx = -1;
activeWordEl = null;
}
// Scroll to new line and set active/inactive
if (newLineIdx !== activeLineIdx && newLineIdx >= 0) { if (newLineIdx !== activeLineIdx && newLineIdx >= 0) {
if (activeLineIdx >= 0 && activeLineIdx < lines.length) { if (activeLineIdx >= 0 && activeLineIdx < lines.length) {
const oldLine = lines[activeLineIdx]; const oldLine = lines[activeLineIdx];
@@ -1939,7 +2014,7 @@ const startTickLoop = (): void => {
hookSyncButton(); hookSyncButton();
} }
// find latest word that just started (for scrubbing and lyric jumps) // find and activate current word
if (activeLineIdx < 0) return; if (activeLineIdx < 0) return;
const currentLine = lines[activeLineIdx]; const currentLine = lines[activeLineIdx];
@@ -1951,15 +2026,16 @@ const startTickLoop = (): void => {
} }
} }
if (activeWordIdx >= 0) { if (activeWordIdx < 0) return;
const word = currentLine.words[activeWordIdx]; const word = currentLine.words[activeWordIdx];
// make all words before are marked finished // mark all words before as finished
for (let i = 0; i < activeWordIdx; i++) { for (let i = 0; i < activeWordIdx; i++) {
const prev = currentLine.words[i].el; const prev = currentLine.words[i].el;
if (prev.classList.contains("rl-wbw-active") || !prev.classList.contains("rl-wbw-finished")) { if (prev.classList.contains(CLS_ACTIVE) || !prev.classList.contains(CLS_FINISHED)) {
prev.classList.remove("rl-wbw-active"); prev.classList.remove(CLS_ACTIVE);
prev.classList.add("rl-wbw-finished"); if (isSyl) prev.style.animation = "";
prev.classList.add(CLS_FINISHED);
} }
} }
@@ -1967,27 +2043,30 @@ const startTickLoop = (): void => {
if (isStillSinging) { if (isStillSinging) {
if (activeWordEl !== word.el) { if (activeWordEl !== word.el) {
if (activeWordEl) { if (activeWordEl) {
activeWordEl.classList.remove("rl-wbw-active"); activeWordEl.classList.remove(CLS_ACTIVE);
activeWordEl.classList.add("rl-wbw-finished"); if (isSyl) activeWordEl.style.animation = "";
activeWordEl.classList.add(CLS_FINISHED);
}
word.el.classList.add(CLS_ACTIVE);
word.el.classList.remove(CLS_FINISHED);
if (isSyl) {
word.el.style.animation = `rl-wipe ${word.duration}ms linear forwards`;
} }
word.el.classList.add("rl-wbw-active");
word.el.classList.remove("rl-wbw-finished");
activeWordEl = word.el; activeWordEl = word.el;
console.log( console.log(
`[RL-Syllable] Word "${word.el.textContent}" | ${word.start} ms - ${word.end} ms [${nowMs.toFixed(0)} ms]`, `[RL-Syllable] Word "${word.el.textContent}" | ${word.start} ms - ${word.end} ms [${nowMs.toFixed(0)} ms]`,
); );
} }
} else { } else {
// Past this words end, waiting for next word word.el.classList.remove(CLS_ACTIVE);
word.el.classList.remove("rl-wbw-active"); if (isSyl) word.el.style.animation = "";
if (!word.el.classList.contains("rl-wbw-finished")) { if (!word.el.classList.contains(CLS_FINISHED)) {
word.el.classList.add("rl-wbw-finished"); word.el.classList.add(CLS_FINISHED);
} }
if (activeWordEl === word.el) { if (activeWordEl === word.el) {
activeWordEl = null; activeWordEl = null;
} }
} }
}
}, 50); }, 50);
}; };
@@ -160,7 +160,53 @@
color: white !important; color: white !important;
} }
/* Finished word */ /* MARKER: Syllable sweep animation CSS */
@keyframes rl-wipe {
from {
background-size: 0.75em 100%, 0% 100%, 100% 100%;
background-position: -0.375em 0%, left, left;
}
to {
background-size: 0.75em 100%, 100% 100%, 100% 100%;
background-position: calc(100% + 0.375em) 0%, left, left;
}
}
/* Syllable active: gradient sweep L-to-R via background-clip */
.rl-wbw-word.rl-syl-active {
/* biome-ignore lint: Kill base transitions so class swaps are instant */
transition: none !important;
/* biome-ignore lint: Transparent fill so gradient paints the text */
color: transparent !important;
/* biome-ignore lint: Clip gradient to text glyphs */
-webkit-background-clip: text !important;
/* biome-ignore lint: Clip gradient to text glyphs */
background-clip: text !important;
background-repeat: no-repeat;
background-image:
linear-gradient(90deg, transparent 0%, var(--cl-glow1, #fff) 50%, transparent 100%),
linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%),
linear-gradient(90deg, rgba(128, 128, 128, 0.4), rgba(128, 128, 128, 0.4));
background-size: 0.75em 100%, 0% 100%, 100% 100%;
background-position: -0.375em 0%, left, left;
/* biome-ignore lint: No glow for syllable mode */
text-shadow: none !important;
/* biome-ignore lint: No glow for syllable mode */
filter: none !important;
}
/* Syllable finished: word stays white, no glow */
.rl-wbw-word.rl-syl-finished {
/* biome-ignore lint: Kill base transitions so class swaps are instant */
transition: none !important;
/* biome-ignore lint: Finished syllable word stays white */
color: white !important;
/* biome-ignore lint: No glow for syllable mode */
text-shadow: none !important;
/* biome-ignore lint: No glow for syllable mode */
filter: none !important;
}
/* Tidals "..." at the top of the container */ /* Tidals "..." at the top of the container */
.rl-wbw-active > span:not([data-test="lyrics-line"]) { .rl-wbw-active > span:not([data-test="lyrics-line"]) {