feat: US-006 - Refactor tools/tts.py — rutare engine, backend pocket-tts și fallback
- synthesize() rezolvă engine din tts_voices.json (fallback la tts.default_engine din config) - _synthesize_pockettts nou: POST multipart la :7789/tts cu voice_wav/voice_url, ignoră lang - fallback tehnic automat pe _synthesize_supertonic(M2, ro) via _PocketTTSUnavailable; erori de conținut (voce lipsă, text gol) nu declanșează fallback; rezultatul include engine_used - gates rulate: tests PASS (1043 passed, 22 eșecuri preexistente neschimbate), /workflow:simplify (4 agenți) cu 1 fix aplicat, /review (backend, manual) PASS
This commit is contained in:
134
tools/tts.py
134
tools/tts.py
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Text-to-speech via Supertonic local server.
|
||||
"""Text-to-speech — rutează pe engine (Supertonic sau pocket-tts) pe baza catalogului de voci.
|
||||
|
||||
CLI:
|
||||
python3 tools/tts.py --text "Salut Marius" [--voice M1] [--lang ro]
|
||||
→ stdout: {"ok": true, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": 12345}
|
||||
→ stdout: {"ok": true, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": 12345, "engine_used": "supertonic"}
|
||||
→ stdout: {"ok": false, "error": "..."}
|
||||
|
||||
Module:
|
||||
@@ -15,13 +15,21 @@ import argparse
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
SUPERTONIC_URL = "http://127.0.0.1:7788"
|
||||
VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
|
||||
DEFAULT_VOICE = "M2"
|
||||
DEFAULT_LANG = "ro"
|
||||
DEFAULT_ENGINE = "supertonic"
|
||||
|
||||
_VOICES_CATALOG_PATH = REPO_ROOT / "tts_voices.json"
|
||||
|
||||
# Punctuation Supertonic synthesis rejects with HTTP 500 (Romanian curly quotes,
|
||||
# smart dashes, ellipsis, angle quotes). Mapped to ASCII so a stray „foo" in
|
||||
@@ -40,6 +48,10 @@ _TTS_PUNCT_MAP = {
|
||||
_MAX_TTS_CHARS = 400
|
||||
|
||||
|
||||
class _PocketTTSUnavailable(Exception):
|
||||
"""pocket-tts server e nereachable sau eșuează server-side (5xx) — caller-ul trebuie să facă fallback pe Supertonic."""
|
||||
|
||||
|
||||
def sanitize_for_supertonic(text: str) -> str:
|
||||
"""Replace Unicode punctuation and strip chars that crash Supertonic's ONNX model."""
|
||||
for src, dst in _TTS_PUNCT_MAP.items():
|
||||
@@ -58,16 +70,43 @@ def sanitize_for_supertonic(text: str) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
|
||||
def _load_voice_catalog() -> dict:
|
||||
from src.jsonlock import read_locked
|
||||
|
||||
try:
|
||||
return read_locked(str(_VOICES_CATALOG_PATH))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
|
||||
def _catalog_entry(voice: str) -> dict | None:
|
||||
catalog = _load_voice_catalog()
|
||||
return catalog.get(voice) or catalog.get(voice.upper())
|
||||
|
||||
|
||||
def _config_get(key: str, default: str) -> str:
|
||||
from src.config import Config
|
||||
|
||||
return Config().get(key, default)
|
||||
|
||||
|
||||
def _write_temp_wav(content: bytes) -> dict:
|
||||
try:
|
||||
fd, path = tempfile.mkstemp(prefix="echo-tts-", suffix=".wav")
|
||||
with open(fd, "wb") as f:
|
||||
f.write(content)
|
||||
return {"ok": True, "path": path, "size_bytes": len(content)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Scriere fișier: {e}"}
|
||||
|
||||
|
||||
def _synthesize_supertonic(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
|
||||
"""Call Supertonic server and save audio to a temp WAV file.
|
||||
|
||||
Returns:
|
||||
{"ok": True, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": N}
|
||||
{"ok": False, "error": "mesaj eroare"}
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {"ok": False, "error": "Text gol."}
|
||||
|
||||
text = sanitize_for_supertonic(text)
|
||||
|
||||
voice = voice.upper()
|
||||
@@ -99,31 +138,94 @@ def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG)
|
||||
body = e.response.text[:300]
|
||||
# Fallback: dacă lang=ro eșuează, încearcă na (language-agnostic)
|
||||
if lang != "na":
|
||||
return synthesize(text, voice=voice, lang="na")
|
||||
return _synthesize_supertonic(text, voice=voice, lang="na")
|
||||
return {"ok": False, "error": f"HTTP {e.response.status_code}: {body}"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
# Salvează în fișier temp
|
||||
return _write_temp_wav(resp.content)
|
||||
|
||||
|
||||
def _synthesize_pockettts(text: str, entry: dict) -> dict:
|
||||
"""POST multipart la serverul pocket-tts (:7789/tts). Ignoră lang (modelul e monolingv per deployment).
|
||||
|
||||
`entry` e intrarea din tts_voices.json (deja rezolvată de caller — evită un al doilea read+lock
|
||||
al catalogului). state_path (voce clonată) -> încarcă fișierul ca voice_wav. voice_url (voce
|
||||
predefinită, ex. 'alba') -> trimite voice_url. Nici unul -> nu trimite voice_wav/voice_url
|
||||
(serverul alege default-ul limbii).
|
||||
|
||||
Raises:
|
||||
_PocketTTSUnavailable: eșec tehnic (connect error/timeout/5xx) — caller-ul face fallback.
|
||||
"""
|
||||
state_path = entry.get("state_path")
|
||||
voice_url = entry.get("voice_url")
|
||||
|
||||
data = {"text": text}
|
||||
files = None
|
||||
if state_path:
|
||||
full_path = REPO_ROOT / state_path
|
||||
if not full_path.exists():
|
||||
return {"ok": False, "error": f"Fișier voce lipsă: {state_path}"}
|
||||
files = {"voice_wav": (full_path.name, full_path.read_bytes())}
|
||||
elif voice_url:
|
||||
data["voice_url"] = voice_url
|
||||
|
||||
try:
|
||||
fd, path = tempfile.mkstemp(prefix="echo-tts-", suffix=".wav")
|
||||
with open(fd, "wb") as f:
|
||||
f.write(resp.content)
|
||||
return {"ok": True, "path": path, "size_bytes": len(resp.content)}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": f"Scriere fișier: {e}"}
|
||||
resp = httpx.post(
|
||||
f"{_config_get('tts.pockettts_url', 'http://127.0.0.1:7789')}/tts",
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=60.0,
|
||||
)
|
||||
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
||||
raise _PocketTTSUnavailable(str(e)) from e
|
||||
|
||||
if resp.status_code >= 500:
|
||||
raise _PocketTTSUnavailable(f"HTTP {resp.status_code}: {resp.text[:300]}")
|
||||
if resp.status_code >= 400:
|
||||
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:300]}"}
|
||||
|
||||
return _write_temp_wav(resp.content)
|
||||
|
||||
|
||||
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
|
||||
"""Rutează sinteza pe engine-ul rezolvat din catalog (fallback la tts.default_engine).
|
||||
|
||||
Returns:
|
||||
{"ok": True, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": N, "engine_used": "pockettts"}
|
||||
{"ok": False, "error": "mesaj eroare", "engine_used": "..."}
|
||||
"""
|
||||
if not text or not text.strip():
|
||||
return {"ok": False, "error": "Text gol."}
|
||||
|
||||
entry = _catalog_entry(voice) or {}
|
||||
engine = entry.get("engine") or _config_get("tts.default_engine", DEFAULT_ENGINE)
|
||||
|
||||
engine_used = "supertonic"
|
||||
if engine == "pockettts":
|
||||
try:
|
||||
result = _synthesize_pockettts(text, entry)
|
||||
engine_used = "pockettts"
|
||||
except _PocketTTSUnavailable:
|
||||
result = _synthesize_supertonic(text, voice=DEFAULT_VOICE, lang=DEFAULT_LANG)
|
||||
else:
|
||||
result = _synthesize_supertonic(text, voice=voice, lang=lang)
|
||||
|
||||
result["engine_used"] = engine_used
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Supertonic TTS CLI")
|
||||
parser = argparse.ArgumentParser(description="Echo Core TTS CLI")
|
||||
parser.add_argument("--text", required=True, help="Text de convertit în audio")
|
||||
parser.add_argument(
|
||||
"--voice", default=DEFAULT_VOICE,
|
||||
help="Voce: M1-M5 (masculin) sau F1-F5 (feminin). Default: M1"
|
||||
help="Voce din tts_voices.json (ex: M1-M5, F1-F5, 'Marius 1', 'alba'). Default: M2"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lang", default=DEFAULT_LANG,
|
||||
help="Limbă (ro, en, na). Default: ro. Fallback automat la na dacă ro eșuează."
|
||||
help="Limbă (ro, en, na) — folosită doar pe calea Supertonic. Default: ro."
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user