diff --git a/.env.example b/.env.example index 62c9334d..35029d97 100644 --- a/.env.example +++ b/.env.example @@ -537,6 +537,7 @@ VTS_TOKEN_FILE=vts_auth_token.json # BEGIN GENERATED CONFIG: presentation # Language & captions # AMADEUS_PRESENTATION_LOCALE=en-US # en-US | zh-CN | ja-JP +AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE=zh-CN # zh-CN | en-US # AMADEUS_CHAT_TRANSLATION_SUBTITLES_ENABLED=false # AMADEUS_WALLPAPER_CAPTION_MODE=translated # translated | source | bilingual | off # END GENERATED CONFIG: presentation diff --git a/config/README.md b/config/README.md index be1b6ecd..c84365a0 100644 --- a/config/README.md +++ b/config/README.md @@ -44,6 +44,16 @@ cannot prevent native/file-origin backend clients from starting. ## Shared configuration declarations +The Language & captions group includes `AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE` +(`zh-CN` by default, or `en-US`). It selects the target language of the existing +shared Japanese subtitle translator for Chat and ordinary Wallpaper playback. +Saving applies without a backend restart: Chat refreshes its translations and +new Wallpaper captions use the selected language. A caption already on screen +finishes its normal playback lifetime. The two surfaces keep their independent +enablement/display modes; speech and conversation history are unchanged. +VN retains its separate translation settings and cache, including VN captions +shown on Wallpaper. Slice process language is also independent. + The shared catalog covers the TTS selector, all four built-in TTS connection/model groups, graphics, voice input/reference controls, model connections, model roles, Provider connections, character, desktop and live presentation/vision settings. diff --git a/config/catalog/general/presentation.json b/config/catalog/general/presentation.json index 0fe0f2dd..4bddb321 100644 --- a/config/catalog/general/presentation.json +++ b/config/catalog/general/presentation.json @@ -30,6 +30,25 @@ "ja-JP" ] }, + "AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE": { + "type": "enum", + "title": { + "en-US": "Subtitle translation language", + "zh-CN": "字幕翻译语言" + }, + "description": { + "en-US": "Shared by Chat and ordinary Wallpaper subtitles. VN subtitles keep their own translation policy.", + "zh-CN": "Chat 和普通壁纸字幕共用;VN 字幕保持独立的翻译策略。" + }, + "default": "zh-CN", + "scope": "session", + "runtime_key": "subtitle_translation_language", + "options": [ + { "value": "zh-CN", "label": { "en-US": "Simplified Chinese", "zh-CN": "简体中文" } }, + { "value": "en-US", "label": { "en-US": "English", "zh-CN": "English" } } + ], + "example_active": true + }, "AMADEUS_CHAT_TRANSLATION_SUBTITLES_ENABLED": { "type": "boolean", "title": { @@ -38,8 +57,8 @@ }, "default": false, "description": { - "en-US": "Show Simplified Chinese below completed Japanese assistant messages. Display-only; never added to conversation history.", - "zh-CN": "在完成的日语助手消息下显示简体中文。仅用于显示,不会写入对话历史。" + "en-US": "Show translations below completed Japanese assistant messages in the selected subtitle language. Display-only; never added to conversation history.", + "zh-CN": "在完成的日语助手消息下显示所选语言的译文。仅用于显示,不会写入对话历史。" }, "scope": "session", "runtime_key": "chat_translation_subtitles_enabled", diff --git a/docs/evidence/subtitle-target-language/README.md b/docs/evidence/subtitle-target-language/README.md new file mode 100644 index 00000000..6c423b8f --- /dev/null +++ b/docs/evidence/subtitle-target-language/README.md @@ -0,0 +1,21 @@ +# Shared subtitle target language + +The existing Chat/Wallpaper translator now offers Simplified Chinese (the +unchanged default) and English in Settings > General > Language & captions. +VN translation remains separate, including VN captions rendered on Wallpaper. + +The images use hidden Electron windows with isolated synthetic settings and +network access blocked. The before image shows the pre-change local build; +the after image shows this branch with English selected. The offline save +notice indicates that no backend was attached to this screenshot profile. + +| Before | After | +| --- | --- | +| ![Before](before.png) | ![English subtitle target](after.png) | + +Validation covers both languages across the existing DeepSeek, OpenAI-compatible +and Gemini request paths using synthetic provider responses. Chat's real effects +are exercised with cached and delayed translations during a language switch. +Wallpaper cache tests cover old successes, failures and tasks queued before a +switch. The offline settings smoke exercises persistence and the Host application +policy. No live translation-model quality assessment is claimed. diff --git a/docs/evidence/subtitle-target-language/after.png b/docs/evidence/subtitle-target-language/after.png new file mode 100644 index 00000000..16732f4d Binary files /dev/null and b/docs/evidence/subtitle-target-language/after.png differ diff --git a/docs/evidence/subtitle-target-language/before.png b/docs/evidence/subtitle-target-language/before.png new file mode 100644 index 00000000..e984f00a Binary files /dev/null and b/docs/evidence/subtitle-target-language/before.png differ diff --git a/electron/scripts/smoke-startup-settings.cjs b/electron/scripts/smoke-startup-settings.cjs index b16569fa..af43a79e 100644 --- a/electron/scripts/smoke-startup-settings.cjs +++ b/electron/scripts/smoke-startup-settings.cjs @@ -23,6 +23,7 @@ app.whenReady().then(async () => { session.defaultSession.webRequest.onBeforeRequest({ urls: ['http://*/*', 'https://*/*', 'ws://*/*', 'wss://*/*'] }, (_request, callback) => callback({ cancel: true })) const { DesktopSettingsStore } = await import(pathToFileURL(path.join(root, 'electron/dist/main/desktopSettings.js')).href) + const { catalogApplication } = await import(pathToFileURL(path.join(root, 'electron/dist/shared/configCatalog.js')).href) const { ExtensionInstaller, ExtensionPackageHelper } = await import(pathToFileURL(path.join(root, 'electron/dist/main/extensions.js')).href) const { isWallpaperStartup } = await import(pathToFileURL(path.join(root, 'electron/dist/main/startupMode.js')).href) const file = path.join(profile, 'settings.json') @@ -39,6 +40,7 @@ app.whenReady().then(async () => { GRAPHICS_PROFILE: null, RENDER_MAX_FPS: null, RENDER_TEXTURE_SAMPLING: null, WORK_EXECUTION_PROVIDER: null, COOPERATIVE_CHAT_PROVIDER: 'openclaw', AMADEUS_VISION_ENABLED: 'false', AMADEUS_VISION_MODE: 'watching', + AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE: null, } }) store.markApplied({}) ipcMain.handle('get-backend-connection', () => null) @@ -82,6 +84,21 @@ app.whenReady().then(async () => { fs.mkdirSync(output, { recursive: true }) fs.writeFileSync(path.join(output, 'startup-settings.png'), (await win.webContents.capturePage(undefined, { stayHidden: true })).toPNG()) console.log('PASS offline GUI startup choices persist and select the next launch without backend restart') + await until(`Boolean(document.querySelector('select[aria-label="Subtitle translation language"]'))`) + assert.equal(await win.webContents.executeJavaScript(`document.querySelector('select[aria-label="Subtitle translation language"]').value`), 'zh-CN') + for (const language of ['en-US', 'zh-CN']) { + await win.webContents.executeJavaScript(`(() => { + const select = document.querySelector('select[aria-label="Subtitle translation language"]'); + select.value = ${JSON.stringify(language)}; + select.dispatchEvent(new Event('change', { bubbles: true })); return true; + })()`) + await until(`(async () => (await window.amadeus.getDesktopSettings()).values.AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE === ${JSON.stringify(language)})()`) + const saved = new DesktopSettingsStore(file, '').snapshot({}) + assert.equal(saved.values.AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE, language) + assert.equal(catalogApplication('AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE'), 'host') + assert.ok(saved.pendingRevisions.AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE) + } + console.log('PASS shared subtitle language persists offline and waits for live application without restart') await win.webContents.executeJavaScript(`(() => { const section = document.getElementById('settings-vision'); section.querySelector('button[aria-pressed]').click(); return true; diff --git a/electron/src/renderer/components/ChatPage.tsx b/electron/src/renderer/components/ChatPage.tsx index b4da82e4..9345f60e 100644 --- a/electron/src/renderer/components/ChatPage.tsx +++ b/electron/src/renderer/components/ChatPage.tsx @@ -141,6 +141,7 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren const { t } = useI18n() const [messages, setMessages] = useState([]) const [chatTranslationEnabled, setChatTranslationEnabled] = useState(false) + const [chatTranslationLanguage, setChatTranslationLanguage] = useState('zh-CN') const [chatTranslations, setChatTranslations] = useState>({}) const [workActivities, setWorkActivities] = useState([]) const [attentionRequests, setAttentionRequests] = useState([]) @@ -334,6 +335,7 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren const enabled = values.chat_translation_subtitles_enabled === true if (!cancelled) { setChatTranslationEnabled(enabled) + setChatTranslationLanguage(String(values.subtitle_translation_language || 'zh-CN')) } } const unsubscribe = subscribe('system.config', applyConfig) @@ -389,12 +391,13 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren }, [attentionResolving, connected, send]) useEffect(() => { - if (!chatTranslationEnabled) { - chatTranslationGenerationRef.current += 1 - chatTranslationRequestedRef.current.clear() - setChatTranslations({}) - return - } + chatTranslationGenerationRef.current += 1 + chatTranslationRequestedRef.current.clear() + setChatTranslations({}) + }, [chatTranslationEnabled, chatTranslationLanguage]) + + useEffect(() => { + if (!chatTranslationEnabled) return const generation = chatTranslationGenerationRef.current for (const candidate of chatTranslationCandidates(messages)) { @@ -417,7 +420,7 @@ export default function ChatPage({ send, subscribe, connected, renderActive, ren } }) } - }, [chatTranslationEnabled, messages, send]) + }, [chatTranslationEnabled, chatTranslationLanguage, messages, send]) // auto-scroll useEffect(() => { diff --git a/electron/src/renderer/components/SettingsPage.tsx b/electron/src/renderer/components/SettingsPage.tsx index 8a3157d8..d681bac2 100644 --- a/electron/src/renderer/components/SettingsPage.tsx +++ b/electron/src/renderer/components/SettingsPage.tsx @@ -1440,7 +1440,7 @@ export default function SettingsPage({ send, subscribe, connected, reconnectBack { + const messages = [ + { role: 'assistant', text: '一つ目よ。', turnId: 'one' }, + { role: 'assistant', text: '二つ目よ。', turnId: 'two' }, + ] + const original = structuredClone(messages) + const requests = [], slots = [] + let notify, translations = {}, index = 0 + const context = vm.createContext({ + connected: true, messages, chatTranslationCandidates, + chatTranslationEnabled: false, chatTranslationLanguage: 'zh-CN', + chatTranslationGenerationRef: { current: 0 }, chatTranslationRequestedRef: { current: new Set() }, + setChatTranslationEnabled: value => { context.chatTranslationEnabled = value }, + setChatTranslationLanguage: value => { context.chatTranslationLanguage = value }, + setChatTranslations: value => { translations = typeof value === 'function' ? value(translations) : value }, + subscribe: (_method, callback) => { notify = callback; return () => {} }, + send: (method, params) => { + if (method === 'system.get_config') return Promise.resolve({ + chat_translation_subtitles_enabled: true, subtitle_translation_language: 'zh-CN', + }) + assert.equal(method, 'chat.translate') + return new Promise(resolve => requests.push({ params, resolve })) + }, + useEffect: (callback, deps) => { + const slot = index++ + if (slots[slot] && deps.every((value, i) => Object.is(value, slots[slot][i]))) return + slots[slot] = deps + callback() + }, + }) + const render = () => { index = 0; vm.runInContext(compiled, context) } + render() + await Promise.resolve() + render() + assert.equal(requests.length, 2) + requests[0].resolve({ translation: '第一句。' }) + await Promise.resolve() + assert.deepEqual(Object.values(translations), ['第一句。']) + + notify({ values: { chat_translation_subtitles_enabled: true, subtitle_translation_language: 'en-US' } }) + render() + assert.deepEqual(Object.values(translations), []) + assert.equal(requests.length, 4) + requests[1].resolve({ translation: '迟到的旧中文。' }) + await Promise.resolve() + assert.deepEqual(Object.values(translations), []) + requests[2].resolve({ translation: 'First line.' }) + requests[3].resolve({ translation: 'Second line.' }) + await Promise.resolve() + assert.deepEqual(Object.values(translations), ['First line.', 'Second line.']) + render() + assert.equal(requests.length, 4, 'ordinary renders reuse current-language subtitles') + assert.deepEqual(messages, original, 'translations remain display-only') +}) diff --git a/server/app.py b/server/app.py index 337725c7..314a18dc 100644 --- a/server/app.py +++ b/server/app.py @@ -19,6 +19,7 @@ import threading import time from concurrent.futures import ThreadPoolExecutor +from functools import partial from pathlib import Path from queue import Queue from typing import Any, TYPE_CHECKING @@ -781,11 +782,13 @@ async def lively_asset(rel_path: str): # audio player. from tts.playback import StreamPlayerWithBuffer as _SPB, PlaybackManager as _PM, SubtitleHooks as _SH from server import presentation_runtime, wallpaper_subtitle_runtime + from server.wallpaper_subtitle_translator import translate_wallpaper_subtitle + from tts.sentence_state import pre_translation_cache def _update_wallpaper_subtitle(japanese_text: str, chinese_text: str = "") -> None: # Wallpaper subtitles are a display layer. Chat bubbles and chat memory # keep the original assistant text; only this surface can switch - # between Japanese, Chinese, bilingual, or hidden captions. + # between source, translated, bilingual, or hidden captions. wallpaper_subtitle_runtime.update(japanese_text, chinese_text) def _update_playback_subtitle(japanese_text: str, chinese_text: str = "") -> None: @@ -797,9 +800,19 @@ def _update_playback_subtitle(japanese_text: str, chinese_text: str = "") -> Non ) wallpaper_subtitle_runtime.set_renderer(lambda text: wallpaper_h.set_subtitle(text)) - presentation_runtime.set_renderer( - lambda profile: wallpaper_h.set_canvas_presentation(profile) - ) + subtitle_language = "" + + def _apply_presentation(profile: dict[str, str]) -> None: + nonlocal subtitle_language + language = profile["subtitle_translation_language"] + if language != subtitle_language: + pre_translation_cache.set_translate_fn( + partial(translate_wallpaper_subtitle, target_language=language) + ) + subtitle_language = language + wallpaper_h.set_canvas_presentation(profile) + + presentation_runtime.set_renderer(_apply_presentation) def _is_vn_playback_sentence(sentence_id: str) -> bool: try: @@ -877,6 +890,10 @@ async def _server_check_and_display_pre_translation(sentence_id: str, japanese_t return cached = await pre_translation_cache.get_translation(japanese_text) + if cached is None: + # A language switch retires pretranslations queued under the + # old policy. Resolve the new policy when this sentence plays. + await pre_translation_cache.start_translation(sentence_id, japanese_text) if cached and cached.get("status") == "completed" and cached.get("chinese"): await _server_display_chinese_subtitle_with_text( sentence_id, @@ -901,8 +918,6 @@ async def _wait_for_translation() -> None: except Exception: logger.exception("server pre-translation display failed") - from tts.sentence_state import pre_translation_cache - player = _SPB( mouth_signal_router, hooks=_SH( @@ -1244,12 +1259,8 @@ async def _apply_visual_selection() -> None: tts_executor = ThreadPoolExecutor(max_workers=MAX_SELECTABLE_TTS_CONCURRENCY) translation_executor = ThreadPoolExecutor(max_workers=4) try: - from server.wallpaper_subtitle_translator import ( - get_translation_runtime_info, - translate_wallpaper_subtitle, - ) + from server.wallpaper_subtitle_translator import get_translation_runtime_info - pre_translation_cache.set_translate_fn(translate_wallpaper_subtitle) subtitle_translation_info = get_translation_runtime_info() logger.info( "Wallpaper subtitle translation configured; active=%s mode=%s provider=%s model=%s", diff --git a/server/presentation_runtime.py b/server/presentation_runtime.py index b0d3039f..2568cc28 100644 --- a/server/presentation_runtime.py +++ b/server/presentation_runtime.py @@ -1,8 +1,8 @@ """Runtime authority for non-conversational presentation choices. The assistant/voice language remains owned by ``assistant_language``. This -module controls Slice process presentation (including Provider reporting copy) -and how the wallpaper caption surface presents an already-authored role line. +module controls Slice process presentation (including Provider reporting copy), +the shared Chat/Wallpaper subtitle target language, and wallpaper caption mode. """ from __future__ import annotations @@ -11,13 +11,15 @@ from collections.abc import Callable, Mapping -from config.catalog import configuration_field, option_values +from config.catalog import configuration_field, option_values, read_catalog_value +from config.environment import EnvironmentReader VALID_PRESENTATION_LOCALES = set(option_values(configuration_field("AMADEUS_PRESENTATION_LOCALE"))) VALID_CAPTION_MODES = set(option_values(configuration_field("AMADEUS_WALLPAPER_CAPTION_MODE"))) DEFAULT_PRESENTATION_LOCALE = configuration_field("AMADEUS_PRESENTATION_LOCALE")["default"] # The legacy combined subtitle input determines the effective default. DEFAULT_CAPTION_MODE = "translated" +_SUBTITLE_LANGUAGE_FIELD = configuration_field("AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE") def normalize_presentation_locale(value: object) -> str: @@ -84,6 +86,11 @@ def _legacy_profile(value: object) -> tuple[str, str]: _caption_mode = normalize_caption_mode( os.environ.get("AMADEUS_WALLPAPER_CAPTION_MODE") or _legacy_mode ) +_subtitle_translation_language = read_catalog_value( + EnvironmentReader(os.environ), "AMADEUS_SUBTITLE_TRANSLATION_LANGUAGE" +) +if _subtitle_translation_language not in option_values(_SUBTITLE_LANGUAGE_FIELD): + _subtitle_translation_language = _SUBTITLE_LANGUAGE_FIELD["default"] _renderer: Callable[[dict[str, str]], None] | None = None @@ -95,10 +102,15 @@ def get_caption_mode() -> str: return _caption_mode +def get_subtitle_translation_language() -> str: + return _subtitle_translation_language + + def get_config() -> dict[str, str]: return { "presentation_locale": _presentation_locale, "wallpaper_caption_mode": _caption_mode, + "subtitle_translation_language": _subtitle_translation_language, } @@ -111,8 +123,15 @@ def set_renderer(renderer: Callable[[dict[str, str]], None] | None) -> None: def set_config(values: Mapping[str, object], *, render_current: bool = True) -> list[str]: """Apply one profile update, accepting the retired combined key at input.""" - global _presentation_locale, _caption_mode + global _presentation_locale, _caption_mode, _subtitle_translation_language changed: list[str] = [] + if "subtitle_translation_language" in values: + language = str(values["subtitle_translation_language"]) + if language not in option_values(_SUBTITLE_LANGUAGE_FIELD): + raise ValueError(f"unsupported subtitle_translation_language: {language!r}") + if language != _subtitle_translation_language: + _subtitle_translation_language = language + changed.append("subtitle_translation_language") has_locale = "presentation_locale" in values has_mode = "wallpaper_caption_mode" in values diff --git a/server/wallpaper_subtitle_runtime.py b/server/wallpaper_subtitle_runtime.py index 509ef84f..685eba9f 100644 --- a/server/wallpaper_subtitle_runtime.py +++ b/server/wallpaper_subtitle_runtime.py @@ -71,7 +71,7 @@ def current_text() -> str: if zh and ja and zh != ja: return f"{zh}\n{ja}" return zh or ja - # Chinese mode is intentionally strict: playback often sends the Japanese + # Translated mode is intentionally strict: playback often sends the Japanese # sentence first and fills the translated subtitle later. Falling back to # Japanese here makes the CRT flicker between languages. return zh diff --git a/server/wallpaper_subtitle_translator.py b/server/wallpaper_subtitle_translator.py index 86283024..403f5069 100644 --- a/server/wallpaper_subtitle_translator.py +++ b/server/wallpaper_subtitle_translator.py @@ -1,4 +1,4 @@ -"""Provider-selectable Japanese -> Chinese presentation subtitle translator. +"""Shared Japanese presentation subtitle translator with a selectable target language. Wallpaper/Lively and GUI Chat may share this provider configuration and output cleaning, but each surface owns its own enablement and render state. This @@ -19,6 +19,7 @@ import aiohttp from config import settings +from server import presentation_runtime logger = logging.getLogger(__name__) @@ -26,8 +27,8 @@ _CLIENTS_LOCK = Lock() _SYSTEM_PROMPT = ( - "Translate Japanese assistant speech into concise, natural Simplified Chinese " - "subtitles. Return only the Chinese translation. Preserve names, technical " + "Translate Japanese assistant speech into concise, natural {language} " + "subtitles. Return only the {language} translation. Preserve names, technical " "terms, URLs, commands, and code identifiers. Do not add explanations, " "markdown, quotes, labels, or new facts." ) @@ -40,6 +41,12 @@ class SubtitleTranslatorConfig: base_url: str timeout_s: float max_tokens: int + target_language: str = "zh-CN" + + +def _system_prompt(config: SubtitleTranslatorConfig) -> str: + language = {"zh-CN": "Simplified Chinese", "en-US": "English"}[config.target_language] + return _SYSTEM_PROMPT.format(language=language) def get_translation_runtime_info() -> dict[str, str | float | int]: @@ -50,15 +57,16 @@ def get_translation_runtime_info() -> dict[str, str | float | int]: "base_url": _redact_url(config.base_url), "timeout_s": config.timeout_s, "max_tokens": config.max_tokens, + "target_language": config.target_language, } -async def translate_presentation_subtitle(japanese_text: str) -> str: +async def translate_presentation_subtitle(japanese_text: str, *, target_language: str | None = None) -> str: text = str(japanese_text or "").strip() if not text: return "" - config = _resolve_config() + config = _resolve_config(target_language=target_language) providers = _provider_order(config.provider) last_error: Exception | None = None started = asyncio.get_running_loop().time() @@ -68,7 +76,7 @@ async def translate_presentation_subtitle(japanese_text: str) -> str: attempt = _config_for_provider(config, provider) result = await _translate_with_config(text, attempt) result = _clean_translation(result) - if _looks_like_valid_translation(result): + if _looks_like_valid_translation(result, config.target_language): elapsed_ms = int((asyncio.get_running_loop().time() - started) * 1000) logger.info( "presentation subtitle translated provider=%s model=%s chars_in=%d chars_out=%d elapsed_ms=%d", @@ -99,13 +107,13 @@ async def translate_presentation_subtitle(japanese_text: str) -> str: return "" -async def translate_wallpaper_subtitle(japanese_text: str) -> str: +async def translate_wallpaper_subtitle(japanese_text: str, *, target_language: str | None = None) -> str: """Compatibility entry point for the established Wallpaper pipeline.""" - return await translate_presentation_subtitle(japanese_text) + return await translate_presentation_subtitle(japanese_text, target_language=target_language) -def _resolve_config() -> SubtitleTranslatorConfig: +def _resolve_config(*, target_language: str | None = None) -> SubtitleTranslatorConfig: provider = ( os.environ.get("AMADEUS_SUBTITLE_TRANSLATE_PROVIDER") or os.environ.get("AMADEUS_WALLPAPER_SUBTITLE_TRANSLATE_PROVIDER") @@ -123,6 +131,7 @@ def _resolve_config() -> SubtitleTranslatorConfig: base_url="", timeout_s=max(3.0, timeout_s), max_tokens=max(80, max_tokens), + target_language=target_language or presentation_runtime.get_subtitle_translation_language(), ), provider, ) @@ -152,6 +161,7 @@ def _config_for_provider(base: SubtitleTranslatorConfig, provider: str) -> Subti base_url=base_url, timeout_s=base.timeout_s, max_tokens=base.max_tokens, + target_language=base.target_language, ) @@ -185,7 +195,7 @@ async def _translate_openai_compatible(text: str, config: SubtitleTranslatorConf kwargs: dict[str, Any] = { "model": config.model, "messages": [ - {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "system", "content": _system_prompt(config)}, {"role": "user", "content": text}, ], "stream": False, @@ -225,7 +235,7 @@ async def _translate_gemini(text: str, config: SubtitleTranslatorConfig) -> str: "parts": [ { "text": ( - f"{_SYSTEM_PROMPT}\n\nJapanese text:\n{text}" + f"{_system_prompt(config)}\n\nJapanese text:\n{text}" ) } ], @@ -277,21 +287,22 @@ def _get_openai_client(provider: str, api_key: str, base_url: str): def _clean_translation(value: str) -> str: text = str(value or "").strip() text = re.sub(r"^```(?:\w+)?\s*|\s*```$", "", text, flags=re.S) - text = re.sub(r"^(?:中文|简体中文|翻译|translation)\s*[::]\s*", "", text, flags=re.I) + text = re.sub(r"^(?:中文|简体中文|英文|英语|翻译|English|translation)\s*[::]\s*", "", text, flags=re.I) if (text.startswith('"') and text.endswith('"')) or (text.startswith("'") and text.endswith("'")): text = text[1:-1].strip() return text.strip() -def _looks_like_valid_translation(value: str) -> bool: +def _looks_like_valid_translation(value: str, target_language: str = "zh-CN") -> bool: text = str(value or "").strip() if not text: return False if re.fullmatch(r"[\s.。…!?!?,,、;;::-]+", text): return False - # At least one CJK ideograph keeps short subtitle fragments such as - # "嗯。" valid, while rejecting pure punctuation or echoed metadata. - return bool(re.search(r"[\u4e00-\u9fff]", text)) + # Retain the lightweight script check for short fragments such as "嗯。" + # or "OK.". The model owns translation semantics. + script = r"[A-Za-z]" if target_language == "en-US" else r"[\u4e00-\u9fff]" + return bool(re.search(script, text)) def _env_int(name: str, default: int) -> int: diff --git a/tests/test_assistant_language.py b/tests/test_assistant_language.py index 6c065239..afdd88cc 100644 --- a/tests/test_assistant_language.py +++ b/tests/test_assistant_language.py @@ -18,32 +18,48 @@ def test_primary_language_does_not_follow_wallpaper_caption_mode(): fake_pipeline = types.SimpleNamespace(TTS_OUTPUT_LANGUAGE="日文") - original_mode = wallpaper_subtitle_runtime.get_mode() + original = presentation_runtime.get_config() try: with patch.dict(sys.modules, {"tts.pipeline": fake_pipeline}): - for mode in ("zh", "ja", "bilingual", "off"): - wallpaper_subtitle_runtime.set_mode(mode, render_current=False) - assert current_assistant_language() == "japanese" + for language in ("zh-CN", "en-US"): + presentation_runtime.set_config( + {"subtitle_translation_language": language}, render_current=False, + ) + for mode in ("zh", "ja", "bilingual", "off"): + wallpaper_subtitle_runtime.set_mode(mode, render_current=False) + assert current_assistant_language() == "japanese" finally: - wallpaper_subtitle_runtime.set_mode(original_mode, render_current=False) + presentation_runtime.set_config(original, render_current=False) def test_presentation_locale_and_caption_mode_are_independent(): original = presentation_runtime.get_config() try: + for language in ("zh-CN", "en-US"): + presentation_runtime.set_config( + {"presentation_locale": "en-US", "wallpaper_caption_mode": "source", + "subtitle_translation_language": language}, + render_current=False, + ) + assert presentation_runtime.get_config() == { + "presentation_locale": "en-US", + "wallpaper_caption_mode": "source", + "subtitle_translation_language": language, + } + presentation_runtime.set_legacy_caption_setting("bilingual", render_current=False) + assert presentation_runtime.get_config() == { + "presentation_locale": "zh-CN", + "wallpaper_caption_mode": "bilingual", + "subtitle_translation_language": language, + } presentation_runtime.set_config( - {"presentation_locale": "en-US", "wallpaper_caption_mode": "source"}, - render_current=False, + {"subtitle_translation_language": "zh-CN"}, render_current=False, ) - assert presentation_runtime.get_config() == { - "presentation_locale": "en-US", - "wallpaper_caption_mode": "source", - } - presentation_runtime.set_legacy_caption_setting("bilingual", render_current=False) assert presentation_runtime.get_config() == { "presentation_locale": "zh-CN", "wallpaper_caption_mode": "bilingual", + "subtitle_translation_language": "zh-CN", } finally: presentation_runtime.set_config(original, render_current=False) @@ -59,14 +75,30 @@ def test_presentation_update_has_one_canonical_profile_and_renderer(): original = presentation_runtime.get_config() rendered = [] try: + presentation_runtime.set_config( + {"presentation_locale": "en-US", "wallpaper_caption_mode": "source", + "subtitle_translation_language": "zh-CN"}, render_current=False, + ) presentation_runtime.set_renderer(rendered.append) rendered.clear() presentation_runtime.set_config( - {"presentation_locale": "ja-JP", "wallpaper_caption_mode": "off"} + {"presentation_locale": "ja-JP", "wallpaper_caption_mode": "off", + "subtitle_translation_language": "en-US"} ) assert rendered == [ - {"presentation_locale": "ja-JP", "wallpaper_caption_mode": "off"} + {"presentation_locale": "ja-JP", "wallpaper_caption_mode": "off", + "subtitle_translation_language": "en-US"} ] + assert presentation_runtime.set_config({"subtitle_translation_language": "zh-CN"}) == [ + "subtitle_translation_language", + ] + assert len(rendered) == 2 + assert rendered[-1] == { + "presentation_locale": "ja-JP", "wallpaper_caption_mode": "off", + "subtitle_translation_language": "zh-CN", + } + assert presentation_runtime.set_config({"subtitle_translation_language": "zh-CN"}) == [] + assert len(rendered) == 2 assert "wallpaper_subtitle_language" not in presentation_runtime.get_config() finally: presentation_runtime.set_renderer(None) diff --git a/tests/test_chat_translation_subtitle.py b/tests/test_chat_translation_subtitle.py index a6ebf606..8c56006b 100644 --- a/tests/test_chat_translation_subtitle.py +++ b/tests/test_chat_translation_subtitle.py @@ -7,6 +7,8 @@ from core.session_manager import conversation_history from server import chat_translation_runtime +from server import presentation_runtime +from server import wallpaper_subtitle_translator from server.handlers.chat_handler import ChatHandler from server.handlers.system_handler import SystemHandler from server.protocol import Method @@ -114,3 +116,41 @@ async def run() -> None: chat_translation_runtime.set_config({"chat_translation_subtitles_enabled": original}) asyncio.run(run()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("language,translation", [("zh-CN", "工作完成了。"), ("en-US", "Work is complete.")]) +async def test_shared_language_setting_controls_chat_and_wallpaper_without_changing_vn( + monkeypatch, language, translation): + from server import vn_tts_bridge + + monkeypatch.setattr(presentation_runtime, "_renderer", None) + monkeypatch.setattr(presentation_runtime, "_subtitle_translation_language", "zh-CN") + monkeypatch.setattr(chat_translation_runtime, "_enabled", True) + vn_cache = {"ゲームよ。": {"chinese": "是游戏。", "status": "completed"}} + monkeypatch.setattr(vn_tts_bridge, "_VN_SUBTITLE_CACHE", vn_cache) + monkeypatch.setenv("AMADEUS_SUBTITLE_TRANSLATE_PROVIDER", "openai") + observed_languages = [] + + async def translate(_text, config): + observed_languages.append(config.target_language) + return translation + + monkeypatch.setattr(wallpaper_subtitle_translator, "_translate_with_config", translate) + handler = SystemHandler() + handler._get_config = AsyncMock(side_effect=lambda _: presentation_runtime.get_config()) + with patch("server.handlers.system_handler.bus.emit", new=AsyncMock()): + result = await handler._set_config({"values": {"subtitle_translation_language": language}}) + assert result["values"]["subtitle_translation_language"] == language + assert (await chat_translation_runtime.translate_completed_message("作業は終わったわ。"))["translation"] == translation + assert await wallpaper_subtitle_translator.translate_wallpaper_subtitle("作業は終わったわ。") == translation + assert observed_languages == [language, language] + assert vn_tts_bridge._VN_SUBTITLE_CACHE == {"ゲームよ。": {"chinese": "是游戏。", "status": "completed"}} + + +@pytest.mark.asyncio +async def test_shared_language_setting_rejects_unsupported_target(monkeypatch): + monkeypatch.setattr(presentation_runtime, "_subtitle_translation_language", "zh-CN") + with pytest.raises(ValueError, match="unsupported subtitle_translation_language"): + await SystemHandler()._set_config({"values": {"subtitle_translation_language": "fr-FR"}}) + assert presentation_runtime.get_subtitle_translation_language() == "zh-CN" diff --git a/tests/test_subtitle_translation_cache.py b/tests/test_subtitle_translation_cache.py new file mode 100644 index 00000000..a9a42014 --- /dev/null +++ b/tests/test_subtitle_translation_cache.py @@ -0,0 +1,68 @@ +"""Wallpaper cache entries and pending work belong to their translation policy.""" +import asyncio + +import pytest + +from tts.sentence_state import PreTranslationCache + + +@pytest.mark.asyncio +@pytest.mark.parametrize("old_result", ["旧的中文译文", RuntimeError("old request failed")]) +async def test_rebinding_discards_cached_and_late_results(old_result): + started, release = asyncio.Event(), asyncio.Event() + + async def chinese(_text): + started.set() + await release.wait() + if isinstance(old_result, Exception): + raise old_result + return old_result + + async def english(_text): + return "New English translation." + + cache = PreTranslationCache(chinese) + await cache.start_translation("old", "同じ文章よ。") + await started.wait() + old_entry = await cache.get_translation("同じ文章よ。") + cache.cache["既存よ。"] = {"status": "completed", "chinese": "已有译文"} + cache.set_translate_fn(english) + assert await cache.get_translation("同じ文章よ。") is None + assert await cache.get_translation("既存よ。") is None + await cache.start_translation("new", "同じ文章よ。") + + async def settle(entry): + while entry["status"] == "translating": + await asyncio.sleep(0) + + new_entry = await cache.get_translation("同じ文章よ。") + await asyncio.wait_for(settle(new_entry), 1) + release.set() + await asyncio.wait_for(settle(old_entry), 1) + assert await cache.get_translation("同じ文章よ。") == { + "status": "completed", "chinese": "New English translation.", + } + + +@pytest.mark.asyncio +async def test_queued_translation_captures_policy_before_task_starts(): + translated = [] + + async def chinese(_text): + translated.append("zh-CN") + return "中文" + + async def english(_text): + translated.append("en-US") + return "English" + + cache = PreTranslationCache(chinese) + await cache.start_translation("old", "待って。") + old_entry = await cache.get_translation("待って。") + cache.set_translate_fn(english) + async def settled(): + while old_entry["status"] == "translating": + await asyncio.sleep(0) + await asyncio.wait_for(settled(), 1) + assert translated == ["zh-CN"] + assert await cache.get_translation("待って。") is None diff --git a/tests/test_wallpaper_subtitle_translator.py b/tests/test_wallpaper_subtitle_translator.py index fe0eded9..be6bd7a4 100644 --- a/tests/test_wallpaper_subtitle_translator.py +++ b/tests/test_wallpaper_subtitle_translator.py @@ -1,6 +1,7 @@ """Subtitle client startup must not occupy the shared audio event loop.""" import asyncio import threading +from contextlib import asynccontextmanager from types import SimpleNamespace import httpx @@ -15,6 +16,8 @@ def isolated_clients(monkeypatch): monkeypatch.setattr(translator, "_CLIENTS", {}) monkeypatch.setattr(translator.settings, "DEEPSEEK_API_KEY", "synthetic-key") monkeypatch.setattr(translator.settings, "OPENAI_API_KEY", "synthetic-key") + monkeypatch.setattr(translator.settings, "GEMINI_API_KEY", "synthetic-key") + monkeypatch.setattr(translator.presentation_runtime, "_subtitle_translation_language", "zh-CN") def config(provider): @@ -111,3 +114,70 @@ def construct(**_kwargs): assert await translator._translate_openai_compatible("二。", config("deepseek")) == "翻译结果" assert translator._CLIENTS == {("deepseek", "https://example.invalid/v1"):client} assert len(attempts) == 2 + + +@pytest.mark.parametrize("provider", ["deepseek", "openai", "gemini"]) +@pytest.mark.parametrize("language,label,raw,expected", [ + ("zh-CN", "Simplified Chinese", "翻译:准备好了。", "准备好了。"), + ("en-US", "English", "English: Ready.", "Ready."), +]) +async def test_selected_language_reaches_each_provider_and_accepts_its_output( + monkeypatch, provider, language, label, raw, expected): + requests = [] + monkeypatch.setenv("AMADEUS_SUBTITLE_TRANSLATE_PROVIDER", provider) + monkeypatch.setattr(translator.presentation_runtime, "_subtitle_translation_language", language) + + def create(**kwargs): + requests.append(kwargs["messages"][0]["content"]) + return SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(content=raw))]) + + client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + monkeypatch.setattr(translator, "_get_openai_client", lambda *_: client) + + async def response_json(): + return {"candidates": [{"content": {"parts": [{"text": raw}]}}]} + + @asynccontextmanager + async def post(_url, *, json, **_kwargs): + requests.append(json["contents"][0]["parts"][0]["text"]) + yield SimpleNamespace(status=200, json=response_json) + + @asynccontextmanager + async def session(): + yield SimpleNamespace(post=post) + + monkeypatch.setattr(translator.aiohttp, "ClientSession", session) + assert await translator.translate_presentation_subtitle("準備できたわ。") == expected + assert len(requests) == 1 + assert label in requests[0] + assert ("English" if language == "zh-CN" else "Simplified Chinese") not in requests[0] + assert translator.get_translation_runtime_info()["target_language"] == language + + +@pytest.mark.parametrize("language,valid,invalid", [ + ("zh-CN", "嗯。", "OK."), + ("en-US", "OK.", "準備できたわ。"), +]) +def test_output_validation_follows_target_script(language, valid, invalid): + assert translator._looks_like_valid_translation(valid, language) + assert not translator._looks_like_valid_translation(invalid, language) + assert not translator._looks_like_valid_translation("...", language) + assert not translator._looks_like_valid_translation("", language) + + +async def test_pending_request_keeps_its_language_snapshot(monkeypatch): + started, release = asyncio.Event(), asyncio.Event() + monkeypatch.setattr(translator.presentation_runtime, "_subtitle_translation_language", "en-US") + + async def translate(_text, config): + assert config.target_language == "en-US" + started.set() + await release.wait() + return "Ready." + + monkeypatch.setattr(translator, "_translate_with_config", translate) + task = asyncio.create_task(translator.translate_presentation_subtitle("準備できたわ。")) + await started.wait() + monkeypatch.setattr(translator.presentation_runtime, "_subtitle_translation_language", "zh-CN") + release.set() + assert await task == "Ready." diff --git a/tts/sentence_state.py b/tts/sentence_state.py index b349fa46..86ec36eb 100644 --- a/tts/sentence_state.py +++ b/tts/sentence_state.py @@ -165,26 +165,29 @@ def __init__(self, translate_fn: TranslateFn | None = None): self._translate_fn = translate_fn def set_translate_fn(self, translate_fn: TranslateFn) -> None: - """在事件循环启动后注入翻译函数。""" + """Bind a translation policy; cached results belong to the old policy.""" self._translate_fn = translate_fn + self.cache.clear() async def start_translation(self, sentence_id: str, japanese_text: str) -> None: normalized = japanese_text.strip() + entry = {"chinese": "", "status": "translating"} async with self.lock: - self.cache[normalized] = {"chinese": "", "status": "translating"} - asyncio.create_task(self._translate_task(normalized)) + self.cache[normalized] = entry + # Capture both the policy and its entry. A late completion cannot write + # into a replacement entry after a language switch or repeated sentence. + asyncio.create_task(self._translate_task(normalized, entry, self._translate_fn)) logger.info( "[PreTranslationCache] started: %s -> %s", sentence_id, protected_text(normalized, limit=30), ) - async def _translate_task(self, japanese_text: str) -> None: - if self._translate_fn is None: + async def _translate_task(self, japanese_text: str, entry: dict, translate_fn: TranslateFn | None) -> None: + if translate_fn is None: logger.warning("[PreTranslationCache] translate_fn is not injected; translation skipped") async with self.lock: - if japanese_text in self.cache: - self.cache[japanese_text]["status"] = "failed" + entry["status"] = "failed" return try: logger.info( @@ -193,25 +196,22 @@ async def _translate_task(self, japanese_text: str) -> None: ) try: chinese_text = await asyncio.wait_for( - self._translate_fn(japanese_text), timeout=12.0 + translate_fn(japanese_text), timeout=12.0 ) except asyncio.TimeoutError: async with self.lock: - if japanese_text in self.cache: - self.cache[japanese_text]["status"] = "translating" + entry["status"] = "translating" return async with self.lock: - if japanese_text in self.cache: - self.cache[japanese_text]["chinese"] = chinese_text - self.cache[japanese_text]["status"] = "completed" + entry["chinese"] = chinese_text + entry["status"] = "completed" logger.info( f"[PreTranslationCache] completed: '{japanese_text[:30]}...' -> '{chinese_text[:30]}...'" ) except Exception as e: async with self.lock: - if japanese_text in self.cache: - self.cache[japanese_text]["chinese"] = "translation failed" - self.cache[japanese_text]["status"] = "failed" + entry["chinese"] = "translation failed" + entry["status"] = "failed" logger.error( "[PreTranslationCache] failed: %s, %s", protected_text(japanese_text, limit=30),