diff --git a/plugins/radiant-lyrics-luna/src/Settings.tsx b/plugins/radiant-lyrics-luna/src/Settings.tsx index 7467444..26b218c 100644 --- a/plugins/radiant-lyrics-luna/src/Settings.tsx +++ b/plugins/radiant-lyrics-luna/src/Settings.tsx @@ -17,6 +17,8 @@ declare global { updateLyricsStyleSetting?: (value: number) => void; updateRomanizeLyrics?: () => void; updateRomanizeLyricsSetting?: (checked: boolean) => void; + updateAiSyllables?: () => void; + updateAiSyllablesSetting?: (checked: boolean) => void; } } @@ -29,6 +31,7 @@ export const settings = await ReactiveStore.getPluginStorage("RadiantLyrics", { contextAwareLyrics: true, bubbledLyrics: true, romanizeLyrics: false, + aiSyllables: false, stickyLyrics: false, syllableStyle: 0, // MARKER: Syllable animations SETTINGS (WIP coming soon) syllableLogging: false, @@ -171,6 +174,16 @@ export const Settings = () => { 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 type BaseSwitchProps = React.ComponentProps; @@ -279,6 +292,18 @@ export const Settings = () => { window.updateLyricsStyle(); } }} + /> + { + settings.stickyLyrics = checked; + setStickyLyrics(checked); + if (window.updateStickyLyricsFeature) { + window.updateStickyLyricsFeature(); + } + }} /> { }} /> { - settings.stickyLyrics = checked; - setStickyLyrics(checked); - if (window.updateStickyLyricsFeature) { - window.updateStickyLyricsFeature(); + settings.aiSyllables = checked; + setAiSyllables(checked); + if (window.updateAiSyllables) { + window.updateAiSyllables(); } }} /> diff --git a/plugins/radiant-lyrics-luna/src/api.ts b/plugins/radiant-lyrics-luna/src/api.ts index 3b16f99..c242b74 100644 --- a/plugins/radiant-lyrics-luna/src/api.ts +++ b/plugins/radiant-lyrics-luna/src/api.ts @@ -43,12 +43,13 @@ function query( title: string, artist: string, isrc: string | undefined, - options?: { romanize?: boolean; flush?: boolean }, + options?: { romanize?: boolean; flush?: boolean; synthesize?: boolean }, ): string { let q = `?title=${encodeURIComponent(title)}&artist=${encodeURIComponent(artist)}`; if (isrc) q += `&isrc=${encodeURIComponent(isrc)}`; if (options?.romanize) q += "&romanize=true"; if (options?.flush) q += "&flush=true"; + if (options?.synthesize) q += "&synthesize=true"; q += `&${platformQs}`; return q; } @@ -61,6 +62,8 @@ export interface WordTiming { duration: number; isBackground: boolean; romanized?: string; + /** 0..1 model confidence for synthesized timings; unset for provider data */ + confidence?: number; } export interface WordLine { @@ -107,6 +110,10 @@ export interface WordLyricsResponse { songParts?: Array<{ name: string; time: number; duration: number }>; }; _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 { @@ -137,8 +144,9 @@ export async function fetchLyrics( artist: string, isrc: string | undefined, romanize: boolean, + synthesize = false, ): Promise { - 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 fallbackUrl = `https://rl-api.kineticsand.net/lyrics${params}`; diff --git a/plugins/radiant-lyrics-luna/src/index.ts b/plugins/radiant-lyrics-luna/src/index.ts index 62e20c8..b6613df 100644 --- a/plugins/radiant-lyrics-luna/src/index.ts +++ b/plugins/radiant-lyrics-luna/src/index.ts @@ -11,6 +11,7 @@ import { } from "@luna/lib"; import { type ApiLine, + type LineLyricsResponse, type LyricsApiResponse, type WordLine, type WordTiming, @@ -1368,6 +1369,19 @@ const ensureStickyDropdown = (): HTMLElement => { +
+ Romanization + +
+
+ AI Syllables +
@@ -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 segButtons = dropdown.querySelectorAll(".rl-seg-btn"); for (const btn of segButtons) { @@ -1622,6 +1656,7 @@ interface SyntheticNativeLyricsState { let trackChangeToken = 0; let lyricsData: WordLine[] | null = null; let lyricsResponse: LyricsApiResponse | null = null; +let lyricsIsAiGenerated = false; let lyricsMode: LyricsOverlayMode = "none"; let tickLoopUnload: LunaUnload | null = null; let isActive = false; @@ -2418,16 +2453,41 @@ const fetchLyrics = async ( artist: string, isrc?: string, ): Promise => { - 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) { sylLog(`[RL-Syllable] Cache hit for "${title}" by "${artist}"`); 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( title, artist, isrc, settings.romanizeLyrics, + settings.aiSyllables, ); cachedLyricsKey = cacheKey; cachedLyricsData = data; @@ -2689,6 +2749,7 @@ const buildWordSpans = (): { if (settings.blurInactive && scrollSynced && blurActivated) wbwContainer.classList.add("rl-blur-active"); if (settings.bubbledLyrics) wbwContainer.classList.add("rl-bubbled"); + if (lyricsIsAiGenerated) wbwContainer.classList.add("rl-ai"); const effectiveStyle = getLyricsStyle(); const allowWordSylStyles = isWordMode(); // MARKER: Syllable animations (WIP coming soon) @@ -2804,6 +2865,7 @@ const buildWordSpans = (): { text: string, seekMs: number, bg: boolean, + confidence?: number, ): HTMLSpanElement => { const span = document.createElement("span"); span.className = "rl-wbw-word"; @@ -2813,6 +2875,12 @@ const buildWordSpans = (): { span.textContent = text; } 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"); span.addEventListener("click", () => { PlayState.seek(seekMs / 1000); @@ -2898,6 +2966,7 @@ const buildWordSpans = (): { sylDisplay(syl).trimEnd(), wordStartMs, syl.isBackground, + syl.confidence, ); span.addEventListener("mouseenter", () => { for (const s of groupSpans) s.classList.add("rl-wbw-word-hover"); @@ -2925,7 +2994,7 @@ const buildWordSpans = (): { const start = firstSyl.time; const end = lastSyl.time + lastSyl.duration; const bg = firstSyl.isBackground; - const span = makeSpan(mergedText, start, bg); + const span = makeSpan(mergedText, start, bg, firstSyl.confidence); targetContainer.appendChild(span); const entry: WordEntry = { el: span, @@ -3523,6 +3592,7 @@ const teardown = (): void => { lyricsMode = "none"; lyricsData = null; lyricsResponse = null; + lyricsIsAiGenerated = false; lines = []; activeWordEls.clear(); activeBgWordEls.clear(); @@ -4126,6 +4196,15 @@ const onTrackChange = async (): Promise => { ); 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"; if (token !== trackChangeToken) return; lyricsData = @@ -4278,6 +4357,10 @@ const updateLyricsStyleFromSettings = (): void => { (window as any).updateLyricsStyle = updateLyricsStyleFromSettings; const updateRomanizeLyricsFromSettings = (): void => { + const checkbox = document.querySelector( + 'input[data-setting="romanizeLyrics"]', + ) as HTMLInputElement | null; + if (checkbox) checkbox.checked = settings.romanizeLyrics; cachedLyricsKey = null; cachedLyricsData = null; cachedTidalRomanizeKey = null; @@ -4286,6 +4369,19 @@ const updateRomanizeLyricsFromSettings = (): void => { }; (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) onGlobalTrackChange(() => { cachedLyricsKey = null; diff --git a/plugins/radiant-lyrics-luna/src/styles.css b/plugins/radiant-lyrics-luna/src/styles.css index 178763e..619b2b9 100644 --- a/plugins/radiant-lyrics-luna/src/styles.css +++ b/plugins/radiant-lyrics-luna/src/styles.css @@ -142,6 +142,11 @@ body.rl-dropdown-open [data-test="toggle-lyrics"] { gap: 10px; } +/* Breathing room between stacked rows */ +.sticky-lyrics-dropdown-row + .sticky-lyrics-dropdown-row { + margin-top: 8px; +} + .sticky-lyrics-label { font-size: 11px; font-weight: 600; @@ -599,21 +604,21 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] { @keyframes rl-wipe { from { background-size: - 0.75em 100%, + var(--rl-wipe-feather, 0.75em) 100%, 0% 100%, 100% 100%; background-position: - -0.375em 0%, + calc(var(--rl-wipe-feather, 0.75em) / -2) 0%, left, left; } to { background-size: - 0.75em 100%, + var(--rl-wipe-feather, 0.75em) 100%, 100% 100%, 100% 100%; background-position: - calc(100% + 0.375em) 0%, + calc(100% + var(--rl-wipe-feather, 0.75em) / 2) 0%, 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, rgba(255, 255, 255, 0.4), rgba(255, 255, 255, 0.4)); background-size: - 0.75em 100%, + var(--rl-wipe-feather, 0.75em) 100%, 0% 100%, 100% 100%; background-position: - -0.375em 0%, + calc(var(--rl-wipe-feather, 0.75em) / -2) 0%, left, left; /* biome-ignore lint: No glow for syllable mode */ @@ -653,6 +658,15 @@ body.rl-owns-lyric-scroll [class*="_syncButtonContainer_"] { 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 */ .rl-wbw-word.rl-syl-finished { /* biome-ignore lint: Kill base transitions so class swaps are instant */