# Plan: integrare pocket-tts (Kyutai) ca engine TTS nou Status: APROBAT de Marius — plan + review conversațional (`/plan-eng-review` manual, fără AskUserQuestion — indisponibil în mediul Echo Core) complete. Gata de implementare. Sursă cerință: `memory/kb/projects/pocket-tts-integration-handoff-prompt.md` ## 0. Descoperire tehnică nouă (nu era în handoff/eval anterior) Am verificat sursa pachetului `pocket-tts` (`/tmp/pocket-tts-test/.venv/.../pocket_tts/main.py` + `models/tts_model.py`): - `pocket-tts` are deja **server HTTP built-in**: `pocket-tts serve --host --port [--language] [--quantize]`, expune `GET /health` și `POST /tts` (form-data: `text`, plus `voice_url` SAU `voice_wav` upload). - `TTSModel.get_state_for_audio_prompt(path)` **detectează automat sufixul `.safetensors`** și îl încarcă rapid (`_import_model_state`), fără să re-proceseze audio. Endpoint-ul `/tts` primește `voice_wav` ca `UploadFile` și păstrează sufixul fișierului original când îl salvează temporar — deci dacă trimitem un fișier cu nume `Marius 1.safetensors`, serverul îl încarcă pe calea rapidă, automat, **fără nicio modificare la codul pocket-tts**. - Concluzie: **nu trebuie scris un server HTTP custom** care încarcă modelul manual — folosim serverul built-in ca atare. Simplifică planul semnificativ față de ce anticipam inițial (evită fork/monkeypatch, evită gestiune proprie a modelului în memorie). - Singurul lucru care tot trebuie scris manual: un script de **export** (`get_state_for_audio_prompt(wav) → export_model_state() → .safetensors`) pentru fluxul de adăugare voce nouă, pentru că endpoint-ul HTTP nu expune un pas separat de "doar exportă starea" — el mereu generează și audio. ## 1. Arhitectură ``` ┌─────────────────────┐ /audio, voice live ──▶│ tools/tts.py │ (neschimbat ca interfață publică: │ synthesize(text, │ synthesize(text, voice, lang)) │ voice, lang) │ └──────────┬───────────┘ │ decide engine după voice_id (catalog) ┌──────────────┴───────────────┐ ▼ ▼ engine=pockettts (default) engine=supertonic (fallback / M*/F*) POST :7789/tts (multipart) POST :7788/v1/audio/speech (json, existent) │ │ pocket-tts.service (nou, supertonic-tts.service (existent, venv separat .venv-pockettts, neschimbat) built-in server, port 7789) ``` **Servicii noi:** - `pocket-tts.service` (systemd user unit, model pe template `supertonic-tts.service`), pornește serverul built-in pe `127.0.0.1:7789`, venv separat `~/echo-core/.venv-pockettts` (torch e greu — nu intră în `.venv` principal, per cerința #1 din handoff). - HF_TOKEN: launcher mic (`tools/pocket_tts_env_launch.sh` sau echivalent Python) care citește `hf_token` din keyring (`src/credential_store.get_secret`) și îl exportă ca `HF_TOKEN` înainte de `exec pocket-tts serve ...`. Evită să scriem tokenul în plaintext într-un `EnvironmentFile=`. `keyring` trebuie instalat și în venv-ul nou (dependință mică, nu torch). - **Verificare acces keyring din systemd --user** — **CONFIRMAT de Marius**: primul pas la implementare, nu presupunere. Bot-ul principal rulează deja ca `systemctl --user` și citește keyring cu succes, deci `pocket-tts.service` (tot user-level) ar trebui să aibă același acces — dar se verifică explicit înainte de a construi restul serviciului. Launcher-ul eșuează CLAR (log + exit non-zero) dacă `get_secret("hf_token")` întoarce `None`, nu silențios. - **`hf_token` lipsă din `REQUIRED_SECRETS`** — **CONFIRMAT de Marius**: `src/credential_store.py` verifică azi doar `discord_token`. Se adaugă `hf_token` la o listă de verificare (fie `REQUIRED_SECRETS`, fie check dedicat în `/voice doctor` §7) — altfel un token lipsă/expirat se descoperă abia la runtime, cu eroare confuză. ## 2. Catalog voci (unificat, extensibil fără redeploy de cod) Fișier nou `tts_voices.json` la rădăcina repo-ului (pattern identic cu `approved-tasks.json` — JSON plat, scris prin `src/jsonlock.py` pentru concurrent-safety, la fel ca `sessions/*.json`): ```json { "M1": {"engine": "supertonic"}, "M2": {"engine": "supertonic"}, ..., "F1": {"engine": "supertonic"}, ..., "Marius 1": {"engine": "pockettts", "state_path": "models/voices/marius-1.safetensors", "owner": "Marius"}, "Marius 2": {"engine": "pockettts", "state_path": "models/voices/marius-2.safetensors", "owner": "Marius"}, "Marius 3": {"engine": "pockettts", "state_path": "models/voices/marius-3.safetensors", "owner": "Marius"} } ``` - `.safetensors` blobs în `models/voices/` — `models/` e deja în `.gitignore` (verificat), consistent cu `models/whisper-small-ro-cv11-int8/` existent. Nu intră în git (fișiere binare per-persoană, private). - `tts_voices.json` (doar mapare nume→path, mic) **intră în git** — la fel ca `approved-tasks.json`. - La pornire, seed cu cele 3 voci deja existente ale lui Marius (sample-urile din `~/workspace/pocket-tts-test/marius_real_voice*.wav`, deja înregistrate) — le exportăm în `.safetensors` o singură dată la implementare, nu trebuie re-trimise de Marius. - Sample-urile **Paula** deja clonate manual în sesiunea de research (`paula_*.wav`) — **CONFIRMAT de Marius: se includ în seed-ul inițial** al catalogului, alături de cele 3 voci Marius. ## 3. `tools/tts.py` — refactor minimal, interfață publică neschimbată - `synthesize(text, voice=DEFAULT_VOICE, lang=DEFAULT_LANG)` rămâne semnătura folosită de tot restul codului (`tts_stream.py`, `fast_commands.py`) — **zero schimbări la apelanți**. - Intern: citește `tts_voices.json`, rezolvă `engine` din `voice` (dacă voice nu e în catalog → fallback la `default_engine` din `config.json`, voce default a acelui engine). - **Sursă unică de adevăr pentru voci valide** — **CONFIRMAT de Marius**: `tts_voices.json` înlocuiește complet cele 4 liste hardcodate existente azi (`VOICES` din `tools/tts.py`, `_VOICES` din `fast_commands.py`, choices statice din `discord_bot.py`/`discord_voice.py`). Toate se rescriu să citească din catalog — elimină riscul de drift între liste. `voice_commands.py._VALID_VOICES` rămâne SEPARAT și neschimbat (regex in-band, vezi §6) — nu e un duplicat de eliminat, e un scop diferit (comenzi vorbite, doar M*/F*). - **Cap de lungime text** — **CONFIRMAT de Marius**: `_MAX_TTS_CHARS = 400` rămâne DOAR pe calea `_synthesize_supertonic` (e specific limitării ONNX, documentat ca atare azi). `_synthesize_pockettts` fără cap inițial — revizuim dacă apar probleme de memorie/latență la text lung, nu preventiv. - `_synthesize_pockettts(text, voice_entry)`: POST multipart la `:7789/tts` cu `text` + (`voice_wav=<.safetensors deschis>` dacă e voce clonată, sau `voice_url=` dacă e voce non-clonată default pocket-tts, ex. "alba"). `lang` e ignorat complet (cerința #3 — pocket-tts vorbește mereu "englezește fonetic", fără rutare pe limbă). - **Voce predefinită pocket-tts** (non-clonată, ex. "alba") — **CONFIRMAT de Marius: se include în selector** ca opțiune suplimentară alături de M1-M5/F1-F5/vocile clonate, adăugată în `tts_voices.json` seed cu `{"engine": "pockettts", "voice_url": "alba"}` (fără `state_path`). - `_synthesize_supertonic(...)` = codul actual, neschimbat, redenumit intern. - **Fallback automat** (cerința #4a): dacă `_synthesize_pockettts` eșuează cu eroare tehnică (`httpx.ConnectError`, timeout, `HTTPStatusError` 5xx, HF token invalid) → log warning + retry automat pe `_synthesize_supertonic(text, voice="M2", lang="ro")` (voce default Supertonic, pentru că vocea pocket-tts cerută n-are corespondent Supertonic 1:1). Rezultatul returnat include `"engine_used"` în dict — **CONFIRMAT de Marius: se implementează**, pentru observabilitate/debug la fallback-uri silențioase. - Fallback NU se declanșează pe erori de conținut (text gol, voce inexistentă) — doar pe eșec tehnic de conectare/serviciu, per cerința #4a explicită. ## 4. Config nou (`config.json`) ```json "tts": { "default_engine": "pockettts", "pockettts_url": "http://127.0.0.1:7789" } ``` - `default_engine` persistă în `config.json` (nu ephemeral) — răspunde la întrebarea deschisă din handoff (§ edge cases, ultimul punct "ephemeral sau persistă"): **persistă**, motivat de faptul că Marius vrea un comportament stabil între restart-uri de sesiune/bot, nu un toggle per conversație. Comanda de switch engine (§5) scrie aici via `Config().set(...)`. - Când `voice` explicit cerut de user e o voce catalogată (ex. "Marius 1" sau "M2"), engine-ul e determinat de catalog — `default_engine` contează DOAR când nu se specifică nicio voce (default absolut, ex. `/audio` fără parametri, sau intrare în voice live fără `/voice setvoice` anterior). Asta rezolvă ambiguitatea "ce înseamnă switch engine dacă fiecare voce știe deja ce engine e" — răspunde la "care e vocea implicită" nu "forțează un engine peste o voce incompatibilă". ## 5. Comenzi Discord noi/modificate Toate sub grupul `/voice` existent (`src/adapters/discord_voice.py`) + `/audio` (`discord_bot.py`): 1. **`/voice engine `** — comandă nouă, setează `config.tts.default_engine`, persistă. Răspunde cu confirmare + vocea default curentă a engine-ului ales. 2. **`/voice addvoice `** — comandă nouă, `sample: discord.Attachment` (wav). Flow: - `await interaction.response.defer(ephemeral=True)` (pattern obligatoriu din CLAUDE.md pentru I/O în callback). - descarcă attachment-ul, validează extensie audio + durată minimă (~3s, sub asta cloning-ul e slab per research anterior). - rulează `<.venv-pockettts>/bin/python tools/pocket_tts_add_voice.py --wav --name ""` (subprocess, script nou care încarcă modelul o singură dată — separat de venv principal, deci nu se poate face în-proces). - scriptul determină automat următorul `N` liber pentru acel nume (scanând `tts_voices.json`), exportă `.safetensors`, scrie catalog-ul prin `jsonlock`. - răspunde cu numele final atribuit (ex. "Marius 4") + un sample audio generat pe loc cu vocea nouă, ca preview. - **Notă cost**: încărcarea modelului pentru export durează ~zeci de secunde și consumă memorie suplimentară CÂT TIME rulează. **CONFIRMAT de Marius: `/voice addvoice` oprește temporar `pocket-tts.service` cât durează export-ul** (evită două instanțe TTSModel simultan pe memorie strânsă), cu mesaj către user: "Adaug voce, TTS indisponibil ~30s". 3. **`/audio`** (`discord_bot.py`) — parametrul `voce` trece de la `@app_commands.choices` (listă statică, max 25 hardcodate) la **`@app_commands.autocomplete`** care citește `tts_voices.json` live — necesar ca vocile clonate noi să apară fără redeploy (cerința #8: "flux clar... fără intervenție manuală de cod"). Restul comenzii (`fast_dispatch`, `__AUDIO__:` convenție) neschimbat. 4. **`/voice setvoice`** (deja există, `discord_voice.py`) — același tratament: choices statice → autocomplete din catalog, pentru selectorul unificat cerut la §6 din handoff. ## 6. Integrare mod live (`src/voice/pipeline.py` + `tts_stream.py`) - `TTSQueue.__init__(voice_id, lang)` — neschimbat structural. `_worker_loop` apelează `synthesize(item, voice=self.voice_id, lang=self.lang)` — deja engine-agnostic după refactor-ul din §3, **zero schimbări în `tts_stream.py`** dincolo de faptul că `synthesize()` intern rutează diferit. - `_ffmpeg_resample` deja normalizează orice WAV primit la 48kHz stereo s16le — pocket-tts produce 24kHz mono (mimi codec), cade pe calea `_ffmpeg_resample` automat (nu e "target format"), **zero schimbări** (răspunde la edge case-ul din handoff despre sample rate). - `detect_voice_change` (in-band voice switching prin STT, `voice_commands.py`) — regex-urile curente prind DOAR `M1-M5`/`F1-F5`. Vocile clonate ("Marius 1") nu vor fi comutabile din voce vorbită cu regex-urile actuale (ambiguu — "Marius 1" ar suna identic cu multe fraze normale). **Decizie propusă**: las in-band switching neschimbat (doar M*/F*), vocile clonate se schimbă doar din `/voice setvoice` (text/slash command), nu din voce. Flag pentru confirmare — nu extindem regex-ul de voce vorbită la nume libere (risc fals-pozitive). - Barge-in mid-conversație pe eșec pocket-tts (edge case explicit din handoff): fallback-ul e în `tools/tts.py` (§3), deci e transparent pentru `tts_stream.py` — un clause care eșuează tehnic pe pocket-tts cade automat pe Supertonic ÎN ACEEAȘI clauză, fără să rupă turul. Restul clauzelor din același turn continuă pe orice engine a răspuns ultima dată cu succes (nu schimbă `self.voice_id`/engine persistent — fallback e per-apel, nu schimbă starea sesiunii). ## 7. `/voice doctor` — extindere Comanda existentă de health-check extinde cu un ping la `GET :7789/health` pentru pocket-tts, alături de verificarea Supertonic existentă (dacă există deja — de verificat implementarea curentă la implementare). ## 8. Ce NU se schimbă (impact minim, per CLAUDE.md) - `normalize_for_tts` (normalizare numere/timp în cuvinte românești) — rămâne neschimbată. Da, asta înseamnă că textul normalizat românește va fi rostit de un model englez (accent + posibil pronunție ciudată pe cuvinte românești vs cifre brute) — dar cerința #3 din handoff acceptă explicit accentul, iar schimbarea normalizării pe baza engine-ului activ ar fi scope creep nesolicitat. Notă pentru Marius, nu acțiune. - Nimic din `src/router.py`, `src/claude_session.py` nu se atinge. - `supertonic-tts.service` neschimbat. ## 9. Fișiere noi vs modificate (rezumat pentru review) **Noi:** - `tools/pocket_tts_add_voice.py` (script export voce, rulat în venv separat) - `tts_voices.json` (catalog, git-tracked) - `models/voices/*.safetensors` (blobs, gitignored) - `~/.config/systemd/user/pocket-tts.service` - `.venv-pockettts/` (venv separat, gitignored) - launcher HF_TOKEN pentru systemd (script mic) **Modificate:** - `tools/tts.py` (rutare engine, fallback) - `config.json` (secțiune `tts`) - `src/adapters/discord_bot.py` (`/audio` → autocomplete voce) - `src/adapters/discord_voice.py` (`/voice engine`, `/voice addvoice`, `/voice setvoice` → autocomplete, `/voice doctor` → check pocket-tts) - `personality/TOOLS.md` (documentare comenzi noi, pattern din regulile proiectului) **Neschimbate:** `src/voice/pipeline.py`, `src/voice/tts_stream.py`, `src/voice/voice_commands.py`, `src/router.py`, `src/claude_session.py`, `supertonic-tts.service`. ## 10. Puncte deschise — TOATE APROBATE de Marius (DA la toate) 1. Vocile Paula deja clonate manual în research — **incluse** în seed-ul inițial al catalogului. 2. `/voice addvoice` — **oprește temporar** `pocket-tts.service` cât exportă (UX: mesaj "Adaug voce, TTS indisponibil ~30s"). 3. `engine_used` în răspunsul `synthesize()` — **implementat**, pentru debug/observabilitate. 4. Voce predefinită pocket-tts (non-clonată, ex. "alba") — **inclusă** ca opțiune suplimentară în selector. ## 11. Teste (minime, realiste — fără model real) Coverage azi: zero teste pe `tools/tts.py`, `tts_stream.py`, `discord_voice.py` (doar `voice_commands.py` e testat, `tests/test_voice_commands.py`). **CONFIRMAT de Marius**: se adaugă teste minime, pure-function, fără dependență de model/hardware real: - `tests/test_tts.py` (nou): - rutare engine din `tts_voices.json`: voce catalogată → engine corect ales (mock fișier catalog). - voce necatalogată → fallback la `default_engine` din config. - trigger fallback tehnic: mock `httpx.ConnectError`/`HTTPStatusError` 5xx pe `_synthesize_pockettts` → verifică apel automat pe `_synthesize_supertonic` cu voce default, și `engine_used` corect în rezultat. - fallback NU se declanșează pe eroare de conținut (voce inexistentă) — verifică `_synthesize_pockettts` nu e apelat de două ori / nu cade pe supertonic pentru input gol. - `lang="na"` retry recursiv existent în `_synthesize_supertonic` — regression test, verifică nu s-a pierdut la redenumire (§3, punctul "notă cod"). - `tests/test_config.py` (extindere, dacă există, sau nou): `Config().set("tts.default_engine", ...)` persistă corect prin `save()`/`reload()`. - **NU** se testează: modelul pocket-tts propriu-zis, `/voice addvoice` end-to-end (necesită model+audio real), streaming live (necesită Discord voice real) — astea rămân verificare manuală la implementare (`/qa` sau test manual ghidat). ## 12. Performanță - **CONFIRMAT de Marius**: verificare manuală RAM disponibil pe box ÎNAINTE de a porni `pocket-tts.service` ca engine default (nu doar la `/voice addvoice`, unde deja era acoperit la §5 punctul 2). Rulare simultană pocket-tts (torch) + Supertonic (ONNX, idle) + Whisper STT (activ în timpul sesiunilor voice) + bot-ul principal, pe același box — pas manual de 30 secunde (`free -h` înainte de a activa `pocket-tts.service`), nu automatizare. Dacă memoria e strânsă, flag pentru Marius înainte de a continua. ## NOT in scope - Rutare automată pe limbă (pocket-tts mereu engleză fonetic, indiferent de input) — respinsă explicit de Marius în handoff (cerința #3). - Extinderea regex-ului de voce vorbită (`voice_commands.py`) la nume libere ("Marius 1") — risc fals-pozitive prea mare; vocile clonate se schimbă doar din `/voice setvoice`. - Teste end-to-end cu model real / audio real — necesită hardware, nu se pretează unit test; verificare manuală la implementare. - Schimbarea `normalize_for_tts` în funcție de engine activ — scope creep nesolicitat (notă în §8, nu acțiune). - Server HTTP custom pentru pocket-tts — serverul built-in e suficient (descoperire §0), evită fork/monkeypatch. ## What already exists (reused, not rebuilt) - `synthesize(text, voice, lang) -> dict` — interfața publică rămâne neschimbată, toți apelanții (`tts_stream.py`, `fast_commands.py`) nu se ating. - `_ffmpeg_resample` — deja generic (auto-detectează sample rate din header WAV), gestionează 24kHz mono de la pocket-tts fără nicio schimbare. - `src/jsonlock.py` — refolosit ca atare pentru `tts_voices.json`, pattern identic cu `approved-tasks.json`. - `src/credential_store.py` — refolosit ca atare pentru `hf_token` (deja în keyring). - `/voice doctor` — pattern de health-check existent, extins (nu reconstruit) cu ping pocket-tts. - `supertonic-tts.service` — folosit ca template pentru `pocket-tts.service`, neschimbat el însuși. ## Rezumat review (`/plan-eng-review` manual, conversațional — AskUserQuestion indisponibil în Echo Core) - Arhitectură: 3 probleme găsite, toate rezolvate (unificare liste voci, cap text Supertonic-only, verificare keyring explicită). - Code quality: 2 probleme găsite (regresie fallback lang="na", `hf_token` lipsă din REQUIRED_SECRETS) — rezolvate. - Teste: gap total identificat (zero coverage) → secțiune de teste minime adăugată (§11). - Performanță: 1 problemă (memorie concurentă) → pas manual de verificare adăugat (§12). - NOT in scope: scris. - What already exists: scris. - Outside voice (codex/subagent independent): skip — nu era disponibil tool-ul interactiv pentru workflow-ul complet; planul a fost deja verificat direct pe sursa pocket-tts (§0) și pe codul echo-core existent (agent Explore dedicat). - Toate cele 7 puncte + cele 4 din §10: aprobate explicit de Marius.