Merge pull request #137 from meowarex/dev

WIP | Syllable AI
This commit is contained in:
meoware.exe
2026-08-11 20:28:08 +10:00
committed by GitHub
4 changed files with 160 additions and 17 deletions
+32 -7
View File
@@ -17,6 +17,8 @@ declare global {
updateLyricsStyleSetting?: (value: number) => void; updateLyricsStyleSetting?: (value: number) => void;
updateRomanizeLyrics?: () => void; updateRomanizeLyrics?: () => void;
updateRomanizeLyricsSetting?: (checked: boolean) => void; updateRomanizeLyricsSetting?: (checked: boolean) => void;
updateAiSyllables?: () => void;
updateAiSyllablesSetting?: (checked: boolean) => void;
} }
} }
@@ -29,6 +31,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", {
contextAwareLyrics: true, contextAwareLyrics: true,
bubbledLyrics: true, bubbledLyrics: true,
romanizeLyrics: false, romanizeLyrics: false,
aiSyllables: false,
stickyLyrics: false, stickyLyrics: false,
syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon) syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon)
syllableLogging: false, syllableLogging: false,
@@ -171,6 +174,16 @@ export const Settings = () => {
window.updateRomanizeLyricsSetting = undefined; window.updateRomanizeLyricsSetting = undefined;
}; };
}, []); }, []);
const [aiSyllables, setAiSyllables] = React.useState(
settings.aiSyllables,
);
React.useEffect(() => {
window.updateAiSyllablesSetting = (checked: boolean) =>
setAiSyllables(checked);
return () => {
window.updateAiSyllablesSetting = undefined;
};
}, []);
// Derive props and override onChange to accept a broader first param type // Derive props and override onChange to accept a broader first param type
type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>; type BaseSwitchProps = React.ComponentProps<typeof LunaSwitchSetting>;
@@ -279,6 +292,18 @@ export const Settings = () => {
window.updateLyricsStyle(); window.updateLyricsStyle();
} }
}} }}
/>
<AnySwitch
title="Sticky Lyrics"
desc="auto-switches to Play Queue when lyrics aren't available (mirrored in lyrics dropdown)"
checked={stickyLyrics}
onChange={(_: unknown, checked: boolean) => {
settings.stickyLyrics = checked;
setStickyLyrics(checked);
if (window.updateStickyLyricsFeature) {
window.updateStickyLyricsFeature();
}
}}
/> />
<AnySwitch <AnySwitch
title="Romanize Lyrics" title="Romanize Lyrics"
@@ -293,14 +318,14 @@ export const Settings = () => {
}} }}
/> />
<AnySwitch <AnySwitch
title="Sticky Lyrics" title="WIP | AI Generated Syllables"
desc="auto-switches to Play Queue when lyrics aren't available (mirrored in lyrics dropdown)" desc="Radiant AI generates word & syllable timings from the Line timings"
checked={stickyLyrics} checked={aiSyllables}
onChange={(_: unknown, checked: boolean) => { onChange={(_: unknown, checked: boolean) => {
settings.stickyLyrics = checked; settings.aiSyllables = checked;
setStickyLyrics(checked); setAiSyllables(checked);
if (window.updateStickyLyricsFeature) { if (window.updateAiSyllables) {
window.updateStickyLyricsFeature(); window.updateAiSyllables();
} }
}} }}
/> />
+10 -2
View File
@@ -43,12 +43,13 @@ function query(
title: string, title: string,
artist: string, artist: string,
isrc: string | undefined, isrc: string | undefined,
options?: { romanize?: boolean; flush?: boolean }, options?: { romanize?: boolean; flush?: boolean; synthesize?: boolean },
): string { ): string {
let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`; let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`;
if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`; if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`;
if (options?.romanize) q += "&romanize=true"; if (options?.romanize) q += "&romanize=true";
if (options?.flush) q += "&flush=true"; if (options?.flush) q += "&flush=true";
if (options?.synthesize) q += "&synthesize=true";
q += `&${platformQs}`; q += `&${platformQs}`;
return q; return q;
} }
@@ -61,6 +62,8 @@ export interface WordTiming {
duration: number; duration: number;
isBackground: boolean; isBackground: boolean;
romanized?: string; romanized?: string;
/** 0..1 model confidence for synthesized timings; unset for provider data */
confidence?: number;
} }
export interface WordLine { export interface WordLine {
@@ -107,6 +110,10 @@ export interface WordLyricsResponse {
songParts?: Array<{ name: string; time: number; duration: number }>; songParts?: Array<{ name: string; time: number; duration: number }>;
}; };
_cached?: boolean; _cached?: boolean;
/** true when timings came from the hosted Radiant AI model (?synthesize=true) */
_synthesized?: boolean;
/** original line-level data, kept so AI timings can be toggled off without a refetch */
lines?: ApiLine[];
} }
export interface LineLyricsResponse { export interface LineLyricsResponse {
@@ -137,8 +144,9 @@ export async function fetchLyrics(
artist: string, artist: string,
isrc: string | undefined, isrc: string | undefined,
romanize: boolean, romanize: boolean,
synthesize = false,
): Promise<LyricsApiResponse | null> { ): Promise<LyricsApiResponse | null> {
const params = query(title, artist, isrc, { romanize }); const params = query(title, artist, isrc, { romanize, synthesize });
const atomixUrl = `https://api.atomix.one/rl-api${params}`; const atomixUrl = `https://api.atomix.one/rl-api${params}`;
const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`; const fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`;
+98 -2
View File
@@ -11,6 +11,7 @@ import {
} from "@luna/lib"; } from "@luna/lib";
import { import {
type ApiLine, type ApiLine,
type LineLyricsResponse,
type LyricsApiResponse, type LyricsApiResponse,
type WordLine, type WordLine,
type WordTiming, type WordTiming,
@@ -1368,6 +1369,19 @@ const ensureStickyDropdown = (): HTMLElement => {
<input type="checkbox" data-setting="stickyLyrics" ${settings.stickyLyrics ? "checked" : ""}> <input type="checkbox" data-setting="stickyLyrics" ${settings.stickyLyrics ? "checked" : ""}>
<span class="sticky-lyrics-slider"></span> <span class="sticky-lyrics-slider"></span>
</label> </label>
</div> <div class="sticky-lyrics-dropdown-row">
<span class="sticky-lyrics-label">Romanization</span>
<label class="sticky-lyrics-switch">
<input type="checkbox" data-setting="romanizeLyrics" ${settings.romanizeLyrics ? "checked" : ""}>
<span class="sticky-lyrics-slider"></span>
</label>
</div>
<div class="sticky-lyrics-dropdown-row">
<span class="sticky-lyrics-label">AI Syllables | WIP</span>
<label class="sticky-lyrics-switch">
<input type="checkbox" data-setting="aiSyllables" ${settings.aiSyllables ? "checked" : ""}>
<span class="sticky-lyrics-slider"></span>
</label>
</div> </div>
<div class="sticky-lyrics-dropdown-row rl-style-row"> <div class="sticky-lyrics-dropdown-row rl-style-row">
<div class="rl-seg-control"> <div class="rl-seg-control">
@@ -1389,6 +1403,26 @@ const ensureStickyDropdown = (): HTMLElement => {
} }
}); });
// AI Generated Syllables (Radiant AI [RL API])
const synthCheckbox = dropdown.querySelector(
'input[data-setting="aiSyllables"]',
) as HTMLInputElement;
synthCheckbox.addEventListener("change", () => {
settings.aiSyllables = synthCheckbox.checked;
(window as any).updateAiSyllablesSetting?.(synthCheckbox.checked);
updateAiSyllablesFromSettings();
});
// Romanization (mirrors the Romanize Lyrics setting)
const romanizeCheckbox = dropdown.querySelector(
'input[data-setting="romanizeLyrics"]',
) as HTMLInputElement;
romanizeCheckbox.addEventListener("change", () => {
settings.romanizeLyrics = romanizeCheckbox.checked;
(window as any).updateRomanizeLyricsSetting?.(romanizeCheckbox.checked);
updateRomanizeLyricsFromSettings();
});
const styleNames = ["Line", "Word", "Syllable"]; const styleNames = ["Line", "Word", "Syllable"];
const segButtons = dropdown.querySelectorAll(".rl-seg-btn"); const segButtons = dropdown.querySelectorAll(".rl-seg-btn");
for (const btn of segButtons) { for (const btn of segButtons) {
@@ -1622,6 +1656,7 @@ interface SyntheticNativeLyricsState {
let trackChangeToken = 0; let trackChangeToken = 0;
let lyricsData: WordLine[] | null = null; let lyricsData: WordLine[] | null = null;
let lyricsResponse: LyricsApiResponse | null = null; let lyricsResponse: LyricsApiResponse | null = null;
let lyricsIsAiGenerated = false;
let lyricsMode: LyricsOverlayMode = "none"; let lyricsMode: LyricsOverlayMode = "none";
let tickLoopUnload: LunaUnload | null = null; let tickLoopUnload: LunaUnload | null = null;
let isActive = false; let isActive = false;
@@ -2418,16 +2453,41 @@ const fetchLyrics = async (
artist: string, artist: string,
isrc?: string, isrc?: string,
): Promise<LyricsApiResponse | null> => { ): Promise<LyricsApiResponse | null> => {
const cacheKey = `${title}\0${artist}\0${isrc ?? ""}\0${settings.romanizeLyrics ? "r" : ""}`; const cacheKey = `${title}\0${artist}\0${isrc ?? ""}\0${settings.romanizeLyrics ? "r" : ""}${settings.aiSyllables ? "a" : ""}`;
if (cachedLyricsKey === cacheKey) { if (cachedLyricsKey === cacheKey) {
sylLog(`[RL-Syllable] Cache hit for "${title}" by "${artist}"`); sylLog(`[RL-Syllable] Cache hit for "${title}" by "${artist}"`);
return cachedLyricsData; return cachedLyricsData;
} }
// AI toggled off mid song, AI response has the original lines (no refetch)
if (
!settings.aiSyllables &&
cachedLyricsKey === `${cacheKey}a` &&
cachedLyricsData?.type === "Word" &&
cachedLyricsData._synthesized &&
cachedLyricsData.lines
) {
const derived: LineLyricsResponse = {
type: "Line",
data: cachedLyricsData.lines,
metadata: {
...cachedLyricsData.metadata,
source: cachedLyricsData.metadata.source.replace(/ • AI$/, ""),
},
_cached: cachedLyricsData._cached,
};
cachedLyricsKey = cacheKey;
cachedLyricsData = derived;
sylLog(`[RL-Syllable] AI off — using line timings from the AI response`);
return derived;
}
const data = await fetchLyricsApi( const data = await fetchLyricsApi(
title, title,
artist, artist,
isrc, isrc,
settings.romanizeLyrics, settings.romanizeLyrics,
settings.aiSyllables,
); );
cachedLyricsKey = cacheKey; cachedLyricsKey = cacheKey;
cachedLyricsData = data; cachedLyricsData = data;
@@ -2689,6 +2749,7 @@ const buildWordSpans = (): {
if (settings.blurInactive && scrollSynced && blurActivated) if (settings.blurInactive && scrollSynced && blurActivated)
wbwContainer.classList.add("rl-blur-active"); wbwContainer.classList.add("rl-blur-active");
if (settings.bubbledLyrics) wbwContainer.classList.add("rl-bubbled"); if (settings.bubbledLyrics) wbwContainer.classList.add("rl-bubbled");
if (lyricsIsAiGenerated) wbwContainer.classList.add("rl-ai");
const effectiveStyle = getLyricsStyle(); const effectiveStyle = getLyricsStyle();
const allowWordSylStyles = isWordMode(); const allowWordSylStyles = isWordMode();
// MARKER: Syllable animations (WIP coming soon) // MARKER: Syllable animations (WIP coming soon)
@@ -2804,6 +2865,7 @@ const buildWordSpans = (): {
text: string, text: string,
seekMs: number, seekMs: number,
bg: boolean, bg: boolean,
confidence?: number,
): HTMLSpanElement => { ): HTMLSpanElement => {
const span = document.createElement("span"); const span = document.createElement("span");
span.className = "rl-wbw-word"; span.className = "rl-wbw-word";
@@ -2813,6 +2875,12 @@ const buildWordSpans = (): {
span.textContent = text; span.textContent = text;
} }
forceStyle(span, WORD_SPAN_STYLE); forceStyle(span, WORD_SPAN_STYLE);
// Confidence-proportional wipe feather: crisp edges where the
// synthesizer had real evidence, soft where it guessed. SUPER WIP
if (confidence !== undefined) {
const feather = 0.55 + (1 - Math.max(0, Math.min(1, confidence))) * 1.3;
span.style.setProperty("--rl-wipe-feather", `${feather.toFixed(2)}em`);
}
if (bg) span.classList.add("rl-wbw-bg"); if (bg) span.classList.add("rl-wbw-bg");
span.addEventListener("click", () => { span.addEventListener("click", () => {
PlayState.seek(seekMs / 1000); PlayState.seek(seekMs / 1000);
@@ -2898,6 +2966,7 @@ const buildWordSpans = (): {
sylDisplay(syl).trimEnd(), sylDisplay(syl).trimEnd(),
wordStartMs, wordStartMs,
syl.isBackground, syl.isBackground,
syl.confidence,
); );
span.addEventListener("mouseenter", () => { span.addEventListener("mouseenter", () => {
for (const s of groupSpans) s.classList.add("rl-wbw-word-hover"); for (const s of groupSpans) s.classList.add("rl-wbw-word-hover");
@@ -2925,7 +2994,7 @@ const buildWordSpans = (): {
const start = firstSyl.time; const start = firstSyl.time;
const end = lastSyl.time + lastSyl.duration; const end = lastSyl.time + lastSyl.duration;
const bg = firstSyl.isBackground; const bg = firstSyl.isBackground;
const span = makeSpan(mergedText, start, bg); const span = makeSpan(mergedText, start, bg, firstSyl.confidence);
targetContainer.appendChild(span); targetContainer.appendChild(span);
const entry: WordEntry = { const entry: WordEntry = {
el: span, el: span,
@@ -3523,6 +3592,7 @@ const teardown = (): void => {
lyricsMode = "none"; lyricsMode = "none";
lyricsData = null; lyricsData = null;
lyricsResponse = null; lyricsResponse = null;
lyricsIsAiGenerated = false;
lines = []; lines = [];
activeWordEls.clear(); activeWordEls.clear();
activeBgWordEls.clear(); activeBgWordEls.clear();
@@ -4126,6 +4196,15 @@ const onTrackChange = async (): Promise<void> => {
); );
unlockFlush(); unlockFlush();
// MARKER: Radiant AI Syllables [RL API]
if (response.type === "Word" && response._synthesized) {
lyricsIsAiGenerated = true;
sylLog(
`[RL-Syllable] AI generated syllable timings for ${response.data.length} lines (source: ${response.metadata.source})`,
);
}
lyricsMode = response.type === "Word" ? "word" : "line-api"; lyricsMode = response.type === "Word" ? "word" : "line-api";
if (token !== trackChangeToken) return; if (token !== trackChangeToken) return;
lyricsData = lyricsData =
@@ -4278,6 +4357,10 @@ const updateLyricsStyleFromSettings = (): void => {
(window as any).updateLyricsStyle = updateLyricsStyleFromSettings; (window as any).updateLyricsStyle = updateLyricsStyleFromSettings;
const updateRomanizeLyricsFromSettings = (): void => { const updateRomanizeLyricsFromSettings = (): void => {
const checkbox = document.querySelector(
'input[data-setting="romanizeLyrics"]',
) as HTMLInputElement | null;
if (checkbox) checkbox.checked = settings.romanizeLyrics;
cachedLyricsKey = null; cachedLyricsKey = null;
cachedLyricsData = null; cachedLyricsData = null;
cachedTidalRomanizeKey = null; cachedTidalRomanizeKey = null;
@@ -4286,6 +4369,19 @@ const updateRomanizeLyricsFromSettings = (): void => {
}; };
(window as any).updateRomanizeLyrics = updateRomanizeLyricsFromSettings; (window as any).updateRomanizeLyrics = updateRomanizeLyricsFromSettings;
// Turning AI off reuses the line timings the AI response already has (no refetch). turning it on refetches with ?synthesize=true
const updateAiSyllablesFromSettings = (): void => {
const checkbox = document.querySelector(
'input[data-setting="aiSyllables"]',
) as HTMLInputElement | null;
if (checkbox) checkbox.checked = settings.aiSyllables;
sylLog(
`[RL-Syllable] AI Generated Syllables ${settings.aiSyllables ? "enabled" : "disabled"}`,
);
toggle();
};
(window as any).updateAiSyllables = updateAiSyllablesFromSettings;
// Update lyrics on track change (wipe cache for new song) // Update lyrics on track change (wipe cache for new song)
onGlobalTrackChange(() => { onGlobalTrackChange(() => {
cachedLyricsKey = null; cachedLyricsKey = null;
+20 -6
View File
@@ -142,6 +142,11 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] {
gap: 10px; gap: 10px;
} }
/* Breathing room between stacked rows */
.sticky-lyrics-dropdown-row + .sticky-lyrics-dropdown-row {
margin-top: 8px;
}
.sticky-lyrics-label { .sticky-lyrics-label {
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
@@ -599,21 +604,21 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
@keyframes rl-wipe { @keyframes rl-wipe {
from { from {
background-size: background-size:
0.75em 100%, var(--rl-wipe-feather, 0.75em) 100%,
0% 100%, 0% 100%,
100% 100%; 100% 100%;
background-position: background-position:
-0.375em 0%, calc(var(--rl-wipe-feather, 0.75em) / -2) 0%,
left, left,
left; left;
} }
to { to {
background-size: background-size:
0.75em 100%, var(--rl-wipe-feather, 0.75em) 100%,
100% 100%, 100% 100%,
100% 100%; 100% 100%;
background-position: background-position:
calc(100% + 0.375em) 0%, calc(100% + var(--rl-wipe-feather, 0.75em) / 2) 0%,
left, left,
left; left;
} }
@@ -640,11 +645,11 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%), linear-gradient(90deg, var(--cl-glow1, #fff) 100%, transparent 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)); linear-gradient(90deg, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4));
background-size: background-size:
0.75em 100%, var(--rl-wipe-feather, 0.75em) 100%,
0% 100%, 0% 100%,
100% 100%; 100% 100%;
background-position: background-position:
-0.375em 0%, calc(var(--rl-wipe-feather, 0.75em) / -2) 0%,
left, left,
left; left;
/* biome-ignore lint: No glow for syllable mode */ /* biome-ignore lint: No glow for syllable mode */
@@ -653,6 +658,15 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] {
filter: none !important; filter: none !important;
} }
/* AI-generated timing: soften word-mode color flips so a boundary that's off
by a bit has no crisp edge to expose it. Per-span --rl-wipe-feather is set
inline from the AI model's confidence. SUPER WIP */
.rl-wbw-container.rl-ai .rl-wbw-word {
transition:
text-shadow 0.25s ease-out,
color 0.3s ease-out;
}
/* Syllable finished: word stays Colorama color */ /* Syllable finished: word stays Colorama color */
.rl-wbw-word.rl-syl-finished { .rl-wbw-word.rl-syl-finished {
/* biome-ignore lint: Kill base transitions so class swaps are instant */ /* biome-ignore lint: Kill base transitions so class swaps are instant */