feat(audio): add intelligent multi-lingual romanization tool (Japanese & Korean -> Latin) to voice asset uploader

This commit is contained in:
DemonGatanjieu
2026-09-22 21:27:45 +08:00
parent 28bf192b6b
commit 2f70ef71b6
7 changed files with 225 additions and 9 deletions
+2 -1
View File
@@ -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.
+2 -1
View File
@@ -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)
+113
View File
@@ -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)
+12 -2
View File
@@ -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"
}
};
+60 -4
View File
@@ -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 = `<span>✨</span> <span>${t('audioRomanizeBtn')}</span>`;
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 = `<span>⏳</span> <span>${t('audioRomanizeConverting')}</span>`;
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 = `<span>✅</span> <span>${t('audioRomanizeSuccess')}</span>`;
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 };
}
+1 -1
View File
@@ -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");
+35
View File
@@ -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;
}