diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index f8408bf..637b570 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -84,6 +84,7 @@ DOM or live LiteGraph state. `api/folder_types.py` own the formerly mixed utility route families. - `model_policies.py` owns shared backend rename and protected-category policy. - `api/audio_catalog.py` owns audio preset scanning, voice character grouping, emotion slice indexing, audio streaming, audio gallery output history listing/deletion, and multipart voice asset uploading (`api_upload_audio_voice`) with companion transcript `.txt` pairing into F5-TTS directories. +- `api/romanizer.py` owns Japanese and Korean automatic language detection and phonetic romanization (`api_romanize_text`), converting Kanji/Kana and Hangul to clean Latin text with speech-alignment punctuation normalization. ### Frontend @@ -95,7 +96,7 @@ DOM or live LiteGraph state. (`isValidSavedTriggerPosition`); `api/__init__.py` injects an aiohttp no-cache middleware (`Cache-Control: no-cache, no-store, must-revalidate`) for extension static files to eliminate browser memory/disk cache desynchronization across regular page refreshes; and `interface_settings.js` owns language and theme preferences. - `ui_domain_switcher.js` owns the dual-domain mode toggle (Visual Studio 🎨 vs Audio & Voice Studio 🎙️) embedded in the brand header. - `ui_audio_studio.js` owns the Audio & Voice Studio workspace, presenting character voice cards, emotion slice playback, prompt syntax copying, real-time search, animated equalizers, 1-click Arona workflow loading (`app.loadGraphData`), direct canvas node drop injection, and voice asset creation button triggers. -- `ui_audio_uploader.js` owns the Voice Asset Ingestion modal dialog, featuring audio file drag-and-drop, instant HTML5 preview playback, character/emotion tag selection with quick chips, companion reference transcript typing, and seamless hot reload into the studio view. +- `ui_audio_uploader.js` owns the Voice Asset Ingestion modal dialog, featuring audio file drag-and-drop, instant HTML5 preview playback, character/emotion tag selection with quick chips, companion reference transcript typing, 1-click Japanese/Korean intelligent romanization (`[✨ 智能转罗马音]`), and seamless hot reload into the studio view. - `ui_audio_sidebar.js` owns the Audio Sidebar navigation, rendering trees for All Voices, Character Presets (e.g. Arona) with count badges, and Output History Vault. - `ui_audio_gallery.js` owns the Generated Audio Vault, supporting track playback, scrubber progress, duration timecodes, audio download, deletion, and canvas drag-and-drop. - `ui_sidebar.js` creates the browser shell, domain-aware header tabs (`[音色预设]`, `[音频库]`, `[工作流]`), domain-aware bottom-left tool suppression, and folder navigation. diff --git a/api/__init__.py b/api/__init__.py index db92813..7ed1fd6 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -1,7 +1,7 @@ from . import ( audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog, model_media, model_metadata, model_resolution, recipe_packages, recipes, - translation_routes, + romanizer, translation_routes, ) from .scanner import * from .config import * @@ -105,3 +105,4 @@ def setup_routes(app): app.router.add_post('/anomalous/delete_audio_gallery', audio_catalog.api_delete_audio_gallery) app.router.add_get('/anomalous/audio_template_workflow', audio_catalog.api_get_audio_template_workflow) app.router.add_post('/anomalous/upload_audio_voice', audio_catalog.api_upload_audio_voice) + app.router.add_post('/anomalous/romanize_text', romanizer.api_romanize_text) diff --git a/api/romanizer.py b/api/romanizer.py new file mode 100644 index 0000000..6478177 --- /dev/null +++ b/api/romanizer.py @@ -0,0 +1,113 @@ +"""Intelligent Romanization Engine for Japanese and Korean text alignment in F5-TTS.""" + +import re +import json +from aiohttp import web + +_kakasi_instance = None +_hangul_transliter = None + + +def _get_kakasi(): + global _kakasi_instance + if _kakasi_instance is None: + try: + import pykakasi + _kakasi_instance = pykakasi.kakasi() + except Exception: + _kakasi_instance = False + return _kakasi_instance if _kakasi_instance else None + + +def _get_hangul_transliter(): + global _hangul_transliter + if _hangul_transliter is None: + try: + from hangul_romanize import Transliter + from hangul_romanize.rule import academic + _hangul_transliter = Transliter(academic) + except Exception: + _hangul_transliter = False + return _hangul_transliter if _hangul_transliter else None + + +def _clean_romanized_spacing(text: str) -> str: + """Normalize whitespace and punctuation spacing for speech synthesis alignment.""" + # Remove hyphens often generated in academic Korean romanization between syllables + text = re.sub(r'(\w)-(\w)', r'\1\2', text) + # Fix spaces preceding punctuation: 'word ,' -> 'word,' + text = re.sub(r'\s+([,.:;!?~])', r'\1', text) + # Ensure a space follows punctuation if directly adjacent to a letter: 'word,next' -> 'word, next' + text = re.sub(r'([,.:;!?])([A-Za-z0-9])', r'\1 \2', text) + # Collapse multiple whitespaces + text = re.sub(r'[ \t]+', ' ', text) + return text.strip() + + +def romanize_text(text: str) -> dict: + """ + Detect language and convert Japanese or Korean into standard Latin/Romaji phonetics. + Returns dict with original, romanized, detected language, and success status. + """ + raw = (text or "").strip() + if not raw: + return {"success": True, "original": "", "romanized": "", "lang": "empty"} + + has_hangul = bool(re.search(r'[\uac00-\ud7a3]', raw)) + has_kana = bool(re.search(r'[\u3040-\u30ff]', raw)) + + # 1. Process Korean text + if has_hangul: + ht = _get_hangul_transliter() + if ht: + try: + romanized = ht.translit(raw) + return { + "success": True, + "original": raw, + "romanized": _clean_romanized_spacing(romanized), + "lang": "ko" + } + except Exception as e: + return {"success": False, "error": f"Korean transliteration failed: {e}", "original": raw} + + # 2. Process Japanese text (Kana or Japanese context) + if has_kana or (not has_hangul and bool(re.search(r'[\u4e00-\u9fff]', raw))): + kakasi = _get_kakasi() + if kakasi: + try: + result = kakasi.convert(raw) + parts = [] + for item in result: + hep = item.get("hepburn", "") + parts.append(hep if hep else item.get("orig", "")) + romanized = " ".join(parts) + return { + "success": True, + "original": raw, + "romanized": _clean_romanized_spacing(romanized), + "lang": "ja" + } + except Exception as e: + return {"success": False, "error": f"Japanese romanization failed: {e}", "original": raw} + + # Default: Already Latin or other language, keep as is + return { + "success": True, + "original": raw, + "romanized": raw, + "lang": "unchanged" + } + + +async def api_romanize_text(request): + """POST /anomalous/romanize_text - Convert input text to Romaji / Latin transliteration.""" + try: + data = await request.json() + except Exception: + return web.json_response({"success": False, "error": "Invalid JSON body"}, status=400) + + text = data.get("text", "") + res = romanize_text(text) + status_code = 200 if res.get("success") else 500 + return web.json_response(res, status=status_code) diff --git a/web/modules/locales.js b/web/modules/locales.js index 6180fc9..86e4e0f 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1291,7 +1291,12 @@ export const i18n = { audioSaveAndIngest: "保存并入库", audioSaving: "正在入库...", audioSaveSuccess: "音色资产已成功入库!", - audioUploadRequiredError: "请上传音频文件并填写角色名称与情绪标签" + audioUploadRequiredError: "请上传音频文件并填写角色名称与情绪标签", + audioRomanizeBtn: "✨ 智能转罗马音", + audioRomanizeConverting: "转换中...", + audioRomanizeSuccess: "已转为罗马音!", + audioRomanizeEmptyHint: "请先在文本框中输入或粘贴日文/韩文台词", + audioRomanizeFailed: "转换失败,请重试" }, en: { actionTools: "Tools", @@ -2585,7 +2590,12 @@ export const i18n = { audioSaveAndIngest: "Save & Ingest", audioSaving: "Saving...", audioSaveSuccess: "Voice asset saved successfully!", - audioUploadRequiredError: "Please provide an audio file, character name, and emotion tag" + audioUploadRequiredError: "Please provide an audio file, character name, and emotion tag", + audioRomanizeBtn: "✨ Romanize (Romaji/RR)", + audioRomanizeConverting: "Converting...", + audioRomanizeSuccess: "Converted to Romaji!", + audioRomanizeEmptyHint: "Please enter Japanese or Korean dialogue first", + audioRomanizeFailed: "Conversion failed, please retry" } }; diff --git a/web/modules/ui_audio_uploader.js b/web/modules/ui_audio_uploader.js index e3eaa1a..4950c6c 100644 --- a/web/modules/ui_audio_uploader.js +++ b/web/modules/ui_audio_uploader.js @@ -162,20 +162,76 @@ function createEmotionSection() { return { wrap, input }; } -function createTranscriptSection() { - const wrap = document.createElement('div'); - wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;'; +function createTranscriptHeader(textarea) { + const header = document.createElement('div'); + header.style.cssText = 'display:flex;align-items:center;justify-content:space-between;'; const label = document.createElement('label'); label.style.cssText = 'font-size:12px;font-weight:600;color:#cbd5e1;'; label.textContent = t('audioRefTextLabel'); + const romanizeBtn = document.createElement('button'); + romanizeBtn.type = 'button'; + romanizeBtn.className = 'anomalous-romanize-btn'; + romanizeBtn.innerHTML = `✨ ${t('audioRomanizeBtn')}`; + romanizeBtn.title = '将输入的日文(假名/汉字)或韩文(谚文)一键转换为标准罗马音'; + + romanizeBtn.onclick = async () => { + const originalText = textarea.value.trim(); + if (!originalText) { + alert(t('audioRomanizeEmptyHint')); + textarea.focus(); + return; + } + + const prevHtml = romanizeBtn.innerHTML; + romanizeBtn.disabled = true; + romanizeBtn.innerHTML = `⏳ ${t('audioRomanizeConverting')}`; + + try { + const resp = await fetch('/anomalous/romanize_text', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: originalText }) + }); + const data = await resp.json(); + if (data.success && data.romanized) { + textarea.value = data.romanized; + romanizeBtn.innerHTML = `✅ ${t('audioRomanizeSuccess')}`; + setTimeout(() => { + romanizeBtn.innerHTML = prevHtml; + romanizeBtn.disabled = false; + }, 1800); + } else { + alert(t('audioRomanizeFailed') + ': ' + (data.error || '')); + romanizeBtn.innerHTML = prevHtml; + romanizeBtn.disabled = false; + } + } catch (err) { + console.error('Failed to romanize text:', err); + alert(t('audioRomanizeFailed')); + romanizeBtn.innerHTML = prevHtml; + romanizeBtn.disabled = false; + } + }; + + header.appendChild(label); + header.appendChild(romanizeBtn); + return header; +} + +function createTranscriptSection() { + const wrap = document.createElement('div'); + wrap.style.cssText = 'display:flex;flex-direction:column;gap:6px;'; + const textarea = document.createElement('textarea'); textarea.className = 'anomalous-uploader-textarea'; textarea.rows = 3; textarea.placeholder = t('audioRefTextPlaceholder'); - wrap.appendChild(label); + const header = createTranscriptHeader(textarea); + + wrap.appendChild(header); wrap.appendChild(textarea); return { wrap, textarea }; } diff --git a/web/styles.css b/web/styles.css index abbf9b8..477b5ef 100644 --- a/web/styles.css +++ b/web/styles.css @@ -9,4 +9,4 @@ @import url("./styles/08-theme-gallery-overrides.css?v=20260918-model-sources-1"); @import url("./styles/09-prompt-drawers.css?v=20260918-model-sources-1"); @import url("./styles/10-model-sources.css?v=20260918-model-sources-1"); -@import url("./styles/11-audio-studio.css?v=20260922-audio-uploader-1"); +@import url("./styles/11-audio-studio.css?v=20260922-romanizer-1"); diff --git a/web/styles/11-audio-studio.css b/web/styles/11-audio-studio.css index 58601fd..761d3fd 100644 --- a/web/styles/11-audio-studio.css +++ b/web/styles/11-audio-studio.css @@ -331,3 +331,38 @@ color: #ffffff; font-weight: 600; } + +/* Romanize Action Button */ +.anomalous-romanize-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + border-radius: 6px; + background: linear-gradient(135deg, rgba(236, 72, 153, 0.15), rgba(168, 85, 247, 0.15)); + border: 1px solid rgba(236, 72, 153, 0.35); + color: #f472b6; + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 0 1px 4px rgba(236, 72, 153, 0.1); +} + +.anomalous-romanize-btn:hover { + background: linear-gradient(135deg, rgba(236, 72, 153, 0.3), rgba(168, 85, 247, 0.25)); + border-color: rgba(236, 72, 153, 0.6); + color: #fbcfe8; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(236, 72, 153, 0.25); +} + +.anomalous-romanize-btn:active { + transform: translateY(0); +} + +.anomalous-romanize-btn:disabled { + opacity: 0.5; + cursor: not-allowed; + transform: none; +}