feat: US-011, US-012 - teste TTS + documentație /voice

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 16:59:41 +00:00
parent 9ddad02879
commit a043e2f177
5 changed files with 206 additions and 5 deletions

View File

@@ -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/<slug>.safetensors"}`. Intrare supertonic: `{"engine": "supertonic"}`.
- **`/voice engine <pockettts|Supertonic>`:** schimbă `tts.default_engine` în config.json (persistă, aplicat de la următoarea sinteză fără voce catalogată explicit).
- **`/voice setvoice <voce>`:** schimbă vocea implicită (autocomplete din `tts_voices.json`); dacă sunt deja în voice channel, swap live pe sesiunea curentă.
- **`/voice addvoice <nume> <sample.wav>`:** 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 <text|url> [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 <id> --file /path --text "mesaj opțional"`
- **De ce nu merge `__AUDIO__:<path>`:** 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ă.