diff --git a/src/features/ai-translate/aiTranslate.test.ts b/src/features/ai-translate/aiTranslate.test.ts new file mode 100644 index 00000000..74e4d668 --- /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 00000000..2500cb26 --- /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 00000000..e92413ee --- /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 00000000..fbf7709b --- /dev/null +++ b/src/features/ai-translate/services/aiTranslateService.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + translateSongLyrics, + romanizeSongLyrics, +} from "./aiTranslateService"; + +function mockFetchJson(data: unknown): typeof fetch { + return vi.fn(async () => + new Response(JSON.stringify(data), { status: 200 }), + ); +} + +function mockGoogleTranslate( + dictionary: Record, + onQuery?: (q: string, tl: string | null) => void, +): typeof fetch { + return vi.fn(async (input: RequestInfo | URL) => { + 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 }); + }); +} + +describe("AI Lyrics Translation Service", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("uses free translator for google_translate provider", async () => { + 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"], { + provider: "google_translate", + targetLang: "vi", + }); + + 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 api key required providers", async () => { + for (const provider of ["gemini", "claude", "openai", "deepseek", "openrouter"] as const) { + const name = + 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}`); + 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 () => { + global.fetch = mockFetchJson({ + candidates: [{ content: { parts: [{ text: "Dòng một\nDòng ba" }] } }], + }); + + 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 () => { + 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 new Response(JSON.stringify([[[null, null, null, result]]]), { status: 200 }); + }); + + 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 = ""; + 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 ?? "{}")) 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", + 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"); + const headers = new Headers(capturedHeaders); + expect(headers.get("Authorization")).toBe("Bearer custom-token-123"); + expect(capturedBody.model).toBe("llama-3.3-70b-versatile"); + 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/aiTranslateService.ts b/src/features/ai-translate/services/aiTranslateService.ts new file mode 100644 index 00000000..79101345 --- /dev/null +++ b/src/features/ai-translate/services/aiTranslateService.ts @@ -0,0 +1,115 @@ +import type { TranslationConfig } from "../types"; +import { dropUnchangedRomanization } from "./lineMatching"; +import { + translateWithGemini, + romanizeWithGemini, +} from "./geminiProvider"; +import { + translateWithClaude, + romanizeWithClaude, +} from "./claudeProvider"; +import { + translateWithOpenAI, + romanizeWithOpenAI, + translateWithDeepSeek, + romanizeWithDeepSeek, + translateWithOpenRouter, + romanizeWithOpenRouter, + translateWithOpenAiCompatible, + romanizeWithOpenAiCompatible, +} from "./openAiProvider"; +import { + translateWithOllama, + romanizeWithOllama, +} from "./ollamaProvider"; +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") { + 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); + } + if (provider === "deepseek") { + 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 === "ollama") { + return translateWithOllama(lines, apiKey, targetLang, baseUrl, 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") { + 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 === "ollama") { + result = await romanizeWithOllama(lines, apiKey, baseUrl, 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/claudeProvider.test.ts b/src/features/ai-translate/services/claudeProvider.test.ts new file mode 100644 index 00000000..0c9d2ac7 --- /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 00000000..f86322f5 --- /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/geminiProvider.ts b/src/features/ai-translate/services/geminiProvider.ts new file mode 100644 index 00000000..96e99457 --- /dev/null +++ b/src/features/ai-translate/services/geminiProvider.ts @@ -0,0 +1,93 @@ +import { + SONG_TRANSLATION_SYSTEM_PROMPT, + SONG_ROMANIZATION_SYSTEM_PROMPT, +} from "../prompts"; +import { matchLineCount } from "./lineMatching"; + +const DEFAULT_GEMINI_MODELS = [ + "gemini-2.0-flash", + "gemini-1.5-flash", +]; + +const REQUEST_TIMEOUT_MS = 60_000; + +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; + + 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" }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + 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: unknown) { + const error = err instanceof Error ? err : new Error(String(err)); + lastError = + error.name === "TimeoutError" || error.message.toLowerCase().includes("timeout") + ? new Error(`Gemini request timed out (${REQUEST_TIMEOUT_MS / 1000}s). Please retry.`) + : error; + if ( + lastError.message.includes("API_KEY_INVALID") || + lastError.message.includes("PERMISSION_DENIED") + ) { + throw lastError; + } + } + } + + 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 00000000..ccc97cf2 --- /dev/null +++ b/src/features/ai-translate/services/googleFreeProvider.ts @@ -0,0 +1,55 @@ +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(); + type GoogleSegment = [string | null, ...unknown[]] | null; + return ((data?.[0] as GoogleSegment[]) || []) + .map((item) => 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 ""; + 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(); + } + } + 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.test.ts b/src/features/ai-translate/services/lineMatching.test.ts new file mode 100644 index 00000000..a4f4a90b --- /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", + ]); + }); +}); diff --git a/src/features/ai-translate/services/lineMatching.ts b/src/features/ai-translate/services/lineMatching.ts new file mode 100644 index 00000000..cfdc6b97 --- /dev/null +++ b/src/features/ai-translate/services/lineMatching.ts @@ -0,0 +1,84 @@ +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) { + const hasMissingLine = original.some( + (orig, index) => orig.trim().length > 0 && trimmed[index].length === 0, + ); + 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], + ); + } + } + + 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 ""; + return cleaned[cursor++]; + }); +} + +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/ollamaProvider.test.ts b/src/features/ai-translate/services/ollamaProvider.test.ts new file mode 100644 index 00000000..281c1f7f --- /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 00000000..124fa019 --- /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/services/openAiProvider.ts b/src/features/ai-translate/services/openAiProvider.ts new file mode 100644 index 00000000..a39fa615 --- /dev/null +++ b/src/features/ai-translate/services/openAiProvider.ts @@ -0,0 +1,145 @@ +import { + SONG_TRANSLATION_SYSTEM_PROMPT, + SONG_ROMANIZATION_SYSTEM_PROMPT, +} from "../prompts"; +import { matchLineCount } from "./lineMatching"; + +const REQUEST_TIMEOUT_MS = 60_000; + +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()}`; + } + + 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(); + 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 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", + cleanBase, + ); + 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 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", + cleanBase, + ); + return matchLineCount(text.split("\n").map((l: string) => l.trim()), lines); +} + +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); +} + +export function romanizeWithOpenAI( + lines: string[], apiKey: string, model?: string, +): Promise { + return romanizeWithOpenAiCompatible(lines, apiKey, "https://api.openai.com/v1", model || "gpt-4o-mini"); +} + +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); +} + +export function romanizeWithDeepSeek( + lines: string[], apiKey: string, model?: string, +): 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/store/aiTranslateActions.ts b/src/features/ai-translate/store/aiTranslateActions.ts new file mode 100644 index 00000000..4f94bc48 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateActions.ts @@ -0,0 +1,140 @@ +import { + translateSongLyrics, + romanizeSongLyrics, +} from "../services/aiTranslateService"; +import { debouncedStorage } from "@/shared/utils/storage"; +import { + trimCache, + getTranslationCacheKey, + getRomanizationCacheKey, +} from "./aiTranslateCache"; +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; +} + +function isCachedMatch(existing?: string[], sources?: string[], lines?: string[]): boolean { + if (!existing || !lines || existing.length !== lines.length) return false; + return !sources || sources.every((l, i) => l.trim() === lines[i]?.trim()); +} + +function saveDualCache( + cache: Record, k1: string, k2: string, val: string[], +): Record { + return trimCache({ ...cache, [k1]: val, [k2]: val }); +} + +export function resetActiveRequestCounts(): void { + activeTranslationCount = 0; + activeRomanizationCount = 0; +} + +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 fallbackKey = getTranslationCacheKey(trackId, targetLang, singableMode); + const existing = get().translationsByTrackId[cacheKey] || get().translationsByTrackId[fallbackKey]; + const sources = get().sourceLinesByCacheKey?.[cacheKey] || get().sourceLinesByCacheKey?.[fallbackKey]; + if (isCachedMatch(existing, sources, lines)) return existing; + + activeTranslationCount++; + 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; + activeTranslationCount = Math.max(0, activeTranslationCount - 1); + set((s) => ({ + isTranslating: activeTranslationCount > 0, + translationsByTrackId: saveDualCache(s.translationsByTrackId, cacheKey, fallbackKey, translated), + sourceLinesByCacheKey: saveDualCache(s.sourceLinesByCacheKey, cacheKey, fallbackKey, lines), + })); + debouncedStorage.flush(); + return translated; + } catch (err: unknown) { + if (currentGen() !== requestGen) return null; + activeTranslationCount = Math.max(0, activeTranslationCount - 1); + const errorMsg = formatErrorMessage(err, "Translation failed"); + set((s) => ({ ...s, isTranslating: activeTranslationCount > 0, error: errorMsg })); + 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 fallbackKey = getRomanizationCacheKey(trackId); + const existing = get().romanizationsByTrackId[cacheKey] || get().romanizationsByTrackId[fallbackKey]; + const sources = get().sourceRomLinesByCacheKey?.[cacheKey] || get().sourceRomLinesByCacheKey?.[fallbackKey]; + if (isCachedMatch(existing, sources, lines)) return existing; + + activeRomanizationCount++; + 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; + activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); + const hasNonEmpty = romanized.some((l) => l.trim().length > 0); + if (hasNonEmpty) { + set((s) => ({ + isRomanizing: activeRomanizationCount > 0, + romanizationsByTrackId: saveDualCache(s.romanizationsByTrackId, cacheKey, fallbackKey, romanized), + sourceRomLinesByCacheKey: saveDualCache(s.sourceRomLinesByCacheKey, cacheKey, fallbackKey, lines), + })); + debouncedStorage.flush(); + } else { + set((s) => ({ ...s, isRomanizing: activeRomanizationCount > 0 })); + } + return romanized; + } catch (err: unknown) { + if (currentGen() !== requestGen) return null; + activeRomanizationCount = Math.max(0, activeRomanizationCount - 1); + const errorMsg = formatErrorMessage(err, "Romanization failed"); + set((s) => ({ ...s, isRomanizing: activeRomanizationCount > 0, error: errorMsg })); + 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 00000000..7a1535f2 --- /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 00000000..2d14d135 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateStore.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + useAiTranslateStore, + getTranslationCacheKey, + getRomanizationCacheKey, + sanitizeAiTranslateForExport, +} from "./aiTranslateStore"; + +function mockGoogleTranslate(dictionary: Record): typeof fetch { + return vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + const q = new URL(url).searchParams.get("q") ?? ""; + const translated = dictionary[q] ?? q; + return new Response(JSON.stringify([[[translated, q]]]), { status: 200 }); + }); +} + +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 () => { + const lines = ["Line 1", "Line 2"]; + const mockFetch = mockGoogleTranslate({ + "Line 1": "Dòng 1", + "Line 2": "Dòng 2", + }); + global.fetch = mockFetch; + + 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; + + 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("preserves translations and romanizations across provider/model changes and clears on clearCache", () => { + const store = useAiTranslateStore.getState(); + useAiTranslateStore.setState({ + translationsByTrackId: { "track-1": ["line 1"] }, + romanizationsByTrackId: { "track-1": ["line 1"] }, + }); + + store.setProvider("openai"); + expect(useAiTranslateStore.getState().translationsByTrackId["track-1"]).toEqual(["line 1"]); + + store.setModel("gpt-4o"); + expect(useAiTranslateStore.getState().translationsByTrackId["track-1"]).toEqual(["line 1"]); + + store.clearCache(); + expect(useAiTranslateStore.getState().translationsByTrackId).toEqual({}); + 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; + + 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: { + 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 00000000..61803166 --- /dev/null +++ b/src/features/ai-translate/store/aiTranslateStore.ts @@ -0,0 +1,141 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { debouncedStorage } from "@/shared/utils/storage"; +import type { AiProvider } from "../types"; +import { + executeTrackTranslation, + executeTrackRomanization, + resetActiveRequestCounts, +} 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; + sourceLinesByCacheKey: Record; + sourceRomLinesByCacheKey: 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: "", + model: "", + singableMode: false, + targetLang: "en", + showTranslation: true, + showRomanization: true, + isTranslating: false, + isRomanizing: false, + error: null, + translationsByTrackId: {}, + romanizationsByTrackId: {}, + sourceLinesByCacheKey: {}, + sourceRomLinesByCacheKey: {}, + + setProvider: (provider) => { + configGeneration++; + resetActiveRequestCounts(); + set({ provider, error: null, isTranslating: false, isRomanizing: false }); + }, + setApiKey: (apiKey) => set({ apiKey, error: null }), + setBaseUrl: (baseUrl) => { + configGeneration++; + resetActiveRequestCounts(); + set({ baseUrl, error: null, isTranslating: false, isRomanizing: false }); + }, + setModel: (model) => { + configGeneration++; + resetActiveRequestCounts(); + set({ model, error: null, isTranslating: false, isRomanizing: false }); + }, + 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++; + resetActiveRequestCounts(); + set({ isTranslating: false, isRomanizing: false, translationsByTrackId: {}, romanizationsByTrackId: {}, sourceLinesByCacheKey: {}, sourceRomLinesByCacheKey: {} }); + debouncedStorage.flush(); + }, + + 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: 4, + storage: createJSONStorage(() => debouncedStorage), + migrate: (persisted: unknown) => { + const state = (persisted ?? {}) as Partial; + return { + ...state, + translationsByTrackId: state.translationsByTrackId ?? {}, + romanizationsByTrackId: state.romanizationsByTrackId ?? {}, + sourceLinesByCacheKey: state.sourceLinesByCacheKey ?? {}, + sourceRomLinesByCacheKey: state.sourceRomLinesByCacheKey ?? {}, + } 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, + sourceLinesByCacheKey: state.sourceLinesByCacheKey, + sourceRomLinesByCacheKey: state.sourceRomLinesByCacheKey, + }), + }, + ), +); diff --git a/src/features/ai-translate/types.ts b/src/features/ai-translate/types.ts new file mode 100644 index 00000000..ad7cca91 --- /dev/null +++ b/src/features/ai-translate/types.ts @@ -0,0 +1,25 @@ +export type AiProvider = + | "gemini" + | "claude" + | "openai" + | "deepseek" + | "openrouter" + | "ollama" + | "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/buildLines.test.ts b/src/features/lyrics/canvas/buildLines.test.ts new file mode 100644 index 00000000..c79a4eb2 --- /dev/null +++ b/src/features/lyrics/canvas/buildLines.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { buildLines, isRtl } 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"); + }); + + 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"); + }); + + 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 27023d76..a5f59429 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; @@ -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/canvas/drawLine.ts b/src/features/lyrics/canvas/drawLine.ts index a5207b67..efdc0cb3 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/layoutLines.ts b/src/features/lyrics/canvas/layoutLines.ts index c0e9b147..2d627409 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/hooks/useEnrichedLyrics.test.ts b/src/features/lyrics/hooks/useEnrichedLyrics.test.ts new file mode 100644 index 00000000..49b5f3da --- /dev/null +++ b/src/features/lyrics/hooks/useEnrichedLyrics.test.ts @@ -0,0 +1,68 @@ +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"); + }); + + 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 new file mode 100644 index 00000000..5a0f83e0 --- /dev/null +++ b/src/features/lyrics/hooks/useEnrichedLyrics.ts @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import type { Lyric } from "@braccato/parsers"; +import { + useAiTranslateStore, + getTranslationCacheKey, + getRomanizationCacheKey, +} 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 { + if (!cached) return true; + if (cached.length !== lines.length) return false; + return cached.every((line, idx) => line.trim() === lines[idx]?.trim()); +} + +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]?.trim(); + const romanizationText = trackRomanizations?.[idx]?.trim(); + + 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 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; + const key = getTranslationCacheKey(currentTrackId, targetLang, singableMode, activeProvider); + const fallbackKey = getTranslationCacheKey(currentTrackId, targetLang, singableMode); + const cached = translationsByTrackId[key] || translationsByTrackId[fallbackKey]; + if (!cached) return undefined; + const sources = sourceLinesByCacheKey[key] || sourceLinesByCacheKey[fallbackKey]; + return matchesSourceLines(sources, getLineTexts(braccatoLyrics)) ? cached : undefined; + }, [ + currentTrackId, + showTranslation, + targetLang, + singableMode, + activeProvider, + translationsByTrackId, + sourceLinesByCacheKey, + braccatoLyrics, + ]); + + const trackRomanizations = useMemo(() => { + if (!currentTrackId || !showRomanization) return undefined; + const key = getRomanizationCacheKey(currentTrackId, activeProvider); + const fallbackKey = getRomanizationCacheKey(currentTrackId); + const cached = romanizationsByTrackId[key] || romanizationsByTrackId[fallbackKey]; + if (!cached) return undefined; + const sources = sourceRomLinesByCacheKey[key] || sourceRomLinesByCacheKey[fallbackKey]; + return matchesSourceLines(sources, getLineTexts(braccatoLyrics)) ? cached : undefined; + }, [ + currentTrackId, + showRomanization, + activeProvider, + romanizationsByTrackId, + sourceRomLinesByCacheKey, + braccatoLyrics, + ]); + + return useMemo( + () => + enrichLyrics( + braccatoLyrics, + trackTranslations, + trackRomanizations, + targetLang, + ), + [braccatoLyrics, trackTranslations, trackRomanizations, targetLang], + ); +} diff --git a/src/features/lyrics/index.ts b/src/features/lyrics/index.ts index da76b0e9..95236e4c 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/LyricsProviderIsland.tsx b/src/features/lyrics/ui/LyricsProviderIsland.tsx index ab040c3b..b33490d1 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 00000000..cf9d086a --- /dev/null +++ b/src/features/lyrics/ui/LyricsTranslateIslandControls.tsx @@ -0,0 +1,148 @@ +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 { 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); + const activeProvider = useLyricsStore((s) => s.activeProvider); + + const { + showTranslation, + showRomanization, + isTranslating, + isRomanizing, + toggleTranslation, + toggleRomanization, + translateTrackLyrics, + romanizeTrackLyrics, + translationsByTrackId, + romanizationsByTrackId, + sourceLinesByCacheKey, + sourceRomLinesByCacheKey, + targetLang, + singableMode, + } = useAiTranslateStore(); + + const id = currentTrack?.id ?? ""; + const cacheKey = id ? getTranslationCacheKey(id, targetLang, singableMode, activeProvider) : ""; + const fallbackKey = id ? getTranslationCacheKey(id, targetLang, singableMode) : ""; + const romKey = id ? getRomanizationCacheKey(id, activeProvider) : ""; + const fallbackRomKey = id ? getRomanizationCacheKey(id) : ""; + + const lines = braccatoLyrics.map( + (l) => l.words || l.parts?.map((p) => p.words).join("") || "", + ); + const isMatch = (cached: string[] | undefined, list?: string[]) => + Boolean(list && list.length === lines.length && (!cached || cached.every((l, i) => l.trim() === lines[i]?.trim()))); + + const cachedTrans = (cacheKey ? translationsByTrackId[cacheKey] : undefined) ?? (fallbackKey ? translationsByTrackId[fallbackKey] : undefined); + const cachedSource = (cacheKey ? sourceLinesByCacheKey[cacheKey] : undefined) ?? (fallbackKey ? sourceLinesByCacheKey[fallbackKey] : undefined); + const hasTrans = Boolean( + cachedTrans?.some((l) => l.trim().length > 0) && + isMatch(cachedSource, cachedTrans), + ); + + const cachedRom = (romKey ? romanizationsByTrackId[romKey] : undefined) ?? (fallbackRomKey ? romanizationsByTrackId[fallbackRomKey] : undefined); + const cachedRomSource = (romKey ? sourceRomLinesByCacheKey[romKey] : undefined) ?? (fallbackRomKey ? sourceRomLinesByCacheKey[fallbackRomKey] : undefined); + const hasRom = Boolean( + cachedRom?.some((l) => l.trim().length > 0) && + isMatch(cachedRomSource, cachedRom), + ); + + const hasRomanizableLyrics = braccatoLyrics.some((lyric) => + NON_LATIN_RE.test( + lyric.words || lyric.parts?.map((p) => p.words).join("") || "", + ), + ); + + 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().showTranslation === prevShow) { + useAiTranslateStore.getState().setShowTranslation(true); + } else if (!res) { + const err = useAiTranslateStore.getState().error; + if (err) showToast.error("Could not translate lyrics", { description: err }); + } + }; + + 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().showRomanization === prevShow) { + useAiTranslateStore.getState().setShowRomanization(true); + } else if (!res) { + const err = useAiTranslateStore.getState().error; + if (err) showToast.error("Could not romanize lyrics", { description: err }); + } + }; + + return ( + <> +
+ + + {hasRomanizableLyrics && ( + + )} + + ); +} diff --git a/src/features/player/ui/FullscreenPlayer.tsx b/src/features/player/ui/FullscreenPlayer.tsx index 170aaea1..7390295e 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"; @@ -80,6 +84,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 +168,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 03b0b9bf..bfd0870d 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/AiLanguageSelect.tsx b/src/features/settings/ui/tabs/AiLanguageSelect.tsx new file mode 100644 index 00000000..ffbcc92c --- /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 00000000..c3086a30 --- /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 00000000..94201140 --- /dev/null +++ b/src/features/settings/ui/tabs/AiProviderCards.tsx @@ -0,0 +1,125 @@ +import type { ReactNode } from "react"; +import { + SiGooglegemini, + SiDeepseek, + SiGoogletranslate, + SiAnthropic, + SiOpenrouter, + SiOllama, +} 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: "claude", + name: "Claude", + subtitle: "Anthropic 3.5 Haiku", + icon: , + }, + { + id: "openai", + name: "OpenAI", + subtitle: "GPT-4o / o3-mini", + icon: , + }, + { + id: "deepseek", + name: "DeepSeek", + subtitle: "V3 / R1 Reasoner", + icon: , + }, + { + id: "openrouter", + name: "OpenRouter", + subtitle: "Unified AI Gateway", + icon: , + }, + { + id: "ollama", + name: "Ollama", + subtitle: "Local & Offline AI", + icon: , + }, + { + id: "openai_compatible", + name: "Compatible", + subtitle: "Custom API / Proxy", + 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 new file mode 100644 index 00000000..0306cb35 --- /dev/null +++ b/src/features/settings/ui/tabs/AiTab.tsx @@ -0,0 +1,72 @@ +import { useAiTranslateStore } from "@/features/ai-translate"; +import { SettingRow, SettingSection } from "../controls"; +import { ToggleSwitch } from "@/shared/ui"; +import { AiLanguageSelect } from "./AiLanguageSelect"; +import { AiTabModelConfig } from "./AiTabModelConfig"; + +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 00000000..66caa358 --- /dev/null +++ b/src/features/settings/ui/tabs/AiTabModelConfig.tsx @@ -0,0 +1,36 @@ +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 } = useAiTranslateStore(); + const { testResult, testing, handleTest, invalidateTest } = useAiModelTest(); + + return ( + +
+ { + invalidateTest(); + setProvider(val); + }} + /> + + {provider !== "google_translate" && ( + + )} +
+
+ ); +} diff --git a/src/features/settings/ui/tabs/AiTabModelInputs.tsx b/src/features/settings/ui/tabs/AiTabModelInputs.tsx new file mode 100644 index 00000000..f135b65c --- /dev/null +++ b/src/features/settings/ui/tabs/AiTabModelInputs.tsx @@ -0,0 +1,124 @@ +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 === "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 === "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..."; +} + +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" || provider === "ollama") && ( +
+ + { + 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/index.ts b/src/features/settings/ui/tabs/index.ts index 62de08b7..5674630e 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/features/settings/ui/tabs/useAiModelTest.ts b/src/features/settings/ui/tabs/useAiModelTest.ts new file mode 100644 index 00000000..4f033985 --- /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 }; +} diff --git a/src/languages/locales/en.json b/src/languages/locales/en.json index 37e0aee7..c6932bdb 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." },