# 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=<nume predefinit>` 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 <pockettts|supertonic>`** — comandă nouă, setează `config.tts.default_engine`, persistă. Răspunde cu confirmare + vocea default curentă a engine-ului ales.
2. **`/voice addvoice <nume> <sample>`** — 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 <path> --name "<Nume>"` (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.
