From 7ede30d156d15d01b2a5e001f2c9cd0c5f15d351 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sat, 11 Jul 2026 10:28:06 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20US-006=20-=20Refactor=20tools/tts.py=20?= =?UTF-8?q?=E2=80=94=20rutare=20engine,=20backend=20pocket-tts=20=C8=99i?= =?UTF-8?q?=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scripts/ralph/prd.json | 8 +-- scripts/ralph/progress.txt | 81 ++++++++++++++++++++++ tools/tts.py | 134 ++++++++++++++++++++++++++++++++----- 3 files changed, 203 insertions(+), 20 deletions(-) diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 54cf881..0cba530 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -159,12 +159,12 @@ ], "requiresBrowserCheck": false, "requiresDesignReview": false, - "passes": false, + "passes": true, "failed": false, "blocked": false, - "retries": 0, + "retries": 1, "failureReason": "", - "notes": "" + "notes": "Fișiere: tools/tts.py (refactor). synthesize() rezolvă engine din tts_voices.json (entry.get(\"engine\") sau fallback la config tts.default_engine); _synthesize_supertonic (fost synthesize, cap _MAX_TTS_CHARS=400 + retry recursiv lang=\"na\" păstrate neschimbate) și _synthesize_pockettts (POST multipart la {pockettts_url}/tts cu voice_wav din state_path sau voice_url, ignoră lang) nou. _PocketTTSUnavailable (ConnectError/Timeout/5xx) declanșează fallback automat pe _synthesize_supertonic(voice=M2, lang=ro) conform AC; erori de conținut (fișier voce lipsă, 4xx, text gol) NU declanșează fallback. Rezultatul include engine_used. Semnătura publică synthesize(text, voice, lang) neschimbată — verificat caller src/fast_commands.py. Gate /workflow:simplify (refactor, 4 agenți reuse/simplification/efficiency/altitude): aplicat 1 fix sigur (engine_used setat o singură dată, nu triplu literal); respinse ca false-positive 3 findings (voice=M2/lang=ro hardcodat pe fallback e cerință explicită din AC, nu bandaid; lookup catalog case-insensitive ar schimba comportament fără beneficiu clar în scope); notat pentru follow-up neaplicat: caching catalog/config/voice-file per sesiune în hot path-ul de streaming (src/voice/tts_stream.py) — necesită schimbări în afara scope-ului acestui story. Gate /review (backend, manual): PASS — fără shell injection, fără secret leakage, error handling scoped corect (ConnectError/TimeoutException/HTTPStatusError), API contract păstrat. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)." }, { "id": "US-007", @@ -330,4 +330,4 @@ "notes": "" } ] -} \ No newline at end of file +} diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index aa059e2..64f233b 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -44,3 +44,84 @@ Project: echo-core - US-003 (db+backend, catalog tts_voices.json) — eligibil, dependsOn gol - US-005 (backend, config.json tts section) — eligibil, dependsOn gol --- + +## Iterație: 2026-07-11 (US-004) +### Story implementat: US-004 - Script de export voce pocket_tts_add_voice.py (tags: backend) +### Status: Complete + +### Gates rulate: +- Typecheck: SKIP (mypy neinstalat în .venv) +- Lint: SKIP (ruff neinstalat în .venv) +- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat) +- /review (backend): PASS — script mic, single-responsibility, erori clare (wav lipsă / hf_token lipsă din keyring), fără shell injection / secret leakage (HF_TOKEN doar în os.environ al subprocesului, niciodată logat) +- Verificare manuală: .venv-pockettts/bin/python tools/pocket_tts_add_voice.py --help rulează curat fără a atinge .venv principal; _next_free_index() -> 4 pentru Marius/Paula (seed 1-3 deja ocupat), 1 pentru nume noi; toate cele 6 blob-uri models/voices/{marius,paula}-{1,2,3}.safetensors validate cu safetensors.torch.load_file (12 tensori fiecare, fără corupere) + +### Learnings: +- Iterația anterioară (US-004, retries=1) a atins max_turns=30 în timp ce lucra pe US-003 și US-004 în aceeași sesiune (script scris, model încărcat, 6 voci exportate, catalog scris) dar nu a apucat să facă commit — la începutul iterației verifică mereu `git status`/`git log` înainte să presupui că trebuie reimplementat de la zero; codul + blob-urile deja produse pot fi validate în loc de regenerate (re-exportul ar fi reîncărcat modelul, costisitor). +- `tools/pocket_tts_add_voice.py` a ajuns commitat accidental în același commit cu US-003 (e3b5cdf) pentru că era deja `git add`-uit din sesiunea anterioară — commit-urile per-story nu sunt garantat izolate dacă lucrul anterior a lăsat fișiere staged; verifică `git diff --cached` înainte de commit ca să știi exact ce intră. +- `scripts/ralph/prd.json` (harness PRD-ul acestui proiect) și `cron/jobs.json` (timestamp-uri live ale schedulerului) nu trebuie commitate ca parte a unui story de cod — doar prd.json e commitat, dar separat, ca update de stare, nu amestecat în diff-ul de cod al story-ului. + +### Next: +- US-005 (backend, config.json secțiune tts) — eligibil, dependsOn gol +- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true) +--- + +## Iterație: 2026-07-11 (US-005) +### Story implementat: US-005 - Adaugă secțiunea tts în config.json (tags: backend) +### Status: Complete + +### Gates rulate: +- Typecheck: SKIP (mypy neinstalat în .venv) +- Lint: SKIP (ruff neinstalat în .venv) +- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat) +- /review (backend): review manual (skill-ul interactiv /review nu se pretează unei iterații autonome — cere AskUserQuestion) — diff e doar date (4 linii JSON), fără cod nou care citește tts.* (US-006 face asta), fără secrete, JSON valid + +### Learnings: +- src/config.py::Config e deja complet generic (get/set/save/reload cu dot-notation pe orice cheie nouă) — adăugarea unei secțiuni noi în config.json nu cere nicio schimbare de cod, doar date. tests/test_config.py deja acoperă roundtrip-ul set→save→reload generic, deci AC-urile de persistență ale story-ului sunt satisfăcute fără teste noi. +- Skill-ul gstack `/review` e construit pentru revizuire PR interactivă (AskUserQuestion, gh pr, telemetry) — pentru iterații Ralph autonome cu diff-uri mici/triviale, un review manual (grep pentru cod care citește noua cheie, verificare JSON valid, verificare secrete) e suficient și evită blocarea pe input interactiv indisponibil. + +### Next: +- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true) +- US-006 (backend+refactor, rutare engine tts.py) — NU eligibil încă, dependsOn include US-003 (passes:true) dar și US-005 (acum passes:true) — devine eligibil următoarea iterație +--- + +## Iterație: 2026-07-11 (US-006) +### Story implementat: US-006 - Refactor tools/tts.py — rutare engine, backend pocket-tts și fallback (tags: backend, refactor) +### Status: Complete + +### Gates rulate: +- Typecheck: SKIP (mypy neinstalat în .venv) +- Lint: SKIP (ruff neinstalat în .venv) +- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat) +- /workflow:simplify (refactor): 4 agenți paraleli (reuse/simplification/efficiency/altitude) pe diff-ul tools/tts.py. + Aplicat 1 fix sigur: `engine_used` setat o singură dată în loc de trei literali duplicate. + Respinse ca false-positive (verificate contra AC-urilor explicite din PRD, nu doar "pare hardcodat"): + - voice=DEFAULT_VOICE, lang=DEFAULT_LANG hardcodate pe calea de fallback tehnic — AC-ul US-006 cere explicit + `_synthesize_supertonic(voice='M2', lang='ro')`; pocket-tts ignoră lang complet, deci la fallback nu există + niciun semnal de încredere pentru ce limbă Supertonic ar trebui folosită — design intenționat, nu bandaid. + - lookup catalog case-insensitive unificat — ar schimba comportamentul (matching pe nume gen "Marius 1") + fără cerință din AC, risc fără beneficiu clar în scope-ul acestui story. + Notat pentru follow-up (NU implementat, în afara scope-ului): `_load_voice_catalog()`/`_config_get()`/citirea + fișierului de voce clonată se repetă per-clauză în hot path-ul de streaming (src/voice/tts_stream.py) — un + cache per-sesiune ar elimina I/O redundant, dar necesită schimbări în afara tools/tts.py. +- /review (backend, manual — skill-ul interactiv nu se pretează unei iterații autonome): PASS — fără shell + injection, fără secret leakage, error handling scoped corect (ConnectError/TimeoutException/HTTPStatusError + separate de erorile de conținut 4xx), semnătura publică synthesize(text, voice, lang) neschimbată (verificat + caller-ul src/fast_commands.py::_tts_synthesize). + +### Learnings: +- Codul pentru US-006 era deja scris (necommitat) dintr-o iterație anterioară care nu a apucat să facă commit — + la fel ca US-003/US-004, verifică mereu `git diff`/`git status` înainte să presupui reimplementare de la zero. +- Gate-urile `/simplify` (agenți automați) găsesc uneori "probleme" care sunt de fapt cerințe explicite din AC + (ex. valorile hardcodate de fallback voice='M2'/lang='ro') — verifică fiecare finding contra textului literal + al acceptance criteria din PRD înainte să aplici un fix, nu doar contra intuiției generale de cod curat. +- Findings de eficiență care ar necesita schimbări cross-file (aici: caching în src/voice/tts_stream.py pentru + hot path-ul de streaming) rămân notate ca follow-up, nu aplicate — story-ul e scopat strict la tools/tts.py. + +### Next: +- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true) +- US-007 (ui+backend, /voice engine) — eligibil, dependsOn US-005+US-006 (ambele acum passes:true) +- US-008 (ui+backend, /voice addvoice) — eligibil, dependsOn US-004+US-006 (ambele acum passes:true) +- US-009 (ui+backend, autocomplete voci) — eligibil, dependsOn US-003+US-006 (ambele acum passes:true) +- US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (acum passes:true) +--- diff --git a/tools/tts.py b/tools/tts.py index 3eb5d87..eeeaee0 100644 --- a/tools/tts.py +++ b/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()