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>
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-ttsare deja server HTTP built-in:pocket-tts serve --host --port [--language] [--quantize], expuneGET /healthșiPOST /tts(form-data:text, plusvoice_urlSAUvoice_wavupload).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/ttsprimeștevoice_wavcaUploadFileși păstrează sufixul fișierului original când îl salvează temporar — deci dacă trimitem un fișier cu numeMarius 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 templatesupertonic-tts.service), pornește serverul built-in pe127.0.0.1:7789, venv separat~/echo-core/.venv-pockettts(torch e greu — nu intră în.venvprincipal, per cerința #1 din handoff).- HF_TOKEN: launcher mic (
tools/pocket_tts_env_launch.shsau echivalent Python) care citeștehf_tokendin keyring (src/credential_store.get_secret) și îl exportă caHF_TOKENînainte deexec pocket-tts serve .... Evită să scriem tokenul în plaintext într-unEnvironmentFile=.keyringtrebuie 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, decipocket-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")întoarceNone, nu silențios. hf_tokenlipsă dinREQUIRED_SECRETS— CONFIRMAT de Marius:src/credential_store.pyverifică azi doardiscord_token. Se adaugăhf_tokenla o listă de verificare (fieREQUIRED_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"}
}
.safetensorsblobs înmodels/voices/—models/e deja în.gitignore(verificat), consistent cumodels/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 caapproved-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.safetensorso 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ăenginedinvoice(dacă voice nu e în catalog → fallback ladefault_enginedinconfig.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 (VOICESdintools/tts.py,_VOICESdinfast_commands.py, choices statice dindiscord_bot.py/discord_voice.py). Toate se rescriu să citească din catalog — elimină riscul de drift între liste.voice_commands.py._VALID_VOICESră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 = 400rămâne DOAR pe calea_synthesize_supertonic(e specific limitării ONNX, documentat ca atare azi)._synthesize_pocketttsfă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/ttscutext+ (voice_wav=<.safetensors deschis>dacă e voce clonată, sauvoice_url=<nume predefinit>dacă e voce non-clonată default pocket-tts, ex. "alba").lange 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.jsonseed cu{"engine": "pockettts", "voice_url": "alba"}(fărăstate_path). _synthesize_supertonic(...)= codul actual, neschimbat, redenumit intern.- Fallback automat (cerința #4a): dacă
_synthesize_pocketttseșuează cu eroare tehnică (httpx.ConnectError, timeout,HTTPStatusError5xx, 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_enginepersistă înconfig.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 viaConfig().set(...).- Când
voiceexplicit cerut de user e o voce catalogată (ex. "Marius 1" sau "M2"), engine-ul e determinat de catalog —default_enginecontează DOAR când nu se specifică nicio voce (default absolut, ex./audiofără parametri, sau intrare în voice live fără/voice setvoiceanterior). 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):
/voice engine <pockettts|supertonic>— comandă nouă, seteazăconfig.tts.default_engine, persistă. Răspunde cu confirmare + vocea default curentă a engine-ului ales./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
Nliber pentru acel nume (scanândtts_voices.json), exportă.safetensors, scrie catalog-ul prinjsonlock. - 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 addvoiceoprește temporarpocket-tts.servicecât durează export-ul (evită două instanțe TTSModel simultan pe memorie strânsă), cu mesaj către user: "Adaug voce, TTS indisponibil ~30s".
/audio(discord_bot.py) — parametrulvocetrece de la@app_commands.choices(listă statică, max 25 hardcodate) la@app_commands.autocompletecare citeștetts_voices.jsonlive — 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./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_loopapeleazăsynthesize(item, voice=self.voice_id, lang=self.lang)— deja engine-agnostic după refactor-ul din §3, zero schimbări întts_stream.pydincolo de faptul căsynthesize()intern rutează diferit._ffmpeg_resampledeja normalizează orice WAV primit la 48kHz stereo s16le — pocket-tts produce 24kHz mono (mimi codec), cade pe calea_ffmpeg_resampleautomat (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 DOARM1-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 pentrutts_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.pynu se atinge. supertonic-tts.serviceneschimbat.
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țiunetts)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)
- Vocile Paula deja clonate manual în research — incluse în seed-ul inițial al catalogului.
/voice addvoice— oprește temporarpocket-tts.servicecât exportă (UX: mesaj "Adaug voce, TTS indisponibil ~30s").engine_usedîn răspunsulsynthesize()— implementat, pentru debug/observabilitate.- 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_enginedin config. - trigger fallback tehnic: mock
httpx.ConnectError/HTTPStatusError5xx pe_synthesize_pockettts→ verifică apel automat pe_synthesize_supertoniccu voce default, șiengine_usedcorect în rezultat. - fallback NU se declanșează pe eroare de conținut (voce inexistentă) — verifică
_synthesize_pocketttsnu 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").
- rutare engine din
tests/test_config.py(extindere, dacă există, sau nou):Config().set("tts.default_engine", ...)persistă corect prinsave()/reload().- NU se testează: modelul pocket-tts propriu-zis,
/voice addvoiceend-to-end (necesită model+audio real), streaming live (necesită Discord voice real) — astea rămân verificare manuală la implementare (/qasau test manual ghidat).
12. Performanță
- CONFIRMAT de Marius: verificare manuală RAM disponibil pe box ÎNAINTE de a porni
pocket-tts.serviceca 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 activapocket-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 pentrutts_voices.json, pattern identic cuapproved-tasks.json.src/credential_store.py— refolosit ca atare pentruhf_token(deja în keyring)./voice doctor— pattern de health-check existent, extins (nu reconstruit) cu ping pocket-tts.supertonic-tts.service— folosit ca template pentrupocket-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_tokenlipsă 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.