feat(audio): add intelligent multi-lingual romanization tool (Japanese & Korean -> Latin) to voice asset uploader
This commit is contained in:
+2
-1
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user