From ca4fa7af22446b953da68dd569adcdd1a3d999b1 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 12:46:40 +0700 Subject: [PATCH 01/11] feat(lyrics): add AI singable translation and dynamic canvas syllable pulse animation --- src/features/ai-translate/aiTranslate.test.ts | 34 +++++ src/features/ai-translate/index.ts | 4 + src/features/ai-translate/prompts.ts | 45 ++++++ .../services/aiTranslateService.test.ts | 125 +++++++++++++++ .../services/aiTranslateService.ts | 80 ++++++++++ .../ai-translate/services/geminiProvider.ts | 86 +++++++++++ .../services/googleFreeProvider.ts | 51 +++++++ .../ai-translate/services/lineMatching.ts | 67 ++++++++ .../ai-translate/services/openAiProvider.ts | 143 ++++++++++++++++++ .../ai-translate/store/aiTranslateActions.ts | 85 +++++++++++ .../ai-translate/store/aiTranslateCache.ts | 51 +++++++ .../store/aiTranslateStore.test.ts | 118 +++++++++++++++ .../ai-translate/store/aiTranslateStore.ts | 124 +++++++++++++++ src/features/ai-translate/types.ts | 22 +++ src/features/lyrics/canvas/drawWord.ts | 19 ++- .../lyrics/canvas/syllablePulse.test.ts | 44 ++++++ src/features/lyrics/canvas/syllablePulse.ts | 50 ++++++ src/features/lyrics/canvas/timing.ts | 7 +- .../lyrics/hooks/useEnrichedLyrics.test.ts | 47 ++++++ .../lyrics/hooks/useEnrichedLyrics.ts | 87 +++++++++++ .../lyrics/ui/LyricsProviderIsland.tsx | 3 + .../ui/LyricsTranslateIslandControls.tsx | 102 +++++++++++++ src/features/player/ui/FullscreenPlayer.tsx | 4 +- src/features/settings/ui/SettingsModal.tsx | 15 +- src/features/settings/ui/tabs/AiTab.tsx | 83 ++++++++++ .../settings/ui/tabs/AiTabModelConfig.tsx | 133 ++++++++++++++++ src/features/settings/ui/tabs/index.ts | 1 + src/languages/locales/en.json | 4 + 28 files changed, 1628 insertions(+), 6 deletions(-) create mode 100644 src/features/ai-translate/aiTranslate.test.ts create mode 100644 src/features/ai-translate/index.ts create mode 100644 src/features/ai-translate/prompts.ts create mode 100644 src/features/ai-translate/services/aiTranslateService.test.ts create mode 100644 src/features/ai-translate/services/aiTranslateService.ts create mode 100644 src/features/ai-translate/services/geminiProvider.ts create mode 100644 src/features/ai-translate/services/googleFreeProvider.ts create mode 100644 src/features/ai-translate/services/lineMatching.ts create mode 100644 src/features/ai-translate/services/openAiProvider.ts create mode 100644 src/features/ai-translate/store/aiTranslateActions.ts create mode 100644 src/features/ai-translate/store/aiTranslateCache.ts create mode 100644 src/features/ai-translate/store/aiTranslateStore.test.ts create mode 100644 src/features/ai-translate/store/aiTranslateStore.ts create mode 100644 src/features/ai-translate/types.ts create mode 100644 src/features/lyrics/canvas/syllablePulse.test.ts create mode 100644 src/features/lyrics/canvas/syllablePulse.ts create mode 100644 src/features/lyrics/hooks/useEnrichedLyrics.test.ts create mode 100644 src/features/lyrics/hooks/useEnrichedLyrics.ts create mode 100644 src/features/lyrics/ui/LyricsTranslateIslandControls.tsx create mode 100644 src/features/settings/ui/tabs/AiTab.tsx create mode 100644 src/features/settings/ui/tabs/AiTabModelConfig.tsx diff --git a/src/features/ai-translate/aiTranslate.test.ts b/src/features/ai-translate/aiTranslate.test.ts new file mode 100644 index 000000000..74e4d6684 --- /dev/null +++ b/src/features/ai-translate/aiTranslate.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { + SONG_TRANSLATION_SYSTEM_PROMPT, + SONG_SINGABLE_TRANSLATION_SYSTEM_PROMPT, + SONG_ROMANIZATION_SYSTEM_PROMPT, +} from "./services/aiTranslateService"; + +describe("AI Lyrics Translation Prompts", () => { + it("generates correct system prompt enforcing line-by-line parity", () => { + const prompt = SONG_TRANSLATION_SYSTEM_PROMPT("English", "Vietnamese"); + expect(prompt).toContain("Maintain line-by-line parity"); + expect(prompt).toContain("poetic song translator"); + expect(prompt).toContain("Vietnamese"); + }); + + it("generates a romanization prompt that forbids translation", () => { + expect(SONG_ROMANIZATION_SYSTEM_PROMPT).toContain("Maintain line-by-line parity"); + expect(SONG_ROMANIZATION_SYSTEM_PROMPT).toContain("Hepburn Romaji"); + expect(SONG_ROMANIZATION_SYSTEM_PROMPT).toContain("Do NOT translate"); + }); + + it("generates musical singable prompt with syllable and rhyme rules", () => { + const singablePrompt = SONG_SINGABLE_TRANSLATION_SYSTEM_PROMPT( + "Japanese", + "English", + ); + expect(singablePrompt).toContain("singable"); + expect(singablePrompt).toContain("SYLLABLE COUNT & CADENCE"); + expect(singablePrompt).toContain("RHYME & TONAL MELODY"); + + const prompt = SONG_TRANSLATION_SYSTEM_PROMPT("Japanese", "English", true); + expect(prompt).toContain("SYLLABLE COUNT & CADENCE"); + }); +}); diff --git a/src/features/ai-translate/index.ts b/src/features/ai-translate/index.ts new file mode 100644 index 000000000..2500cb263 --- /dev/null +++ b/src/features/ai-translate/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./prompts"; +export * from "./services/aiTranslateService"; +export * from "./store/aiTranslateStore"; diff --git a/src/features/ai-translate/prompts.ts b/src/features/ai-translate/prompts.ts new file mode 100644 index 000000000..e92413ee5 --- /dev/null +++ b/src/features/ai-translate/prompts.ts @@ -0,0 +1,45 @@ +export const SONG_SINGABLE_TRANSLATION_SYSTEM_PROMPT = ( + sourceLang = "auto", + targetLang = "English", +) => `You are a master musical lyricist and poetic song adapter specializing in writing singable ${targetLang} song lyrics. + +Your task is NOT a dry or literal translation. You must adapt and recreate the lyrics into ${targetLang} so that an artist can SING IT DIRECTLY to the original vocal melody, rhythm, beat, and BPM. + +CRITICAL MUSICAL & POETIC RULES: +1. STRICT LINE-BY-LINE PARITY: If the input has N lines, return EXACTLY N lines in the same order. +2. SYLLABLE COUNT & CADENCE: Match the syllable count and musical cadence of each original line closely so that every translated word fits naturally on the musical notes without squeezing or stretching. +3. RHYME & TONAL MELODY: + - Weave harmonious internal and end rhymes and balance melodic rhythm typical of professional vocal songwriting. + - For tonal languages (such as Vietnamese or Chinese), accents and tones MUST align naturally with the pitch contour of the song melody, avoiding awkward or comical mispronunciations when sung. +4. EMOTIONAL ESSENCE & POETIC BEAUTY: Preserve the emotional heart, storyline, atmosphere, and metaphors of the song. Express the meaning with rich, artistic, and evocative ${targetLang} imagery. +5. VOCALIZATIONS: Keep non-lexical musical interjections and hooks (e.g., 'Oh', 'Yeah', 'La la la', 'Na na na') unchanged. +6. OUTPUT FORMAT: Output ONLY the singable lyrics lines. Never output line numbers, romanizations, commentary, or markdown formatting.`; + +export const SONG_TRANSLATION_SYSTEM_PROMPT = ( + sourceLang = "auto", + targetLang = "English", + singable = false, +) => { + if (singable) { + return SONG_SINGABLE_TRANSLATION_SYSTEM_PROMPT(sourceLang, targetLang); + } + return `You are an elite, poetic song translator specializing in musical context and emotional nuance. +Your task is to translate the following song lyrics from ${sourceLang} into ${targetLang}. +STRICT RULES: +1. Maintain line-by-line parity. If the input has N lines, return EXACTLY N lines in the same order. +2. Preserve emotional resonance, metaphors, and rhythm. Avoid literal, robotic word-by-word translation. +3. Natural phrasing: The translated line should sound melodic and natural when sung or read aloud. +4. Do NOT output line numbers, markdown formatting, prefixes, or commentary. Output ONLY the translated lines. +5. If a line is purely an interjection or musical vocalization (e.g., 'Oh', 'Yeah', 'La la la'), keep it as is.`; +}; + +export const SONG_ROMANIZATION_SYSTEM_PROMPT = `You are an expert lyrics romanizer and transliteration specialist. +Your task is to transliterate song lyrics into standard Latin alphabet (pronunciation): +- For Japanese lyrics, provide standard Hepburn Romaji with spaces between words (e.g. "zankoku na tenshi no te-ze"). +- For Korean lyrics, provide Revised Romanization. +- For Chinese lyrics, provide Pinyin. +- If a line is already in English or Latin alphabet, leave it exactly as is. +STRICT RULES: +1. Maintain line-by-line parity. If the input has N lines, return EXACTLY N lines in the same order. +2. Do NOT translate the meaning. Output ONLY the pronunciation/romanization in Latin alphabet. +3. Do NOT output line numbers, markdown formatting, commentary, or quotes. Output ONLY the romanized lines.`; diff --git a/src/features/ai-translate/services/aiTranslateService.test.ts b/src/features/ai-translate/services/aiTranslateService.test.ts new file mode 100644 index 000000000..9a827e8dc --- /dev/null +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + translateSongLyrics, + romanizeSongLyrics, +} from "./aiTranslateService"; + +function mockGoogleTranslate(dictionary: Record) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const q = decodeURIComponent(new URL(url).searchParams.get("q") ?? ""); + const translated = dictionary[q] ?? q; + return { + ok: true, + json: async () => [[[translated, q]]], + } as Response; + }); +} + +describe("AI Lyrics Translation Service", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("falls back to free translator when API key is omitted", async () => { + const mockFetch = mockGoogleTranslate({ + Hello: "Xin chào", + World: "Thế giới", + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const result = await translateSongLyrics(["Hello", "", "World"], { + provider: "gemini", + targetLang: "vi", + }); + + expect(result).toEqual(["Xin chào", "", "Thế giới"]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("keeps subtitle indices aligned when an LLM drops blank lines", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + candidates: [ + { content: { parts: [{ text: "Dòng một\nDòng ba" }] } }, + ], + }), + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const result = await translateSongLyrics( + ["Line one", "", "Line three"], + { + provider: "gemini", + apiKey: "test-key", + targetLang: "vi", + }, + ); + + expect(result).toEqual(["Dòng một", "", "Dòng ba"]); + }); + + it("romanizes Japanese lyrics and skips lines that are already Latin", async () => { + const mockFetch = vi.fn(async (input: RequestInfo | URL) => { + const q = decodeURIComponent( + new URL(String(input)).searchParams.get("q") ?? "", + ); + const result = q === "残酷な天使のテーゼ" ? "Zankoku na tenshi no tēze" : q; + return { + ok: true, + json: async () => [[[null, null, null, result]]], + } as Response; + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const result = await romanizeSongLyrics( + ["残酷な天使のテーゼ", "Hello world"], + { + provider: "google_translate", + targetLang: "vi", + }, + ); + + expect(result).toEqual(["Zankoku na tenshi no tēze", ""]); + }); + + it("translates via OpenAI compatible provider with custom baseUrl and model", async () => { + let capturedUrl = ""; + let capturedBody: any = null; + let capturedHeaders: any = null; + + global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + capturedUrl = String(input); + capturedHeaders = init?.headers; + capturedBody = JSON.parse(String(init?.body ?? "{}")); + return { + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: "Xin chào bầu trời xanh\nNắng chiếu lấp lánh quanh", + }, + }, + ], + }), + } as Response; + }) as unknown as typeof fetch; + + const result = await translateSongLyrics(["Blue sky", "Bright sun"], { + provider: "openai_compatible", + apiKey: "custom-token-123", + baseUrl: "https://api.groq.com/openai/v1", + model: "llama-3.3-70b-versatile", + targetLang: "vi", + singableMode: true, + }); + + expect(capturedUrl).toBe("https://api.groq.com/openai/v1/chat/completions"); + expect(capturedHeaders["Authorization"]).toBe("Bearer custom-token-123"); + expect(capturedBody.model).toBe("llama-3.3-70b-versatile"); + expect(capturedBody.messages[0].content).toContain("SYLLABLE COUNT"); + expect(result).toEqual(["Xin chào bầu trời xanh", "Nắng chiếu lấp lánh quanh"]); + }); +}); diff --git a/src/features/ai-translate/services/aiTranslateService.ts b/src/features/ai-translate/services/aiTranslateService.ts new file mode 100644 index 000000000..d6389de6e --- /dev/null +++ b/src/features/ai-translate/services/aiTranslateService.ts @@ -0,0 +1,80 @@ +import type { TranslationConfig } from "../types"; +import { dropUnchangedRomanization } from "./lineMatching"; +import { + translateWithGemini, + romanizeWithGemini, +} from "./geminiProvider"; +import { + translateWithOpenAI, + romanizeWithOpenAI, + translateWithDeepSeek, + romanizeWithDeepSeek, + translateWithOpenAiCompatible, + romanizeWithOpenAiCompatible, +} from "./openAiProvider"; +import { + translateWithGoogleFree, + romanizeWithGoogleFree, +} from "./googleFreeProvider"; + +export * from "../prompts"; + +export async function translateSongLyrics( + lines: string[], + config: TranslationConfig, +): Promise { + if (lines.length === 0) return []; + + const { provider, apiKey, targetLang, model, baseUrl, singableMode } = config; + + if (provider === "gemini" && apiKey) { + return translateWithGemini(lines, apiKey, targetLang, model, singableMode); + } + if (provider === "openai" && apiKey) { + return translateWithOpenAI(lines, apiKey, targetLang, model, singableMode); + } + if (provider === "deepseek" && apiKey) { + return translateWithDeepSeek(lines, apiKey, targetLang, model, singableMode); + } + if (provider === "openai_compatible") { + return translateWithOpenAiCompatible( + lines, + apiKey || "", + targetLang, + baseUrl, + model, + singableMode, + ); + } + + return translateWithGoogleFree(lines, targetLang); +} + +export async function romanizeSongLyrics( + lines: string[], + config: TranslationConfig, +): Promise { + if (lines.length === 0) return []; + + const { provider, apiKey, model, baseUrl } = config; + + let result: string[]; + if (provider === "gemini" && apiKey) { + result = await romanizeWithGemini(lines, apiKey, model); + } else if (provider === "openai" && apiKey) { + result = await romanizeWithOpenAI(lines, apiKey, model); + } else if (provider === "deepseek" && apiKey) { + result = await romanizeWithDeepSeek(lines, apiKey, model); + } else if (provider === "openai_compatible") { + result = await romanizeWithOpenAiCompatible( + lines, + apiKey || "", + baseUrl, + model, + ); + } else { + result = await romanizeWithGoogleFree(lines); + } + + return dropUnchangedRomanization(result, lines); +} diff --git a/src/features/ai-translate/services/geminiProvider.ts b/src/features/ai-translate/services/geminiProvider.ts new file mode 100644 index 000000000..07deb6c45 --- /dev/null +++ b/src/features/ai-translate/services/geminiProvider.ts @@ -0,0 +1,86 @@ +import { + SONG_TRANSLATION_SYSTEM_PROMPT, + SONG_ROMANIZATION_SYSTEM_PROMPT, +} from "../prompts"; +import { matchLineCount } from "./lineMatching"; + +const DEFAULT_GEMINI_MODELS = [ + "gemini-2.5-flash", + "gemini-2.0-flash", + "gemini-1.5-flash", +]; + +async function callGeminiApi( + prompt: string, + apiKey: string, + customModel?: string, +): Promise { + const cleanKey = apiKey.trim(); + const trimmedCustom = customModel?.trim(); + const modelsToTry = trimmedCustom + ? [trimmedCustom, ...DEFAULT_GEMINI_MODELS.filter((m) => m !== trimmedCustom)] + : DEFAULT_GEMINI_MODELS; + + let lastError: Error | null = null; + for (const model of modelsToTry) { + try { + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${cleanKey}`; + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contents: [{ parts: [{ text: prompt }] }], + generationConfig: { + temperature: 0.3, + maxOutputTokens: 8192, + }, + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Gemini API error (${model}: ${response.status}): ${errorText}`); + } + + const data = await response.json(); + const candidate = data.candidates?.[0]; + const text = candidate?.content?.parts?.[0]?.text; + if (!text) { + throw new Error(`Empty response from Gemini model ${model}`); + } + return text; + } catch (err: any) { + lastError = err; + if (err.message?.includes("API_KEY_INVALID") || err.message?.includes("PERMISSION_DENIED")) { + throw err; + } + } + } + + throw lastError ?? new Error("All Gemini models failed"); +} + +export async function translateWithGemini( + lines: string[], + apiKey: string, + targetLang: string, + customModel?: string, + singable = false, +): Promise { + const systemPrompt = SONG_TRANSLATION_SYSTEM_PROMPT("auto", targetLang, singable); + const prompt = `${systemPrompt}\n\nSong lyrics:\n${lines.join("\n")}`; + const text = await callGeminiApi(prompt, apiKey, customModel); + const translatedLines = text.split("\n").map((l: string) => l.trim()); + return matchLineCount(translatedLines, lines); +} + +export async function romanizeWithGemini( + lines: string[], + apiKey: string, + customModel?: string, +): Promise { + const prompt = `${SONG_ROMANIZATION_SYSTEM_PROMPT}\n\nSong lyrics:\n${lines.join("\n")}`; + const text = await callGeminiApi(prompt, apiKey, customModel); + const romanizedLines = text.split("\n").map((l: string) => l.trim()); + return matchLineCount(romanizedLines, lines); +} diff --git a/src/features/ai-translate/services/googleFreeProvider.ts b/src/features/ai-translate/services/googleFreeProvider.ts new file mode 100644 index 000000000..495f84ad7 --- /dev/null +++ b/src/features/ai-translate/services/googleFreeProvider.ts @@ -0,0 +1,51 @@ +import { mapWithConcurrency, matchLineCount } from "./lineMatching"; + +export async function translateWithGoogleFree( + lines: string[], + targetLang: string, +): Promise { + const tl = + targetLang === "Vietnamese" || targetLang === "vi" ? "vi" : targetLang; + + const translated = await mapWithConcurrency(lines, 4, async (line) => { + const trimmed = line.trim(); + if (!trimmed) return ""; + const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${tl}&dt=t&q=${encodeURIComponent(trimmed)}`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Google Translate error: ${response.status}`); + } + const data = await response.json(); + return (data[0] || []) + .map((item: any) => item?.[0] ?? "") + .join("") + .trim(); + }); + + return matchLineCount(translated, lines); +} + +export async function romanizeWithGoogleFree( + lines: string[], +): Promise { + const results = await mapWithConcurrency(lines, 4, async (line) => { + const trimmed = line.trim(); + if (!trimmed) return ""; + try { + const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=rm&q=${encodeURIComponent(trimmed)}`; + const res = await fetch(url); + if (!res.ok) return line; + const data = await res.json(); + const parts = data?.[0] || []; + for (const item of parts) { + if (item && item[3]) { + return String(item[3]).trim(); + } + } + return line; + } catch { + return line; + } + }); + return results; +} diff --git a/src/features/ai-translate/services/lineMatching.ts b/src/features/ai-translate/services/lineMatching.ts new file mode 100644 index 000000000..e78871ff6 --- /dev/null +++ b/src/features/ai-translate/services/lineMatching.ts @@ -0,0 +1,67 @@ +export function matchLineCount( + translated: string[], + original: string[], +): string[] { + const trimmed = translated.map((line) => + typeof line === "string" ? line.trim() : "", + ); + while (trimmed.length > 0 && trimmed[trimmed.length - 1] === "") { + trimmed.pop(); + } + + if (trimmed.length === original.length) { + return original.map((orig, index) => + orig.trim().length === 0 ? "" : trimmed[index], + ); + } + + const cleaned = trimmed.filter((line) => line.length > 0); + let cursor = 0; + return original.map((orig) => { + if (!orig || orig.trim().length === 0) return ""; + const next = cleaned[cursor++]; + return next !== undefined ? next : orig; + }); +} + +function normalizeForCompare(text: string): string { + return text + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, ""); +} + +export function dropUnchangedRomanization( + romanized: string[], + original: string[], +): string[] { + return romanized.map((line, index) => { + const source = original[index] ?? ""; + if (!line || !line.trim()) return ""; + return normalizeForCompare(line) === normalizeForCompare(source) + ? "" + : line; + }); +} + +export async function mapWithConcurrency( + items: T[], + limit: number, + task: (item: T, index: number) => Promise, +): Promise { + const results: R[] = []; + let next = 0; + const workers = Array.from( + { length: Math.min(Math.max(1, limit), items.length) }, + async () => { + while (true) { + const index = next++; + if (index >= items.length) return; + results[index] = await task(items[index], index); + } + }, + ); + await Promise.all(workers); + return results; +} diff --git a/src/features/ai-translate/services/openAiProvider.ts b/src/features/ai-translate/services/openAiProvider.ts new file mode 100644 index 000000000..b914c92f4 --- /dev/null +++ b/src/features/ai-translate/services/openAiProvider.ts @@ -0,0 +1,143 @@ +import { + SONG_TRANSLATION_SYSTEM_PROMPT, + SONG_ROMANIZATION_SYSTEM_PROMPT, +} from "../prompts"; +import { matchLineCount } from "./lineMatching"; + +async function callOpenAiChat( + systemPrompt: string, + userText: string, + apiKey: string, + model: string, + baseUrl = "https://api.openai.com/v1", +): Promise { + const cleanBase = baseUrl.replace(/\/+$/, ""); + const url = `${cleanBase}/chat/completions`; + + const headers: Record = { + "Content-Type": "application/json", + }; + if (apiKey.trim()) { + headers["Authorization"] = `Bearer ${apiKey.trim()}`; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + model, + temperature: 0.3, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userText }, + ], + }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`OpenAI API error (${response.status}): ${errorText}`); + } + + const data = await response.json(); + const text = data.choices?.[0]?.message?.content; + if (!text) { + throw new Error("Empty response from AI service"); + } + return text; +} + +export async function translateWithOpenAiCompatible( + lines: string[], + apiKey: string, + targetLang: string, + baseUrl?: string, + model?: string, + singable = false, +): Promise { + const prompt = SONG_TRANSLATION_SYSTEM_PROMPT("auto", targetLang, singable); + const text = await callOpenAiChat( + prompt, + lines.join("\n"), + apiKey, + model || "gpt-4o-mini", + baseUrl || "https://api.openai.com/v1", + ); + return matchLineCount(text.split("\n").map((l: string) => l.trim()), lines); +} + +export async function romanizeWithOpenAiCompatible( + lines: string[], + apiKey: string, + baseUrl?: string, + model?: string, +): Promise { + const text = await callOpenAiChat( + SONG_ROMANIZATION_SYSTEM_PROMPT, + lines.join("\n"), + apiKey, + model || "gpt-4o-mini", + baseUrl || "https://api.openai.com/v1", + ); + return matchLineCount(text.split("\n").map((l: string) => l.trim()), lines); +} + +export async function translateWithOpenAI( + lines: string[], + apiKey: string, + targetLang: string, + model?: string, + singable = false, +): Promise { + return translateWithOpenAiCompatible( + lines, + apiKey, + targetLang, + "https://api.openai.com/v1", + model || "gpt-4o-mini", + singable, + ); +} + +export async function romanizeWithOpenAI( + lines: string[], + apiKey: string, + model?: string, +): Promise { + return romanizeWithOpenAiCompatible( + lines, + apiKey, + "https://api.openai.com/v1", + model || "gpt-4o-mini", + ); +} + +export async function translateWithDeepSeek( + lines: string[], + apiKey: string, + targetLang: string, + model?: string, + singable = false, +): Promise { + return translateWithOpenAiCompatible( + lines, + apiKey, + targetLang, + "https://api.deepseek.com", + model || "deepseek-chat", + singable, + ); +} + +export async function romanizeWithDeepSeek( + lines: string[], + apiKey: string, + model?: string, +): Promise { + return romanizeWithOpenAiCompatible( + lines, + apiKey, + "https://api.deepseek.com", + model || "deepseek-chat", + ); +} diff --git a/src/features/ai-translate/store/aiTranslateActions.ts b/src/features/ai-translate/store/aiTranslateActions.ts new file mode 100644 index 000000000..ed0d94945 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateActions.ts @@ -0,0 +1,85 @@ +import { + translateSongLyrics, + romanizeSongLyrics, +} from "../services/aiTranslateService"; +import { + trimCache, + getTranslationCacheKey, + getRomanizationCacheKey, +} from "./aiTranslateCache"; +import type { AiTranslateState } from "./aiTranslateStore"; + +export async function executeTrackTranslation( + get: () => AiTranslateState, + set: (fn: (s: AiTranslateState) => Partial) => void, + currentGen: () => number, + trackId: string, + lines: string[], + lyricsProvider?: string | null, +): Promise { + if (!trackId || lines.length === 0) return null; + const requestGen = currentGen(); + const { provider, apiKey, targetLang, model, baseUrl, singableMode } = get(); + const cacheKey = getTranslationCacheKey(trackId, targetLang, singableMode, lyricsProvider); + const existing = get().translationsByTrackId[cacheKey]; + if (existing && existing.length === lines.length) return existing; + + set((s) => ({ ...s, isTranslating: true, error: null })); + try { + const translated = await translateSongLyrics(lines, { + provider, + apiKey: apiKey.trim() || undefined, + targetLang, + model: model.trim() || undefined, + baseUrl: baseUrl.trim() || undefined, + singableMode, + }); + if (currentGen() !== requestGen) return null; + set((s) => ({ + isTranslating: false, + translationsByTrackId: trimCache({ ...s.translationsByTrackId, [cacheKey]: translated }), + })); + return translated; + } catch (err: any) { + if (currentGen() !== requestGen) return null; + set((s) => ({ ...s, isTranslating: false, error: err.message || "Translation failed" })); + return null; + } +} + +export async function executeTrackRomanization( + get: () => AiTranslateState, + set: (fn: (s: AiTranslateState) => Partial) => void, + currentGen: () => number, + trackId: string, + lines: string[], + lyricsProvider?: string | null, +): Promise { + if (!trackId || lines.length === 0) return null; + const requestGen = currentGen(); + const cacheKey = getRomanizationCacheKey(trackId, lyricsProvider); + const existing = get().romanizationsByTrackId[cacheKey]; + if (existing && existing.length === lines.length) return existing; + + set((s) => ({ ...s, isRomanizing: true, error: null })); + try { + const { provider, apiKey, targetLang, model, baseUrl } = get(); + const romanized = await romanizeSongLyrics(lines, { + provider, + apiKey: apiKey.trim() || undefined, + targetLang, + model: model.trim() || undefined, + baseUrl: baseUrl.trim() || undefined, + }); + if (currentGen() !== requestGen) return null; + set((s) => ({ + isRomanizing: false, + romanizationsByTrackId: trimCache({ ...s.romanizationsByTrackId, [cacheKey]: romanized }), + })); + return romanized; + } catch (err: any) { + if (currentGen() !== requestGen) return null; + set((s) => ({ ...s, isRomanizing: false, error: err.message || "Romanization failed" })); + return null; + } +} diff --git a/src/features/ai-translate/store/aiTranslateCache.ts b/src/features/ai-translate/store/aiTranslateCache.ts new file mode 100644 index 000000000..7a1535f21 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateCache.ts @@ -0,0 +1,51 @@ +const MAX_CACHE_ENTRIES = 500; + +export function trimCache( + cache: Record, + max = MAX_CACHE_ENTRIES, +): Record { + const keys = Object.keys(cache); + if (keys.length <= max) return cache; + const pruned: Record = {}; + for (const k of keys.slice(keys.length - max)) { + pruned[k] = cache[k]; + } + return pruned; +} + +export function getTranslationCacheKey( + trackId: string, + targetLang: string, + singableMode = false, + provider?: string | null, +): string { + const base = singableMode + ? `${trackId}:${targetLang}:singable` + : `${trackId}:${targetLang}`; + return provider ? `${base}:${provider}` : base; +} + +export function getRomanizationCacheKey( + trackId: string, + provider?: string | null, +): string { + return provider ? `${trackId}:${provider}` : trackId; +} + +export function sanitizeAiTranslateForExport( + persistedString: string, +): string { + try { + const data = JSON.parse(persistedString); + if (data && typeof data === "object" && "state" in data) { + if (data.state && typeof data.state === "object") { + data.state.apiKey = ""; + } + } else if (data && typeof data === "object" && "apiKey" in data) { + data.apiKey = ""; + } + return JSON.stringify(data); + } catch { + return persistedString; + } +} diff --git a/src/features/ai-translate/store/aiTranslateStore.test.ts b/src/features/ai-translate/store/aiTranslateStore.test.ts new file mode 100644 index 000000000..9e8d88555 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateStore.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + useAiTranslateStore, + getTranslationCacheKey, + getRomanizationCacheKey, + sanitizeAiTranslateForExport, +} from "./aiTranslateStore"; + +function mockGoogleTranslate(dictionary: Record) { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const q = decodeURIComponent(new URL(url).searchParams.get("q") ?? ""); + const translated = dictionary[q] ?? q; + return { + ok: true, + json: async () => [[[translated, q]]], + } as Response; + }); +} + +describe("AI Lyrics Translation Store", () => { + beforeEach(() => { + vi.restoreAllMocks(); + useAiTranslateStore.getState().clearCache(); + }); + + it("caches translations in store to prevent duplicate requests", async () => { + const lines = ["Line 1", "Line 2"]; + const mockFetch = mockGoogleTranslate({ + "Line 1": "Dòng 1", + "Line 2": "Dòng 2", + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const store = useAiTranslateStore.getState(); + const res1 = await store.translateTrackLyrics("track-123", lines); + expect(res1).toEqual(["Dòng 1", "Dòng 2"]); + expect(mockFetch).toHaveBeenCalledTimes(2); + + const res2 = await store.translateTrackLyrics("track-123", lines); + expect(res2).toEqual(["Dòng 1", "Dòng 2"]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it("differentiates cache keys between standard and singable mode", () => { + const standardKey = getTranslationCacheKey("track-abc", "vi", false); + const singableKey = getTranslationCacheKey("track-abc", "vi", true); + expect(standardKey).toBe("track-abc:vi"); + expect(singableKey).toBe("track-abc:vi:singable"); + }); + + it("differentiates cache keys across lyric providers for translation and romanization", () => { + const keyDefault = getTranslationCacheKey("track-abc", "vi", false); + const keyGenius = getTranslationCacheKey("track-abc", "vi", false, "genius"); + const keyMusix = getTranslationCacheKey("track-abc", "vi", false, "musixmatch"); + expect(keyDefault).toBe("track-abc:vi"); + expect(keyGenius).toBe("track-abc:vi:genius"); + expect(keyMusix).toBe("track-abc:vi:musixmatch"); + + const romDefault = getRomanizationCacheKey("track-abc"); + const romGenius = getRomanizationCacheKey("track-abc", "genius"); + expect(romDefault).toBe("track-abc"); + expect(romGenius).toBe("track-abc:genius"); + }); + + it("stores entries under provider-qualified keys when provider is supplied", async () => { + const mockFetch = mockGoogleTranslate({ + Song: "Bài hát", + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const store = useAiTranslateStore.getState(); + await store.translateTrackLyrics("track-genius", ["Song"], "genius"); + + const state = useAiTranslateStore.getState(); + const expectedKey = getTranslationCacheKey( + "track-genius", + state.targetLang, + state.singableMode, + "genius", + ); + expect(state.translationsByTrackId[expectedKey]).toEqual(["Bài hát"]); + }); + + it("clears translations and romanizations when configuration changes", () => { + const store = useAiTranslateStore.getState(); + useAiTranslateStore.setState({ + translationsByTrackId: { "track-1": ["line 1"] }, + romanizationsByTrackId: { "track-1": ["line 1"] }, + }); + + store.setProvider("openai"); + expect(useAiTranslateStore.getState().translationsByTrackId).toEqual({}); + expect(useAiTranslateStore.getState().romanizationsByTrackId).toEqual({}); + + useAiTranslateStore.setState({ + translationsByTrackId: { "track-2": ["line 2"] }, + romanizationsByTrackId: { "track-2": ["line 2"] }, + }); + store.setModel("gpt-4o"); + expect(useAiTranslateStore.getState().translationsByTrackId).toEqual({}); + expect(useAiTranslateStore.getState().romanizationsByTrackId).toEqual({}); + }); + + it("sanitizes apiKey before export to prevent credential leakage", () => { + const payload = JSON.stringify({ + state: { + provider: "gemini", + apiKey: "secret-super-key-12345", + targetLang: "vi", + }, + version: 2, + }); + const sanitized = sanitizeAiTranslateForExport(payload); + expect(sanitized).not.toContain("secret-super-key-12345"); + expect(JSON.parse(sanitized).state.apiKey).toBe(""); + }); +}); diff --git a/src/features/ai-translate/store/aiTranslateStore.ts b/src/features/ai-translate/store/aiTranslateStore.ts new file mode 100644 index 000000000..42ee6d510 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateStore.ts @@ -0,0 +1,124 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import type { AiProvider } from "../types"; +import { + executeTrackTranslation, + executeTrackRomanization, +} from "./aiTranslateActions"; + +export * from "./aiTranslateCache"; + +let configGeneration = 0; + +export interface AiTranslateState { + provider: AiProvider; + apiKey: string; + baseUrl: string; + model: string; + singableMode: boolean; + targetLang: string; + showTranslation: boolean; + showRomanization: boolean; + isTranslating: boolean; + isRomanizing: boolean; + error: string | null; + translationsByTrackId: Record; + romanizationsByTrackId: Record; + + setProvider: (provider: AiProvider) => void; + setApiKey: (key: string) => void; + setBaseUrl: (url: string) => void; + setModel: (model: string) => void; + setSingableMode: (enabled: boolean) => void; + toggleSingableMode: () => void; + setTargetLang: (lang: string) => void; + setShowTranslation: (show: boolean) => void; + setShowRomanization: (show: boolean) => void; + toggleTranslation: () => void; + toggleRomanization: () => void; + clearCache: () => void; + translateTrackLyrics: ( + trackId: string, + lines: string[], + lyricsProvider?: string | null, + ) => Promise; + romanizeTrackLyrics: ( + trackId: string, + lines: string[], + lyricsProvider?: string | null, + ) => Promise; +} + +export const useAiTranslateStore = create()( + persist( + (set, get) => ({ + provider: "gemini", + apiKey: "", + baseUrl: "https://api.openai.com/v1", + model: "", + singableMode: false, + targetLang: "en", + showTranslation: true, + showRomanization: true, + isTranslating: false, + isRomanizing: false, + error: null, + translationsByTrackId: {}, + romanizationsByTrackId: {}, + + setProvider: (provider) => { + configGeneration++; + set({ provider, error: null, translationsByTrackId: {}, romanizationsByTrackId: {} }); + }, + setApiKey: (apiKey) => set({ apiKey, error: null }), + setBaseUrl: (baseUrl) => { + configGeneration++; + set({ baseUrl, error: null, translationsByTrackId: {}, romanizationsByTrackId: {} }); + }, + setModel: (model) => { + configGeneration++; + set({ model, error: null, translationsByTrackId: {}, romanizationsByTrackId: {} }); + }, + setSingableMode: (singableMode) => set({ singableMode }), + toggleSingableMode: () => set((s) => ({ singableMode: !s.singableMode })), + setTargetLang: (targetLang) => set({ targetLang }), + setShowTranslation: (showTranslation) => set({ showTranslation }), + setShowRomanization: (showRomanization) => set({ showRomanization }), + toggleTranslation: () => set((s) => ({ showTranslation: !s.showTranslation })), + toggleRomanization: () => set((s) => ({ showRomanization: !s.showRomanization })), + clearCache: () => { + configGeneration++; + set({ translationsByTrackId: {}, romanizationsByTrackId: {} }); + }, + + translateTrackLyrics: (trackId, lines, lyricsProvider) => + executeTrackTranslation(get, set, () => configGeneration, trackId, lines, lyricsProvider), + + romanizeTrackLyrics: (trackId, lines, lyricsProvider) => + executeTrackRomanization(get, set, () => configGeneration, trackId, lines, lyricsProvider), + }), + { + name: "liner_ai_translate_v1", + version: 2, + storage: createJSONStorage(() => localStorage), + migrate: (persisted: unknown, version: number) => { + const state = (persisted ?? {}) as Partial; + return version < 2 + ? ({ ...state, translationsByTrackId: {}, romanizationsByTrackId: {} } as AiTranslateState) + : (state as AiTranslateState); + }, + partialize: (state) => ({ + provider: state.provider, + apiKey: state.apiKey, + baseUrl: state.baseUrl, + model: state.model, + singableMode: state.singableMode, + targetLang: state.targetLang, + showTranslation: state.showTranslation, + showRomanization: state.showRomanization, + translationsByTrackId: state.translationsByTrackId, + romanizationsByTrackId: state.romanizationsByTrackId, + }), + }, + ), +); diff --git a/src/features/ai-translate/types.ts b/src/features/ai-translate/types.ts new file mode 100644 index 000000000..3940d4b73 --- /dev/null +++ b/src/features/ai-translate/types.ts @@ -0,0 +1,22 @@ +export type AiProvider = + | "gemini" + | "openai" + | "deepseek" + | "openai_compatible" + | "google_translate"; + +export interface TranslationConfig { + provider: AiProvider; + apiKey?: string; + targetLang: string; + model?: string; + baseUrl?: string; + singableMode?: boolean; +} + +export interface TranslationCacheItem { + trackId: string; + targetLang: string; + lines: string[]; + timestamp: number; +} diff --git a/src/features/lyrics/canvas/drawWord.ts b/src/features/lyrics/canvas/drawWord.ts index 8390a6420..eaab1fafe 100644 --- a/src/features/lyrics/canvas/drawWord.ts +++ b/src/features/lyrics/canvas/drawWord.ts @@ -51,10 +51,23 @@ export function drawToken( if (!highlight || highlight.opacity <= 0) return; if (highlight.clear <= 0) return; ctx.fillStyle = highlightStyle(paint, placed, highlight); - if (highlight.glowRem > 0) { + const hasScale = Boolean( + highlight.scale && Math.abs(highlight.scale - 1) > 0.003, + ); + const hasGlow = highlight.glowRem > 0; + if (hasScale || hasGlow) { ctx.save(); - ctx.shadowColor = white(1); - ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; + if (hasScale && highlight.scale) { + const cx = placed.x + placed.width / 2; + const cy = y - placed.baseline / 3; + ctx.translate(cx, cy); + ctx.scale(highlight.scale, highlight.scale); + ctx.translate(-cx, -cy); + } + if (hasGlow) { + ctx.shadowColor = white(1); + ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; + } ctx.fillText(text, placed.x, y); ctx.restore(); return; diff --git a/src/features/lyrics/canvas/syllablePulse.test.ts b/src/features/lyrics/canvas/syllablePulse.test.ts new file mode 100644 index 000000000..2e928dfbf --- /dev/null +++ b/src/features/lyrics/canvas/syllablePulse.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { syllablePulse } from "./syllablePulse"; +import type { LyricToken } from "./types"; + +describe("syllablePulse", () => { + const shortToken: LyricToken = { + text: "la", + startMs: 1000, + durationMs: 200, + background: false, + space: false, + }; + + const longToken: LyricToken = { + text: "forever", + startMs: 2000, + durationMs: 1200, + background: false, + space: false, + }; + + it("does not apply bounce or pulse to short syllables (< 320ms)", () => { + const effect = syllablePulse(shortToken, 100); + expect(effect.scale).toBe(1); + expect(effect.glowRem).toBe(0); + }); + + it("applies bounce scale on attack for sustained syllables", () => { + const attackEffect = syllablePulse(longToken, 140); + expect(attackEffect.scale).toBeGreaterThan(1.05); + expect(attackEffect.scale).toBeLessThanOrEqual(1.1); + }); + + it("returns glowing highlight during mid-point of sustained singing", () => { + const midEffect = syllablePulse(longToken, 600); + expect(midEffect.glowRem).toBeGreaterThan(0.4); + }); + + it("returns neutral scale when elapsed is beyond token duration", () => { + const pastEffect = syllablePulse(longToken, 1500); + expect(pastEffect.scale).toBe(1); + expect(pastEffect.glowRem).toBe(0); + }); +}); diff --git a/src/features/lyrics/canvas/syllablePulse.ts b/src/features/lyrics/canvas/syllablePulse.ts new file mode 100644 index 000000000..6bede275c --- /dev/null +++ b/src/features/lyrics/canvas/syllablePulse.ts @@ -0,0 +1,50 @@ +import type { LyricToken } from "./types"; + +const MIN_SUSTAINED_MS = 320; +const BOUNCE_ATTACK_MS = 140; +const MAX_BOUNCE_SCALE = 1.075; + +export interface SyllableEffect { + scale: number; + glowRem: number; +} + +export function syllablePulse( + token: LyricToken, + elapsedMs: number, +): SyllableEffect { + if ( + token.durationMs < MIN_SUSTAINED_MS || + elapsedMs < 0 || + elapsedMs > token.durationMs + 60 + ) { + return { scale: 1, glowRem: 0 }; + } + + let scale = 1; + if (elapsedMs <= BOUNCE_ATTACK_MS) { + const p = elapsedMs / BOUNCE_ATTACK_MS; + scale = 1 + (MAX_BOUNCE_SCALE - 1) * Math.sin(p * Math.PI * 0.5); + } else { + const holdMs = token.durationMs - BOUNCE_ATTACK_MS; + const holdProgress = Math.min( + 1, + (elapsedMs - BOUNCE_ATTACK_MS) / Math.max(1, holdMs), + ); + const settle = + 1 + (MAX_BOUNCE_SCALE - 1) * Math.cos(holdProgress * Math.PI * 0.5); + const vibrato = + token.durationMs >= 600 + ? 0.015 * Math.sin((elapsedMs - BOUNCE_ATTACK_MS) * 0.015) + : 0; + scale = Math.max(1, settle * 0.5 + 0.5 + vibrato); + } + + const normProgress = Math.min(1, elapsedMs / token.durationMs); + const glowRem = 0.55 * Math.sin(normProgress * Math.PI); + + return { + scale: Math.min(scale, 1.1), + glowRem: Math.max(0, glowRem), + }; +} diff --git a/src/features/lyrics/canvas/timing.ts b/src/features/lyrics/canvas/timing.ts index 10a4e5ad6..0c2a954a0 100644 --- a/src/features/lyrics/canvas/timing.ts +++ b/src/features/lyrics/canvas/timing.ts @@ -1,5 +1,6 @@ import { clamp01, cssEase } from "./easing"; import { exitFade, isExiting, lineExitMs } from "./exitClock"; +import { syllablePulse } from "./syllablePulse"; import type { LyricLineModel, LyricToken } from "./types"; const SWIPE_LEAD_RATIO = 0.1; @@ -19,6 +20,7 @@ export interface TokenHighlight { solid: number; clear: number; glowRem: number; + scale?: number; } export function isLineSelected(line: LyricLineModel, timeMs: number) { @@ -73,11 +75,14 @@ export function tokenHighlight( }; } const solid = sweepAt(token, elapsed); + const pulse = syllablePulse(token, elapsed); + const baseGlow = glowAt(token, elapsed); return { opacity: 1, solid, clear: solid + SWIPE_WIDTH, - glowRem: glowAt(token, elapsed), + glowRem: Math.max(baseGlow, pulse.glowRem), + scale: pulse.scale, }; } diff --git a/src/features/lyrics/hooks/useEnrichedLyrics.test.ts b/src/features/lyrics/hooks/useEnrichedLyrics.test.ts new file mode 100644 index 000000000..80c5d4085 --- /dev/null +++ b/src/features/lyrics/hooks/useEnrichedLyrics.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "vitest"; +import { enrichLyrics } from "./useEnrichedLyrics"; +import type { Lyric } from "@braccato/parsers"; + +describe("enrichLyrics", () => { + const dummyLyrics: Lyric[] = [ + { + words: "Hello world", + startTimeMs: 1000, + durationMs: 2000, + isInstrumental: false, + }, + { + words: "Peace to all", + startTimeMs: 3000, + durationMs: 2000, + isInstrumental: false, + }, + ]; + + it("returns base lyrics untouched when translations are omitted", () => { + const result = enrichLyrics(dummyLyrics); + expect(result).toEqual(dummyLyrics); + }); + + it("attaches translated text when provided", () => { + const result = enrichLyrics( + dummyLyrics, + ["Xin chào thế giới", "Bình yên cho muôn loài"], + undefined, + "vi", + ); + expect(result[0].translation?.text).toBe("Xin chào thế giới"); + expect(result[0].translation?.lang).toBe("vi"); + expect(result[1].translation?.text).toBe("Bình yên cho muôn loài"); + }); + + it("attaches romanization text when provided", () => { + const result = enrichLyrics( + dummyLyrics, + undefined, + ["Zankoku na tenshi", "Aoi sora"], + ); + expect(result[0].romanization).toBe("Zankoku na tenshi"); + expect(result[1].romanization).toBe("Aoi sora"); + }); +}); diff --git a/src/features/lyrics/hooks/useEnrichedLyrics.ts b/src/features/lyrics/hooks/useEnrichedLyrics.ts new file mode 100644 index 000000000..b1bd9d0ab --- /dev/null +++ b/src/features/lyrics/hooks/useEnrichedLyrics.ts @@ -0,0 +1,87 @@ +import { useMemo } from "react"; +import type { Lyric } from "@braccato/parsers"; +import { + useAiTranslateStore, + getTranslationCacheKey, + getRomanizationCacheKey, +} from "@/features/ai-translate"; +import { useLyricsStore } from "../store/lyricsStore"; + +export function enrichLyrics( + braccatoLyrics: Lyric[], + trackTranslations?: string[], + trackRomanizations?: string[], + targetLang = "en", +): Lyric[] { + if (!braccatoLyrics || braccatoLyrics.length === 0) return braccatoLyrics; + if (!trackTranslations && !trackRomanizations) return braccatoLyrics; + + return braccatoLyrics.map((line, idx) => { + const translationText = trackTranslations?.[idx]; + const romanizationText = trackRomanizations?.[idx]; + + if (!translationText && !romanizationText) return line; + + return { + ...line, + translation: translationText + ? { text: translationText, lang: targetLang } + : line.translation, + romanization: romanizationText ?? line.romanization, + }; + }); +} + +export function useEnrichedLyrics( + currentTrackId: string | undefined, + braccatoLyrics: Lyric[], +): Lyric[] { + const activeProvider = useLyricsStore((s) => s.activeProvider); + const showTranslation = useAiTranslateStore((s) => s.showTranslation); + const showRomanization = useAiTranslateStore((s) => s.showRomanization); + const translationsByTrackId = useAiTranslateStore( + (s) => s.translationsByTrackId, + ); + const romanizationsByTrackId = useAiTranslateStore( + (s) => s.romanizationsByTrackId, + ); + const targetLang = useAiTranslateStore((s) => s.targetLang); + const singableMode = useAiTranslateStore((s) => s.singableMode); + + const trackTranslations = useMemo(() => { + if (!currentTrackId || !showTranslation) return undefined; + return translationsByTrackId[ + getTranslationCacheKey( + currentTrackId, + targetLang, + singableMode, + activeProvider, + ) + ]; + }, [ + currentTrackId, + showTranslation, + targetLang, + singableMode, + activeProvider, + translationsByTrackId, + ]); + + const trackRomanizations = useMemo(() => { + if (!currentTrackId || !showRomanization) return undefined; + return romanizationsByTrackId[ + getRomanizationCacheKey(currentTrackId, activeProvider) + ]; + }, [currentTrackId, showRomanization, activeProvider, romanizationsByTrackId]); + + return useMemo( + () => + enrichLyrics( + braccatoLyrics, + trackTranslations, + trackRomanizations, + targetLang, + ), + [braccatoLyrics, trackTranslations, trackRomanizations, targetLang], + ); +} diff --git a/src/features/lyrics/ui/LyricsProviderIsland.tsx b/src/features/lyrics/ui/LyricsProviderIsland.tsx index ab040c3bb..b33490d1f 100644 --- a/src/features/lyrics/ui/LyricsProviderIsland.tsx +++ b/src/features/lyrics/ui/LyricsProviderIsland.tsx @@ -13,6 +13,7 @@ import { import { useLyricsStore } from "../store/lyricsStore"; import { useTranslation } from "@/languages"; import { cn } from "@/shared/lib"; +import { LyricsTranslateIslandControls } from "./LyricsTranslateIslandControls"; function formatOffsetBadge(ms: number): string { if (ms === 0) return "0.0s"; @@ -248,6 +249,8 @@ export function LyricsProviderIsland() { )} + + diff --git a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx new file mode 100644 index 000000000..ae904e24c --- /dev/null +++ b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx @@ -0,0 +1,102 @@ +import { TranslateLine, FontLine } from "@mingcute/react"; +import { useLyricsStore } from "../store/lyricsStore"; +import { + useAiTranslateStore, + getTranslationCacheKey, + getRomanizationCacheKey, +} from "@/features/ai-translate"; +import { usePlayerStore } from "@/features/player/store/playerStore"; +import { showToast } from "@/shared/ui"; + +export function LyricsTranslateIslandControls() { + const currentTrack = usePlayerStore((s) => s.currentTrack); + const braccatoLyrics = useLyricsStore((s) => s.braccatoLyrics); + const activeProvider = useLyricsStore((s) => s.activeProvider); + + const { + showTranslation, + showRomanization, + isTranslating, + isRomanizing, + toggleTranslation, + toggleRomanization, + translateTrackLyrics, + romanizeTrackLyrics, + translationsByTrackId, + romanizationsByTrackId, + targetLang, + singableMode, + } = useAiTranslateStore(); + + const id = currentTrack?.id ?? ""; + const cacheKey = id ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) : ""; + const romKey = id ? getRomanizationCacheKey(id, activeProvider) : ""; + const hasTrans = Boolean(cacheKey && translationsByTrackId[cacheKey]?.length); + const hasRom = Boolean(romKey && romanizationsByTrackId[romKey]?.length); + + const hasRomanizableLyrics = braccatoLyrics.some((lyric) => + /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/.test( + lyric.words || lyric.parts?.map((p) => p.words).join("") || "", + ), + ); + + const getLines = () => + braccatoLyrics.map((l) => l.words || l.parts?.map((p) => p.words).join("") || ""); + + const handleTrans = async () => { + if (hasTrans) return toggleTranslation(); + if (!currentTrack || braccatoLyrics.length === 0) return; + const lines = getLines(); + if (lines.length === 0) return; + const res = await translateTrackLyrics(currentTrack.id, lines, activeProvider); + if (res) useAiTranslateStore.getState().setShowTranslation(true); + else showToast.error("Could not translate lyrics", { + description: useAiTranslateStore.getState().error ?? "Check API key in Settings.", + }); + }; + + const handleRom = async () => { + if (hasRom) return toggleRomanization(); + if (!currentTrack || braccatoLyrics.length === 0) return; + const lines = getLines(); + if (lines.length === 0) return; + const res = await romanizeTrackLyrics(currentTrack.id, lines, activeProvider); + if (res) useAiTranslateStore.getState().setShowRomanization(true); + else showToast.error("Could not romanize lyrics", { + description: useAiTranslateStore.getState().error ?? "Check API key in Settings.", + }); + }; + + return ( + <> +
+ + + {hasRomanizableLyrics && ( + + )} + + ); +} diff --git a/src/features/player/ui/FullscreenPlayer.tsx b/src/features/player/ui/FullscreenPlayer.tsx index 170aaea11..27057c34f 100644 --- a/src/features/player/ui/FullscreenPlayer.tsx +++ b/src/features/player/ui/FullscreenPlayer.tsx @@ -18,6 +18,7 @@ import { BetaVisualsWarning } from "./BetaVisualsWarning"; import { CanvasLyricsView } from "./CanvasLyricsView"; import { useTranslation } from "@/languages"; import { useSettledLyricsLayout } from "../hooks/useSettledLyricsLayout"; +import { useEnrichedLyrics } from "@/features/lyrics/hooks/useEnrichedLyrics"; const KAWARP_FADE_IN_MS = 600; @@ -80,6 +81,7 @@ export function FullscreenPlayer({ currentTrackId && currentLyricsTrackId === currentTrackId, ); const hasLyrics = isMatchingTrack && braccatoLyrics.length > 0; + const enrichedLyrics = useEnrichedLyrics(currentTrackId, braccatoLyrics); const showProviderIsland = hasLyrics && availableProviders.length > 0; const isInstrumental = isMatchingTrack && @@ -163,7 +165,7 @@ export function FullscreenPlayer({ transition={{ duration: 0.22, ease: "easeInOut" }} className="absolute inset-0 flex flex-col" > - + )} diff --git a/src/features/settings/ui/SettingsModal.tsx b/src/features/settings/ui/SettingsModal.tsx index 03b0b9bfa..bfd0870d2 100644 --- a/src/features/settings/ui/SettingsModal.tsx +++ b/src/features/settings/ui/SettingsModal.tsx @@ -5,6 +5,7 @@ import { DiscordFill, KeyboardFill, YoutubeFill, + TranslateLine, } from "@mingcute/react"; import { Play, @@ -27,6 +28,7 @@ import { OverlayTab, DiscordTab, YoutubeTab, + AiTab, } from "./tabs"; import { SettingsSidebar, type SettingsNavIconProps } from "./SettingsSidebar"; @@ -36,6 +38,7 @@ type TabId = | "Overlay" | "Discord" | "Youtube" + | "Ai" | "Appearance" | "Storage" | "About"; @@ -46,6 +49,7 @@ const TAB_ICONS: Record> = { Overlay: Widget5, Discord: DiscordFill, Youtube: YoutubeFill, + Ai: TranslateLine, Appearance: Palette, Storage: Database, About: InfoCircle, @@ -56,7 +60,7 @@ type TabGroupKey = "player" | "interface" | "integrations" | "application"; const TAB_GROUPS: { labelKey: TabGroupKey; ids: TabId[] }[] = [ { labelKey: "player", ids: ["Playback", "Shortcuts"] }, { labelKey: "interface", ids: ["Appearance", "Overlay"] }, - { labelKey: "integrations", ids: ["Youtube", "Discord"] }, + { labelKey: "integrations", ids: ["Youtube", "Discord", "Ai"] }, { labelKey: "application", ids: ["Storage", "About"] }, ]; @@ -257,6 +261,7 @@ export default function SettingsModal() { Overlay: t("settings.tabs.overlay") || "Overlay", Discord: t("settings.tabs.discord") || "Discord RPC", Youtube: t("settings.tabs.youtube") || "YouTube", + Ai: t("settings.tabs.ai") || "AI Lyrics", Appearance: t("settings.tabs.appearance"), Storage: t("settings.tabs.storage"), About: t("settings.tabs.about"), @@ -276,6 +281,9 @@ export default function SettingsModal() { Youtube: t("settings.youtube.description") || "Sign in to your YouTube account to prevent playback errors.", + Ai: + t("settings.ai.description") || + "Bilingual lyrics translation, singable adaptation, and pronunciation guide.", Appearance: t("settings.theme.description"), Storage: t("settings.storage.description"), About: t("settings.about.tagline"), @@ -292,6 +300,7 @@ export default function SettingsModal() { Overlay: "settings.tabs.overlay", Discord: "settings.tabs.discord", Youtube: "settings.tabs.youtube", + Ai: "settings.tabs.ai", Appearance: "settings.theme.title", Storage: "settings.tabs.storage", About: "settings.tabs.about", @@ -303,6 +312,7 @@ export default function SettingsModal() { Overlay: "settings.overlay.description", Discord: "settings.discord.description", Youtube: "settings.youtube.description", + Ai: "settings.ai.description", Appearance: "settings.theme.description", Storage: "settings.storage.description", About: "settings.about.tagline", @@ -426,6 +436,9 @@ export default function SettingsModal() { {activeTab === "Youtube" && ( )} + {activeTab === "Ai" && ( + + )} {activeTab === "Appearance" && ( )} diff --git a/src/features/settings/ui/tabs/AiTab.tsx b/src/features/settings/ui/tabs/AiTab.tsx new file mode 100644 index 000000000..cb576af26 --- /dev/null +++ b/src/features/settings/ui/tabs/AiTab.tsx @@ -0,0 +1,83 @@ +import { useAiTranslateStore } from "@/features/ai-translate"; +import { SettingRow, SettingSection, SegmentedControl } from "../controls"; +import { ToggleSwitch } from "@/shared/ui"; +import { AiTabModelConfig } from "./AiTabModelConfig"; + +const TARGET_LANG_OPTIONS = [ + { value: "en", label: "English" }, + { value: "vi", label: "Vietnamese" }, + { value: "ja", label: "Japanese" }, + { value: "ko", label: "Korean" }, + { value: "zh", label: "Chinese" }, + { value: "fr", label: "French" }, + { value: "es", label: "Spanish" }, +]; + +export function AiTab({ searchQuery }: { searchQuery?: string }) { + const { + showTranslation, + setShowTranslation, + singableMode, + setSingableMode, + showRomanization, + setShowRomanization, + targetLang, + setTargetLang, + } = useAiTranslateStore(); + + return ( + + + } + /> + + + } + /> + + + } + /> + + + } + /> + + + + ); +} diff --git a/src/features/settings/ui/tabs/AiTabModelConfig.tsx b/src/features/settings/ui/tabs/AiTabModelConfig.tsx new file mode 100644 index 000000000..136b958da --- /dev/null +++ b/src/features/settings/ui/tabs/AiTabModelConfig.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { + useAiTranslateStore, + translateSongLyrics, + type AiProvider, +} from "@/features/ai-translate"; +import { SettingBlock, SegmentedControl } from "../controls"; +import { showToast } from "@/shared/ui"; + +const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [ + { value: "gemini", label: "Gemini" }, + { value: "openai", label: "OpenAI" }, + { value: "deepseek", label: "DeepSeek" }, + { value: "openai_compatible", label: "OpenAI Compatible" }, + { value: "google_translate", label: "Free (Google)" }, +]; + +function getPlaceholder(p: AiProvider): string { + if (p === "gemini") return "Default: gemini-2.5-flash (e.g. gemini-2.5-pro, gemini-2.0-flash)"; + if (p === "openai") return "Default: gpt-4o-mini (e.g. gpt-4o, o3-mini)"; + if (p === "deepseek") return "Default: deepseek-chat (e.g. deepseek-reasoner)"; + if (p === "openai_compatible") return "e.g. meta-llama/llama-3.3-70b-instruct, qwen-2.5-72b"; + return "Model name..."; +} + +export function AiTabModelConfig() { + const { provider, setProvider, apiKey, setApiKey, baseUrl, setBaseUrl, model, setModel, singableMode, targetLang, clearCache } = useAiTranslateStore(); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); + + const handleTest = async () => { + setTesting(true); + setTestResult(null); + try { + const res = await translateSongLyrics( + ["I'm walking on sunshine, and don't it feel good!"], + { + provider, + apiKey: apiKey.trim() || undefined, + targetLang, + model: model.trim() || undefined, + baseUrl: baseUrl.trim() || undefined, + singableMode, + }, + ); + setTestResult(res?.[0] ?? "Translation error, please check your key."); + } catch (err: any) { + setTestResult(`Error: ${err.message || String(err)}`); + } finally { + setTesting(false); + } + }; + + return ( + +
+ setProvider(val as AiProvider)} + options={PROVIDER_OPTIONS} + /> + {provider !== "google_translate" && ( +
+ {provider === "openai_compatible" && ( +
+ + setBaseUrl(e.target.value)} + className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" + /> +
+ )} +
+ +
+ setApiKey(e.target.value)} + className="flex-1 px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 placeholder:text-white/30" + /> + +
+
+
+ + setModel(e.target.value)} + className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" + /> +
+ {testResult && ( +
+ Test result: {testResult} +
+ )} +
+
+ Clear Translation Cache + Purge cached lyrics to re-translate +
+ +
+
+ )} +
+
+ ); +} diff --git a/src/features/settings/ui/tabs/index.ts b/src/features/settings/ui/tabs/index.ts index 62de08b72..5674630ee 100644 --- a/src/features/settings/ui/tabs/index.ts +++ b/src/features/settings/ui/tabs/index.ts @@ -6,3 +6,4 @@ export { AboutTab } from "./AboutTab"; export { OverlayTab } from "./OverlayTab"; export { DiscordTab } from "./DiscordTab"; export { YoutubeTab } from "./YoutubeTab"; +export { AiTab } from "./AiTab"; diff --git a/src/languages/locales/en.json b/src/languages/locales/en.json index 37e0aee75..c6932bdbd 100644 --- a/src/languages/locales/en.json +++ b/src/languages/locales/en.json @@ -598,8 +598,12 @@ "overlay": "Overlay", "discord": "Discord RPC", "youtube": "YouTube", + "ai": "AI Lyrics", "privacy": "Privacy" }, + "ai": { + "description": "Bilingual lyrics translation, singable adaptation, and pronunciation guide." + }, "playback": { "description": "Queue behavior, click actions and audio output." }, From acefddf827d64222ea2f184f81891640e84b8bb2 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 13:46:26 +0700 Subject: [PATCH 02/11] fix(lyrics): address code review feedback on AI translation and canvas syllable pulse --- .../services/aiTranslateService.test.ts | 18 ++++- .../services/aiTranslateService.ts | 18 +++-- .../ai-translate/services/geminiProvider.ts | 13 +++- .../services/googleFreeProvider.ts | 30 ++++---- .../ai-translate/services/lineMatching.ts | 13 +++- .../ai-translate/services/openAiProvider.ts | 73 ++++++------------- .../ai-translate/store/aiTranslateActions.ts | 73 ++++++++++++++++--- .../store/aiTranslateStore.test.ts | 17 +++++ .../ai-translate/store/aiTranslateStore.ts | 16 ++-- .../lyrics/canvas/syllablePulse.test.ts | 4 + src/features/lyrics/canvas/syllablePulse.ts | 8 +- .../lyrics/hooks/useEnrichedLyrics.test.ts | 21 ++++++ .../lyrics/hooks/useEnrichedLyrics.ts | 6 +- src/features/lyrics/index.ts | 4 + .../ui/LyricsTranslateIslandControls.tsx | 69 +++++++++++++----- src/features/player/ui/FullscreenPlayer.tsx | 7 +- .../settings/ui/tabs/AiTabModelConfig.tsx | 14 ++-- 17 files changed, 278 insertions(+), 126 deletions(-) diff --git a/src/features/ai-translate/services/aiTranslateService.test.ts b/src/features/ai-translate/services/aiTranslateService.test.ts index 9a827e8dc..1341cb25e 100644 --- a/src/features/ai-translate/services/aiTranslateService.test.ts +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -21,7 +21,7 @@ describe("AI Lyrics Translation Service", () => { vi.restoreAllMocks(); }); - it("falls back to free translator when API key is omitted", async () => { + it("uses free translator for google_translate provider", async () => { const mockFetch = mockGoogleTranslate({ Hello: "Xin chào", World: "Thế giới", @@ -29,7 +29,7 @@ describe("AI Lyrics Translation Service", () => { global.fetch = mockFetch as unknown as typeof fetch; const result = await translateSongLyrics(["Hello", "", "World"], { - provider: "gemini", + provider: "google_translate", targetLang: "vi", }); @@ -37,6 +37,20 @@ describe("AI Lyrics Translation Service", () => { expect(mockFetch).toHaveBeenCalledTimes(2); }); + it("throws when API key is missing for gemini, openai, or deepseek", async () => { + await expect( + translateSongLyrics(["Hello"], { provider: "gemini", targetLang: "vi" }), + ).rejects.toThrow("API key is required for Gemini"); + + await expect( + translateSongLyrics(["Hello"], { provider: "openai", targetLang: "vi" }), + ).rejects.toThrow("API key is required for OpenAI"); + + await expect( + translateSongLyrics(["Hello"], { provider: "deepseek", targetLang: "vi" }), + ).rejects.toThrow("API key is required for DeepSeek"); + }); + it("keeps subtitle indices aligned when an LLM drops blank lines", async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, diff --git a/src/features/ai-translate/services/aiTranslateService.ts b/src/features/ai-translate/services/aiTranslateService.ts index d6389de6e..94ab9a988 100644 --- a/src/features/ai-translate/services/aiTranslateService.ts +++ b/src/features/ai-translate/services/aiTranslateService.ts @@ -27,13 +27,16 @@ export async function translateSongLyrics( const { provider, apiKey, targetLang, model, baseUrl, singableMode } = config; - if (provider === "gemini" && apiKey) { + if (provider === "gemini") { + if (!apiKey?.trim()) throw new Error("API key is required for Gemini"); return translateWithGemini(lines, apiKey, targetLang, model, singableMode); } - if (provider === "openai" && apiKey) { + if (provider === "openai") { + if (!apiKey?.trim()) throw new Error("API key is required for OpenAI"); return translateWithOpenAI(lines, apiKey, targetLang, model, singableMode); } - if (provider === "deepseek" && apiKey) { + if (provider === "deepseek") { + if (!apiKey?.trim()) throw new Error("API key is required for DeepSeek"); return translateWithDeepSeek(lines, apiKey, targetLang, model, singableMode); } if (provider === "openai_compatible") { @@ -59,11 +62,14 @@ export async function romanizeSongLyrics( const { provider, apiKey, model, baseUrl } = config; let result: string[]; - if (provider === "gemini" && apiKey) { + if (provider === "gemini") { + if (!apiKey?.trim()) throw new Error("API key is required for Gemini"); result = await romanizeWithGemini(lines, apiKey, model); - } else if (provider === "openai" && apiKey) { + } else if (provider === "openai") { + if (!apiKey?.trim()) throw new Error("API key is required for OpenAI"); result = await romanizeWithOpenAI(lines, apiKey, model); - } else if (provider === "deepseek" && apiKey) { + } else if (provider === "deepseek") { + if (!apiKey?.trim()) throw new Error("API key is required for DeepSeek"); result = await romanizeWithDeepSeek(lines, apiKey, model); } else if (provider === "openai_compatible") { result = await romanizeWithOpenAiCompatible( diff --git a/src/features/ai-translate/services/geminiProvider.ts b/src/features/ai-translate/services/geminiProvider.ts index 07deb6c45..89513b584 100644 --- a/src/features/ai-translate/services/geminiProvider.ts +++ b/src/features/ai-translate/services/geminiProvider.ts @@ -28,6 +28,7 @@ async function callGeminiApi( const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, + signal: AbortSignal.timeout(30_000), body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { @@ -49,10 +50,14 @@ async function callGeminiApi( throw new Error(`Empty response from Gemini model ${model}`); } return text; - } catch (err: any) { - lastError = err; - if (err.message?.includes("API_KEY_INVALID") || err.message?.includes("PERMISSION_DENIED")) { - throw err; + } catch (err: unknown) { + const error = err instanceof Error ? err : new Error(String(err)); + lastError = error; + if ( + error.message.includes("API_KEY_INVALID") || + error.message.includes("PERMISSION_DENIED") + ) { + throw error; } } } diff --git a/src/features/ai-translate/services/googleFreeProvider.ts b/src/features/ai-translate/services/googleFreeProvider.ts index 495f84ad7..0cb1e4672 100644 --- a/src/features/ai-translate/services/googleFreeProvider.ts +++ b/src/features/ai-translate/services/googleFreeProvider.ts @@ -16,8 +16,9 @@ export async function translateWithGoogleFree( throw new Error(`Google Translate error: ${response.status}`); } const data = await response.json(); - return (data[0] || []) - .map((item: any) => item?.[0] ?? "") + type GoogleSegment = [string | null, ...unknown[]] | null; + return ((data?.[0] as GoogleSegment[]) || []) + .map((item) => item?.[0] ?? "") .join("") .trim(); }); @@ -31,21 +32,20 @@ export async function romanizeWithGoogleFree( const results = await mapWithConcurrency(lines, 4, async (line) => { const trimmed = line.trim(); if (!trimmed) return ""; - try { - const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=rm&q=${encodeURIComponent(trimmed)}`; - const res = await fetch(url); - if (!res.ok) return line; - const data = await res.json(); - const parts = data?.[0] || []; - for (const item of parts) { - if (item && item[3]) { - return String(item[3]).trim(); - } + const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=rm&q=${encodeURIComponent(trimmed)}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Google romanization error: ${res.status}`); + } + const data = await res.json(); + type RomanizeSegment = [unknown, unknown, unknown, string | null] | null; + const parts = ((data?.[0] as RomanizeSegment[]) || []); + for (const item of parts) { + if (item && item[3]) { + return String(item[3]).trim(); } - return line; - } catch { - return line; } + return line; }); return results; } diff --git a/src/features/ai-translate/services/lineMatching.ts b/src/features/ai-translate/services/lineMatching.ts index e78871ff6..bf8cd860a 100644 --- a/src/features/ai-translate/services/lineMatching.ts +++ b/src/features/ai-translate/services/lineMatching.ts @@ -16,11 +16,20 @@ export function matchLineCount( } const cleaned = trimmed.filter((line) => line.length > 0); + const originalCleaned = original.filter( + (line) => line && line.trim().length > 0, + ); + + if (cleaned.length !== originalCleaned.length) { + throw new Error( + `Line count mismatch: expected ${originalCleaned.length} non-empty lines, received ${cleaned.length}`, + ); + } + let cursor = 0; return original.map((orig) => { if (!orig || orig.trim().length === 0) return ""; - const next = cleaned[cursor++]; - return next !== undefined ? next : orig; + return cleaned[cursor++]; }); } diff --git a/src/features/ai-translate/services/openAiProvider.ts b/src/features/ai-translate/services/openAiProvider.ts index b914c92f4..86b375e3e 100644 --- a/src/features/ai-translate/services/openAiProvider.ts +++ b/src/features/ai-translate/services/openAiProvider.ts @@ -24,6 +24,7 @@ async function callOpenAiChat( const response = await fetch(url, { method: "POST", headers, + signal: AbortSignal.timeout(30_000), body: JSON.stringify({ model, temperature: 0.3, @@ -55,13 +56,17 @@ export async function translateWithOpenAiCompatible( model?: string, singable = false, ): Promise { + const cleanBase = baseUrl?.trim(); + if (!cleanBase) { + throw new Error("Base URL is required for OpenAI Compatible provider"); + } const prompt = SONG_TRANSLATION_SYSTEM_PROMPT("auto", targetLang, singable); const text = await callOpenAiChat( prompt, lines.join("\n"), apiKey, model || "gpt-4o-mini", - baseUrl || "https://api.openai.com/v1", + cleanBase, ); return matchLineCount(text.split("\n").map((l: string) => l.trim()), lines); } @@ -72,72 +77,40 @@ export async function romanizeWithOpenAiCompatible( baseUrl?: string, model?: string, ): Promise { + const cleanBase = baseUrl?.trim(); + if (!cleanBase) { + throw new Error("Base URL is required for OpenAI Compatible provider"); + } const text = await callOpenAiChat( SONG_ROMANIZATION_SYSTEM_PROMPT, lines.join("\n"), apiKey, model || "gpt-4o-mini", - baseUrl || "https://api.openai.com/v1", + cleanBase, ); return matchLineCount(text.split("\n").map((l: string) => l.trim()), lines); } -export async function translateWithOpenAI( - lines: string[], - apiKey: string, - targetLang: string, - model?: string, - singable = false, +export function translateWithOpenAI( + lines: string[], apiKey: string, targetLang: string, model?: string, singable = false, ): Promise { - return translateWithOpenAiCompatible( - lines, - apiKey, - targetLang, - "https://api.openai.com/v1", - model || "gpt-4o-mini", - singable, - ); + return translateWithOpenAiCompatible(lines, apiKey, targetLang, "https://api.openai.com/v1", model || "gpt-4o-mini", singable); } -export async function romanizeWithOpenAI( - lines: string[], - apiKey: string, - model?: string, +export function romanizeWithOpenAI( + lines: string[], apiKey: string, model?: string, ): Promise { - return romanizeWithOpenAiCompatible( - lines, - apiKey, - "https://api.openai.com/v1", - model || "gpt-4o-mini", - ); + return romanizeWithOpenAiCompatible(lines, apiKey, "https://api.openai.com/v1", model || "gpt-4o-mini"); } -export async function translateWithDeepSeek( - lines: string[], - apiKey: string, - targetLang: string, - model?: string, - singable = false, +export function translateWithDeepSeek( + lines: string[], apiKey: string, targetLang: string, model?: string, singable = false, ): Promise { - return translateWithOpenAiCompatible( - lines, - apiKey, - targetLang, - "https://api.deepseek.com", - model || "deepseek-chat", - singable, - ); + return translateWithOpenAiCompatible(lines, apiKey, targetLang, "https://api.deepseek.com", model || "deepseek-chat", singable); } -export async function romanizeWithDeepSeek( - lines: string[], - apiKey: string, - model?: string, +export function romanizeWithDeepSeek( + lines: string[], apiKey: string, model?: string, ): Promise { - return romanizeWithOpenAiCompatible( - lines, - apiKey, - "https://api.deepseek.com", - model || "deepseek-chat", - ); + return romanizeWithOpenAiCompatible(lines, apiKey, "https://api.deepseek.com", model || "deepseek-chat"); } diff --git a/src/features/ai-translate/store/aiTranslateActions.ts b/src/features/ai-translate/store/aiTranslateActions.ts index ed0d94945..55655f090 100644 --- a/src/features/ai-translate/store/aiTranslateActions.ts +++ b/src/features/ai-translate/store/aiTranslateActions.ts @@ -9,6 +9,9 @@ import { } from "./aiTranslateCache"; import type { AiTranslateState } from "./aiTranslateStore"; +let activeTranslationCount = 0; +let activeRomanizationCount = 0; + export async function executeTrackTranslation( get: () => AiTranslateState, set: (fn: (s: AiTranslateState) => Partial) => void, @@ -22,8 +25,17 @@ export async function executeTrackTranslation( const { provider, apiKey, targetLang, model, baseUrl, singableMode } = get(); const cacheKey = getTranslationCacheKey(trackId, targetLang, singableMode, lyricsProvider); const existing = get().translationsByTrackId[cacheKey]; - if (existing && existing.length === lines.length) return existing; + const storedSources = get().sourceLinesByCacheKey?.[cacheKey]; + if ( + existing && + storedSources && + storedSources.length === lines.length && + storedSources.every((line, idx) => line === lines[idx]) + ) { + return existing; + } + activeTranslationCount++; set((s) => ({ ...s, isTranslating: true, error: null })); try { const translated = await translateSongLyrics(lines, { @@ -34,15 +46,29 @@ export async function executeTrackTranslation( baseUrl: baseUrl.trim() || undefined, singableMode, }); - if (currentGen() !== requestGen) return null; + activeTranslationCount = Math.max(0, activeTranslationCount - 1); + if (currentGen() !== requestGen) { + if (activeTranslationCount === 0) { + set((s) => ({ ...s, isTranslating: false })); + } + return null; + } set((s) => ({ - isTranslating: false, + isTranslating: activeTranslationCount > 0, translationsByTrackId: trimCache({ ...s.translationsByTrackId, [cacheKey]: translated }), + sourceLinesByCacheKey: trimCache({ ...s.sourceLinesByCacheKey, [cacheKey]: lines }), })); return translated; - } catch (err: any) { - if (currentGen() !== requestGen) return null; - set((s) => ({ ...s, isTranslating: false, error: err.message || "Translation failed" })); + } catch (err: unknown) { + activeTranslationCount = Math.max(0, activeTranslationCount - 1); + const errorMsg = err instanceof Error ? err.message : "Translation failed"; + if (currentGen() !== requestGen) { + if (activeTranslationCount === 0) { + set((s) => ({ ...s, isTranslating: false })); + } + return null; + } + set((s) => ({ ...s, isTranslating: activeTranslationCount > 0, error: errorMsg })); return null; } } @@ -59,8 +85,17 @@ export async function executeTrackRomanization( const requestGen = currentGen(); const cacheKey = getRomanizationCacheKey(trackId, lyricsProvider); const existing = get().romanizationsByTrackId[cacheKey]; - if (existing && existing.length === lines.length) return existing; + const storedSources = get().sourceRomLinesByCacheKey?.[cacheKey]; + if ( + existing && + storedSources && + storedSources.length === lines.length && + storedSources.every((line, idx) => line === lines[idx]) + ) { + return existing; + } + activeRomanizationCount++; set((s) => ({ ...s, isRomanizing: true, error: null })); try { const { provider, apiKey, targetLang, model, baseUrl } = get(); @@ -71,15 +106,29 @@ export async function executeTrackRomanization( model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined, }); - if (currentGen() !== requestGen) return null; + activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); + if (currentGen() !== requestGen) { + if (activeRomanizationCount === 0) { + set((s) => ({ ...s, isRomanizing: false })); + } + return null; + } set((s) => ({ - isRomanizing: false, + isRomanizing: activeRomanizationCount > 0, romanizationsByTrackId: trimCache({ ...s.romanizationsByTrackId, [cacheKey]: romanized }), + sourceRomLinesByCacheKey: trimCache({ ...s.sourceRomLinesByCacheKey, [cacheKey]: lines }), })); return romanized; - } catch (err: any) { - if (currentGen() !== requestGen) return null; - set((s) => ({ ...s, isRomanizing: false, error: err.message || "Romanization failed" })); + } catch (err: unknown) { + activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); + const errorMsg = err instanceof Error ? err.message : "Romanization failed"; + if (currentGen() !== requestGen) { + if (activeRomanizationCount === 0) { + set((s) => ({ ...s, isRomanizing: false })); + } + return null; + } + set((s) => ({ ...s, isRomanizing: activeRomanizationCount > 0, error: errorMsg })); return null; } } diff --git a/src/features/ai-translate/store/aiTranslateStore.test.ts b/src/features/ai-translate/store/aiTranslateStore.test.ts index 9e8d88555..5e8bb6598 100644 --- a/src/features/ai-translate/store/aiTranslateStore.test.ts +++ b/src/features/ai-translate/store/aiTranslateStore.test.ts @@ -22,6 +22,7 @@ describe("AI Lyrics Translation Store", () => { beforeEach(() => { vi.restoreAllMocks(); useAiTranslateStore.getState().clearCache(); + useAiTranslateStore.setState({ provider: "google_translate" }); }); it("caches translations in store to prevent duplicate requests", async () => { @@ -102,6 +103,22 @@ describe("AI Lyrics Translation Store", () => { expect(useAiTranslateStore.getState().romanizationsByTrackId).toEqual({}); }); + it("does not reuse cached translations when source lines differ", async () => { + const mockFetch = mockGoogleTranslate({ + "Line 1": "Dòng 1", + "Different 1": "Khác 1", + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const store = useAiTranslateStore.getState(); + const res1 = await store.translateTrackLyrics("track-diff", ["Line 1"]); + expect(res1).toEqual(["Dòng 1"]); + + const res2 = await store.translateTrackLyrics("track-diff", ["Different 1"]); + expect(res2).toEqual(["Khác 1"]); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + it("sanitizes apiKey before export to prevent credential leakage", () => { const payload = JSON.stringify({ state: { diff --git a/src/features/ai-translate/store/aiTranslateStore.ts b/src/features/ai-translate/store/aiTranslateStore.ts index 42ee6d510..29ab1b237 100644 --- a/src/features/ai-translate/store/aiTranslateStore.ts +++ b/src/features/ai-translate/store/aiTranslateStore.ts @@ -24,6 +24,8 @@ export interface AiTranslateState { error: string | null; translationsByTrackId: Record; romanizationsByTrackId: Record; + sourceLinesByCacheKey: Record; + sourceRomLinesByCacheKey: Record; setProvider: (provider: AiProvider) => void; setApiKey: (key: string) => void; @@ -65,19 +67,21 @@ export const useAiTranslateStore = create()( error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, + sourceLinesByCacheKey: {}, + sourceRomLinesByCacheKey: {}, setProvider: (provider) => { configGeneration++; - set({ provider, error: null, translationsByTrackId: {}, romanizationsByTrackId: {} }); + set({ provider, error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, setApiKey: (apiKey) => set({ apiKey, error: null }), setBaseUrl: (baseUrl) => { configGeneration++; - set({ baseUrl, error: null, translationsByTrackId: {}, romanizationsByTrackId: {} }); + set({ baseUrl, error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, setModel: (model) => { configGeneration++; - set({ model, error: null, translationsByTrackId: {}, romanizationsByTrackId: {} }); + set({ model, error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, setSingableMode: (singableMode) => set({ singableMode }), toggleSingableMode: () => set((s) => ({ singableMode: !s.singableMode })), @@ -88,7 +92,7 @@ export const useAiTranslateStore = create()( toggleRomanization: () => set((s) => ({ showRomanization: !s.showRomanization })), clearCache: () => { configGeneration++; - set({ translationsByTrackId: {}, romanizationsByTrackId: {} }); + set({ translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, translateTrackLyrics: (trackId, lines, lyricsProvider) => @@ -104,7 +108,7 @@ export const useAiTranslateStore = create()( migrate: (persisted: unknown, version: number) => { const state = (persisted ?? {}) as Partial; return version < 2 - ? ({ ...state, translationsByTrackId: {}, romanizationsByTrackId: {} } as AiTranslateState) + ? ({ ...state, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} } as AiTranslateState) : (state as AiTranslateState); }, partialize: (state) => ({ @@ -118,6 +122,8 @@ export const useAiTranslateStore = create()( showRomanization: state.showRomanization, translationsByTrackId: state.translationsByTrackId, romanizationsByTrackId: state.romanizationsByTrackId, + sourceLinesByCacheKey: state.sourceLinesByCacheKey, + sourceRomLinesByCacheKey: state.sourceRomLinesByCacheKey, }), }, ), diff --git a/src/features/lyrics/canvas/syllablePulse.test.ts b/src/features/lyrics/canvas/syllablePulse.test.ts index 2e928dfbf..ec19c69c2 100644 --- a/src/features/lyrics/canvas/syllablePulse.test.ts +++ b/src/features/lyrics/canvas/syllablePulse.test.ts @@ -37,6 +37,10 @@ describe("syllablePulse", () => { }); it("returns neutral scale when elapsed is beyond token duration", () => { + const endEffect = syllablePulse(longToken, 1200); + expect(endEffect.scale).toBe(1); + expect(endEffect.glowRem).toBe(0); + const pastEffect = syllablePulse(longToken, 1500); expect(pastEffect.scale).toBe(1); expect(pastEffect.glowRem).toBe(0); diff --git a/src/features/lyrics/canvas/syllablePulse.ts b/src/features/lyrics/canvas/syllablePulse.ts index 6bede275c..8f2aba95d 100644 --- a/src/features/lyrics/canvas/syllablePulse.ts +++ b/src/features/lyrics/canvas/syllablePulse.ts @@ -16,7 +16,7 @@ export function syllablePulse( if ( token.durationMs < MIN_SUSTAINED_MS || elapsedMs < 0 || - elapsedMs > token.durationMs + 60 + elapsedMs >= token.durationMs ) { return { scale: 1, glowRem: 0 }; } @@ -35,9 +35,11 @@ export function syllablePulse( 1 + (MAX_BOUNCE_SCALE - 1) * Math.cos(holdProgress * Math.PI * 0.5); const vibrato = token.durationMs >= 600 - ? 0.015 * Math.sin((elapsedMs - BOUNCE_ATTACK_MS) * 0.015) + ? 0.015 * + Math.sin((elapsedMs - BOUNCE_ATTACK_MS) * 0.015) * + (1 - holdProgress) : 0; - scale = Math.max(1, settle * 0.5 + 0.5 + vibrato); + scale = Math.max(1, settle + vibrato); } const normProgress = Math.min(1, elapsedMs / token.durationMs); diff --git a/src/features/lyrics/hooks/useEnrichedLyrics.test.ts b/src/features/lyrics/hooks/useEnrichedLyrics.test.ts index 80c5d4085..49b5f3da4 100644 --- a/src/features/lyrics/hooks/useEnrichedLyrics.test.ts +++ b/src/features/lyrics/hooks/useEnrichedLyrics.test.ts @@ -44,4 +44,25 @@ describe("enrichLyrics", () => { expect(result[0].romanization).toBe("Zankoku na tenshi"); expect(result[1].romanization).toBe("Aoi sora"); }); + + it("preserves native romanization when AI romanization entry is empty", () => { + const nativeLyrics: Lyric[] = [ + { + words: "残酷な天使のテーゼ", + startTimeMs: 1000, + durationMs: 2000, + isInstrumental: false, + romanization: "Zankoku na tenshi no teeze (native)", + }, + ]; + + const result = enrichLyrics( + nativeLyrics, + ["A Cruel Angel's Thesis"], + [""], + "en", + ); + expect(result[0].translation?.text).toBe("A Cruel Angel's Thesis"); + expect(result[0].romanization).toBe("Zankoku na tenshi no teeze (native)"); + }); }); diff --git a/src/features/lyrics/hooks/useEnrichedLyrics.ts b/src/features/lyrics/hooks/useEnrichedLyrics.ts index b1bd9d0ab..7f870eb57 100644 --- a/src/features/lyrics/hooks/useEnrichedLyrics.ts +++ b/src/features/lyrics/hooks/useEnrichedLyrics.ts @@ -17,8 +17,8 @@ export function enrichLyrics( if (!trackTranslations && !trackRomanizations) return braccatoLyrics; return braccatoLyrics.map((line, idx) => { - const translationText = trackTranslations?.[idx]; - const romanizationText = trackRomanizations?.[idx]; + const translationText = trackTranslations?.[idx]?.trim(); + const romanizationText = trackRomanizations?.[idx]?.trim(); if (!translationText && !romanizationText) return line; @@ -27,7 +27,7 @@ export function enrichLyrics( translation: translationText ? { text: translationText, lang: targetLang } : line.translation, - romanization: romanizationText ?? line.romanization, + romanization: romanizationText || line.romanization, }; }); } diff --git a/src/features/lyrics/index.ts b/src/features/lyrics/index.ts index da76b0e9d..95236e4c2 100644 --- a/src/features/lyrics/index.ts +++ b/src/features/lyrics/index.ts @@ -26,6 +26,10 @@ export { type SyncedLine, } from "./hooks/useLyricsAnimator"; export { LyricsProviderIsland } from "./ui/LyricsProviderIsland"; +export { + useEnrichedLyrics, + enrichLyrics, +} from "./hooks/useEnrichedLyrics"; export { LyricsCanvasRenderer, type LyricsClock, diff --git a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx index ae904e24c..1d6faec92 100644 --- a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx +++ b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx @@ -6,8 +6,11 @@ import { getRomanizationCacheKey, } from "@/features/ai-translate"; import { usePlayerStore } from "@/features/player/store/playerStore"; +import { cn } from "@/shared/lib"; import { showToast } from "@/shared/ui"; +const NON_LATIN_RE = /[^\p{Script=Latin}\p{P}\p{Z}\p{N}\p{M}\p{S}]/u; + export function LyricsTranslateIslandControls() { const currentTrack = usePlayerStore((s) => s.currentTrack); const braccatoLyrics = useLyricsStore((s) => s.braccatoLyrics); @@ -29,19 +32,23 @@ export function LyricsTranslateIslandControls() { } = useAiTranslateStore(); const id = currentTrack?.id ?? ""; - const cacheKey = id ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) : ""; + const cacheKey = id + ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) + : ""; const romKey = id ? getRomanizationCacheKey(id, activeProvider) : ""; const hasTrans = Boolean(cacheKey && translationsByTrackId[cacheKey]?.length); const hasRom = Boolean(romKey && romanizationsByTrackId[romKey]?.length); const hasRomanizableLyrics = braccatoLyrics.some((lyric) => - /[\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/.test( + NON_LATIN_RE.test( lyric.words || lyric.parts?.map((p) => p.words).join("") || "", ), ); const getLines = () => - braccatoLyrics.map((l) => l.words || l.parts?.map((p) => p.words).join("") || ""); + braccatoLyrics.map( + (l) => l.words || l.parts?.map((p) => p.words).join("") || "", + ); const handleTrans = async () => { if (hasTrans) return toggleTranslation(); @@ -50,9 +57,12 @@ export function LyricsTranslateIslandControls() { if (lines.length === 0) return; const res = await translateTrackLyrics(currentTrack.id, lines, activeProvider); if (res) useAiTranslateStore.getState().setShowTranslation(true); - else showToast.error("Could not translate lyrics", { - description: useAiTranslateStore.getState().error ?? "Check API key in Settings.", - }); + else { + showToast.error("Could not translate lyrics", { + description: + useAiTranslateStore.getState().error ?? "Check API key in Settings.", + }); + } }; const handleRom = async () => { @@ -62,9 +72,12 @@ export function LyricsTranslateIslandControls() { if (lines.length === 0) return; const res = await romanizeTrackLyrics(currentTrack.id, lines, activeProvider); if (res) useAiTranslateStore.getState().setShowRomanization(true); - else showToast.error("Could not romanize lyrics", { - description: useAiTranslateStore.getState().error ?? "Check API key in Settings.", - }); + else { + showToast.error("Could not romanize lyrics", { + description: + useAiTranslateStore.getState().error ?? "Check API key in Settings.", + }); + } }; return ( @@ -75,10 +88,22 @@ export function LyricsTranslateIslandControls() { onClick={handleTrans} disabled={isTranslating} aria-label="Toggle Lyrics Translation" - title={hasTrans ? (showTranslation ? "Hide translation" : "Show translation") : singableMode ? "Singable translation" : "Translate lyrics"} - className={`w-[26px] h-[26px] min-w-[26px] min-h-[26px] max-h-[26px] rounded-control flex items-center justify-center transition-all border-none bg-transparent cursor-pointer ${ - showTranslation && hasTrans ? "text-white bg-white/20 font-bold" : "text-white/70 hover:text-white hover:bg-white/10" - } ${isTranslating ? "animate-pulse opacity-60" : "active:scale-95"}`} + title={ + hasTrans + ? showTranslation + ? "Hide translation" + : "Show translation" + : singableMode + ? "Singable translation" + : "Translate lyrics" + } + className={cn( + "w-[26px] h-[26px] min-w-[26px] min-h-[26px] max-h-[26px] rounded-control flex items-center justify-center transition-all border-none bg-transparent cursor-pointer", + showTranslation && hasTrans + ? "text-white bg-white/20 font-bold" + : "text-white/70 hover:text-white hover:bg-white/10", + isTranslating ? "animate-pulse opacity-60" : "active:scale-95", + )} > @@ -89,10 +114,20 @@ export function LyricsTranslateIslandControls() { onClick={handleRom} disabled={isRomanizing} aria-label="Toggle Pronunciation/Romaji" - title={hasRom ? (showRomanization ? "Hide pronunciation" : "Show pronunciation") : "Pronunciation / Romaji"} - className={`w-[26px] h-[26px] min-w-[26px] min-h-[26px] max-h-[26px] rounded-control flex items-center justify-center transition-all border-none bg-transparent cursor-pointer ${ - showRomanization && hasRom ? "text-white bg-white/20 font-bold" : "text-white/70 hover:text-white hover:bg-white/10" - } ${isRomanizing ? "animate-pulse opacity-60" : "active:scale-95"}`} + title={ + hasRom + ? showRomanization + ? "Hide pronunciation" + : "Show pronunciation" + : "Pronunciation / Romaji" + } + className={cn( + "w-[26px] h-[26px] min-w-[26px] min-h-[26px] max-h-[26px] rounded-control flex items-center justify-center transition-all border-none bg-transparent cursor-pointer", + showRomanization && hasRom + ? "text-white bg-white/20 font-bold" + : "text-white/70 hover:text-white hover:bg-white/10", + isRomanizing ? "animate-pulse opacity-60" : "active:scale-95", + )} > diff --git a/src/features/player/ui/FullscreenPlayer.tsx b/src/features/player/ui/FullscreenPlayer.tsx index 27057c34f..7390295e4 100644 --- a/src/features/player/ui/FullscreenPlayer.tsx +++ b/src/features/player/ui/FullscreenPlayer.tsx @@ -7,7 +7,11 @@ import { Notch } from "@/shared/ui"; import { FullscreenTopNotch } from "./FullscreenTopNotch"; import { LyricsNotchStatus } from "./LyricsNotchStatus"; import { FullscreenFrameGlass } from "./FullscreenFrameGlass"; -import { useLyricsStore, LyricsProviderIsland } from "@/features/lyrics"; +import { + useLyricsStore, + LyricsProviderIsland, + useEnrichedLyrics, +} from "@/features/lyrics"; import { AnimatePresence, motion } from "framer-motion"; import { PlayerUiElements } from "./PlayerUiElements"; import { DownLine } from "@mingcute/react"; @@ -18,7 +22,6 @@ import { BetaVisualsWarning } from "./BetaVisualsWarning"; import { CanvasLyricsView } from "./CanvasLyricsView"; import { useTranslation } from "@/languages"; import { useSettledLyricsLayout } from "../hooks/useSettledLyricsLayout"; -import { useEnrichedLyrics } from "@/features/lyrics/hooks/useEnrichedLyrics"; const KAWARP_FADE_IN_MS = 600; diff --git a/src/features/settings/ui/tabs/AiTabModelConfig.tsx b/src/features/settings/ui/tabs/AiTabModelConfig.tsx index 136b958da..bf8486cca 100644 --- a/src/features/settings/ui/tabs/AiTabModelConfig.tsx +++ b/src/features/settings/ui/tabs/AiTabModelConfig.tsx @@ -44,8 +44,9 @@ export function AiTabModelConfig() { }, ); setTestResult(res?.[0] ?? "Translation error, please check your key."); - } catch (err: any) { - setTestResult(`Error: ${err.message || String(err)}`); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + setTestResult(`Error: ${message}`); } finally { setTesting(false); } @@ -64,8 +65,9 @@ export function AiTabModelConfig() {
{provider === "openai_compatible" && (
- + )}
- +
- + Date: Mon, 2 Nov 2026 16:26:15 +0700 Subject: [PATCH 03/11] fix(lyrics): resolve CodeRabbit review findings on AI translation and canvas subtitles --- .../services/aiTranslateService.test.ts | 109 ++++++++---------- .../ai-translate/services/geminiProvider.ts | 4 +- .../services/googleFreeProvider.ts | 4 + .../ai-translate/services/lineMatching.ts | 9 +- .../ai-translate/store/aiTranslateActions.ts | 46 +++----- .../store/aiTranslateStore.test.ts | 15 +-- .../ai-translate/store/aiTranslateStore.ts | 21 ++-- src/features/lyrics/canvas/buildLines.test.ts | 53 +++++++++ src/features/lyrics/canvas/buildLines.ts | 7 +- .../lyrics/hooks/useEnrichedLyrics.ts | 62 ++++++++-- .../ui/LyricsTranslateIslandControls.tsx | 49 ++++---- .../settings/ui/tabs/AiTabModelConfig.tsx | 39 ++++--- 12 files changed, 254 insertions(+), 164 deletions(-) create mode 100644 src/features/lyrics/canvas/buildLines.test.ts diff --git a/src/features/ai-translate/services/aiTranslateService.test.ts b/src/features/ai-translate/services/aiTranslateService.test.ts index 1341cb25e..78689f798 100644 --- a/src/features/ai-translate/services/aiTranslateService.test.ts +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -4,15 +4,17 @@ import { romanizeSongLyrics, } from "./aiTranslateService"; -function mockGoogleTranslate(dictionary: Record) { - return vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - const q = decodeURIComponent(new URL(url).searchParams.get("q") ?? ""); +function mockFetchJson(data: unknown): typeof fetch { + return vi.fn(async () => + new Response(JSON.stringify(data), { status: 200 }), + ); +} + +function mockGoogleTranslate(dictionary: Record): typeof fetch { + return vi.fn(async (input: RequestInfo | URL) => { + const q = decodeURIComponent(new URL(String(input)).searchParams.get("q") ?? ""); const translated = dictionary[q] ?? q; - return { - ok: true, - json: async () => [[[translated, q]]], - } as Response; + return new Response(JSON.stringify([[[translated, q]]]), { status: 200 }); }); } @@ -26,7 +28,7 @@ describe("AI Lyrics Translation Service", () => { Hello: "Xin chào", World: "Thế giới", }); - global.fetch = mockFetch as unknown as typeof fetch; + global.fetch = mockFetch; const result = await translateSongLyrics(["Hello", "", "World"], { provider: "google_translate", @@ -38,61 +40,43 @@ describe("AI Lyrics Translation Service", () => { }); it("throws when API key is missing for gemini, openai, or deepseek", async () => { - await expect( - translateSongLyrics(["Hello"], { provider: "gemini", targetLang: "vi" }), - ).rejects.toThrow("API key is required for Gemini"); - - await expect( - translateSongLyrics(["Hello"], { provider: "openai", targetLang: "vi" }), - ).rejects.toThrow("API key is required for OpenAI"); - - await expect( - translateSongLyrics(["Hello"], { provider: "deepseek", targetLang: "vi" }), - ).rejects.toThrow("API key is required for DeepSeek"); + for (const provider of ["gemini", "openai", "deepseek"] as const) { + const name = + provider === "gemini" ? "Gemini" : provider === "openai" ? "OpenAI" : "DeepSeek"; + await expect( + translateSongLyrics(["Hello"], { provider, targetLang: "vi" }), + ).rejects.toThrow(`API key is required for ${name}`); + await expect( + romanizeSongLyrics(["Hello"], { provider, targetLang: "vi" }), + ).rejects.toThrow(`API key is required for ${name}`); + } }); it("keeps subtitle indices aligned when an LLM drops blank lines", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - candidates: [ - { content: { parts: [{ text: "Dòng một\nDòng ba" }] } }, - ], - }), + global.fetch = mockFetchJson({ + candidates: [{ content: { parts: [{ text: "Dòng một\nDòng ba" }] } }], }); - global.fetch = mockFetch as unknown as typeof fetch; const result = await translateSongLyrics( ["Line one", "", "Line three"], - { - provider: "gemini", - apiKey: "test-key", - targetLang: "vi", - }, + { provider: "gemini", apiKey: "test-key", targetLang: "vi" }, ); expect(result).toEqual(["Dòng một", "", "Dòng ba"]); }); it("romanizes Japanese lyrics and skips lines that are already Latin", async () => { - const mockFetch = vi.fn(async (input: RequestInfo | URL) => { + global.fetch = vi.fn(async (input: RequestInfo | URL) => { const q = decodeURIComponent( new URL(String(input)).searchParams.get("q") ?? "", ); const result = q === "残酷な天使のテーゼ" ? "Zankoku na tenshi no tēze" : q; - return { - ok: true, - json: async () => [[[null, null, null, result]]], - } as Response; + return new Response(JSON.stringify([[[null, null, null, result]]]), { status: 200 }); }); - global.fetch = mockFetch as unknown as typeof fetch; const result = await romanizeSongLyrics( ["残酷な天使のテーゼ", "Hello world"], - { - provider: "google_translate", - targetLang: "vi", - }, + { provider: "google_translate", targetLang: "vi" }, ); expect(result).toEqual(["Zankoku na tenshi no tēze", ""]); @@ -100,26 +84,23 @@ describe("AI Lyrics Translation Service", () => { it("translates via OpenAI compatible provider with custom baseUrl and model", async () => { let capturedUrl = ""; - let capturedBody: any = null; - let capturedHeaders: any = null; - - global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + interface ChatPayload { + model?: string; + messages?: Array<{ content?: string }>; + } + let capturedBody: ChatPayload = {}; + let capturedHeaders: HeadersInit | undefined = undefined; + + global.fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { capturedUrl = String(input); capturedHeaders = init?.headers; - capturedBody = JSON.parse(String(init?.body ?? "{}")); - return { - ok: true, - json: async () => ({ - choices: [ - { - message: { - content: "Xin chào bầu trời xanh\nNắng chiếu lấp lánh quanh", - }, - }, - ], - }), - } as Response; - }) as unknown as typeof fetch; + capturedBody = JSON.parse(String(init?.body ?? "{}")) as ChatPayload; + return new Response(JSON.stringify({ + choices: [ + { message: { content: "Xin chào bầu trời xanh\nNắng chiếu lấp lánh quanh" } }, + ], + }), { status: 200 }); + }); const result = await translateSongLyrics(["Blue sky", "Bright sun"], { provider: "openai_compatible", @@ -131,9 +112,11 @@ describe("AI Lyrics Translation Service", () => { }); expect(capturedUrl).toBe("https://api.groq.com/openai/v1/chat/completions"); - expect(capturedHeaders["Authorization"]).toBe("Bearer custom-token-123"); + const headers = new Headers(capturedHeaders); + expect(headers.get("Authorization")).toBe("Bearer custom-token-123"); expect(capturedBody.model).toBe("llama-3.3-70b-versatile"); - expect(capturedBody.messages[0].content).toContain("SYLLABLE COUNT"); + const content = capturedBody.messages?.[0]?.content ?? ""; + expect(content).toContain("SYLLABLE COUNT"); expect(result).toEqual(["Xin chào bầu trời xanh", "Nắng chiếu lấp lánh quanh"]); }); }); diff --git a/src/features/ai-translate/services/geminiProvider.ts b/src/features/ai-translate/services/geminiProvider.ts index 89513b584..0976d9956 100644 --- a/src/features/ai-translate/services/geminiProvider.ts +++ b/src/features/ai-translate/services/geminiProvider.ts @@ -17,9 +17,7 @@ async function callGeminiApi( ): Promise { const cleanKey = apiKey.trim(); const trimmedCustom = customModel?.trim(); - const modelsToTry = trimmedCustom - ? [trimmedCustom, ...DEFAULT_GEMINI_MODELS.filter((m) => m !== trimmedCustom)] - : DEFAULT_GEMINI_MODELS; + const modelsToTry = trimmedCustom ? [trimmedCustom] : DEFAULT_GEMINI_MODELS; let lastError: Error | null = null; for (const model of modelsToTry) { diff --git a/src/features/ai-translate/services/googleFreeProvider.ts b/src/features/ai-translate/services/googleFreeProvider.ts index 0cb1e4672..ccc97cf27 100644 --- a/src/features/ai-translate/services/googleFreeProvider.ts +++ b/src/features/ai-translate/services/googleFreeProvider.ts @@ -45,6 +45,10 @@ export async function romanizeWithGoogleFree( return String(item[3]).trim(); } } + const isNonLatin = /[^\p{Script=Latin}\p{P}\p{Z}\p{N}\p{M}\p{S}]/u.test(trimmed); + if (isNonLatin) { + throw new Error(`Google romanization failed for: ${trimmed}`); + } return line; }); return results; diff --git a/src/features/ai-translate/services/lineMatching.ts b/src/features/ai-translate/services/lineMatching.ts index bf8cd860a..0d33c7828 100644 --- a/src/features/ai-translate/services/lineMatching.ts +++ b/src/features/ai-translate/services/lineMatching.ts @@ -10,9 +10,14 @@ export function matchLineCount( } if (trimmed.length === original.length) { - return original.map((orig, index) => - orig.trim().length === 0 ? "" : trimmed[index], + const hasMissingLine = original.some( + (orig, index) => orig.trim().length > 0 && trimmed[index].length === 0, ); + if (!hasMissingLine) { + return original.map((orig, index) => + orig.trim().length === 0 ? "" : trimmed[index], + ); + } } const cleaned = trimmed.filter((line) => line.length > 0); diff --git a/src/features/ai-translate/store/aiTranslateActions.ts b/src/features/ai-translate/store/aiTranslateActions.ts index 55655f090..870c90284 100644 --- a/src/features/ai-translate/store/aiTranslateActions.ts +++ b/src/features/ai-translate/store/aiTranslateActions.ts @@ -12,6 +12,11 @@ import type { AiTranslateState } from "./aiTranslateStore"; let activeTranslationCount = 0; let activeRomanizationCount = 0; +export function resetActiveRequestCounts(): void { + activeTranslationCount = 0; + activeRomanizationCount = 0; +} + export async function executeTrackTranslation( get: () => AiTranslateState, set: (fn: (s: AiTranslateState) => Partial) => void, @@ -46,13 +51,8 @@ export async function executeTrackTranslation( baseUrl: baseUrl.trim() || undefined, singableMode, }); + if (currentGen() !== requestGen) return null; activeTranslationCount = Math.max(0, activeTranslationCount - 1); - if (currentGen() !== requestGen) { - if (activeTranslationCount === 0) { - set((s) => ({ ...s, isTranslating: false })); - } - return null; - } set((s) => ({ isTranslating: activeTranslationCount > 0, translationsByTrackId: trimCache({ ...s.translationsByTrackId, [cacheKey]: translated }), @@ -60,14 +60,9 @@ export async function executeTrackTranslation( })); return translated; } catch (err: unknown) { + if (currentGen() !== requestGen) return null; activeTranslationCount = Math.max(0, activeTranslationCount - 1); const errorMsg = err instanceof Error ? err.message : "Translation failed"; - if (currentGen() !== requestGen) { - if (activeTranslationCount === 0) { - set((s) => ({ ...s, isTranslating: false })); - } - return null; - } set((s) => ({ ...s, isTranslating: activeTranslationCount > 0, error: errorMsg })); return null; } @@ -106,28 +101,23 @@ export async function executeTrackRomanization( model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined, }); + if (currentGen() !== requestGen) return null; activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); - if (currentGen() !== requestGen) { - if (activeRomanizationCount === 0) { - set((s) => ({ ...s, isRomanizing: false })); - } - return null; + const hasNonEmpty = romanized.some((l) => l.trim().length > 0); + if (hasNonEmpty) { + set((s) => ({ + isRomanizing: activeRomanizationCount > 0, + romanizationsByTrackId: trimCache({ ...s.romanizationsByTrackId, [cacheKey]: romanized }), + sourceRomLinesByCacheKey: trimCache({ ...s.sourceRomLinesByCacheKey, [cacheKey]: lines }), + })); + } else { + set((s) => ({ ...s, isRomanizing: activeRomanizationCount > 0 })); } - set((s) => ({ - isRomanizing: activeRomanizationCount > 0, - romanizationsByTrackId: trimCache({ ...s.romanizationsByTrackId, [cacheKey]: romanized }), - sourceRomLinesByCacheKey: trimCache({ ...s.sourceRomLinesByCacheKey, [cacheKey]: lines }), - })); return romanized; } catch (err: unknown) { + if (currentGen() !== requestGen) return null; activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); const errorMsg = err instanceof Error ? err.message : "Romanization failed"; - if (currentGen() !== requestGen) { - if (activeRomanizationCount === 0) { - set((s) => ({ ...s, isRomanizing: false })); - } - return null; - } set((s) => ({ ...s, isRomanizing: activeRomanizationCount > 0, error: errorMsg })); return null; } diff --git a/src/features/ai-translate/store/aiTranslateStore.test.ts b/src/features/ai-translate/store/aiTranslateStore.test.ts index 5e8bb6598..1eaa3141a 100644 --- a/src/features/ai-translate/store/aiTranslateStore.test.ts +++ b/src/features/ai-translate/store/aiTranslateStore.test.ts @@ -6,15 +6,12 @@ import { sanitizeAiTranslateForExport, } from "./aiTranslateStore"; -function mockGoogleTranslate(dictionary: Record) { - return vi.fn(async (input: RequestInfo | URL) => { +function mockGoogleTranslate(dictionary: Record): typeof fetch { + return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); const q = decodeURIComponent(new URL(url).searchParams.get("q") ?? ""); const translated = dictionary[q] ?? q; - return { - ok: true, - json: async () => [[[translated, q]]], - } as Response; + return new Response(JSON.stringify([[[translated, q]]]), { status: 200 }); }); } @@ -31,7 +28,7 @@ describe("AI Lyrics Translation Store", () => { "Line 1": "Dòng 1", "Line 2": "Dòng 2", }); - global.fetch = mockFetch as unknown as typeof fetch; + global.fetch = mockFetch; const store = useAiTranslateStore.getState(); const res1 = await store.translateTrackLyrics("track-123", lines); @@ -68,7 +65,7 @@ describe("AI Lyrics Translation Store", () => { const mockFetch = mockGoogleTranslate({ Song: "Bài hát", }); - global.fetch = mockFetch as unknown as typeof fetch; + global.fetch = mockFetch; const store = useAiTranslateStore.getState(); await store.translateTrackLyrics("track-genius", ["Song"], "genius"); @@ -108,7 +105,7 @@ describe("AI Lyrics Translation Store", () => { "Line 1": "Dòng 1", "Different 1": "Khác 1", }); - global.fetch = mockFetch as unknown as typeof fetch; + global.fetch = mockFetch; const store = useAiTranslateStore.getState(); const res1 = await store.translateTrackLyrics("track-diff", ["Line 1"]); diff --git a/src/features/ai-translate/store/aiTranslateStore.ts b/src/features/ai-translate/store/aiTranslateStore.ts index 29ab1b237..c54d57266 100644 --- a/src/features/ai-translate/store/aiTranslateStore.ts +++ b/src/features/ai-translate/store/aiTranslateStore.ts @@ -4,6 +4,7 @@ import type { AiProvider } from "../types"; import { executeTrackTranslation, executeTrackRomanization, + resetActiveRequestCounts, } from "./aiTranslateActions"; export * from "./aiTranslateCache"; @@ -56,7 +57,7 @@ export const useAiTranslateStore = create()( (set, get) => ({ provider: "gemini", apiKey: "", - baseUrl: "https://api.openai.com/v1", + baseUrl: "", model: "", singableMode: false, targetLang: "en", @@ -72,16 +73,19 @@ export const useAiTranslateStore = create()( setProvider: (provider) => { configGeneration++; - set({ provider, error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); + resetActiveRequestCounts(); + set({ provider, error: null, isTranslating: false, isRomanizing: false, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, setApiKey: (apiKey) => set({ apiKey, error: null }), setBaseUrl: (baseUrl) => { configGeneration++; - set({ baseUrl, error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); + resetActiveRequestCounts(); + set({ baseUrl, error: null, isTranslating: false, isRomanizing: false, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, setModel: (model) => { configGeneration++; - set({ model, error: null, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); + resetActiveRequestCounts(); + set({ model, error: null, isTranslating: false, isRomanizing: false, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, setSingableMode: (singableMode) => set({ singableMode }), toggleSingableMode: () => set((s) => ({ singableMode: !s.singableMode })), @@ -92,7 +96,8 @@ export const useAiTranslateStore = create()( toggleRomanization: () => set((s) => ({ showRomanization: !s.showRomanization })), clearCache: () => { configGeneration++; - set({ translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); + resetActiveRequestCounts(); + set({ isTranslating: false, isRomanizing: false, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); }, translateTrackLyrics: (trackId, lines, lyricsProvider) => @@ -103,12 +108,12 @@ export const useAiTranslateStore = create()( }), { name: "liner_ai_translate_v1", - version: 2, + version: 3, storage: createJSONStorage(() => localStorage), migrate: (persisted: unknown, version: number) => { const state = (persisted ?? {}) as Partial; - return version < 2 - ? ({ ...state, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} } as AiTranslateState) + return version < 3 + ? ({ ...state, baseUrl: state.baseUrl === "https://api.openai.com/v1" ? "" : state.baseUrl, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} } as AiTranslateState) : (state as AiTranslateState); }, partialize: (state) => ({ diff --git a/src/features/lyrics/canvas/buildLines.test.ts b/src/features/lyrics/canvas/buildLines.test.ts new file mode 100644 index 000000000..41eae557c --- /dev/null +++ b/src/features/lyrics/canvas/buildLines.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { buildLines } from "./buildLines"; +import type { Lyric } from "@braccato/parsers"; + +describe("buildLines", () => { + it("renders both romanization and translation separated by bullet when both exist", () => { + const lyrics: Lyric[] = [ + { + words: "残酷な天使のテーゼ", + startTimeMs: 1000, + durationMs: 2000, + isInstrumental: false, + romanization: "Zankoku na tenshi no teeze", + translation: { text: "A Cruel Angel's Thesis", lang: "en" }, + }, + ]; + + const model = buildLines(lyrics); + expect(model.lines[0].subtitle).toBe( + "Zankoku na tenshi no teeze • A Cruel Angel's Thesis", + ); + }); + + it("renders only translation when romanization is absent", () => { + const lyrics: Lyric[] = [ + { + words: "Hello", + startTimeMs: 1000, + durationMs: 2000, + isInstrumental: false, + translation: { text: "Xin chào", lang: "vi" }, + }, + ]; + + const model = buildLines(lyrics); + expect(model.lines[0].subtitle).toBe("Xin chào"); + }); + + it("renders only romanization when translation is absent", () => { + const lyrics: Lyric[] = [ + { + words: "안녕", + startTimeMs: 1000, + durationMs: 2000, + isInstrumental: false, + romanization: "annyeong", + }, + ]; + + const model = buildLines(lyrics); + expect(model.lines[0].subtitle).toBe("annyeong"); + }); +}); diff --git a/src/features/lyrics/canvas/buildLines.ts b/src/features/lyrics/canvas/buildLines.ts index 27023d761..03c33514f 100644 --- a/src/features/lyrics/canvas/buildLines.ts +++ b/src/features/lyrics/canvas/buildLines.ts @@ -32,7 +32,12 @@ function hasTimedParts(lyrics: Lyric[]): boolean { } function subtitleOf(line: Lyric): string | null { - const text = line.translation?.text ?? line.romanization ?? null; + const trans = line.translation?.text?.trim(); + const rom = line.romanization?.trim(); + if (rom && trans && rom !== trans) { + return `${rom} • ${trans}`; + } + const text = trans ?? rom ?? null; return text && text.trim() ? text.trim() : null; } diff --git a/src/features/lyrics/hooks/useEnrichedLyrics.ts b/src/features/lyrics/hooks/useEnrichedLyrics.ts index 7f870eb57..3b602abfa 100644 --- a/src/features/lyrics/hooks/useEnrichedLyrics.ts +++ b/src/features/lyrics/hooks/useEnrichedLyrics.ts @@ -7,6 +7,23 @@ import { } from "@/features/ai-translate"; import { useLyricsStore } from "../store/lyricsStore"; +function getLineTexts(lyrics: Lyric[]): string[] { + return lyrics.map( + (l) => l.words || l.parts?.map((p) => p.words).join("") || "", + ); +} + +function matchesSourceLines( + cached: string[] | undefined, + lines: string[], +): boolean { + return Boolean( + cached && + cached.length === lines.length && + cached.every((line, idx) => line === lines[idx]), + ); +} + export function enrichLyrics( braccatoLyrics: Lyric[], trackTranslations?: string[], @@ -45,19 +62,28 @@ export function useEnrichedLyrics( const romanizationsByTrackId = useAiTranslateStore( (s) => s.romanizationsByTrackId, ); + const sourceLinesByCacheKey = useAiTranslateStore( + (s) => s.sourceLinesByCacheKey, + ); + const sourceRomLinesByCacheKey = useAiTranslateStore( + (s) => s.sourceRomLinesByCacheKey, + ); const targetLang = useAiTranslateStore((s) => s.targetLang); const singableMode = useAiTranslateStore((s) => s.singableMode); const trackTranslations = useMemo(() => { if (!currentTrackId || !showTranslation) return undefined; - return translationsByTrackId[ - getTranslationCacheKey( - currentTrackId, - targetLang, - singableMode, - activeProvider, - ) - ]; + const key = getTranslationCacheKey( + currentTrackId, + targetLang, + singableMode, + activeProvider, + ); + const cached = translationsByTrackId[key]; + if (!cached) return undefined; + return matchesSourceLines(sourceLinesByCacheKey[key], getLineTexts(braccatoLyrics)) + ? cached + : undefined; }, [ currentTrackId, showTranslation, @@ -65,14 +91,26 @@ export function useEnrichedLyrics( singableMode, activeProvider, translationsByTrackId, + sourceLinesByCacheKey, + braccatoLyrics, ]); const trackRomanizations = useMemo(() => { if (!currentTrackId || !showRomanization) return undefined; - return romanizationsByTrackId[ - getRomanizationCacheKey(currentTrackId, activeProvider) - ]; - }, [currentTrackId, showRomanization, activeProvider, romanizationsByTrackId]); + const key = getRomanizationCacheKey(currentTrackId, activeProvider); + const cached = romanizationsByTrackId[key]; + if (!cached) return undefined; + return matchesSourceLines(sourceRomLinesByCacheKey[key], getLineTexts(braccatoLyrics)) + ? cached + : undefined; + }, [ + currentTrackId, + showRomanization, + activeProvider, + romanizationsByTrackId, + sourceRomLinesByCacheKey, + braccatoLyrics, + ]); return useMemo( () => diff --git a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx index 1d6faec92..94a57b7d1 100644 --- a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx +++ b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx @@ -27,6 +27,8 @@ export function LyricsTranslateIslandControls() { romanizeTrackLyrics, translationsByTrackId, romanizationsByTrackId, + sourceLinesByCacheKey, + sourceRomLinesByCacheKey, targetLang, singableMode, } = useAiTranslateStore(); @@ -36,8 +38,26 @@ export function LyricsTranslateIslandControls() { ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) : ""; const romKey = id ? getRomanizationCacheKey(id, activeProvider) : ""; - const hasTrans = Boolean(cacheKey && translationsByTrackId[cacheKey]?.length); - const hasRom = Boolean(romKey && romanizationsByTrackId[romKey]?.length); + + const lines = braccatoLyrics.map( + (l) => l.words || l.parts?.map((p) => p.words).join("") || "", + ); + const isMatch = (cached: string[] | undefined) => + Boolean( + cached && + cached.length === lines.length && + cached.every((l, i) => l === lines[i]), + ); + const hasTrans = Boolean( + cacheKey && + translationsByTrackId[cacheKey]?.some((l) => l.trim().length > 0) && + isMatch(sourceLinesByCacheKey[cacheKey]), + ); + const hasRom = Boolean( + romKey && + romanizationsByTrackId[romKey]?.some((l) => l.trim().length > 0) && + isMatch(sourceRomLinesByCacheKey[romKey]), + ); const hasRomanizableLyrics = braccatoLyrics.some((lyric) => NON_LATIN_RE.test( @@ -45,38 +65,25 @@ export function LyricsTranslateIslandControls() { ), ); - const getLines = () => - braccatoLyrics.map( - (l) => l.words || l.parts?.map((p) => p.words).join("") || "", - ); - const handleTrans = async () => { if (hasTrans) return toggleTranslation(); - if (!currentTrack || braccatoLyrics.length === 0) return; - const lines = getLines(); - if (lines.length === 0) return; + if (!currentTrack || lines.length === 0) return; const res = await translateTrackLyrics(currentTrack.id, lines, activeProvider); if (res) useAiTranslateStore.getState().setShowTranslation(true); else { - showToast.error("Could not translate lyrics", { - description: - useAiTranslateStore.getState().error ?? "Check API key in Settings.", - }); + const err = useAiTranslateStore.getState().error; + if (err) showToast.error("Could not translate lyrics", { description: err }); } }; const handleRom = async () => { if (hasRom) return toggleRomanization(); - if (!currentTrack || braccatoLyrics.length === 0) return; - const lines = getLines(); - if (lines.length === 0) return; + if (!currentTrack || lines.length === 0) return; const res = await romanizeTrackLyrics(currentTrack.id, lines, activeProvider); if (res) useAiTranslateStore.getState().setShowRomanization(true); else { - showToast.error("Could not romanize lyrics", { - description: - useAiTranslateStore.getState().error ?? "Check API key in Settings.", - }); + const err = useAiTranslateStore.getState().error; + if (err) showToast.error("Could not romanize lyrics", { description: err }); } }; diff --git a/src/features/settings/ui/tabs/AiTabModelConfig.tsx b/src/features/settings/ui/tabs/AiTabModelConfig.tsx index bf8486cca..9e47425e9 100644 --- a/src/features/settings/ui/tabs/AiTabModelConfig.tsx +++ b/src/features/settings/ui/tabs/AiTabModelConfig.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useRef } from "react"; import { useAiTranslateStore, translateSongLyrics, @@ -27,28 +27,33 @@ export function AiTabModelConfig() { const { provider, setProvider, apiKey, setApiKey, baseUrl, setBaseUrl, model, setModel, singableMode, targetLang, clearCache } = useAiTranslateStore(); const [testResult, setTestResult] = useState(null); const [testing, setTesting] = useState(false); + const testIdRef = useRef(0); + + const invalidateTest = () => { + testIdRef.current++; + setTesting(false); + setTestResult(null); + }; const handleTest = async () => { + const currentId = ++testIdRef.current; setTesting(true); setTestResult(null); try { const res = await translateSongLyrics( ["I'm walking on sunshine, and don't it feel good!"], - { - provider, - apiKey: apiKey.trim() || undefined, - targetLang, - model: model.trim() || undefined, - baseUrl: baseUrl.trim() || undefined, - singableMode, - }, + { provider, apiKey: apiKey.trim() || undefined, targetLang, model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined, singableMode }, ); - setTestResult(res?.[0] ?? "Translation error, please check your key."); + if (testIdRef.current === currentId) { + setTestResult(res?.[0] ?? "Translation error, please check your key."); + } } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err); - setTestResult(`Error: ${message}`); + if (testIdRef.current === currentId) { + const message = err instanceof Error ? err.message : String(err); + setTestResult(`Error: ${message}`); + } } finally { - setTesting(false); + if (testIdRef.current === currentId) setTesting(false); } }; @@ -58,7 +63,7 @@ export function AiTabModelConfig() { setProvider(val as AiProvider)} + onChange={(val) => { invalidateTest(); setProvider(val as AiProvider); }} options={PROVIDER_OPTIONS} /> {provider !== "google_translate" && ( @@ -71,7 +76,7 @@ export function AiTabModelConfig() { type="text" placeholder="https://api.openai.com/v1 (or OpenRouter, Groq, Ollama...)" value={baseUrl} - onChange={(e) => setBaseUrl(e.target.value)} + onChange={(e) => { invalidateTest(); setBaseUrl(e.target.value); }} className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" />
@@ -84,7 +89,7 @@ export function AiTabModelConfig() { type="password" placeholder={`Enter ${provider.toUpperCase()} API key...`} value={apiKey} - onChange={(e) => setApiKey(e.target.value)} + onChange={(e) => { invalidateTest(); setApiKey(e.target.value); }} className="flex-1 px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 placeholder:text-white/30" />
From 984c15195f3a30786b46cd863110ac5a613e4fa7 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 18:53:54 +0700 Subject: [PATCH 04/11] fix(lyrics): address CodeRabbit follow-up review findings on line matching and subtitles --- .../services/aiTranslateService.test.ts | 10 ++++----- .../ai-translate/services/lineMatching.ts | 5 ++++- .../store/aiTranslateStore.test.ts | 2 +- src/features/lyrics/canvas/buildLines.test.ts | 16 ++++++++++++++ src/features/lyrics/canvas/buildLines.ts | 2 +- .../ui/LyricsTranslateIslandControls.tsx | 22 +++++++++---------- 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/features/ai-translate/services/aiTranslateService.test.ts b/src/features/ai-translate/services/aiTranslateService.test.ts index 78689f798..4d2d68a6b 100644 --- a/src/features/ai-translate/services/aiTranslateService.test.ts +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -12,7 +12,7 @@ function mockFetchJson(data: unknown): typeof fetch { function mockGoogleTranslate(dictionary: Record): typeof fetch { return vi.fn(async (input: RequestInfo | URL) => { - const q = decodeURIComponent(new URL(String(input)).searchParams.get("q") ?? ""); + const q = new URL(String(input)).searchParams.get("q") ?? ""; const translated = dictionary[q] ?? q; return new Response(JSON.stringify([[[translated, q]]]), { status: 200 }); }); @@ -25,17 +25,17 @@ describe("AI Lyrics Translation Service", () => { it("uses free translator for google_translate provider", async () => { const mockFetch = mockGoogleTranslate({ - Hello: "Xin chào", - World: "Thế giới", + "Hello World": "Xin chào thế giới", + Peace: "Hòa bình", }); global.fetch = mockFetch; - const result = await translateSongLyrics(["Hello", "", "World"], { + const result = await translateSongLyrics(["Hello World", "", "Peace"], { provider: "google_translate", targetLang: "vi", }); - expect(result).toEqual(["Xin chào", "", "Thế giới"]); + expect(result).toEqual(["Xin chào thế giới", "", "Hòa bình"]); expect(mockFetch).toHaveBeenCalledTimes(2); }); diff --git a/src/features/ai-translate/services/lineMatching.ts b/src/features/ai-translate/services/lineMatching.ts index 0d33c7828..cfdc6b977 100644 --- a/src/features/ai-translate/services/lineMatching.ts +++ b/src/features/ai-translate/services/lineMatching.ts @@ -13,7 +13,10 @@ export function matchLineCount( const hasMissingLine = original.some( (orig, index) => orig.trim().length > 0 && trimmed[index].length === 0, ); - if (!hasMissingLine) { + const hasMisplacedLine = original.some( + (orig, index) => orig.trim().length === 0 && trimmed[index].length > 0, + ); + if (!hasMissingLine && !hasMisplacedLine) { return original.map((orig, index) => orig.trim().length === 0 ? "" : trimmed[index], ); diff --git a/src/features/ai-translate/store/aiTranslateStore.test.ts b/src/features/ai-translate/store/aiTranslateStore.test.ts index 1eaa3141a..bf81a8642 100644 --- a/src/features/ai-translate/store/aiTranslateStore.test.ts +++ b/src/features/ai-translate/store/aiTranslateStore.test.ts @@ -9,7 +9,7 @@ import { function mockGoogleTranslate(dictionary: Record): typeof fetch { return vi.fn(async (input: RequestInfo | URL) => { const url = String(input); - const q = decodeURIComponent(new URL(url).searchParams.get("q") ?? ""); + const q = new URL(url).searchParams.get("q") ?? ""; const translated = dictionary[q] ?? q; return new Response(JSON.stringify([[[translated, q]]]), { status: 200 }); }); diff --git a/src/features/lyrics/canvas/buildLines.test.ts b/src/features/lyrics/canvas/buildLines.test.ts index 41eae557c..25ade1c4f 100644 --- a/src/features/lyrics/canvas/buildLines.test.ts +++ b/src/features/lyrics/canvas/buildLines.test.ts @@ -50,4 +50,20 @@ describe("buildLines", () => { const model = buildLines(lyrics); expect(model.lines[0].subtitle).toBe("annyeong"); }); + + it("falls back to romanization when translation is whitespace-only", () => { + const lyrics: Lyric[] = [ + { + words: "안녕", + startTimeMs: 1000, + durationMs: 2000, + isInstrumental: false, + translation: { text: " ", lang: "en" }, + romanization: "annyeong", + }, + ]; + + const model = buildLines(lyrics); + expect(model.lines[0].subtitle).toBe("annyeong"); + }); }); diff --git a/src/features/lyrics/canvas/buildLines.ts b/src/features/lyrics/canvas/buildLines.ts index 03c33514f..fe7eaa52d 100644 --- a/src/features/lyrics/canvas/buildLines.ts +++ b/src/features/lyrics/canvas/buildLines.ts @@ -37,7 +37,7 @@ function subtitleOf(line: Lyric): string | null { if (rom && trans && rom !== trans) { return `${rom} • ${trans}`; } - const text = trans ?? rom ?? null; + const text = trans || rom || null; return text && text.trim() ? text.trim() : null; } diff --git a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx index 94a57b7d1..3fcc50d45 100644 --- a/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx +++ b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx @@ -34,20 +34,14 @@ export function LyricsTranslateIslandControls() { } = useAiTranslateStore(); const id = currentTrack?.id ?? ""; - const cacheKey = id - ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) - : ""; + const cacheKey = id ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) : ""; const romKey = id ? getRomanizationCacheKey(id, activeProvider) : ""; const lines = braccatoLyrics.map( (l) => l.words || l.parts?.map((p) => p.words).join("") || "", ); const isMatch = (cached: string[] | undefined) => - Boolean( - cached && - cached.length === lines.length && - cached.every((l, i) => l === lines[i]), - ); + Boolean(cached && cached.length === lines.length && cached.every((l, i) => l === lines[i])); const hasTrans = Boolean( cacheKey && translationsByTrackId[cacheKey]?.some((l) => l.trim().length > 0) && @@ -68,9 +62,11 @@ export function LyricsTranslateIslandControls() { const handleTrans = async () => { if (hasTrans) return toggleTranslation(); if (!currentTrack || lines.length === 0) return; + const prevShow = useAiTranslateStore.getState().showTranslation; const res = await translateTrackLyrics(currentTrack.id, lines, activeProvider); - if (res) useAiTranslateStore.getState().setShowTranslation(true); - else { + if (res && useAiTranslateStore.getState().showTranslation === prevShow) { + useAiTranslateStore.getState().setShowTranslation(true); + } else if (!res) { const err = useAiTranslateStore.getState().error; if (err) showToast.error("Could not translate lyrics", { description: err }); } @@ -79,9 +75,11 @@ export function LyricsTranslateIslandControls() { const handleRom = async () => { if (hasRom) return toggleRomanization(); if (!currentTrack || lines.length === 0) return; + const prevShow = useAiTranslateStore.getState().showRomanization; const res = await romanizeTrackLyrics(currentTrack.id, lines, activeProvider); - if (res) useAiTranslateStore.getState().setShowRomanization(true); - else { + if (res && useAiTranslateStore.getState().showRomanization === prevShow) { + useAiTranslateStore.getState().setShowRomanization(true); + } else if (!res) { const err = useAiTranslateStore.getState().error; if (err) showToast.error("Could not romanize lyrics", { description: err }); } From 68a79f55316c115f4d9eb3cc691c79cf1722f153 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 19:54:00 +0700 Subject: [PATCH 05/11] test(ai-translate): add line-matching regression tests and verify target language parameter --- .../services/aiTranslateService.test.ts | 24 +++++++--- .../services/lineMatching.test.ts | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 6 deletions(-) create mode 100644 src/features/ai-translate/services/lineMatching.test.ts diff --git a/src/features/ai-translate/services/aiTranslateService.test.ts b/src/features/ai-translate/services/aiTranslateService.test.ts index 4d2d68a6b..f3136dd2c 100644 --- a/src/features/ai-translate/services/aiTranslateService.test.ts +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -10,9 +10,15 @@ function mockFetchJson(data: unknown): typeof fetch { ); } -function mockGoogleTranslate(dictionary: Record): typeof fetch { +function mockGoogleTranslate( + dictionary: Record, + onQuery?: (q: string, tl: string | null) => void, +): typeof fetch { return vi.fn(async (input: RequestInfo | URL) => { - const q = new URL(String(input)).searchParams.get("q") ?? ""; + const url = new URL(String(input)); + const q = url.searchParams.get("q") ?? ""; + const tl = url.searchParams.get("tl"); + onQuery?.(q, tl); const translated = dictionary[q] ?? q; return new Response(JSON.stringify([[[translated, q]]]), { status: 200 }); }); @@ -24,10 +30,14 @@ describe("AI Lyrics Translation Service", () => { }); it("uses free translator for google_translate provider", async () => { - const mockFetch = mockGoogleTranslate({ - "Hello World": "Xin chào thế giới", - Peace: "Hòa bình", - }); + const targetLangs: (string | null)[] = []; + const mockFetch = mockGoogleTranslate( + { + "Hello World": "Xin chào thế giới", + Peace: "Hòa bình", + }, + (_q, tl) => targetLangs.push(tl), + ); global.fetch = mockFetch; const result = await translateSongLyrics(["Hello World", "", "Peace"], { @@ -37,6 +47,8 @@ describe("AI Lyrics Translation Service", () => { expect(result).toEqual(["Xin chào thế giới", "", "Hòa bình"]); expect(mockFetch).toHaveBeenCalledTimes(2); + expect(targetLangs.length).toBe(2); + expect(targetLangs.every((tl) => tl === "vi")).toBe(true); }); it("throws when API key is missing for gemini, openai, or deepseek", async () => { diff --git a/src/features/ai-translate/services/lineMatching.test.ts b/src/features/ai-translate/services/lineMatching.test.ts new file mode 100644 index 000000000..a4f4a90ba --- /dev/null +++ b/src/features/ai-translate/services/lineMatching.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { + matchLineCount, + dropUnchangedRomanization, +} from "./lineMatching"; + +describe("lineMatching", () => { + it("aligns lines 1:1 when line counts and blank slots match exactly", () => { + const source = ["Line 1", "", "Line 2"]; + const translated = ["Dòng 1", "", "Dòng 2"]; + expect(matchLineCount(translated, source)).toEqual(["Dòng 1", "", "Dòng 2"]); + }); + + it("re-aligns translated lines when an LLM drops blank lines", () => { + const source = ["Line 1", "", "Line 2"]; + const translated = ["Dòng 1", "Dòng 2"]; + expect(matchLineCount(translated, source)).toEqual(["Dòng 1", "", "Dòng 2"]); + }); + + it("throws Line count mismatch when equal-count response places translation in a blank slot", () => { + const source = ["A", "", "B"]; + const response = ["a", "misplaced", "b"]; + expect(() => matchLineCount(response, source)).toThrow( + "Line count mismatch: expected 2 non-empty lines, received 3", + ); + }); + + it("throws Line count mismatch when nonblank source is opposite blank translation while blank source has nonblank translation", () => { + const source = ["A", "", "B", ""]; + const response = ["a", "misplaced", "", "extra"]; + expect(() => matchLineCount(response, source)).toThrow( + "Line count mismatch: expected 2 non-empty lines, received 3", + ); + }); + + it("cleans unchanged romanization lines for Latin text", () => { + const source = ["Hello world", "残酷な天使のテーゼ"]; + const romanized = ["Hello world", "Zankoku na tenshi no teeze"]; + expect(dropUnchangedRomanization(romanized, source)).toEqual([ + "", + "Zankoku na tenshi no teeze", + ]); + }); +}); From 2eda8982f773afe2e63ba12a7aa68792d5fe235c Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 20:22:00 +0700 Subject: [PATCH 06/11] fix(canvas): eliminate syllable scale tearing and enable dynamic luminous pulse on all syllables --- src/features/lyrics/canvas/drawWord.ts | 19 ++----- .../lyrics/canvas/syllablePulse.test.ts | 18 +++---- src/features/lyrics/canvas/syllablePulse.ts | 52 ++++++++++--------- 3 files changed, 39 insertions(+), 50 deletions(-) diff --git a/src/features/lyrics/canvas/drawWord.ts b/src/features/lyrics/canvas/drawWord.ts index eaab1fafe..8390a6420 100644 --- a/src/features/lyrics/canvas/drawWord.ts +++ b/src/features/lyrics/canvas/drawWord.ts @@ -51,23 +51,10 @@ export function drawToken( if (!highlight || highlight.opacity <= 0) return; if (highlight.clear <= 0) return; ctx.fillStyle = highlightStyle(paint, placed, highlight); - const hasScale = Boolean( - highlight.scale && Math.abs(highlight.scale - 1) > 0.003, - ); - const hasGlow = highlight.glowRem > 0; - if (hasScale || hasGlow) { + if (highlight.glowRem > 0) { ctx.save(); - if (hasScale && highlight.scale) { - const cx = placed.x + placed.width / 2; - const cy = y - placed.baseline / 3; - ctx.translate(cx, cy); - ctx.scale(highlight.scale, highlight.scale); - ctx.translate(-cx, -cy); - } - if (hasGlow) { - ctx.shadowColor = white(1); - ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; - } + ctx.shadowColor = white(1); + ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; ctx.fillText(text, placed.x, y); ctx.restore(); return; diff --git a/src/features/lyrics/canvas/syllablePulse.test.ts b/src/features/lyrics/canvas/syllablePulse.test.ts index ec19c69c2..2d7ec6abc 100644 --- a/src/features/lyrics/canvas/syllablePulse.test.ts +++ b/src/features/lyrics/canvas/syllablePulse.test.ts @@ -19,24 +19,24 @@ describe("syllablePulse", () => { space: false, }; - it("does not apply bounce or pulse to short syllables (< 320ms)", () => { - const effect = syllablePulse(shortToken, 100); + it("applies dynamic glow pulse to all syllables including short ones", () => { + const effect = syllablePulse(shortToken, 60); expect(effect.scale).toBe(1); - expect(effect.glowRem).toBe(0); + expect(effect.glowRem).toBeGreaterThan(0.5); }); - it("applies bounce scale on attack for sustained syllables", () => { - const attackEffect = syllablePulse(longToken, 140); - expect(attackEffect.scale).toBeGreaterThan(1.05); - expect(attackEffect.scale).toBeLessThanOrEqual(1.1); + it("applies peak luminous bloom on attack for sustained syllables", () => { + const attackEffect = syllablePulse(longToken, 80); + expect(attackEffect.scale).toBe(1); + expect(attackEffect.glowRem).toBeGreaterThan(0.7); }); it("returns glowing highlight during mid-point of sustained singing", () => { const midEffect = syllablePulse(longToken, 600); - expect(midEffect.glowRem).toBeGreaterThan(0.4); + expect(midEffect.glowRem).toBeGreaterThan(0.3); }); - it("returns neutral scale when elapsed is beyond token duration", () => { + it("returns 0 glow and neutral scale when elapsed is beyond token duration", () => { const endEffect = syllablePulse(longToken, 1200); expect(endEffect.scale).toBe(1); expect(endEffect.glowRem).toBe(0); diff --git a/src/features/lyrics/canvas/syllablePulse.ts b/src/features/lyrics/canvas/syllablePulse.ts index 8f2aba95d..362e8ca1d 100644 --- a/src/features/lyrics/canvas/syllablePulse.ts +++ b/src/features/lyrics/canvas/syllablePulse.ts @@ -1,8 +1,9 @@ import type { LyricToken } from "./types"; -const MIN_SUSTAINED_MS = 320; -const BOUNCE_ATTACK_MS = 140; -const MAX_BOUNCE_SCALE = 1.075; +const MIN_PULSE_MS = 60; +const ATTACK_MS = 100; +const PEAK_GLOW_REM = 0.95; +const SUSTAIN_GLOW_REM = 0.4; export interface SyllableEffect { scale: number; @@ -14,39 +15,40 @@ export function syllablePulse( elapsedMs: number, ): SyllableEffect { if ( - token.durationMs < MIN_SUSTAINED_MS || + token.durationMs < MIN_PULSE_MS || elapsedMs < 0 || elapsedMs >= token.durationMs ) { return { scale: 1, glowRem: 0 }; } - let scale = 1; - if (elapsedMs <= BOUNCE_ATTACK_MS) { - const p = elapsedMs / BOUNCE_ATTACK_MS; - scale = 1 + (MAX_BOUNCE_SCALE - 1) * Math.sin(p * Math.PI * 0.5); + const attackDuration = Math.min(ATTACK_MS, token.durationMs * 0.4); + let glowRem = 0; + + if (elapsedMs <= attackDuration) { + const progress = elapsedMs / attackDuration; + glowRem = PEAK_GLOW_REM * Math.sin(progress * Math.PI * 0.5); } else { - const holdMs = token.durationMs - BOUNCE_ATTACK_MS; - const holdProgress = Math.min( - 1, - (elapsedMs - BOUNCE_ATTACK_MS) / Math.max(1, holdMs), - ); - const settle = - 1 + (MAX_BOUNCE_SCALE - 1) * Math.cos(holdProgress * Math.PI * 0.5); + const sustainProgress = + (elapsedMs - attackDuration) / + Math.max(1, token.durationMs - attackDuration); + const decay = + SUSTAIN_GLOW_REM + + (PEAK_GLOW_REM - SUSTAIN_GLOW_REM) * + Math.cos(sustainProgress * Math.PI * 0.5); + const vibrato = - token.durationMs >= 600 - ? 0.015 * - Math.sin((elapsedMs - BOUNCE_ATTACK_MS) * 0.015) * - (1 - holdProgress) + token.durationMs >= 450 + ? 0.12 * + Math.sin((elapsedMs - attackDuration) * 0.015) * + (1 - sustainProgress) : 0; - scale = Math.max(1, settle + vibrato); - } - const normProgress = Math.min(1, elapsedMs / token.durationMs); - const glowRem = 0.55 * Math.sin(normProgress * Math.PI); + glowRem = Math.max(0, decay + vibrato); + } return { - scale: Math.min(scale, 1.1), - glowRem: Math.max(0, glowRem), + scale: 1, + glowRem: Math.max(0, Math.min(1.2, glowRem)), }; } From 36f428aeee1af552bbb3af7e143668ef834eb343 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 20:28:46 +0700 Subject: [PATCH 07/11] feat(canvas): restore unified syllable bounce lift and pop for all tokens without ghosting --- src/features/lyrics/canvas/drawWord.ts | 42 ++++++++++---- .../lyrics/canvas/syllablePulse.test.ts | 21 ++++--- src/features/lyrics/canvas/syllablePulse.ts | 55 ++++++++++--------- src/features/lyrics/canvas/timing.ts | 2 + 4 files changed, 75 insertions(+), 45 deletions(-) diff --git a/src/features/lyrics/canvas/drawWord.ts b/src/features/lyrics/canvas/drawWord.ts index 8390a6420..aaef93806 100644 --- a/src/features/lyrics/canvas/drawWord.ts +++ b/src/features/lyrics/canvas/drawWord.ts @@ -46,18 +46,40 @@ export function drawToken( const { ctx } = paint; const y = Math.round((paint.top + placed.baseline) * paint.dpr) / paint.dpr; const text = placed.token.text; + + const scale = highlight?.scale ?? 1; + const dy = highlight?.dy ?? 0; + const hasTransform = + Boolean(highlight) && + (Math.abs(scale - 1) > 0.003 || Math.abs(dy) > 0.1); + + if (hasTransform) { + ctx.save(); + const cx = placed.x + placed.width / 2; + const cy = y - paint.remPx * 0.6; + ctx.translate(cx, cy); + ctx.scale(scale, scale); + ctx.translate(0, dy); + ctx.translate(-cx, -cy); + } + ctx.fillStyle = white(paint.base); ctx.fillText(text, placed.x, y); - if (!highlight || highlight.opacity <= 0) return; - if (highlight.clear <= 0) return; - ctx.fillStyle = highlightStyle(paint, placed, highlight); - if (highlight.glowRem > 0) { - ctx.save(); - ctx.shadowColor = white(1); - ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; - ctx.fillText(text, placed.x, y); + + if (highlight && highlight.opacity > 0 && highlight.clear > 0) { + ctx.fillStyle = highlightStyle(paint, placed, highlight); + if (highlight.glowRem > 0) { + ctx.save(); + ctx.shadowColor = white(1); + ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; + ctx.fillText(text, placed.x, y); + ctx.restore(); + } else { + ctx.fillText(text, placed.x, y); + } + } + + if (hasTransform) { ctx.restore(); - return; } - ctx.fillText(text, placed.x, y); } diff --git a/src/features/lyrics/canvas/syllablePulse.test.ts b/src/features/lyrics/canvas/syllablePulse.test.ts index 2d7ec6abc..8b8de40d6 100644 --- a/src/features/lyrics/canvas/syllablePulse.test.ts +++ b/src/features/lyrics/canvas/syllablePulse.test.ts @@ -19,30 +19,35 @@ describe("syllablePulse", () => { space: false, }; - it("applies dynamic glow pulse to all syllables including short ones", () => { + it("applies bounce pop and lift to short syllables", () => { const effect = syllablePulse(shortToken, 60); - expect(effect.scale).toBe(1); + expect(effect.scale).toBeGreaterThan(1.02); + expect(effect.dy).toBeLessThan(0); expect(effect.glowRem).toBeGreaterThan(0.5); }); - it("applies peak luminous bloom on attack for sustained syllables", () => { - const attackEffect = syllablePulse(longToken, 80); - expect(attackEffect.scale).toBe(1); + it("applies peak bounce on attack for sustained syllables", () => { + const attackEffect = syllablePulse(longToken, 70); + expect(attackEffect.scale).toBeGreaterThan(1.04); + expect(attackEffect.dy).toBeLessThan(-2.0); expect(attackEffect.glowRem).toBeGreaterThan(0.7); }); - it("returns glowing highlight during mid-point of sustained singing", () => { + it("lands back to baseline during sustain phase", () => { const midEffect = syllablePulse(longToken, 600); - expect(midEffect.glowRem).toBeGreaterThan(0.3); + expect(midEffect.scale).toBe(1); + expect(midEffect.glowRem).toBeGreaterThan(0.2); }); - it("returns 0 glow and neutral scale when elapsed is beyond token duration", () => { + it("returns neutral state when elapsed is beyond token duration", () => { const endEffect = syllablePulse(longToken, 1200); expect(endEffect.scale).toBe(1); + expect(endEffect.dy).toBe(0); expect(endEffect.glowRem).toBe(0); const pastEffect = syllablePulse(longToken, 1500); expect(pastEffect.scale).toBe(1); + expect(pastEffect.dy).toBe(0); expect(pastEffect.glowRem).toBe(0); }); }); diff --git a/src/features/lyrics/canvas/syllablePulse.ts b/src/features/lyrics/canvas/syllablePulse.ts index 362e8ca1d..d35d806b8 100644 --- a/src/features/lyrics/canvas/syllablePulse.ts +++ b/src/features/lyrics/canvas/syllablePulse.ts @@ -1,12 +1,15 @@ import type { LyricToken } from "./types"; -const MIN_PULSE_MS = 60; -const ATTACK_MS = 100; -const PEAK_GLOW_REM = 0.95; -const SUSTAIN_GLOW_REM = 0.4; +const MIN_PULSE_MS = 50; +const ATTACK_MS = 140; +const MAX_BOUNCE_SCALE = 1.065; +const MAX_BOUNCE_LIFT = -3.0; +const PEAK_GLOW_REM = 0.85; +const SUSTAIN_GLOW_REM = 0.35; export interface SyllableEffect { scale: number; + dy: number; glowRem: number; } @@ -19,36 +22,34 @@ export function syllablePulse( elapsedMs < 0 || elapsedMs >= token.durationMs ) { - return { scale: 1, glowRem: 0 }; + return { scale: 1, dy: 0, glowRem: 0 }; } - const attackDuration = Math.min(ATTACK_MS, token.durationMs * 0.4); - let glowRem = 0; + const attackDuration = Math.min(ATTACK_MS, token.durationMs * 0.6); if (elapsedMs <= attackDuration) { - const progress = elapsedMs / attackDuration; - glowRem = PEAK_GLOW_REM * Math.sin(progress * Math.PI * 0.5); - } else { - const sustainProgress = - (elapsedMs - attackDuration) / - Math.max(1, token.durationMs - attackDuration); - const decay = - SUSTAIN_GLOW_REM + - (PEAK_GLOW_REM - SUSTAIN_GLOW_REM) * - Math.cos(sustainProgress * Math.PI * 0.5); - - const vibrato = - token.durationMs >= 450 - ? 0.12 * - Math.sin((elapsedMs - attackDuration) * 0.015) * - (1 - sustainProgress) - : 0; - - glowRem = Math.max(0, decay + vibrato); + const p = elapsedMs / attackDuration; + const wave = Math.sin(p * Math.PI); + return { + scale: 1 + (MAX_BOUNCE_SCALE - 1) * wave, + dy: MAX_BOUNCE_LIFT * wave, + glowRem: PEAK_GLOW_REM * wave, + }; } + const sustainMs = Math.max(1, token.durationMs - attackDuration); + const sustainProgress = (elapsedMs - attackDuration) / sustainMs; + const decay = + SUSTAIN_GLOW_REM * Math.cos(sustainProgress * Math.PI * 0.5); + + const isHeldNote = token.durationMs >= 450; + const vibrato = isHeldNote + ? Math.sin((elapsedMs - attackDuration) * 0.015) * (1 - sustainProgress) + : 0; + return { scale: 1, - glowRem: Math.max(0, Math.min(1.2, glowRem)), + dy: isHeldNote ? -0.6 * Math.max(0, vibrato) : 0, + glowRem: Math.max(0, decay + 0.12 * vibrato), }; } diff --git a/src/features/lyrics/canvas/timing.ts b/src/features/lyrics/canvas/timing.ts index 0c2a954a0..0a2eb2662 100644 --- a/src/features/lyrics/canvas/timing.ts +++ b/src/features/lyrics/canvas/timing.ts @@ -21,6 +21,7 @@ export interface TokenHighlight { clear: number; glowRem: number; scale?: number; + dy?: number; } export function isLineSelected(line: LyricLineModel, timeMs: number) { @@ -83,6 +84,7 @@ export function tokenHighlight( clear: solid + SWIPE_WIDTH, glowRem: Math.max(baseGlow, pulse.glowRem), scale: pulse.scale, + dy: pulse.dy, }; } From 9983948e19d9bffbe41d362f1d7a79f76eb6ba4f Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 20:48:23 +0700 Subject: [PATCH 08/11] feat(lyrics): remove canvas bounce and enhance AI settings with provider cards and language picker --- .../ai-translate/services/geminiProvider.ts | 16 +- .../ai-translate/services/openAiProvider.ts | 39 +++-- .../ai-translate/store/aiTranslateActions.ts | 18 ++- src/features/lyrics/canvas/buildLines.test.ts | 9 +- src/features/lyrics/canvas/buildLines.ts | 2 +- src/features/lyrics/canvas/drawLine.ts | 2 + src/features/lyrics/canvas/drawWord.ts | 42 ++--- src/features/lyrics/canvas/layoutLines.ts | 4 +- .../lyrics/canvas/syllablePulse.test.ts | 53 ------ src/features/lyrics/canvas/syllablePulse.ts | 55 ------- src/features/lyrics/canvas/timing.ts | 9 +- .../settings/ui/tabs/AiLanguageSelect.tsx | 116 +++++++++++++ src/features/settings/ui/tabs/AiLanguages.ts | 30 ++++ .../settings/ui/tabs/AiProviderCards.tsx | 96 +++++++++++ src/features/settings/ui/tabs/AiTab.tsx | 17 +- .../settings/ui/tabs/AiTabModelConfig.tsx | 152 +++--------------- .../settings/ui/tabs/AiTabModelInputs.tsx | 113 +++++++++++++ .../settings/ui/tabs/useAiModelTest.ts | 60 +++++++ 18 files changed, 518 insertions(+), 315 deletions(-) delete mode 100644 src/features/lyrics/canvas/syllablePulse.test.ts delete mode 100644 src/features/lyrics/canvas/syllablePulse.ts create mode 100644 src/features/settings/ui/tabs/AiLanguageSelect.tsx create mode 100644 src/features/settings/ui/tabs/AiLanguages.ts create mode 100644 src/features/settings/ui/tabs/AiProviderCards.tsx create mode 100644 src/features/settings/ui/tabs/AiTabModelInputs.tsx create mode 100644 src/features/settings/ui/tabs/useAiModelTest.ts diff --git a/src/features/ai-translate/services/geminiProvider.ts b/src/features/ai-translate/services/geminiProvider.ts index 0976d9956..96e994571 100644 --- a/src/features/ai-translate/services/geminiProvider.ts +++ b/src/features/ai-translate/services/geminiProvider.ts @@ -5,11 +5,12 @@ import { import { matchLineCount } from "./lineMatching"; const DEFAULT_GEMINI_MODELS = [ - "gemini-2.5-flash", "gemini-2.0-flash", "gemini-1.5-flash", ]; +const REQUEST_TIMEOUT_MS = 60_000; + async function callGeminiApi( prompt: string, apiKey: string, @@ -26,7 +27,7 @@ async function callGeminiApi( const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, - signal: AbortSignal.timeout(30_000), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), body: JSON.stringify({ contents: [{ parts: [{ text: prompt }] }], generationConfig: { @@ -50,12 +51,15 @@ async function callGeminiApi( return text; } catch (err: unknown) { const error = err instanceof Error ? err : new Error(String(err)); - lastError = error; + lastError = + error.name === "TimeoutError" || error.message.toLowerCase().includes("timeout") + ? new Error(`Gemini request timed out (${REQUEST_TIMEOUT_MS / 1000}s). Please retry.`) + : error; if ( - error.message.includes("API_KEY_INVALID") || - error.message.includes("PERMISSION_DENIED") + lastError.message.includes("API_KEY_INVALID") || + lastError.message.includes("PERMISSION_DENIED") ) { - throw error; + throw lastError; } } } diff --git a/src/features/ai-translate/services/openAiProvider.ts b/src/features/ai-translate/services/openAiProvider.ts index 86b375e3e..84bfe9f2b 100644 --- a/src/features/ai-translate/services/openAiProvider.ts +++ b/src/features/ai-translate/services/openAiProvider.ts @@ -4,6 +4,8 @@ import { } from "../prompts"; import { matchLineCount } from "./lineMatching"; +const REQUEST_TIMEOUT_MS = 60_000; + async function callOpenAiChat( systemPrompt: string, userText: string, @@ -21,19 +23,30 @@ async function callOpenAiChat( headers["Authorization"] = `Bearer ${apiKey.trim()}`; } - const response = await fetch(url, { - method: "POST", - headers, - signal: AbortSignal.timeout(30_000), - body: JSON.stringify({ - model, - temperature: 0.3, - messages: [ - { role: "system", content: systemPrompt }, - { role: "user", content: userText }, - ], - }), - }); + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + body: JSON.stringify({ + model, + temperature: 0.3, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: userText }, + ], + }), + }); + } catch (err: unknown) { + if ( + err instanceof Error && + (err.name === "TimeoutError" || err.message.toLowerCase().includes("timeout")) + ) { + throw new Error(`AI request timed out (${REQUEST_TIMEOUT_MS / 1000}s). Please retry.`); + } + throw err; + } if (!response.ok) { const errorText = await response.text(); diff --git a/src/features/ai-translate/store/aiTranslateActions.ts b/src/features/ai-translate/store/aiTranslateActions.ts index 870c90284..74334d075 100644 --- a/src/features/ai-translate/store/aiTranslateActions.ts +++ b/src/features/ai-translate/store/aiTranslateActions.ts @@ -12,6 +12,20 @@ import type { AiTranslateState } from "./aiTranslateStore"; let activeTranslationCount = 0; let activeRomanizationCount = 0; +function formatErrorMessage(err: unknown, fallback: string): string { + if (err instanceof Error) { + if ( + err.name === "TimeoutError" || + err.message.toLowerCase().includes("timeout") || + err.message.toLowerCase().includes("aborted") + ) { + return "Request timed out. The AI service took too long to respond. Please retry."; + } + return err.message; + } + return fallback; +} + export function resetActiveRequestCounts(): void { activeTranslationCount = 0; activeRomanizationCount = 0; @@ -62,7 +76,7 @@ export async function executeTrackTranslation( } catch (err: unknown) { if (currentGen() !== requestGen) return null; activeTranslationCount = Math.max(0, activeTranslationCount - 1); - const errorMsg = err instanceof Error ? err.message : "Translation failed"; + const errorMsg = formatErrorMessage(err, "Translation failed"); set((s) => ({ ...s, isTranslating: activeTranslationCount > 0, error: errorMsg })); return null; } @@ -117,7 +131,7 @@ export async function executeTrackRomanization( } catch (err: unknown) { if (currentGen() !== requestGen) return null; activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); - const errorMsg = err instanceof Error ? err.message : "Romanization failed"; + const errorMsg = formatErrorMessage(err, "Romanization failed"); set((s) => ({ ...s, isRomanizing: activeRomanizationCount > 0, error: errorMsg })); return null; } diff --git a/src/features/lyrics/canvas/buildLines.test.ts b/src/features/lyrics/canvas/buildLines.test.ts index 25ade1c4f..c79a4eb2f 100644 --- a/src/features/lyrics/canvas/buildLines.test.ts +++ b/src/features/lyrics/canvas/buildLines.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildLines } from "./buildLines"; +import { buildLines, isRtl } from "./buildLines"; import type { Lyric } from "@braccato/parsers"; describe("buildLines", () => { @@ -66,4 +66,11 @@ describe("buildLines", () => { const model = buildLines(lyrics); expect(model.lines[0].subtitle).toBe("annyeong"); }); + + it("detects RTL scripts accurately with isRtl", () => { + expect(isRtl("مرحبا")).toBe(true); + expect(isRtl("שלום")).toBe(true); + expect(isRtl("Hello world")).toBe(false); + expect(isRtl("Xin chào")).toBe(false); + }); }); diff --git a/src/features/lyrics/canvas/buildLines.ts b/src/features/lyrics/canvas/buildLines.ts index fe7eaa52d..a5f594294 100644 --- a/src/features/lyrics/canvas/buildLines.ts +++ b/src/features/lyrics/canvas/buildLines.ts @@ -15,7 +15,7 @@ function alignFor(agent: string | undefined): LineAlign { return "start"; } -function isRtl(text: string): boolean { +export function isRtl(text: string): boolean { for (const char of text) { if (RTL.test(char)) return true; if (LTR.test(char)) return false; diff --git a/src/features/lyrics/canvas/drawLine.ts b/src/features/lyrics/canvas/drawLine.ts index a5207b67f..efdc0cb39 100644 --- a/src/features/lyrics/canvas/drawLine.ts +++ b/src/features/lyrics/canvas/drawLine.ts @@ -1,3 +1,4 @@ +import { isRtl } from "./buildLines"; import { drawToken, white } from "./drawWord"; import { backgroundFont, mainFont, subtitleFont } from "./fonts"; import { tokenHighlight } from "./timing"; @@ -41,6 +42,7 @@ export function drawLine( ); } if (layout.subtitle.length === 0) return; + ctx.direction = isRtl(line.subtitle ?? "") ? "rtl" : "ltr"; ctx.font = subtitleFont(type); ctx.fillStyle = white(SUBTITLE_ALPHA); for (const text of layout.subtitle) { diff --git a/src/features/lyrics/canvas/drawWord.ts b/src/features/lyrics/canvas/drawWord.ts index aaef93806..8390a6420 100644 --- a/src/features/lyrics/canvas/drawWord.ts +++ b/src/features/lyrics/canvas/drawWord.ts @@ -46,40 +46,18 @@ export function drawToken( const { ctx } = paint; const y = Math.round((paint.top + placed.baseline) * paint.dpr) / paint.dpr; const text = placed.token.text; - - const scale = highlight?.scale ?? 1; - const dy = highlight?.dy ?? 0; - const hasTransform = - Boolean(highlight) && - (Math.abs(scale - 1) > 0.003 || Math.abs(dy) > 0.1); - - if (hasTransform) { - ctx.save(); - const cx = placed.x + placed.width / 2; - const cy = y - paint.remPx * 0.6; - ctx.translate(cx, cy); - ctx.scale(scale, scale); - ctx.translate(0, dy); - ctx.translate(-cx, -cy); - } - ctx.fillStyle = white(paint.base); ctx.fillText(text, placed.x, y); - - if (highlight && highlight.opacity > 0 && highlight.clear > 0) { - ctx.fillStyle = highlightStyle(paint, placed, highlight); - if (highlight.glowRem > 0) { - ctx.save(); - ctx.shadowColor = white(1); - ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; - ctx.fillText(text, placed.x, y); - ctx.restore(); - } else { - ctx.fillText(text, placed.x, y); - } - } - - if (hasTransform) { + if (!highlight || highlight.opacity <= 0) return; + if (highlight.clear <= 0) return; + ctx.fillStyle = highlightStyle(paint, placed, highlight); + if (highlight.glowRem > 0) { + ctx.save(); + ctx.shadowColor = white(1); + ctx.shadowBlur = highlight.glowRem * paint.remPx * paint.dpr; + ctx.fillText(text, placed.x, y); ctx.restore(); + return; } + ctx.fillText(text, placed.x, y); } diff --git a/src/features/lyrics/canvas/layoutLines.ts b/src/features/lyrics/canvas/layoutLines.ts index c0e9b1478..2d627409a 100644 --- a/src/features/lyrics/canvas/layoutLines.ts +++ b/src/features/lyrics/canvas/layoutLines.ts @@ -1,3 +1,4 @@ +import { isRtl } from "./buildLines"; import { backgroundFont, mainFont, @@ -37,11 +38,12 @@ function layoutSubtitle( const placed = wrapRuns(runs, frame.width); const extents = rowExtents(runs, placed); const align = line.rtl && line.align === "start" ? "end" : line.align; + const subtitleRtl = isRtl(line.subtitle); const rowHeight = type.size * SUBTITLE_SCALE * 1.3; const items = placed.map((item): PlacedText => { const ink = runs[item.index].inkWidth; const rowWidth = extents[item.row]; - const logical = line.rtl ? rowWidth - item.x - ink : item.x; + const logical = subtitleRtl ? rowWidth - item.x - ink : item.x; return { text: chunks[item.index].trimEnd(), x: frame.left + alignOffset(align, rowWidth, frame.width) + logical, diff --git a/src/features/lyrics/canvas/syllablePulse.test.ts b/src/features/lyrics/canvas/syllablePulse.test.ts deleted file mode 100644 index 8b8de40d6..000000000 --- a/src/features/lyrics/canvas/syllablePulse.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { syllablePulse } from "./syllablePulse"; -import type { LyricToken } from "./types"; - -describe("syllablePulse", () => { - const shortToken: LyricToken = { - text: "la", - startMs: 1000, - durationMs: 200, - background: false, - space: false, - }; - - const longToken: LyricToken = { - text: "forever", - startMs: 2000, - durationMs: 1200, - background: false, - space: false, - }; - - it("applies bounce pop and lift to short syllables", () => { - const effect = syllablePulse(shortToken, 60); - expect(effect.scale).toBeGreaterThan(1.02); - expect(effect.dy).toBeLessThan(0); - expect(effect.glowRem).toBeGreaterThan(0.5); - }); - - it("applies peak bounce on attack for sustained syllables", () => { - const attackEffect = syllablePulse(longToken, 70); - expect(attackEffect.scale).toBeGreaterThan(1.04); - expect(attackEffect.dy).toBeLessThan(-2.0); - expect(attackEffect.glowRem).toBeGreaterThan(0.7); - }); - - it("lands back to baseline during sustain phase", () => { - const midEffect = syllablePulse(longToken, 600); - expect(midEffect.scale).toBe(1); - expect(midEffect.glowRem).toBeGreaterThan(0.2); - }); - - it("returns neutral state when elapsed is beyond token duration", () => { - const endEffect = syllablePulse(longToken, 1200); - expect(endEffect.scale).toBe(1); - expect(endEffect.dy).toBe(0); - expect(endEffect.glowRem).toBe(0); - - const pastEffect = syllablePulse(longToken, 1500); - expect(pastEffect.scale).toBe(1); - expect(pastEffect.dy).toBe(0); - expect(pastEffect.glowRem).toBe(0); - }); -}); diff --git a/src/features/lyrics/canvas/syllablePulse.ts b/src/features/lyrics/canvas/syllablePulse.ts deleted file mode 100644 index d35d806b8..000000000 --- a/src/features/lyrics/canvas/syllablePulse.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { LyricToken } from "./types"; - -const MIN_PULSE_MS = 50; -const ATTACK_MS = 140; -const MAX_BOUNCE_SCALE = 1.065; -const MAX_BOUNCE_LIFT = -3.0; -const PEAK_GLOW_REM = 0.85; -const SUSTAIN_GLOW_REM = 0.35; - -export interface SyllableEffect { - scale: number; - dy: number; - glowRem: number; -} - -export function syllablePulse( - token: LyricToken, - elapsedMs: number, -): SyllableEffect { - if ( - token.durationMs < MIN_PULSE_MS || - elapsedMs < 0 || - elapsedMs >= token.durationMs - ) { - return { scale: 1, dy: 0, glowRem: 0 }; - } - - const attackDuration = Math.min(ATTACK_MS, token.durationMs * 0.6); - - if (elapsedMs <= attackDuration) { - const p = elapsedMs / attackDuration; - const wave = Math.sin(p * Math.PI); - return { - scale: 1 + (MAX_BOUNCE_SCALE - 1) * wave, - dy: MAX_BOUNCE_LIFT * wave, - glowRem: PEAK_GLOW_REM * wave, - }; - } - - const sustainMs = Math.max(1, token.durationMs - attackDuration); - const sustainProgress = (elapsedMs - attackDuration) / sustainMs; - const decay = - SUSTAIN_GLOW_REM * Math.cos(sustainProgress * Math.PI * 0.5); - - const isHeldNote = token.durationMs >= 450; - const vibrato = isHeldNote - ? Math.sin((elapsedMs - attackDuration) * 0.015) * (1 - sustainProgress) - : 0; - - return { - scale: 1, - dy: isHeldNote ? -0.6 * Math.max(0, vibrato) : 0, - glowRem: Math.max(0, decay + 0.12 * vibrato), - }; -} diff --git a/src/features/lyrics/canvas/timing.ts b/src/features/lyrics/canvas/timing.ts index 0a2eb2662..10a4e5ad6 100644 --- a/src/features/lyrics/canvas/timing.ts +++ b/src/features/lyrics/canvas/timing.ts @@ -1,6 +1,5 @@ import { clamp01, cssEase } from "./easing"; import { exitFade, isExiting, lineExitMs } from "./exitClock"; -import { syllablePulse } from "./syllablePulse"; import type { LyricLineModel, LyricToken } from "./types"; const SWIPE_LEAD_RATIO = 0.1; @@ -20,8 +19,6 @@ export interface TokenHighlight { solid: number; clear: number; glowRem: number; - scale?: number; - dy?: number; } export function isLineSelected(line: LyricLineModel, timeMs: number) { @@ -76,15 +73,11 @@ export function tokenHighlight( }; } const solid = sweepAt(token, elapsed); - const pulse = syllablePulse(token, elapsed); - const baseGlow = glowAt(token, elapsed); return { opacity: 1, solid, clear: solid + SWIPE_WIDTH, - glowRem: Math.max(baseGlow, pulse.glowRem), - scale: pulse.scale, - dy: pulse.dy, + glowRem: glowAt(token, elapsed), }; } diff --git a/src/features/settings/ui/tabs/AiLanguageSelect.tsx b/src/features/settings/ui/tabs/AiLanguageSelect.tsx new file mode 100644 index 000000000..ffbcc92c4 --- /dev/null +++ b/src/features/settings/ui/tabs/AiLanguageSelect.tsx @@ -0,0 +1,116 @@ +import { useState, useRef, useEffect } from "react"; +import { LuChevronDown, LuCheck, LuSearch } from "react-icons/lu"; +import { TARGET_LANG_OPTIONS, type LanguageOption } from "./AiLanguages"; + +interface AiLanguageSelectProps { + value: string; + onChange: (value: string) => void; +} + +export function AiLanguageSelect({ value, onChange }: AiLanguageSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [search, setSearch] = useState(""); + const containerRef = useRef(null); + const inputRef = useRef(null); + + const selected = TARGET_LANG_OPTIONS.find((l) => l.value === value) ?? { + value, + label: value.toUpperCase(), + native: value, + }; + + const filtered = TARGET_LANG_OPTIONS.filter( + (l) => + l.label.toLowerCase().includes(search.toLowerCase()) || + l.native.toLowerCase().includes(search.toLowerCase()) || + l.value.toLowerCase().includes(search.toLowerCase()), + ); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + if (isOpen) { + document.addEventListener("mousedown", handleClickOutside); + inputRef.current?.focus(); + } + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen]); + + const handleSelect = (lang: LanguageOption) => { + onChange(lang.value); + setIsOpen(false); + setSearch(""); + }; + + return ( +
+ + + {isOpen && ( +
+
+ + setSearch(e.target.value)} + className="w-full bg-transparent text-xs text-white placeholder:text-white/30 focus:outline-none" + /> +
+ +
+ {filtered.length === 0 ? ( +
No language found
+ ) : ( + filtered.map((lang) => { + const isCurrent = lang.value === value; + return ( + + ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/src/features/settings/ui/tabs/AiLanguages.ts b/src/features/settings/ui/tabs/AiLanguages.ts new file mode 100644 index 000000000..c3086a309 --- /dev/null +++ b/src/features/settings/ui/tabs/AiLanguages.ts @@ -0,0 +1,30 @@ +export interface LanguageOption { + value: string; + label: string; + native: string; +} + +export const TARGET_LANG_OPTIONS: LanguageOption[] = [ + { value: "vi", label: "Vietnamese", native: "Tiếng Việt" }, + { value: "en", label: "English", native: "English" }, + { value: "ja", label: "Japanese", native: "日本語" }, + { value: "ko", label: "Korean", native: "한국어" }, + { value: "zh", label: "Chinese (Simplified)", native: "简体中文" }, + { value: "zh-TW", label: "Chinese (Traditional)", native: "繁體中文" }, + { value: "es", label: "Spanish", native: "Español" }, + { value: "fr", label: "French", native: "Français" }, + { value: "de", label: "German", native: "Deutsch" }, + { value: "ru", label: "Russian", native: "Русский" }, + { value: "pt", label: "Portuguese", native: "Português" }, + { value: "it", label: "Italian", native: "Italiano" }, + { value: "id", label: "Indonesian", native: "Bahasa Indonesia" }, + { value: "th", label: "Thai", native: "ไทย" }, + { value: "hi", label: "Hindi", native: "हिन्दी" }, + { value: "ar", label: "Arabic", native: "العربية" }, + { value: "tr", label: "Turkish", native: "Türkçe" }, + { value: "tl", label: "Filipino", native: "Tagalog" }, + { value: "pl", label: "Polish", native: "Polski" }, + { value: "nl", label: "Dutch", native: "Nederlands" }, + { value: "ms", label: "Malay", native: "Bahasa Melayu" }, + { value: "sv", label: "Swedish", native: "Svenska" }, +]; diff --git a/src/features/settings/ui/tabs/AiProviderCards.tsx b/src/features/settings/ui/tabs/AiProviderCards.tsx new file mode 100644 index 000000000..f24228dd8 --- /dev/null +++ b/src/features/settings/ui/tabs/AiProviderCards.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from "react"; +import { SiGooglegemini, SiDeepseek, SiGoogletranslate } from "react-icons/si"; +import { TbBrandOpenai } from "react-icons/tb"; +import { LuServer, LuCheck } from "react-icons/lu"; +import type { AiProvider } from "@/features/ai-translate"; + +interface ProviderItem { + id: AiProvider; + name: string; + badge?: string; + subtitle: string; + icon: ReactNode; +} + +const PROVIDERS: ProviderItem[] = [ + { + id: "gemini", + name: "Gemini", + badge: "Recommended", + subtitle: "Google Gemini 2.0", + icon: , + }, + { + id: "openai", + name: "OpenAI", + subtitle: "GPT-4o / o3-mini", + icon: , + }, + { + id: "deepseek", + name: "DeepSeek", + subtitle: "V3 / R1 Reasoner", + icon: , + }, + { + id: "openai_compatible", + name: "Compatible", + subtitle: "Custom API / Local", + icon: , + }, + { + id: "google_translate", + name: "Free", + subtitle: "No API key needed", + icon: , + }, +]; + +interface AiProviderCardsProps { + value: AiProvider; + onChange: (provider: AiProvider) => void; +} + +export function AiProviderCards({ value, onChange }: AiProviderCardsProps) { + return ( +
+ {PROVIDERS.map((item) => { + const isSelected = item.id === value; + return ( + + ); + })} +
+ ); +} diff --git a/src/features/settings/ui/tabs/AiTab.tsx b/src/features/settings/ui/tabs/AiTab.tsx index cb576af26..0306cb35f 100644 --- a/src/features/settings/ui/tabs/AiTab.tsx +++ b/src/features/settings/ui/tabs/AiTab.tsx @@ -1,18 +1,9 @@ import { useAiTranslateStore } from "@/features/ai-translate"; -import { SettingRow, SettingSection, SegmentedControl } from "../controls"; +import { SettingRow, SettingSection } from "../controls"; import { ToggleSwitch } from "@/shared/ui"; +import { AiLanguageSelect } from "./AiLanguageSelect"; import { AiTabModelConfig } from "./AiTabModelConfig"; -const TARGET_LANG_OPTIONS = [ - { value: "en", label: "English" }, - { value: "vi", label: "Vietnamese" }, - { value: "ja", label: "Japanese" }, - { value: "ko", label: "Korean" }, - { value: "zh", label: "Chinese" }, - { value: "fr", label: "French" }, - { value: "es", label: "Spanish" }, -]; - export function AiTab({ searchQuery }: { searchQuery?: string }) { const { showTranslation, @@ -68,11 +59,9 @@ export function AiTab({ searchQuery }: { searchQuery?: string }) { description="Select the language for bilingual subtitles and singable adaptation" searchQuery={searchQuery} control={ - } /> diff --git a/src/features/settings/ui/tabs/AiTabModelConfig.tsx b/src/features/settings/ui/tabs/AiTabModelConfig.tsx index 9e47425e9..66caa3588 100644 --- a/src/features/settings/ui/tabs/AiTabModelConfig.tsx +++ b/src/features/settings/ui/tabs/AiTabModelConfig.tsx @@ -1,140 +1,34 @@ -import { useState, useRef } from "react"; -import { - useAiTranslateStore, - translateSongLyrics, - type AiProvider, -} from "@/features/ai-translate"; -import { SettingBlock, SegmentedControl } from "../controls"; -import { showToast } from "@/shared/ui"; - -const PROVIDER_OPTIONS: { value: AiProvider; label: string }[] = [ - { value: "gemini", label: "Gemini" }, - { value: "openai", label: "OpenAI" }, - { value: "deepseek", label: "DeepSeek" }, - { value: "openai_compatible", label: "OpenAI Compatible" }, - { value: "google_translate", label: "Free (Google)" }, -]; - -function getPlaceholder(p: AiProvider): string { - if (p === "gemini") return "Default: gemini-2.5-flash (e.g. gemini-2.5-pro, gemini-2.0-flash)"; - if (p === "openai") return "Default: gpt-4o-mini (e.g. gpt-4o, o3-mini)"; - if (p === "deepseek") return "Default: deepseek-chat (e.g. deepseek-reasoner)"; - if (p === "openai_compatible") return "e.g. meta-llama/llama-3.3-70b-instruct, qwen-2.5-72b"; - return "Model name..."; -} +import { useAiTranslateStore } from "@/features/ai-translate"; +import { SettingBlock } from "../controls"; +import { AiProviderCards } from "./AiProviderCards"; +import { AiTabModelInputs } from "./AiTabModelInputs"; +import { useAiModelTest } from "./useAiModelTest"; export function AiTabModelConfig() { - const { provider, setProvider, apiKey, setApiKey, baseUrl, setBaseUrl, model, setModel, singableMode, targetLang, clearCache } = useAiTranslateStore(); - const [testResult, setTestResult] = useState(null); - const [testing, setTesting] = useState(false); - const testIdRef = useRef(0); - - const invalidateTest = () => { - testIdRef.current++; - setTesting(false); - setTestResult(null); - }; - - const handleTest = async () => { - const currentId = ++testIdRef.current; - setTesting(true); - setTestResult(null); - try { - const res = await translateSongLyrics( - ["I'm walking on sunshine, and don't it feel good!"], - { provider, apiKey: apiKey.trim() || undefined, targetLang, model: model.trim() || undefined, baseUrl: baseUrl.trim() || undefined, singableMode }, - ); - if (testIdRef.current === currentId) { - setTestResult(res?.[0] ?? "Translation error, please check your key."); - } - } catch (err: unknown) { - if (testIdRef.current === currentId) { - const message = err instanceof Error ? err.message : String(err); - setTestResult(`Error: ${message}`); - } - } finally { - if (testIdRef.current === currentId) setTesting(false); - } - }; + const { provider, setProvider } = useAiTranslateStore(); + const { testResult, testing, handleTest, invalidateTest } = useAiModelTest(); return ( - +
- { invalidateTest(); setProvider(val as AiProvider); }} - options={PROVIDER_OPTIONS} + onChange={(val) => { + invalidateTest(); + setProvider(val); + }} /> + {provider !== "google_translate" && ( -
- {provider === "openai_compatible" && ( -
- - { invalidateTest(); setBaseUrl(e.target.value); }} - className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" - /> -
- )} -
- -
- { invalidateTest(); setApiKey(e.target.value); }} - className="flex-1 px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 placeholder:text-white/30" - /> - -
-
-
- - { invalidateTest(); setModel(e.target.value); }} - className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" - /> -
- {testResult && ( -
- Test result: {testResult} -
- )} -
-
- Clear Translation Cache - Purge cached lyrics to re-translate -
- -
-
+ )}
diff --git a/src/features/settings/ui/tabs/AiTabModelInputs.tsx b/src/features/settings/ui/tabs/AiTabModelInputs.tsx new file mode 100644 index 000000000..83ec7621a --- /dev/null +++ b/src/features/settings/ui/tabs/AiTabModelInputs.tsx @@ -0,0 +1,113 @@ +import { useAiTranslateStore, type AiProvider } from "@/features/ai-translate"; +import { showToast } from "@/shared/ui"; + +function getPlaceholder(p: AiProvider): string { + if (p === "gemini") return "Default: gemini-2.0-flash (e.g. gemini-1.5-flash, gemini-2.0-pro)"; + if (p === "openai") return "Default: gpt-4o-mini (e.g. gpt-4o, o3-mini)"; + if (p === "deepseek") return "Default: deepseek-chat (e.g. deepseek-reasoner)"; + if (p === "openai_compatible") return "e.g. meta-llama/llama-3.3-70b-instruct, qwen-2.5-72b"; + return "Model name..."; +} + +interface AiTabModelInputsProps { + testResult: string | null; + testing: boolean; + onTest: () => void; + onInvalidate: () => void; +} + +export function AiTabModelInputs({ + testResult, + testing, + onTest, + onInvalidate, +}: AiTabModelInputsProps) { + const { provider, apiKey, setApiKey, baseUrl, setBaseUrl, model, setModel, clearCache } = + useAiTranslateStore(); + + return ( +
+ {provider === "openai_compatible" && ( +
+ + { + onInvalidate(); + setBaseUrl(e.target.value); + }} + className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" + /> +
+ )} +
+ +
+ { + onInvalidate(); + setApiKey(e.target.value); + }} + className="flex-1 px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 placeholder:text-white/30" + /> + +
+
+
+ + { + onInvalidate(); + setModel(e.target.value); + }} + className="px-3 py-1.5 rounded-lg bg-white/5 border border-white/15 text-sm text-white focus:outline-none focus:border-white/30 font-mono placeholder:text-white/30" + /> +
+ {testResult && ( +
+ Test result: {testResult} +
+ )} +
+
+ Clear Translation Cache + Purge cached lyrics to re-translate +
+ +
+
+ ); +} diff --git a/src/features/settings/ui/tabs/useAiModelTest.ts b/src/features/settings/ui/tabs/useAiModelTest.ts new file mode 100644 index 000000000..4f0339850 --- /dev/null +++ b/src/features/settings/ui/tabs/useAiModelTest.ts @@ -0,0 +1,60 @@ +import { useState, useRef, useEffect } from "react"; +import { useAiTranslateStore, translateSongLyrics } from "@/features/ai-translate"; + +export function useAiModelTest() { + const { provider, apiKey, baseUrl, model, singableMode, targetLang } = + useAiTranslateStore(); + const [testResult, setTestResult] = useState(null); + const [testing, setTesting] = useState(false); + const testIdRef = useRef(0); + const prevTargetLangRef = useRef(targetLang); + const prevSingableModeRef = useRef(singableMode); + + const invalidateTest = () => { + testIdRef.current++; + setTesting(false); + setTestResult(null); + }; + + useEffect(() => { + if ( + prevTargetLangRef.current !== targetLang || + prevSingableModeRef.current !== singableMode + ) { + prevTargetLangRef.current = targetLang; + prevSingableModeRef.current = singableMode; + invalidateTest(); + } + }, [targetLang, singableMode]); + + const handleTest = async () => { + const currentId = ++testIdRef.current; + setTesting(true); + setTestResult(null); + try { + const res = await translateSongLyrics( + ["I'm walking on sunshine, and don't it feel good!"], + { + provider, + apiKey: apiKey.trim() || undefined, + targetLang, + model: model.trim() || undefined, + baseUrl: baseUrl.trim() || undefined, + singableMode, + }, + ); + if (testIdRef.current === currentId) { + setTestResult(res?.[0] ?? "Translation error, please check your key."); + } + } catch (err: unknown) { + if (testIdRef.current === currentId) { + const message = err instanceof Error ? err.message : String(err); + setTestResult(`Error: ${message}`); + } + } finally { + if (testIdRef.current === currentId) setTesting(false); + } + }; + + return { testResult, testing, handleTest, invalidateTest }; +} From 8d556f014c1aa1f4dbe94d382707c5e1cc004c12 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 20:55:32 +0700 Subject: [PATCH 09/11] feat(ai-translate): add Claude and OpenRouter providers, fix provider card layout and badge persistence --- .../services/aiTranslateService.test.ts | 14 ++- .../services/aiTranslateService.ts | 20 +++++ .../services/claudeProvider.test.ts | 52 +++++++++++ .../ai-translate/services/claudeProvider.ts | 86 +++++++++++++++++++ .../ai-translate/services/openAiProvider.ts | 16 ++++ src/features/ai-translate/types.ts | 2 + .../settings/ui/tabs/AiProviderCards.tsx | 54 ++++++++---- .../settings/ui/tabs/AiTabModelInputs.tsx | 2 + 8 files changed, 227 insertions(+), 19 deletions(-) create mode 100644 src/features/ai-translate/services/claudeProvider.test.ts create mode 100644 src/features/ai-translate/services/claudeProvider.ts diff --git a/src/features/ai-translate/services/aiTranslateService.test.ts b/src/features/ai-translate/services/aiTranslateService.test.ts index f3136dd2c..fbf7709b8 100644 --- a/src/features/ai-translate/services/aiTranslateService.test.ts +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -51,10 +51,18 @@ describe("AI Lyrics Translation Service", () => { expect(targetLangs.every((tl) => tl === "vi")).toBe(true); }); - it("throws when API key is missing for gemini, openai, or deepseek", async () => { - for (const provider of ["gemini", "openai", "deepseek"] as const) { + it("throws when API key is missing for api key required providers", async () => { + for (const provider of ["gemini", "claude", "openai", "deepseek", "openrouter"] as const) { const name = - provider === "gemini" ? "Gemini" : provider === "openai" ? "OpenAI" : "DeepSeek"; + provider === "gemini" + ? "Gemini" + : provider === "claude" + ? "Claude" + : provider === "openai" + ? "OpenAI" + : provider === "deepseek" + ? "DeepSeek" + : "OpenRouter"; await expect( translateSongLyrics(["Hello"], { provider, targetLang: "vi" }), ).rejects.toThrow(`API key is required for ${name}`); diff --git a/src/features/ai-translate/services/aiTranslateService.ts b/src/features/ai-translate/services/aiTranslateService.ts index 94ab9a988..e2a5caff0 100644 --- a/src/features/ai-translate/services/aiTranslateService.ts +++ b/src/features/ai-translate/services/aiTranslateService.ts @@ -4,11 +4,17 @@ import { translateWithGemini, romanizeWithGemini, } from "./geminiProvider"; +import { + translateWithClaude, + romanizeWithClaude, +} from "./claudeProvider"; import { translateWithOpenAI, romanizeWithOpenAI, translateWithDeepSeek, romanizeWithDeepSeek, + translateWithOpenRouter, + romanizeWithOpenRouter, translateWithOpenAiCompatible, romanizeWithOpenAiCompatible, } from "./openAiProvider"; @@ -31,6 +37,10 @@ export async function translateSongLyrics( if (!apiKey?.trim()) throw new Error("API key is required for Gemini"); return translateWithGemini(lines, apiKey, targetLang, model, singableMode); } + if (provider === "claude") { + if (!apiKey?.trim()) throw new Error("API key is required for Claude"); + return translateWithClaude(lines, apiKey, targetLang, model, singableMode); + } if (provider === "openai") { if (!apiKey?.trim()) throw new Error("API key is required for OpenAI"); return translateWithOpenAI(lines, apiKey, targetLang, model, singableMode); @@ -39,6 +49,10 @@ export async function translateSongLyrics( if (!apiKey?.trim()) throw new Error("API key is required for DeepSeek"); return translateWithDeepSeek(lines, apiKey, targetLang, model, singableMode); } + if (provider === "openrouter") { + if (!apiKey?.trim()) throw new Error("API key is required for OpenRouter"); + return translateWithOpenRouter(lines, apiKey, targetLang, model, singableMode); + } if (provider === "openai_compatible") { return translateWithOpenAiCompatible( lines, @@ -65,12 +79,18 @@ export async function romanizeSongLyrics( if (provider === "gemini") { if (!apiKey?.trim()) throw new Error("API key is required for Gemini"); result = await romanizeWithGemini(lines, apiKey, model); + } else if (provider === "claude") { + if (!apiKey?.trim()) throw new Error("API key is required for Claude"); + result = await romanizeWithClaude(lines, apiKey, model); } else if (provider === "openai") { if (!apiKey?.trim()) throw new Error("API key is required for OpenAI"); result = await romanizeWithOpenAI(lines, apiKey, model); } else if (provider === "deepseek") { if (!apiKey?.trim()) throw new Error("API key is required for DeepSeek"); result = await romanizeWithDeepSeek(lines, apiKey, model); + } else if (provider === "openrouter") { + if (!apiKey?.trim()) throw new Error("API key is required for OpenRouter"); + result = await romanizeWithOpenRouter(lines, apiKey, model); } else if (provider === "openai_compatible") { result = await romanizeWithOpenAiCompatible( lines, diff --git a/src/features/ai-translate/services/claudeProvider.test.ts b/src/features/ai-translate/services/claudeProvider.test.ts new file mode 100644 index 000000000..0c9d2ac7b --- /dev/null +++ b/src/features/ai-translate/services/claudeProvider.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { translateWithClaude, romanizeWithClaude } from "./claudeProvider"; + +describe("claudeProvider", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("calls Claude API with proper headers and payload", async () => { + let capturedUrl = ""; + let capturedHeaders: HeadersInit | undefined; + interface ClaudePayload { + model?: string; + messages?: Array<{ content?: string }>; + } + let capturedBody: ClaudePayload = {}; + + global.fetch = vi.fn(async (input, init) => { + capturedUrl = String(input); + capturedHeaders = init?.headers; + capturedBody = JSON.parse(String(init?.body ?? "{}")) as ClaudePayload; + return new Response( + JSON.stringify({ + content: [{ type: "text", text: "Xin chào\nTạm biệt" }], + }), + { status: 200 }, + ); + }); + + const res = await translateWithClaude(["Hello", "Goodbye"], "sk-ant-test", "vi"); + expect(capturedUrl).toBe("https://api.anthropic.com/v1/messages"); + const headers = new Headers(capturedHeaders); + expect(headers.get("x-api-key")).toBe("sk-ant-test"); + expect(headers.get("anthropic-version")).toBe("2023-06-01"); + expect(capturedBody.model).toBe("claude-3-5-haiku-20241022"); + expect(res).toEqual(["Xin chào", "Tạm biệt"]); + }); + + it("handles romanization with Claude", async () => { + global.fetch = vi.fn(async () => + new Response( + JSON.stringify({ + content: [{ type: "text", text: "Konnichiwa" }], + }), + { status: 200 }, + ), + ); + + const res = await romanizeWithClaude(["こんにちは"], "sk-ant-test"); + expect(res).toEqual(["Konnichiwa"]); + }); +}); diff --git a/src/features/ai-translate/services/claudeProvider.ts b/src/features/ai-translate/services/claudeProvider.ts new file mode 100644 index 000000000..f86322f57 --- /dev/null +++ b/src/features/ai-translate/services/claudeProvider.ts @@ -0,0 +1,86 @@ +import { + SONG_TRANSLATION_SYSTEM_PROMPT, + SONG_ROMANIZATION_SYSTEM_PROMPT, +} from "../prompts"; +import { matchLineCount } from "./lineMatching"; + +const DEFAULT_CLAUDE_MODEL = "claude-3-5-haiku-20241022"; +const REQUEST_TIMEOUT_MS = 60_000; + +async function callClaudeApi( + systemPrompt: string, + userText: string, + apiKey: string, + customModel?: string, +): Promise { + const model = customModel?.trim() || DEFAULT_CLAUDE_MODEL; + const url = "https://api.anthropic.com/v1/messages"; + + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": apiKey.trim(), + "anthropic-version": "2023-06-01", + "anthropic-dangerous-direct-browser-access": "true", + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + body: JSON.stringify({ + model, + max_tokens: 4096, + system: systemPrompt, + messages: [{ role: "user", content: userText }], + }), + }); + } catch (err: unknown) { + if ( + err instanceof Error && + (err.name === "TimeoutError" || err.message.toLowerCase().includes("timeout")) + ) { + throw new Error(`Claude request timed out (${REQUEST_TIMEOUT_MS / 1000}s). Please retry.`); + } + throw err; + } + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Claude API error (${model}: ${response.status}): ${errorText}`); + } + + const data = await response.json(); + const text = data.content?.[0]?.text; + if (!text) { + throw new Error(`Empty response from Claude model ${model}`); + } + return text; +} + +export async function translateWithClaude( + lines: string[], + apiKey: string, + targetLang: string, + customModel?: string, + singable = false, +): Promise { + const systemPrompt = SONG_TRANSLATION_SYSTEM_PROMPT("auto", targetLang, singable); + const text = await callClaudeApi(systemPrompt, lines.join("\n"), apiKey, customModel); + const translatedLines = text.split("\n").map((l: string) => l.trim()); + return matchLineCount(translatedLines, lines); +} + +export async function romanizeWithClaude( + lines: string[], + apiKey: string, + customModel?: string, +): Promise { + const text = await callClaudeApi( + SONG_ROMANIZATION_SYSTEM_PROMPT, + lines.join("\n"), + apiKey, + customModel, + ); + const romanizedLines = text.split("\n").map((l: string) => l.trim()); + return matchLineCount(romanizedLines, lines); +} diff --git a/src/features/ai-translate/services/openAiProvider.ts b/src/features/ai-translate/services/openAiProvider.ts index 84bfe9f2b..a39fa6153 100644 --- a/src/features/ai-translate/services/openAiProvider.ts +++ b/src/features/ai-translate/services/openAiProvider.ts @@ -127,3 +127,19 @@ export function romanizeWithDeepSeek( ): Promise { return romanizeWithOpenAiCompatible(lines, apiKey, "https://api.deepseek.com", model || "deepseek-chat"); } + +export function translateWithOpenRouter( + lines: string[], apiKey: string, targetLang: string, model?: string, singable = false, +): Promise { + return translateWithOpenAiCompatible( + lines, apiKey, targetLang, "https://openrouter.ai/api/v1", model || "google/gemini-2.0-flash-001", singable, + ); +} + +export function romanizeWithOpenRouter( + lines: string[], apiKey: string, model?: string, +): Promise { + return romanizeWithOpenAiCompatible( + lines, apiKey, "https://openrouter.ai/api/v1", model || "google/gemini-2.0-flash-001", + ); +} diff --git a/src/features/ai-translate/types.ts b/src/features/ai-translate/types.ts index 3940d4b73..040bcf297 100644 --- a/src/features/ai-translate/types.ts +++ b/src/features/ai-translate/types.ts @@ -1,7 +1,9 @@ export type AiProvider = | "gemini" + | "claude" | "openai" | "deepseek" + | "openrouter" | "openai_compatible" | "google_translate"; diff --git a/src/features/settings/ui/tabs/AiProviderCards.tsx b/src/features/settings/ui/tabs/AiProviderCards.tsx index f24228dd8..1311cf3a2 100644 --- a/src/features/settings/ui/tabs/AiProviderCards.tsx +++ b/src/features/settings/ui/tabs/AiProviderCards.tsx @@ -1,5 +1,11 @@ import type { ReactNode } from "react"; -import { SiGooglegemini, SiDeepseek, SiGoogletranslate } from "react-icons/si"; +import { + SiGooglegemini, + SiDeepseek, + SiGoogletranslate, + SiAnthropic, + SiOpenrouter, +} from "react-icons/si"; import { TbBrandOpenai } from "react-icons/tb"; import { LuServer, LuCheck } from "react-icons/lu"; import type { AiProvider } from "@/features/ai-translate"; @@ -20,6 +26,12 @@ const PROVIDERS: ProviderItem[] = [ subtitle: "Google Gemini 2.0", icon: , }, + { + id: "claude", + name: "Claude", + subtitle: "Anthropic 3.5 Haiku", + icon: , + }, { id: "openai", name: "OpenAI", @@ -32,10 +44,16 @@ const PROVIDERS: ProviderItem[] = [ subtitle: "V3 / R1 Reasoner", icon: , }, + { + id: "openrouter", + name: "OpenRouter", + subtitle: "Unified AI Gateway", + icon: , + }, { id: "openai_compatible", name: "Compatible", - subtitle: "Custom API / Local", + subtitle: "Custom API / Ollama", icon: , }, { @@ -53,7 +71,7 @@ interface AiProviderCardsProps { export function AiProviderCards({ value, onChange }: AiProviderCardsProps) { return ( -
+
{PROVIDERS.map((item) => { const isSelected = item.id === value; return ( @@ -63,31 +81,35 @@ export function AiProviderCards({ value, onChange }: AiProviderCardsProps) { role="radio" aria-checked={isSelected} onClick={() => onChange(item.id)} - className={`group relative flex flex-col p-3 rounded-xl border text-left transition-all cursor-pointer select-none ${ + className={`group relative flex flex-col justify-between min-h-[105px] p-3 rounded-xl border text-left transition-all cursor-pointer select-none ${ isSelected ? "bg-white/10 border-white/40 ring-1 ring-white/30 shadow-md" : "bg-white/[0.03] border-white/10 hover:bg-white/[0.07] hover:border-white/20 text-white/70" }`} > -
-
+ {isSelected && ( +
+ +
+ )} + +
+
{item.icon}
- {isSelected ? ( -
- -
- ) : item.badge ? ( + {item.badge && ( {item.badge} - ) : null} + )}
- {item.name} - - {item.subtitle} - +
+ {item.name} + + {item.subtitle} + +
); })} diff --git a/src/features/settings/ui/tabs/AiTabModelInputs.tsx b/src/features/settings/ui/tabs/AiTabModelInputs.tsx index 83ec7621a..f6d6cc932 100644 --- a/src/features/settings/ui/tabs/AiTabModelInputs.tsx +++ b/src/features/settings/ui/tabs/AiTabModelInputs.tsx @@ -3,8 +3,10 @@ import { showToast } from "@/shared/ui"; function getPlaceholder(p: AiProvider): string { if (p === "gemini") return "Default: gemini-2.0-flash (e.g. gemini-1.5-flash, gemini-2.0-pro)"; + if (p === "claude") return "Default: claude-3-5-haiku-20241022 (e.g. claude-3-5-sonnet-20241022)"; if (p === "openai") return "Default: gpt-4o-mini (e.g. gpt-4o, o3-mini)"; if (p === "deepseek") return "Default: deepseek-chat (e.g. deepseek-reasoner)"; + if (p === "openrouter") return "Default: google/gemini-2.0-flash-001 (e.g. anthropic/claude-3.5-haiku)"; if (p === "openai_compatible") return "e.g. meta-llama/llama-3.3-70b-instruct, qwen-2.5-72b"; return "Model name..."; } From 0fdbe243dbf5b547d07f87d34b04c9b0fda55b82 Mon Sep 17 00:00:00 2001 From: bchhngsaygez Date: Mon, 2 Nov 2026 21:01:07 +0700 Subject: [PATCH 10/11] feat(ai-translate): add Ollama provider for balanced 4x2 grid and fix recommended badge overlap --- .../services/aiTranslateService.ts | 9 ++++ .../services/ollamaProvider.test.ts | 45 +++++++++++++++++++ .../ai-translate/services/ollamaProvider.ts | 43 ++++++++++++++++++ src/features/ai-translate/types.ts | 1 + .../settings/ui/tabs/AiProviderCards.tsx | 29 +++++++----- .../settings/ui/tabs/AiTabModelInputs.tsx | 15 +++++-- 6 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 src/features/ai-translate/services/ollamaProvider.test.ts create mode 100644 src/features/ai-translate/services/ollamaProvider.ts diff --git a/src/features/ai-translate/services/aiTranslateService.ts b/src/features/ai-translate/services/aiTranslateService.ts index e2a5caff0..791013459 100644 --- a/src/features/ai-translate/services/aiTranslateService.ts +++ b/src/features/ai-translate/services/aiTranslateService.ts @@ -18,6 +18,10 @@ import { translateWithOpenAiCompatible, romanizeWithOpenAiCompatible, } from "./openAiProvider"; +import { + translateWithOllama, + romanizeWithOllama, +} from "./ollamaProvider"; import { translateWithGoogleFree, romanizeWithGoogleFree, @@ -53,6 +57,9 @@ export async function translateSongLyrics( if (!apiKey?.trim()) throw new Error("API key is required for OpenRouter"); return translateWithOpenRouter(lines, apiKey, targetLang, model, singableMode); } + if (provider === "ollama") { + return translateWithOllama(lines, apiKey, targetLang, baseUrl, model, singableMode); + } if (provider === "openai_compatible") { return translateWithOpenAiCompatible( lines, @@ -91,6 +98,8 @@ export async function romanizeSongLyrics( } else if (provider === "openrouter") { if (!apiKey?.trim()) throw new Error("API key is required for OpenRouter"); result = await romanizeWithOpenRouter(lines, apiKey, model); + } else if (provider === "ollama") { + result = await romanizeWithOllama(lines, apiKey, baseUrl, model); } else if (provider === "openai_compatible") { result = await romanizeWithOpenAiCompatible( lines, diff --git a/src/features/ai-translate/services/ollamaProvider.test.ts b/src/features/ai-translate/services/ollamaProvider.test.ts new file mode 100644 index 000000000..281c1f7f7 --- /dev/null +++ b/src/features/ai-translate/services/ollamaProvider.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { translateWithOllama, romanizeWithOllama } from "./ollamaProvider"; + +describe("ollamaProvider", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("calls local Ollama endpoint without requiring an API key", async () => { + let capturedUrl = ""; + global.fetch = vi.fn(async (input) => { + capturedUrl = String(input); + return new Response( + JSON.stringify({ + choices: [{ message: { content: "Xin chào thế giới" } }], + }), + { status: 200 }, + ); + }); + + const res = await translateWithOllama(["Hello world"], undefined, "vi"); + expect(capturedUrl).toBe("http://localhost:11434/v1/chat/completions"); + expect(res).toEqual(["Xin chào thế giới"]); + }); + + it("romanizes using custom model when specified", async () => { + interface ChatPayload { + model?: string; + } + let capturedBody: ChatPayload = {}; + global.fetch = vi.fn(async (_input, init) => { + capturedBody = JSON.parse(String(init?.body ?? "{}")) as ChatPayload; + return new Response( + JSON.stringify({ + choices: [{ message: { content: "Konnichiwa" } }], + }), + { status: 200 }, + ); + }); + + const res = await romanizeWithOllama(["こんにちは"], undefined, undefined, "qwen2.5:7b"); + expect(capturedBody.model).toBe("qwen2.5:7b"); + expect(res).toEqual(["Konnichiwa"]); + }); +}); diff --git a/src/features/ai-translate/services/ollamaProvider.ts b/src/features/ai-translate/services/ollamaProvider.ts new file mode 100644 index 000000000..124fa019a --- /dev/null +++ b/src/features/ai-translate/services/ollamaProvider.ts @@ -0,0 +1,43 @@ +import { + translateWithOpenAiCompatible, + romanizeWithOpenAiCompatible, +} from "./openAiProvider"; + +const DEFAULT_OLLAMA_BASE_URL = "http://localhost:11434/v1"; +const DEFAULT_OLLAMA_MODEL = "llama3.2"; + +export function translateWithOllama( + lines: string[], + apiKey: string | undefined, + targetLang: string, + baseUrl?: string, + model?: string, + singable = false, +): Promise { + const url = baseUrl?.trim() || DEFAULT_OLLAMA_BASE_URL; + const chosenModel = model?.trim() || DEFAULT_OLLAMA_MODEL; + return translateWithOpenAiCompatible( + lines, + apiKey?.trim() || "ollama", + targetLang, + url, + chosenModel, + singable, + ); +} + +export function romanizeWithOllama( + lines: string[], + apiKey: string | undefined, + baseUrl?: string, + model?: string, +): Promise { + const url = baseUrl?.trim() || DEFAULT_OLLAMA_BASE_URL; + const chosenModel = model?.trim() || DEFAULT_OLLAMA_MODEL; + return romanizeWithOpenAiCompatible( + lines, + apiKey?.trim() || "ollama", + url, + chosenModel, + ); +} diff --git a/src/features/ai-translate/types.ts b/src/features/ai-translate/types.ts index 040bcf297..ad7cca912 100644 --- a/src/features/ai-translate/types.ts +++ b/src/features/ai-translate/types.ts @@ -4,6 +4,7 @@ export type AiProvider = | "openai" | "deepseek" | "openrouter" + | "ollama" | "openai_compatible" | "google_translate"; diff --git a/src/features/settings/ui/tabs/AiProviderCards.tsx b/src/features/settings/ui/tabs/AiProviderCards.tsx index 1311cf3a2..94201140f 100644 --- a/src/features/settings/ui/tabs/AiProviderCards.tsx +++ b/src/features/settings/ui/tabs/AiProviderCards.tsx @@ -5,6 +5,7 @@ import { SiGoogletranslate, SiAnthropic, SiOpenrouter, + SiOllama, } from "react-icons/si"; import { TbBrandOpenai } from "react-icons/tb"; import { LuServer, LuCheck } from "react-icons/lu"; @@ -50,10 +51,16 @@ const PROVIDERS: ProviderItem[] = [ subtitle: "Unified AI Gateway", icon: , }, + { + id: "ollama", + name: "Ollama", + subtitle: "Local & Offline AI", + icon: , + }, { id: "openai_compatible", name: "Compatible", - subtitle: "Custom API / Ollama", + subtitle: "Custom API / Proxy", icon: , }, { @@ -93,19 +100,19 @@ export function AiProviderCards({ value, onChange }: AiProviderCardsProps) {
)} -
-
- {item.icon} -
- {item.badge && ( - - {item.badge} - - )} +
+ {item.icon}
- {item.name} +
+ {item.name} + {item.badge && ( + + {item.badge} + + )} +
{item.subtitle} diff --git a/src/features/settings/ui/tabs/AiTabModelInputs.tsx b/src/features/settings/ui/tabs/AiTabModelInputs.tsx index f6d6cc932..f135b65ce 100644 --- a/src/features/settings/ui/tabs/AiTabModelInputs.tsx +++ b/src/features/settings/ui/tabs/AiTabModelInputs.tsx @@ -7,6 +7,7 @@ function getPlaceholder(p: AiProvider): string { if (p === "openai") return "Default: gpt-4o-mini (e.g. gpt-4o, o3-mini)"; if (p === "deepseek") return "Default: deepseek-chat (e.g. deepseek-reasoner)"; if (p === "openrouter") return "Default: google/gemini-2.0-flash-001 (e.g. anthropic/claude-3.5-haiku)"; + if (p === "ollama") return "Default: llama3.2 (e.g. qwen2.5:7b, deepseek-r1:8b)"; if (p === "openai_compatible") return "e.g. meta-llama/llama-3.3-70b-instruct, qwen-2.5-72b"; return "Model name..."; } @@ -29,7 +30,7 @@ export function AiTabModelInputs({ return (
- {provider === "openai_compatible" && ( + {(provider === "openai_compatible" || provider === "ollama") && (