From a043e2f1778545f8eb9249e35129a3f3b8802463 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sat, 11 Jul 2026 16:59:41 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20US-011,=20US-012=20-=20teste=20TTS=20+?= =?UTF-8?q?=20documenta=C8=9Bie=20/voice?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Finalizare manuală după ce ralph.sh a epuizat bugetul de 15 iterații la 10/12 povești (self-improve TTS pocket-tts + Supertonic). - tests/test_tts.py: rutare engine din catalog, fallback tehnic vs eroare de conținut, regression retry lang=na - tests/test_config.py: test dedicat persistență tts.default_engine - personality/TOOLS.md: documentează /voice engine, setvoice, addvoice, doctor și /audio Co-Authored-By: Claude Sonnet 5 --- personality/TOOLS.md | 12 ++- scripts/ralph/prd.json | 8 +- scripts/ralph/progress.txt | 21 +++++ tests/test_config.py | 8 ++ tests/test_tts.py | 162 +++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 tests/test_tts.py diff --git a/personality/TOOLS.md b/personality/TOOLS.md index d3f2796..e4a3fe3 100644 --- a/personality/TOOLS.md +++ b/personality/TOOLS.md @@ -64,12 +64,22 @@ - **Utilizare:** `whisper.load_model('base').transcribe(path, language='ro')` ### Discord Voice -- **Ce este:** Bot conectat la un voice channel Discord — ascultă microfonul lui Marius, transcrie cu faster-whisper (`small` int8, RO), rutează prin router și răspunde rostit cu Supertonic TTS. +- **Ce este:** Bot conectat la un voice channel Discord — ascultă microfonul lui Marius, transcrie cu faster-whisper (`small` int8, RO), rutează prin router și răspunde rostit prin TTS. - **Cum sunt "în voce":** Slash command `/voice join` mă cheamă în channel; cât stau acolo, presence-ul arată că ascult. `/voice leave` sau auto-leave după 5 minute fără voce. - **Latență așteptată:** ~5 secunde perceput end-to-end (STT p50 2.25s + LLM + TTS first chunk). Peste 3s pornesc un filler audio ("Stai să-mi adun gândurile") ca să nu pară mort. - **Streaming TTS:** răspunsul iese pe clauze, nu cuvânt-cu-cuvânt și nu frază întreagă — primul sunet pleacă imediat ce am o propoziție scurtă. - **Limitări:** 1-3 propoziții max (vezi AGENTS.md § Voice mode). Cuvinte rare, nume proprii sau acronime pot apărea ciudat în STT — dacă sună greșit, cer reformulare în loc să ghicesc. +#### TTS: engine pocket-tts + Supertonic +- **Motoare:** `supertonic` (voci predefinite M1-M5/F1-F5, server local `:7788`) și `pockettts` (Kyutai pocket-tts, voice cloning din sample WAV, server local `:7789`, config `tts.pockettts_url`). +- **Rutare (`tools/tts.py::synthesize`):** engine-ul se decide din catalogul `tts_voices.json` — fiecare voce are un `engine` asociat. Voce necatalogată → cade pe `tts.default_engine` din `config.json`. Dacă pocket-tts e indisponibil tehnic (connect error / 5xx) → fallback automat pe Supertonic pentru cererea curentă; erorile de conținut (voce inexistentă, fișier `.safetensors` lipsă) NU declanșează fallback, se raportează direct. +- **Catalog voci:** `tts_voices.json` — sursă live pentru autocomplete pe `/audio` și `/voice setvoice` (o voce nouă adăugată apare imediat, fără redeploy). Intrare pocket-tts: `{"engine": "pockettts", "state_path": "models/voices/.safetensors"}`. Intrare supertonic: `{"engine": "supertonic"}`. +- **`/voice engine `:** schimbă `tts.default_engine` în config.json (persistă, aplicat de la următoarea sinteză fără voce catalogată explicit). +- **`/voice setvoice `:** schimbă vocea implicită (autocomplete din `tts_voices.json`); dacă sunt deja în voice channel, swap live pe sesiunea curentă. +- **`/voice addvoice `:** clonează o voce nouă din sample WAV (minim 3s). Oprește temporar `pocket-tts.service`, rulează `tools/pocket_tts_add_voice.py` în venv-ul separat (`.venv-pockettts/`, niciodată `.venv` principal) care exportă `.safetensors` și scrie catalogul prin `src.jsonlock`, repornește serviciul, trimite preview audio. +- **`/voice doctor`:** health-check complet — libopus, eroare de încărcare voce, `hf_token` în keyring, ping `GET /health` pe serverul pocket-tts (config-driven URL, timeout 3s). +- **`/audio [voce]`:** TTS ad-hoc în afara unui voice channel — text sau URL convertit în voice note trimis pe canal; autocomplete voce din același catalog. + ### Discord — trimite fișier direct (audio, imagini) - **Script:** `python3 tools/discord_send_file.py --channel --file /path --text "mesaj opțional"` - **De ce nu merge `__AUDIO__:`:** convenția aia (din `fast_commands.py`) e interceptată de `discord_bot.py` doar când niciun bloc de text n-a fost deja trimis pe canal în turul curent. Într-un răspuns conversațional normal (situația mea), fiecare bloc de text pe care îl scriu e streamat live — deci `__AUDIO__:` ajunge trimis ca text brut, nu convertit în attachment. Nu încerca trucul ăsta pentru fișiere trimise din conversație normală. diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 84fdf4a..4f9cd1c 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -295,12 +295,12 @@ ], "requiresBrowserCheck": false, "requiresDesignReview": false, - "passes": false, + "passes": true, "failed": false, "blocked": false, "retries": 0, "failureReason": "", - "notes": "" + "notes": "tests/test_tts.py (nou, 11 teste): rutare engine din catalog (pockettts/supertonic), fallback pe default_engine pentru voce necatalogată, fallback tehnic (ConnectError/5xx) pe Supertonic cu engine_used corect, eroare de conținut (fișier .safetensors lipsă) NU declanșează fallback, regression retry lang na->na (fără retry dublu). tests/test_config.py: test dedicat persistență tts.default_engine prin save()/reload() (peste acoperirea generică deja existentă). pytest tests/test_tts.py tests/test_config.py: 34 passed. Suită completă: 1055 passed, 22 preexisting failures neschimbate." }, { "id": "US-012", @@ -322,12 +322,12 @@ ], "requiresBrowserCheck": false, "requiresDesignReview": false, - "passes": false, + "passes": true, "failed": false, "blocked": false, "retries": 0, "failureReason": "", - "notes": "" + "notes": "personality/TOOLS.md, secțiune Discord Voice extinsă cu subsecțiunea TTS: engine pocket-tts + Supertonic — motoare, rutare din tts_voices.json, /voice engine, /voice setvoice, /voice addvoice (venv separat .venv-pockettts, tools/pocket_tts_add_voice.py), /voice doctor (health-check), /audio. Comportament documentat (rutare din catalog, fallback tehnic vs eroare de conținut) verificat contra tools/tts.py și src/adapters/discord_voice.py — nu contrazice implementarea." } ] } diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index e3bcf3d..9ffcbb4 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -273,3 +273,24 @@ Project: echo-core - US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (passes:true) - US-012 (docs, TOOLS.md) — eligibil, dependsOn US-007+US-008+US-009 (toate passes:true) --- + +## Iterație: 2026-07-11 (US-011, US-012 — finalizate manual după epuizarea bugetului ralph de 15 iterații) +### Story implementat: US-011 - Teste minime pentru rutare TTS și config (tags: backend) +### Status: Complete + +### Gates rulate: +- Tests: PASS — pytest tests/test_tts.py tests/test_config.py: 34 passed (11 teste noi tts + 1 test nou config, restul preexistente) +- Suită completă: pytest tests/: 1055 passed, 22 preexisting failures (neschimbate față de toate iterațiile anterioare) + +### Story implementat: US-012 - Documentează comenzile TTS noi în TOOLS.md (tags: docs) +### Status: Complete + +### Gates rulate: +- Review manual: secțiune nouă în personality/TOOLS.md verificată propoziție cu propoziție contra tools/tts.py::synthesize și src/adapters/discord_voice.py (rutare din catalog, fallback tehnic vs eroare de conținut, /voice engine/setvoice/addvoice/doctor, /audio) + +### Learnings: +- ralph.sh a epuizat bugetul de 15 iterații cu 10/12 povești complete (retry-uri repetate pe max-turns=30 la US-004/006/008/009 + o pauză de 30min pe rate limit) — cele două povești rămase (teste + docs) nu aveau nicio complexitate arhitecturală, doar volum de context (citit tools/tts.py, discord_voice.py, tts_voices.json), potrivite pentru finalizare directă fără un nou run ralph. +- _synthesize_pockettts distinge tehnic (ConnectError/timeout/5xx → raise _PocketTTSUnavailable → fallback) de eroare de conținut (fișier stat_path lipsă, 4xx → dict {ok:false} direct, fără fallback) — testat explicit ca regression guard, pentru că e ușor de stricat accidental dacă cineva mută un check de validare în interiorul try-ului. + +### Next: +- Toate cele 12 povești din PRD sunt passes:true. Self-improve TTS (pocket-tts + Supertonic routing) e complet. diff --git a/tests/test_config.py b/tests/test_config.py index 104840d..44478ef 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -133,6 +133,14 @@ class TestConfigSave: assert raw["bot"]["name"] == "TestBot" assert raw["new_key"] == "new_value" + def test_tts_default_engine_persists_through_save_and_reload(self, tmp_config): + cfg = Config(tmp_config) + cfg.set("tts.default_engine", "pockettts") + cfg.save() + + cfg2 = Config(tmp_config) + assert cfg2.get("tts.default_engine") == "pockettts" + class TestConfigReload: def test_reload_picks_up_external_changes(self, tmp_config): diff --git a/tests/test_tts.py b/tests/test_tts.py new file mode 100644 index 0000000..b1c3225 --- /dev/null +++ b/tests/test_tts.py @@ -0,0 +1,162 @@ +"""Tests for tools/tts.py — rutare engine, fallback pockettts -> supertonic, retry lang=na.""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from tools.tts import _PocketTTSUnavailable, _synthesize_pockettts, _synthesize_supertonic, synthesize + + +def _fake_response(status_code=200, content=b"RIFF....WAVE", text=""): + resp = MagicMock() + resp.status_code = status_code + resp.content = content + resp.text = text + if status_code >= 400: + resp.raise_for_status.side_effect = httpx.HTTPStatusError( + "error", request=MagicMock(), response=resp + ) + else: + resp.raise_for_status = MagicMock() + return resp + + +class TestEngineRouting: + @patch("tools.tts._load_voice_catalog") + @patch("tools.tts._synthesize_pockettts") + def test_catalogued_voice_uses_pockettts(self, mock_pockettts, mock_catalog): + mock_catalog.return_value = { + "Marius 1": {"engine": "pockettts", "state_path": "models/voices/marius-1.safetensors"} + } + mock_pockettts.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10} + + result = synthesize("salut", voice="Marius 1") + + mock_pockettts.assert_called_once() + assert result["engine_used"] == "pockettts" + assert result["ok"] is True + + @patch("tools.tts._load_voice_catalog") + @patch("tools.tts._synthesize_supertonic") + def test_catalogued_voice_uses_supertonic(self, mock_supertonic, mock_catalog): + mock_catalog.return_value = {"M1": {"engine": "supertonic"}} + mock_supertonic.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10} + + result = synthesize("salut", voice="M1") + + mock_supertonic.assert_called_once() + assert result["engine_used"] == "supertonic" + + @patch("tools.tts._config_get") + @patch("tools.tts._load_voice_catalog") + @patch("tools.tts._synthesize_pockettts") + def test_uncatalogued_voice_falls_back_to_default_engine_config( + self, mock_pockettts, mock_catalog, mock_config_get + ): + mock_catalog.return_value = {} + mock_config_get.return_value = "pockettts" + mock_pockettts.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10} + + result = synthesize("salut", voice="voce-inexistenta") + + mock_config_get.assert_called_with("tts.default_engine", "supertonic") + assert result["engine_used"] == "pockettts" + + +class TestPockettsFallback: + @patch("tools.tts._load_voice_catalog") + @patch("tools.tts._synthesize_supertonic") + @patch("tools.tts._synthesize_pockettts") + def test_connect_error_falls_back_to_supertonic( + self, mock_pockettts, mock_supertonic, mock_catalog + ): + mock_catalog.return_value = {"Marius 1": {"engine": "pockettts"}} + mock_pockettts.side_effect = _PocketTTSUnavailable("connection refused") + mock_supertonic.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10} + + result = synthesize("salut", voice="Marius 1") + + mock_supertonic.assert_called_once() + assert result["engine_used"] == "supertonic" + assert result["ok"] is True + + @patch("tools.tts._load_voice_catalog") + @patch("tools.tts._synthesize_supertonic") + @patch("tools.tts._synthesize_pockettts") + def test_5xx_falls_back_to_supertonic(self, mock_pockettts, mock_supertonic, mock_catalog): + mock_catalog.return_value = {"Marius 1": {"engine": "pockettts"}} + mock_pockettts.side_effect = _PocketTTSUnavailable("HTTP 503: unavailable") + mock_supertonic.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10} + + result = synthesize("salut", voice="Marius 1") + + mock_supertonic.assert_called_once() + assert result["engine_used"] == "supertonic" + + @patch("tools.tts._load_voice_catalog") + @patch("tools.tts._synthesize_supertonic") + def test_content_error_does_not_fall_back_to_supertonic(self, mock_supertonic, mock_catalog): + """Missing state_path file -> ok:false dict, not a raised _PocketTTSUnavailable — no fallback.""" + mock_catalog.return_value = { + "Marius 1": {"engine": "pockettts", "state_path": "models/voices/does-not-exist.safetensors"} + } + + result = synthesize("salut", voice="Marius 1") + + mock_supertonic.assert_not_called() + assert result["ok"] is False + assert result["engine_used"] == "pockettts" + assert "lipsă" in result["error"] + + @patch("httpx.post") + def test_synthesize_pockettts_raises_on_connect_error(self, mock_post): + mock_post.side_effect = httpx.ConnectError("connection refused") + with pytest.raises(_PocketTTSUnavailable): + _synthesize_pockettts("salut", {}) + + @patch("httpx.post") + def test_synthesize_pockettts_raises_on_5xx(self, mock_post): + mock_post.return_value = _fake_response(status_code=503, text="unavailable") + with pytest.raises(_PocketTTSUnavailable): + _synthesize_pockettts("salut", {}) + + @patch("httpx.post") + def test_synthesize_pockettts_returns_error_dict_on_4xx(self, mock_post): + mock_post.return_value = _fake_response(status_code=400, text="bad request") + result = _synthesize_pockettts("salut", {}) + assert result["ok"] is False + assert "400" in result["error"] + + +class TestSupertonicLangNaRetry: + @patch("tools.tts._load_voice_catalog") + @patch("httpx.post") + def test_ro_failure_retries_with_lang_na(self, mock_post, mock_catalog): + mock_catalog.return_value = {} + + def side_effect(url, json, timeout): + if json.get("lang") == "ro": + return _fake_response(status_code=500, text="ro synthesis failed") + return _fake_response(status_code=200, content=b"RIFF....WAVE") + + mock_post.side_effect = side_effect + + result = _synthesize_supertonic("salut", voice="M1", lang="ro") + + assert result["ok"] is True + assert mock_post.call_count == 2 + langs_requested = [call.kwargs["json"]["lang"] for call in mock_post.call_args_list] + assert langs_requested == ["ro", "na"] + + @patch("tools.tts._load_voice_catalog") + @patch("httpx.post") + def test_na_failure_does_not_retry_again(self, mock_post, mock_catalog): + """Regression: retry only happens once (ro -> na), na failure returns the error directly.""" + mock_catalog.return_value = {} + mock_post.return_value = _fake_response(status_code=500, text="still failing") + + result = _synthesize_supertonic("salut", voice="M1", lang="na") + + assert result["ok"] is False + assert mock_post.call_count == 1