Files
echo-core/tasks/pocket-tts-integration-plan.md
Marius Mutu 155d6cb9c1 Update TOOLS.md, cron jobs, KB index; add discord file sender + pocket-tts plan
Pre-existing work committed before starting Ralph self-improvement run on
ralph/echo-improve branch, so that branch's diff stays isolated to the
pocket-tts integration.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-11 09:54:36 +00:00

20 KiB

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 --userCONFIRMAT 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_SECRETSCONFIRMAT 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):

{
  "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 valideCONFIRMAT 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 textCONFIRMAT 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)

"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 addvoiceopreș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.