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;
+}