Files
echo-core/tools/tts.py
Marius Mutu 80eb8034b3 fix(voice): instrucțiune de limbă inline + fallback Supertonic pe română
Markerul opac [tts-lang:en] depindea de regula din VOICE_MODE.md, aflată
la ~30k caractere distanță în system prompt — modelul o rata pe ~1 din 5
turnuri. Răspunsul în română era apoi respins integral de pocket-tts
(English-only) și turnul rămânea fără audio.

- src/router.py: markerul poartă instrucțiunea inline (un singur token,
  acoperit în continuare de strip-ul anti-jailbreak); 5/5 EN la repro
- src/voice/tts_stream.py: dacă modelul tot scapă română pe o voce
  pockettts, blocul cade pe Supertonic (M2, ro) în loc de tăcere;
  coada TTS transportă (clause, voice, lang) per clauză
- tools/tts.py: looks_romanian public (folosit de tts_stream)
- personality/VOICE_MODE.md: regula gated pe prefixul [tts-lang:en
- tests: 4 teste noi fallback + expectații marker actualizate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 19:20:28 +00:00

285 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""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, "engine_used": "supertonic"}
→ stdout: {"ok": false, "error": "..."}
Module:
from tools.tts import synthesize
result = synthesize("text", voice="M1", lang="ro")
"""
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"
# Safety net dacă tts_voices.json lipsește/e gol — normal, catalogul e sursa de adevăr.
_FALLBACK_SUPERTONIC_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
# any caller's text doesn't kill the whole request.
_TTS_PUNCT_MAP = {
'': '"', '': '"', '': '"',
'': "'", '': "'", '': "'",
'«': '"', '»': '"',
'': '-', '': '-',
'': '...',
}
# Supertonic ONNX model hard limit: inputs longer than this trigger
# Mul node dimension mismatches in attention layers.
_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."""
# pocket-tts e monolingv engleză per deployment — diacriticele RO sunt un semnal
# sigur că textul n-a fost tradus (vezi personality/TOOLS.md).
_RO_DIACRITICS = set("ăâîșțĂÂÎȘȚşţŞŢ")
def looks_romanian(text: str) -> bool:
return any(ch in _RO_DIACRITICS for ch in text)
def map_tts_punctuation(text: str) -> str:
"""Replace Unicode punctuation with ASCII and strip emoji — NO length cap.
Folosit și de pipeline-ul de normalizare voice (src/voice/normalize.py),
care rulează pe textul întreg ÎNAINTE de segmentarea în clauze — un cap de
lungime aici ar reteza răspunsurile lungi în mijlocul cuvântului și ar
face moartă trunchierea la 200 de cuvinte din normalize_for_tts.
"""
for src, dst in _TTS_PUNCT_MAP.items():
text = text.replace(src, dst)
# Strip emoji and high-codepoint chars (keep ASCII printable + Latin/Romanian diacritice)
cleaned = []
for ch in text:
cp = ord(ch)
if (32 <= cp <= 126) or (128 <= cp <= 591):
cleaned.append(ch)
else:
cleaned.append(' ')
return ' '.join(''.join(cleaned).split())
def sanitize_for_supertonic(text: str) -> str:
"""map_tts_punctuation + hard cap la limita ONNX Supertonic (per request)."""
text = map_tts_punctuation(text)
if len(text) > _MAX_TTS_CHARS:
text = text[:_MAX_TTS_CHARS]
return text
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 engine_for_voice(voice: str) -> str:
"""Engine-ul rezolvat pentru o voce: catalog → fallback `tts.default_engine`.
Aceeași regulă de rutare ca `synthesize()` — folosită de router ca să știe
dinainte dacă răspunsul va fi sintetizat de un engine English-only.
"""
entry = _catalog_entry(voice) or {}
return entry.get("engine") or _config_get("tts.default_engine", DEFAULT_ENGINE)
def list_voice_names(engine: str | None = None) -> list[str]:
"""Nume de voci din tts_voices.json, opțional filtrate pe `engine`.
Sursă live pentru autocomplete Discord (/audio, /voice setvoice) — o voce
nouă adăugată în catalog apare imediat, fără redeploy.
"""
catalog = _load_voice_catalog()
if engine is not None:
return sorted(name for name, entry in catalog.items() if entry.get("engine") == engine)
return sorted(catalog.keys())
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"}
"""
text = sanitize_for_supertonic(text)
voice = voice.upper()
supertonic_voices = set(list_voice_names(engine="supertonic")) or _FALLBACK_SUPERTONIC_VOICES
if voice not in supertonic_voices:
voice = DEFAULT_VOICE
try:
resp = httpx.post(
f"{SUPERTONIC_URL}/v1/audio/speech",
json={
"model": "supertonic-3",
"input": text,
"voice": voice,
"response_format": "wav",
"lang": lang,
},
timeout=60.0,
)
resp.raise_for_status()
except httpx.ConnectError:
return {
"ok": False,
"error": (
"Serverul Supertonic nu rulează pe :7788. "
"Pornește cu: systemctl --user start supertonic-tts"
),
}
except httpx.HTTPStatusError as e:
body = e.response.text[:300]
# Fallback: dacă lang=ro eșuează, încearcă na (language-agnostic)
if 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.
"""
if looks_romanian(text):
return {
"ok": False,
"error": "pocket-tts nu suportă română — textul trebuie să fie în engleză.",
}
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:
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="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 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) — folosită doar pe calea Supertonic. Default: ro."
)
args = parser.parse_args()
result = synthesize(args.text, voice=args.voice, lang=args.lang)
print(json.dumps(result, ensure_ascii=False))
sys.exit(0 if result.get("ok") else 1)