Compare commits

..

15 Commits

Author SHA1 Message Date
a043e2f177 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>
2026-07-11 16:59:41 +00:00
9ddad02879 feat: US-010 - marchează complet în prd.json
Actualizează prd.json (passes:true, notes) și progress.txt cu rezultatele
gate-urilor pentru US-010.
2026-07-11 11:16:39 +00:00
c1414616ad feat: US-010 - Extinde /voice doctor cu health-check pocket-tts
- adaugă ping GET /health la pocket-tts (URL din config tts.pockettts_url)
- adaugă verificare prezență hf_token în keyring
- checks-urile existente (libopus, voice load error) rămân neschimbate

gates rulate: tests PASS (1043 passed, 22 preexistente neschimbate), /review (backend) manual PASS
2026-07-11 11:16:06 +00:00
8700276816 feat: US-009 - marchează complet în prd.json 2026-07-11 10:44:06 +00:00
504e12ac4e feat: US-009 - Autocomplete voci din catalog pentru /audio și /voice setvoice
- tools/tts.py: +list_voice_names(engine=None), sursă live din tts_voices.json
- src/fast_commands.py: _VOICES statice -> _supertonic_voice_names() din catalog
- discord_bot.py /audio și discord_voice.py /voice setvoice: choices statice -> autocomplete live
- gates rulate: tests PASS (1043 passed, 22 eșecuri preexistente neschimbate), ui/backend review PASS manual
2026-07-11 10:44:04 +00:00
3d5c2a4ace feat: US-008 - Comandă Discord /voice addvoice
- /voice addvoice <nume> <sample:Attachment>: validează wav+durată min 3s,
  oprește pocket-tts.service, exportă vocea prin pocket_tts_add_voice.py
  în venv separat, repornește serviciul, răspunde cu numele final + preview audio
- gates rulate: tests PASS (1043 passed, 22 preexistente neschimbate), review backend manual PASS
2026-07-11 10:38:15 +00:00
c7db236247 feat: US-007 - Comandă Discord /voice engine
- Comandă nouă /voice engine <pockettts|supertonic> setează tts.default_engine în config, persistă via Config().set+save()
- Helper _default_voice_for_engine() confirmă vocea implicită a engine-ului ales (alba pentru pockettts, voice.default_voice pentru supertonic)
- Gates: pytest PASS (22 eșecuri preexistente neschimbate), review manual PASS, wiring comandă verificat izolat (fără restart pe bot-ul live)
2026-07-11 10:31:38 +00:00
7ede30d156 feat: US-006 - Refactor tools/tts.py — rutare engine, backend pocket-tts și fallback
- synthesize() rezolvă engine din tts_voices.json (fallback la tts.default_engine din config)
- _synthesize_pockettts nou: POST multipart la :7789/tts cu voice_wav/voice_url, ignoră lang
- fallback tehnic automat pe _synthesize_supertonic(M2, ro) via _PocketTTSUnavailable; erori de
  conținut (voce lipsă, text gol) nu declanșează fallback; rezultatul include engine_used
- gates rulate: tests PASS (1043 passed, 22 eșecuri preexistente neschimbate), /workflow:simplify
  (4 agenți) cu 1 fix aplicat, /review (backend, manual) PASS
2026-07-11 10:28:06 +00:00
90b1fbdffe feat: US-005 - marchează complet în prd.json
- US-005 (config.json secțiune tts) verificat și marcat passes:true
2026-07-11 10:17:23 +00:00
e14a2d7189 feat: US-005 - Adaugă secțiunea tts în config.json
- config.json: secțiune tts nouă (default_engine=pockettts, pockettts_url=http://127.0.0.1:7789)
- gates rulate: tests PASS (1043 passed, 22 eșecuri preexistente neschimbate), review manual PASS (diff data-only, Config deja generic)
2026-07-11 10:17:09 +00:00
6e73887867 feat: US-004 - Script de export voce pocket_tts_add_voice.py
- tools/pocket_tts_add_voice.py fusese deja creat și commitat cu US-003
  (e3b5cdf); acest commit doar marchează US-004 complet în prd.json după
  verificare
- verificat: script rulează cu .venv-pockettts/bin/python fără .venv
  principal; _next_free_index întoarce 4 pentru Marius/Paula existenți
  (1-3 ocupate), 1 pentru nume noi
- verificat integritatea celor 6 blob-uri models/voices/*.safetensors deja
  exportate (12 tensori fiecare, safetensors.torch.load_file OK)
- gates: /review (backend) PASS, pytest 1043 passed (22 eșecuri
  preexistente neschimbate)
2026-07-11 10:15:07 +00:00
e3b5cdf0e5 feat: US-003 - Creează catalogul de voci tts_voices.json cu seed inițial
- tts_voices.json (nou): seed 17 intrări — M1-M5/F1-F5 supertonic, Marius 1-3
  și Paula 1-3 pockettts (state_path spre models/voices/*.safetensors), alba
  pockettts (voice_url)
- .gitignore: +*.lock pentru sidecar-uri jsonlock
- elimină approved-tasks.json.lock din git (sidecar gol, jsonlock îl recreează)
- scris prin src/jsonlock.py write_locked()
- gates: /review (backend) PASS, db (schema manuală) PASS, pytest 1043 passed
  (22 eșecuri preexistente neschimbate)
2026-07-11 10:14:40 +00:00
18c05ede39 feat: US-002 - Adaugă hf_token la verificarea de secrete în credential_store
- REQUIRED_SECRETS include acum hf_token alături de discord_token
- teste actualizate pentru noul set de secrete obligatorii (+ test_partial_required)
- gates rulate: tests PASS (22 eșecuri preexistente neschimbate), /review (backend) PASS
2026-07-11 10:05:05 +00:00
ed407b49a6 feat: US-001 - Setup venv-pockettts și pocket-tts.service systemd unit
- venv separat .venv-pockettts (torch + pocket-tts + keyring), gitignored
- tools/pocket_tts_env_launch.sh citește hf_token din keyring, exit non-zero + log clar dacă lipsește
- pocket-tts.service (systemd --user) pornește serverul built-in pe 127.0.0.1:7789 prin launcher
- gates: /review (manual, diff minimal) PASS, smoke test curl /health 200 + restart test PASS
2026-07-11 10:02:26 +00:00
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
24 changed files with 1772 additions and 64 deletions

4
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.venv/
.venv-pockettts/
venv/
__pycache__/
*.pyc
@@ -25,6 +26,9 @@ memory.bak/
.use_openrouter
.gstack/
# jsonlock sidecar files (src/jsonlock.py) — empty flock handles, never data
*.lock
# Runtime state — auto-modified by dashboard/cron/heartbeat
approved-tasks.json
dashboard/status.json

View File

@@ -119,5 +119,9 @@
"base_url": "https://roa2web.romfast.ro/roa2web/api",
"default_company": "114",
"default_company_name": "ROMFAST SRL"
},
"tts": {
"default_engine": "pockettts",
"pockettts_url": "http://127.0.0.1:7789"
}
}

View File

@@ -11,9 +11,9 @@
"report_on": "changes",
"timeout": 120,
"enabled": true,
"last_run": "2026-07-10T10:00:00.001575+00:00",
"last_run": "2026-07-10T16:00:00.002193+00:00",
"last_status": "ok",
"next_run": "2026-07-10T16:00:00+00:00"
"next_run": "2026-07-11T10:00:00+00:00"
},
{
"name": "security-audit-daily",
@@ -43,9 +43,9 @@
"report_on": "never",
"timeout": 120,
"enabled": true,
"last_run": "2026-07-10T03:30:00.002026+00:00",
"last_run": "2026-07-11T03:30:00.002049+00:00",
"last_status": "ok",
"next_run": "2026-07-11T03:30:00+00:00"
"next_run": "2026-07-12T03:30:00+00:00"
},
{
"name": "archive-tasks-daily",
@@ -59,9 +59,9 @@
"report_on": "changes",
"timeout": 60,
"enabled": true,
"last_run": "2026-07-10T03:00:00.002135+00:00",
"last_run": "2026-07-11T03:00:00.001905+00:00",
"last_status": "ok",
"next_run": "2026-07-11T03:00:00+00:00"
"next_run": "2026-07-12T03:00:00+00:00"
},
{
"name": "backup-config",
@@ -75,9 +75,9 @@
"report_on": "never",
"timeout": 120,
"enabled": true,
"last_run": "2026-07-10T02:00:00.002082+00:00",
"last_run": "2026-07-11T02:00:00.002411+00:00",
"last_status": "ok",
"next_run": "2026-07-11T02:00:00+00:00"
"next_run": "2026-07-12T02:00:00+00:00"
},
{
"name": "insights-extract",
@@ -243,9 +243,9 @@
"prompt": "Heartbeat check. Rulează src/heartbeat.py printr-un scurt raport de status.\nDacă nu e nimic de raportat (email=0, calendar nu are evenimente <2h, kb ok), răspunde doar cu HEARTBEAT_OK și oprește-te — nu trimite mesaj.\nDacă e ceva: raport scurt pe Discord #echo-work.",
"allowed_tools": [],
"enabled": true,
"last_run": "2026-07-10T14:00:00.002633+00:00",
"last_run": "2026-07-11T06:00:00.002630+00:00",
"last_status": "ok",
"next_run": "2026-07-10T18:00:00+00:00"
"next_run": "2026-07-11T10:00:00+00:00"
},
{
"name": "night-execute",

View File

@@ -1,5 +1,18 @@
{
"notes": [
{
"file": "notes-data/projects/pocket-tts-integration-handoff-prompt.md",
"title": "pocket-tts-integration-handoff-prompt",
"date": "2026-07-11",
"tags": [],
"domains": [],
"types": [],
"category": "projects",
"project": null,
"subdir": null,
"video": "",
"tldr": ""
},
{
"file": "notes-data/youtube/2026-07-10_opencode-token-usage-96-percent.md",
"title": "I Cut My OpenCode Token Usage by 96% - Here's How",
@@ -9727,8 +9740,8 @@
"title": "Proiect: Vending Master - Integrare Website → ROA",
"date": "2026-01-30",
"tags": [
"vending-master",
"integrare"
"integrare",
"vending-master"
],
"domains": [
"work"
@@ -10208,7 +10221,7 @@
}
],
"stats": {
"total": 590,
"total": 591,
"by_domain": {
"work": 202,
"health": 104,
@@ -10225,7 +10238,7 @@
"facebook": 11,
"health": 6,
"insights": 46,
"projects": 234,
"projects": 235,
"reflectii": 3,
"retete": 1,
"tools": 7,

View File

@@ -10,7 +10,7 @@
- **[facebook/](facebook/index.md)** — 11 note
- **[health/](health/index.md)** — 6 note
- **[insights/](insights/index.md)** — 46 note
- **[projects/](projects/index.md)** — 234 note
- **[projects/](projects/index.md)** — 235 note
- **[reflectii/](reflectii/index.md)** — 3 note
- **[retete/](retete/index.md)** — 1 note
- **[tools/](tools/index.md)** — 7 note

View File

@@ -1,6 +1,6 @@
# Index — projects/
> 234 note. Citește acest index întâi; deschide doar fișierele relevante.
> 235 note. Citește acest index întâi; deschide doar fișierele relevante.
- **[Feature: PDF Download Button in Files Dashboard](FEATURE-files-pdf-download.md)**
- User is comfortable with multi-session handoff (can track progress across sub-agents)
@@ -356,6 +356,7 @@
Marc e la stabilitate financiară. Ședința lucrează pe mila față de angajați — momentele în care "lași de la tine" și eviți limite ferme. Pri
- **[Monica Ion - Povestea lui Marc - Episodul 9: Anxietatea, frica de control și pierdere](monica-ion/youtube/monica-ion-povestea-lui-marc-ep9-anxietatea.md)** `@growth @work @sprijin`
Marc revine la ședință devastat: un proiect european (30% din cifra de afaceri) a fost înghețat, clientul nu mai finanțează. Monica lucrează
- **[pocket-tts-integration-handoff-prompt](pocket-tts-integration-handoff-prompt.md)**
- **[Proiect: Import Bonuri Fiscale via Telegram/WhatsApp → ROA](roa2web-telegram-import/README.md)** `@work`
Sistem pentru importul bonurilor fiscale de achiziție din Telegram/WhatsApp în contabilitatea ROA. OCR prin Doctr (cost zero, local). Integr
- **[Flux Contabil - Import Bonuri Fiscale Achiziție](roa2web-telegram-import/flux-contabil.md)**
@@ -450,5 +451,5 @@
- **[Episodul 20 - Ce au în comun traderii profitabili](trading-basics/_duplicates/21-episodul-20-ce-au-n-comun-traderii-profitabili.md)**
**Pentru conținut complet structurat (concepte, quote-uri, aplicații practice), consultă Episodul 9.**
- **[22-episodul-19-motivul-cheie-f-r-de-care-nu-func-ioneaz-nicio-strategie-n-trading-](trading-basics/_duplicates/22-episodul-19-motivul-cheie-f-r-de-care-nu-func-ioneaz-nicio-strategie-n-trading-.md)**
- **[Proiect: Vending Master - Integrare Website → ROA](vending-master/README.md)** `@work #vending-master #integrare`
- **[Proiect: Vending Master - Integrare Website → ROA](vending-master/README.md)** `@work #integrare #vending-master`
[conversations/2026-01-30-conversatie-completa.md](https://moltbot.tailf7372d.ts.net/echo/files.html#conversations/2026-01-30-conversatie-co

View File

@@ -0,0 +1,54 @@
Vreau să integrezi pocket-tts (Kyutai) ca motor TTS nou în echo-core, alături de Supertonic (motorul actual). Task arhitectural, nu un fix simplu — **intră în plan mode înainte să atingi cod**, și trece planul prin `/plan-eng-review` (arhitectură, edge cases, cum ating fișierele existente fără să stric fluxul live) înainte de implementare. Nu implementa nimic până planul nu e aprobat.
## Context — ce există deja
- **Motor actual:** Supertonic, rulează ca serviciu systemd separat (`supertonic-tts.service`), HTTP pe `127.0.0.1:7788`. Wrapper: `tools/tts.py` (`synthesize(text, voice, lang)`).
- **Comandă on-demand:** `/audio` (Discord slash command, `src/adapters/discord_bot.py` ~L911-995) — parametri `voce` (M1-M5/F1-F5), `text_sau_url`, `rezumat`. Apelează `fast_dispatch("audio", ...)` din `src/fast_commands.py`, care întoarce `__AUDIO__:<path>` — interceptat de discord_bot.py și trimis ca attachment.
- **Mod live:** Discord Voice (`/voice join`), `src/voice/pipeline.py` + `src/voice/tts_stream.py` — streaming TTS pe clauze (`clause_segments()`), buget de latență strict (~5s perceput end-to-end, filler audio după 3s fără răspuns). Folosește `tools/tts.py` intern.
- **Credențiale:** keyring, serviciu `"echo-core"` (`src/credential_store.py`). Există deja `discord_token` și `hf_token` (HuggingFace, pentru modelul gated pocket-tts cu voice cloning — vezi mai jos).
## Research + teste deja făcute (nu le repeta)
Am evaluat deja pocket-tts manual într-o sesiune anterioară (vezi memory: `project_pocket_tts_voice_clone_eval.md`):
- **Nu suportă română oficial** — doar english, french, german, portuguese, italian, spanish. Testele confirmă: text românesc sintetizat cu modelul englez sună cu accent englezesc puternic.
- **Voice cloning funcționează bine** din sample-uri scurte (~4-35s testate), via `get_state_for_audio_prompt(path_sau_nume_voce_predefinita)`.
- **Viteză reală măsurată pe acest CPU:** ~2.7-2.9x faster-than-real-time (Kyutai raportează 6x pe Apple M4 — CPU-ul ăsta e mai lent per-core, dar tot confortabil peste real-time).
- **Modelul cu voice cloning e gated pe HuggingFace** — cere account + accept termeni + token (`HF_TOKEN`). Tokenul lui Marius e deja în keyring (`hf_token`).
- **Instalare testată:** `pip install pocket-tts` (venv izolat de test la `/tmp/pocket-tts-test/.venv`, nu producție). CLI: `pocket-tts generate --text "..." --voice <nume_sau_path.wav> --output-path out.wav`.
- **Sample-uri de voce ale lui Marius, deja înregistrate** (la `~/workspace/pocket-tts-test/`, convertite la wav mono 24kHz):
- `marius_real_voice.wav` (7.8s) → devine voce **"Marius 1"**
- `marius_real_voice_2.wav` (12.6s) → devine voce **"Marius 2"**
- `marius_real_voice_3.wav` (35s, mai expresiv) → devine voce **"Marius 3"**
- **Tool nou creat, deja în repo:** `tools/discord_send_file.py` — trimite fișier direct pe un canal Discord via REST API (necesar pentru că `__AUDIO__:` prefix nu funcționează în afara fast_commands — vezi docstring din script și `personality/TOOLS.md` § "Discord — trimite fișier direct").
## Cerințe confirmate de Marius (decizii arhitecturale — NU le renegocia, doar implementează)
1. **pocket-tts rulează ca proces/serviciu separat**, exact ca Supertonic — systemd propriu, port propriu, apelat via HTTP din restul codului. Nu integrat direct în procesul bot (evită să bagi `torch` ca dependență grea în `.venv` principal).
2. **pocket-tts = engine default. Supertonic = fallback.**
3. **pocket-tts răspunde MEREU în engleză** (fonetic), indiferent de limba textului de intrare — inclusiv pe text românesc (va suna cu accent, e acceptat/intenționat). **Nu** face rutare automată pe limbă.
4. **Fallback pe Supertonic doar la:**
- (a) eșec tehnic pocket-tts (model indisponibil, HF token expirat, server jos, etc.)
- (b) comandă manuală de schimbare engine de la Marius — **trebuie adăugată o comandă nouă** pentru switch engine (ex: `/engine supertonic` / `/engine pockettts`, sau similar — alege convenția care se potrivește cel mai bine cu comenzile existente).
5. **Scope complet:** atât **mod live** (Discord Voice, streaming pe clauze) **cât și on-demand** (`/audio`). Nu doar unul din ele.
6. **Selector de voce unificat:** vocile Supertonic existente (M1-M5, F1-F5) **plus** vocile clonate, ca opțiuni suplimentare în același selector/aceeași comandă.
7. **Voci clonate salvate ca `.safetensors`** (export via `export_model_state()`, încărcare rapidă — vezi README pocket-tts pentru API exact), cu convenție de nume **`<Nume> <N>`** (ex: `Marius 1`, `Marius 2`, `Marius 3`; extensibil la alte persoane, ex: `Paula 1`, dacă Marius mai trimite sample-uri).
8. Marius va mai trimite sample-uri de voce în timp — trebuie un flux clar (comandă + atașament) pentru a adăuga o voce clonată nouă la catalog, fără intervenție manuală de cod de fiecare dată.
## Ce trebuie să decidă/detalieze planul (edge cases de rezolvat explicit)
- Cum se comportă fallback-ul din mod **live** dacă pocket-tts pică la mijlocul unei conversații (schimbare de engine mid-sesiune, fără să rupă turul curent)?
- Ce se întâmplă cu `lang="ro"` / `--lang` parametrul existent din `tools/tts.py` odată ce pocket-tts (mereu engleză) devine default — rămâne relevant doar pentru calea Supertonic?
- Cum se integrează streaming-ul propriu al pocket-tts cu `clause_segments()` din `tts_stream.py` (format audio, sample rate, chunking) — pocket-tts produce 24kHz, pipeline-ul Discord așteaptă 48kHz stereo s16le (vezi `_ffmpeg_resample` în `tts_stream.py`).
- Unde se stochează fișierele `.safetensors` ale vocilor clonate (director nou în repo? `models/voices/`? `~/workspace/`?) — și cum se mapează nume → fișier (config nou în `config.json`, sau fișier JSON dedicat).
- Cum se gestionează HF_TOKEN pentru serviciul systemd nou (environment file, similar cu cum `discord_token` e disponibil altor procese).
- Comanda de switch engine — ephemeral (doar sesiunea curentă) sau persistă în `config.json` / `sessions/active.json`?
- Ce se întâmplă cu comanda `/audio` existentă când alegi o voce clonată — flow-ul de azi (`fast_dispatch``__AUDIO__:` prefix) rămâne valid pentru pocket-tts, sau outputul trebuie tratat diferit?
## Instrucțiuni pentru sesiunea care preia
1. Citește `memory/kb/` relevant + `project_pocket_tts_voice_clone_eval.md` din memory pentru context complet de research.
2. Intră în **plan mode**, scrie un plan concret care acoperă toate punctele de mai sus.
3. Rulează planul prin **`/plan-eng-review`** (arhitectură, edge cases, performance) înainte de orice modificare de cod.
4. Prezintă planul lui Marius pentru aprobare explicită înainte de implementare.
5. Respectă principiile din `CLAUDE.md`: impact minim, fără scope creep, verifică înainte de "done".

View File

@@ -64,12 +64,28 @@
- **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ă.
- **Channel ID:** dacă nu-l ai din context, citește `sessions/active.json` (cheia e channel_id; de regulă o singură sesiune activă).
- **Modul Python:** `from tools.discord_send_file import send_file``send_file(channel_id, path, content="text opțional")`
### Pauze respirație
- **Script:** `python3 tools/pauza_random.py`
- **Bancă:** memory/kb/tehnici-pauza.md

333
scripts/ralph/prd.json Normal file
View File

@@ -0,0 +1,333 @@
{
"projectName": "echo-core",
"branchName": "ralph/echo-improve",
"description": "Integrare pocket-tts (Kyutai) ca engine TTS nou in Echo Core",
"techStack": {
"type": "python",
"commands": {
"start": "python main.py",
"build": "",
"lint": "ruff check .",
"typecheck": "mypy .",
"test": "pytest"
},
"port": 8000
},
"userStories": [
{
"id": "US-001",
"title": "Setup venv-pockettts și pocket-tts.service systemd unit",
"description": "Creează venv separat .venv-pockettts (torch + keyring, nu în .venv principal), un launcher care exportă HF_TOKEN din keyring, și systemd user unit pocket-tts.service care pornește serverul built-in pe 127.0.0.1:7789. Verifică explicit accesul la keyring din systemd --user.",
"priority": 10,
"acceptanceCriteria": [
"Directorul ~/echo-core/.venv-pockettts există cu pocket-tts și keyring instalate; .venv-pockettts/ e în .gitignore",
"tools/pocket_tts_env_launch.sh (sau echivalent) citește hf_token din keyring și eșuează cu exit non-zero + log clar dacă get_secret('hf_token') e None",
"~/.config/systemd/user/pocket-tts.service pornește 'pocket-tts serve --host 127.0.0.1 --port 7789' prin launcher",
"curl -s http://127.0.0.1:7789/health întoarce 200 după 'systemctl --user start pocket-tts.service'"
],
"tags": [
"infra"
],
"dependsOn": [],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 1,
"failureReason": "",
"notes": "Fișiere: .gitignore, tools/pocket_tts_env_launch.sh (nou). Deja existente din iterația anterioară: .venv-pockettts/ (torch+pocket-tts+keyring), ~/.config/systemd/user/pocket-tts.service. Verificat: curl :7789/health -> 200, systemctl restart -> health revine în ~3s, systemctl is-enabled -> enabled. Gate /review (infra): diff minimal, fără shell injection / secret leakage, PASS."
},
{
"id": "US-002",
"title": "Adaugă hf_token la verificarea de secrete în credential_store",
"description": "src/credential_store.py verifică azi doar discord_token; adaugă hf_token la lista de secrete verificate (REQUIRED_SECRETS sau check dedicat) ca un token lipsă/expirat să fie detectat la startup, nu la runtime.",
"priority": 20,
"acceptanceCriteria": [
"hf_token apare în REQUIRED_SECRETS (sau check echivalent) în src/credential_store.py",
"Verificarea semnalează clar când hf_token lipsește din keyring",
"source .venv/bin/activate && pytest tests/ rămâne PASS"
],
"tags": [
"backend"
],
"dependsOn": [],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "Fișiere: src/credential_store.py (REQUIRED_SECRETS += hf_token), tests/test_secrets.py (assert-uri actualizate + test_partial_required nou). pytest tests/ -> 22 eșecuri preexistente neschimbate, 1043 passed (vs 1042 înainte). Gate /review (backend): diff trivial, fără risc, PASS."
},
{
"id": "US-003",
"title": "Creează catalogul de voci tts_voices.json cu seed inițial",
"description": "Fișier nou tts_voices.json la rădăcina repo-ului (git-tracked, scris prin src/jsonlock.py), mapare nume→{engine, state_path, owner}. Seed cu vocile M*/F* Supertonic existente, cele 3 voci Marius, vocile Paula clonate și vocea predefinită pocket-tts 'alba'.",
"priority": 30,
"acceptanceCriteria": [
"tts_voices.json există la rădăcină, e valid JSON și e git-tracked",
"Conține intrările Supertonic M1-M5/F1-F5 cu {engine: supertonic}",
"Conține vocile Marius (state_path spre models/voices/*.safetensors), vocile Paula și intrarea 'alba' cu {engine: pockettts, voice_url: alba}",
"models/voices/ este acoperit de .gitignore (blob-urile .safetensors nu intră în git)",
"Citirea/scrierea catalogului trece prin src/jsonlock.py"
],
"tags": [
"db",
"backend"
],
"dependsOn": [],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "Fișiere: tts_voices.json (nou, seed 17 intrări: M1-M5/F1-F5 supertonic, Marius 1-3 și Paula 1-3 pockettts cu state_path spre models/voices/*.safetensors, alba pockettts cu voice_url). .gitignore (+*.lock pentru sidecar-uri jsonlock, models/ deja acoperea models/voices/). Eliminat approved-tasks.json.lock din git (sidecar gol, prins accidental de un commit anterior — jsonlock îl recreează automat). Scris prin src/jsonlock.py write_locked(). Gate /review (backend): PASS — diff data-only, fără logică nouă. Gate db: PASS — validare schema manuală (fiecare intrare are engine; pockettts are state_path SAU voice_url). pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-004",
"title": "Script de export voce pocket_tts_add_voice.py + export seed voci existente",
"description": "tools/pocket_tts_add_voice.py rulează în venv-ul separat, încarcă modelul o singură dată, exportă .safetensors dintr-un wav (get_state_for_audio_prompt → export_model_state), determină automat următorul N liber pentru un nume din catalog și scrie catalogul prin jsonlock. Exportă cele 3 voci Marius și Paula ca seed.",
"priority": 40,
"acceptanceCriteria": [
"tools/pocket_tts_add_voice.py --wav <path> --name '<Nume>' produce un fișier models/voices/<slug>.safetensors",
"Scriptul determină automat următorul index liber scanând tts_voices.json și scrie intrarea prin jsonlock",
"Rularea pe sample-urile marius_real_voice*.wav și paula_*.wav produce blob-urile .safetensors referite în seed-ul din tts_voices.json",
"Scriptul rulează cu <.venv-pockettts>/bin/python fără a atinge .venv principal"
],
"tags": [
"backend"
],
"dependsOn": [
"US-001",
"US-003"
],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 1,
"failureReason": "",
"notes": "Fișiere: tools/pocket_tts_add_voice.py (nou, commitat accidental împreună cu US-003 în e3b5cdf — codul era deja scris din iterația anterioară care a atins max_turns). Scriptul încarcă modelul o singură dată, exportă .safetensors via get_state_for_audio_prompt/export_model_state, determină automat N liber scanând tts_voices.json (liber dacă intrarea lipsește SAU state_path nu există pe disc), scrie catalogul prin src.jsonlock.write_locked. Seed deja exportat (din iterația anterioară): models/voices/{marius,paula}-{1,2,3}.safetensors din ~/workspace/pocket-tts-test/*.wav, verificate integru cu safetensors.torch.load_file (12 tensori fiecare) și referite corect în tts_voices.json. Verificat: _next_free_index -> 4 pentru Marius/Paula (1-3 ocupate), 1 pentru nume nou; --help rulează curat cu .venv-pockettts/bin/python fără a atinge .venv principal. Gate /review (backend): PASS — script mic, single-responsibility, erori clare (wav lipsă, hf_token lipsă), fără shell injection/secret leakage. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-005",
"title": "Adaugă secțiunea tts în config.json",
"description": "Adaugă în config.json secțiunea tts cu default_engine=pockettts și pockettts_url=http://127.0.0.1:7789, persistabilă prin Config().set/save.",
"priority": 50,
"acceptanceCriteria": [
"config.json conține tts.default_engine='pockettts' și tts.pockettts_url='http://127.0.0.1:7789'",
"Config().get('tts.default_engine') întoarce valoarea corectă cu dot-notation",
"Config().set('tts.default_engine', 'supertonic') urmat de save()/reload() persistă valoarea"
],
"tags": [
"backend"
],
"dependsOn": [],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "Fișiere: config.json (+4 linii, secțiune tts nouă). Config class (src/config.py) era deja generică (get/set/save/reload cu dot-notation), fără schimbări de cod necesare. Verificat manual: Config().get('tts.default_engine') -> 'pockettts', get('tts.pockettts_url') -> 'http://127.0.0.1:7789'; set('tts.default_engine','supertonic')+save()+Config() nou (reload implicit) -> 'supertonic', apoi restaurat la 'pockettts'+save(). git diff confirmă restaurarea corectă. Gate /review (backend): review manual (skill-ul /review interactiv nu se pretează la iterații autonome) — fără cod nou care citește tts.* încă (US-006 le adaugă), deci zero risc de regresie; fără secrete, JSON valid, schema aditivă. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat) — tests/test_config.py deja acoperă generic set/save/reload roundtrip."
},
{
"id": "US-006",
"title": "Refactor tools/tts.py — rutare engine, backend pocket-tts și fallback",
"description": "synthesize(text, voice, lang) rămâne cu semnătura publică neschimbată, dar rutează intern pe engine din tts_voices.json (fallback la default_engine din config). Adaugă _synthesize_pockettts (POST multipart la :7789/tts), redenumește codul actual _synthesize_supertonic (păstrând cap 400 char doar aici), fallback tehnic automat pe Supertonic și câmpul engine_used în rezultat.",
"priority": 60,
"acceptanceCriteria": [
"synthesize() rezolvă engine din tts_voices.json; voce necatalogată cade pe tts.default_engine din config",
"_synthesize_pockettts trimite POST multipart cu text + voice_wav (voce clonată) sau voice_url (voce predefinită), ignorând lang",
"Eșec tehnic (httpx.ConnectError/timeout/5xx) în _synthesize_pockettts declanșează retry automat pe _synthesize_supertonic(voice='M2', lang='ro'); eroare de conținut (voce inexistentă/text gol) NU declanșează fallback",
"Rezultatul synthesize() include cheia engine_used",
"_MAX_TTS_CHARS=400 rămâne doar pe calea Supertonic; retry-ul recursiv lang='na' din Supertonic e păstrat"
],
"tags": [
"backend",
"refactor"
],
"dependsOn": [
"US-003",
"US-005"
],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 1,
"failureReason": "",
"notes": "Fișiere: tools/tts.py (refactor). synthesize() rezolvă engine din tts_voices.json (entry.get(\"engine\") sau fallback la config tts.default_engine); _synthesize_supertonic (fost synthesize, cap _MAX_TTS_CHARS=400 + retry recursiv lang=\"na\" păstrate neschimbate) și _synthesize_pockettts (POST multipart la {pockettts_url}/tts cu voice_wav din state_path sau voice_url, ignoră lang) nou. _PocketTTSUnavailable (ConnectError/Timeout/5xx) declanșează fallback automat pe _synthesize_supertonic(voice=M2, lang=ro) conform AC; erori de conținut (fișier voce lipsă, 4xx, text gol) NU declanșează fallback. Rezultatul include engine_used. Semnătura publică synthesize(text, voice, lang) neschimbată — verificat caller src/fast_commands.py. Gate /workflow:simplify (refactor, 4 agenți reuse/simplification/efficiency/altitude): aplicat 1 fix sigur (engine_used setat o singură dată, nu triplu literal); respinse ca false-positive 3 findings (voice=M2/lang=ro hardcodat pe fallback e cerință explicită din AC, nu bandaid; lookup catalog case-insensitive ar schimba comportament fără beneficiu clar în scope); notat pentru follow-up neaplicat: caching catalog/config/voice-file per sesiune în hot path-ul de streaming (src/voice/tts_stream.py) — necesită schimbări în afara scope-ului acestui story. Gate /review (backend, manual): PASS — fără shell injection, fără secret leakage, error handling scoped corect (ConnectError/TimeoutException/HTTPStatusError), API contract păstrat. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-007",
"title": "Comandă Discord /voice engine",
"description": "Comandă nouă /voice engine <pockettts|supertonic> în src/adapters/discord_voice.py care setează config.tts.default_engine (persistă) și răspunde cu confirmare + vocea default a engine-ului ales.",
"priority": 70,
"acceptanceCriteria": [
"/voice engine acceptă doar pockettts|supertonic și scrie config.tts.default_engine prin Config().set",
"Valoarea persistă între restart-uri (verificat prin reload config)",
"Răspunsul confirmă engine-ul ales și vocea default curentă"
],
"tags": [
"ui",
"backend"
],
"dependsOn": [
"US-005",
"US-006"
],
"requiresBrowserCheck": true,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "Fișiere: src/adapters/discord_voice.py (comandă nouă /voice engine + helper _default_voice_for_engine). Comanda folosește @app_commands.choices cu exact 2 valori (pockettts/supertonic, la fel ca pattern-ul /voice setvoice existent), defer(ephemeral=True), scrie tts.default_engine prin Config().set+save(), răspunde cu engine-ul ales + vocea default asociată (_default_voice_for_engine: 'alba' pentru pockettts, voice.default_voice curent pentru supertonic). Verificat: register() rulează curat într-un CommandTree izolat (fără a atinge bot-ul live) — comanda 'engine' apare cu choices corecte; Config().set/save/reload roundtrip verificat direct (pockettts<->supertonic, valoare restaurată la final, git diff config.json curat). Gate ui: browser/agent-browser nu se aplică — nu există UI web pentru această comandă (e slash command Discord, nu pagină pe techStack.port); am evitat intenționat restart-ul serviciului live echo-core.service (bot-ul de producție Discord/Telegram/WhatsApp folosit activ de Marius) pentru testare end-to-end, ca să nu întrerup serviciul mid-day — recomand test manual /voice engine după următorul restart/deploy. Gate /review (backend, manual): PASS — diff mic, urmează exact pattern-ul setvoice existent, fără shell injection/secret leakage, error handling scoped pe save failure. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-008",
"title": "Comandă Discord /voice addvoice",
"description": "Comandă nouă /voice addvoice <nume> <sample:Attachment> care descarcă+validează wav-ul (extensie + durată min ~3s), oprește temporar pocket-tts.service, rulează scriptul de export în venv separat, scrie catalogul și răspunde cu numele final atribuit + un preview audio generat cu vocea nouă.",
"priority": 80,
"acceptanceCriteria": [
"Callback-ul folosește await interaction.response.defer(ephemeral=True)",
"Validează extensia audio și durata minimă (~3s), respinge clar sample-uri prea scurte",
"Oprește pocket-tts.service pe durata export-ului cu mesaj 'Adaug voce, TTS indisponibil ~30s' și îl repornește după",
"Invocă tools/pocket_tts_add_voice.py cu <.venv-pockettts>/bin/python și răspunde cu numele final (ex. 'Marius 4') + sample audio preview"
],
"tags": [
"ui",
"backend"
],
"dependsOn": [
"US-004",
"US-006"
],
"requiresBrowserCheck": true,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 1,
"failureReason": "",
"notes": "Fișiere: src/adapters/discord_voice.py (comandă nouă /voice addvoice + helperi _systemctl_user, _parse_registered_voice_name, _tts_synthesize_preview). Codul era deja scris (necommitat) dintr-o iterație anterioară care a atins max_turns fără commit — verificat linie cu linie contra AC-urilor, nu a fost nevoie de rescriere. Validează extensia .wav + durata minimă 3s prin modulul wave (stdlib), trimite mesaj interimar 'Adaug voce, TTS indisponibil ~30s...', oprește pocket-tts.service prin systemctl --user (best-effort, nu ridică), rulează tools/pocket_tts_add_voice.py cu .venv-pockettts/bin/python (subprocess list-args, fără shell=True — fără risc de shell injection pe --name), repornește serviciul în finally indiferent de rezultat/timeout, parsează numele final atribuit din stdout ('Registered voice ... ->'), generează preview audio prin tools/tts.py synthesize() și îl atașează ca discord.File, apoi șterge fișierul temporar. Verificat: register() rulează curat într-un CommandTree izolat — comanda 'addvoice' apare cu parametrii nume:str și sample:Attachment. hf_token nu apare niciodată în discord_voice.py — rămâne complet în subprocesul pocket_tts_add_voice.py via keyring. Gate ui: fără server web pe techStack.port pentru această comandă (e slash command Discord, nu pagină) — verificare structurală prin CommandTree izolat, la fel ca US-007; nu am restart-uit echo-core.service (bot live folosit de Marius) pentru test end-to-end. Gate /review (backend, manual): PASS — subprocess cu argumente ca listă (fără shell injection), fără secret leakage, systemctl start garantat în finally chiar la timeout, fișier temporar curățat în finally. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-009",
"title": "Autocomplete voci din catalog pentru /audio și /voice setvoice",
"description": "Înlocuiește listele statice @app_commands.choices din /audio (discord_bot.py) și /voice setvoice (discord_voice.py) cu @app_commands.autocomplete care citește tts_voices.json live, astfel încât vocile clonate noi apar fără redeploy. Elimină listele hardcodate VOICES/_VOICES în favoarea catalogului.",
"priority": 90,
"acceptanceCriteria": [
"/audio parametrul voce folosește autocomplete care citește tts_voices.json la runtime",
"/voice setvoice folosește autocomplete din același catalog",
"Listele hardcodate de voci (VOICES din tools/tts.py, _VOICES din fast_commands.py, choices statice din discord_bot.py/discord_voice.py) citesc din tts_voices.json; voice_commands.py._VALID_VOICES rămâne neschimbat",
"O voce clonată nou adăugată în catalog apare în autocomplete fără modificare de cod"
],
"tags": [
"ui",
"backend"
],
"dependsOn": [
"US-003",
"US-006"
],
"requiresBrowserCheck": true,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 1,
"failureReason": "",
"notes": "Fișiere: tools/tts.py (+list_voice_names(engine=None), citește tts_voices.json prin _load_voice_catalog/jsonlock; _synthesize_supertonic folosește list_voice_names(engine='supertonic') cu fallback pe _FALLBACK_SUPERTONIC_VOICES dacă catalogul lipsește), src/fast_commands.py (_VOICES înlocuit cu _supertonic_voice_names() care importă tools/tts.py lazy), src/adapters/discord_bot.py (/audio: @app_commands.choices static înlocuit cu @app_commands.autocomplete(voce=_voice_autocomplete) care filtrează pe input curent din tts.list_voice_names()), src/adapters/discord_voice.py (/voice setvoice: @app_commands.choices static înlocuit cu autocomplete similar; parametrul voice devine str simplu + validare explicită 'voce necunoscută' dacă nu e în catalog). Codul era deja scris (necommitat) dintr-o iterație anterioară care a atins max_turns — verificat linie cu linie contra AC-urilor, fără nevoie de rescriere. voice_commands.py._VALID_VOICES neatins, conform AC. Verificat: tts.list_voice_names() -> toate cele 17 intrări din tts_voices.json; list_voice_names(engine='supertonic') -> doar M1-M5/F1-F5; list_voice_names(engine='pockettts') -> Marius 1-3/Paula 1-3/alba. Gate ui: fără server web pe techStack.port pentru comenzi Discord — verificat structural prin create_bot()+discord_voice.register() izolat: parametrul 'voce' din /audio și 'voice' din /voice setvoice au autocomplete legat corect (p.autocomplete e callback-ul, nu None), celelalte parametri (text_sau_url, rezumat) rămân fără autocomplete. Gate /review (backend, manual): PASS — _load_voice_catalog citește prin src.jsonlock (read_locked), fără shell/subprocess nou, fără secret leakage, import lazy tools/tts.py identic cu pattern-ul existent din _tts_synthesize_preview. pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-010",
"title": "Extinde /voice doctor cu health-check pocket-tts",
"description": "Comanda /voice doctor adaugă un ping la GET :7789/health pentru pocket-tts alături de verificarea Supertonic existentă și un check pentru prezența hf_token în keyring.",
"priority": 100,
"acceptanceCriteria": [
"/voice doctor face ping la http://127.0.0.1:7789/health și raportează status pocket-tts",
"Raportează prezența/absența hf_token în keyring",
"Verificarea Supertonic existentă rămâne funcțională"
],
"tags": [
"backend"
],
"dependsOn": [
"US-001",
"US-002"
],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "Fișiere: src/adapters/discord_voice.py (comanda /voice doctor extinsă). Adăugat check hf_token în keyring (src.credential_store.get_secret, doar prezență, fără leakage) și check pocket-tts GET {tts.pockettts_url}/health (timeout 3s, httpx.AsyncClient, wrapped try/except -> FAIL pe orice excepție). Checks-urile existente (libopus, voice load error) neatinse. Verificat manual: get_secret(\"hf_token\") is not None -> True; GET http://127.0.0.1:7789/health -> 200 (pocket-tts.service live din US-001). Structural: register(tree, bot) izolat -> comanda doctor prezentă în /voice group. Gate /review (backend, manual): PASS — fără shell injection, fără secret leakage (doar bool presence), error handling scoped (try/except pe fiecare check nou, degrade la FAIL nu crash). pytest tests/: aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)."
},
{
"id": "US-011",
"title": "Teste minime pentru rutare TTS și config",
"description": "tests/test_tts.py (nou) acoperă rutarea engine din catalog, fallback la default_engine, trigger fallback tehnic cu engine_used corect, non-declanșare pe erori de conținut și regression pe retry-ul lang='na'. tests/test_config.py verifică persistența tts.default_engine.",
"priority": 110,
"acceptanceCriteria": [
"tests/test_tts.py: voce catalogată → engine corect (mock catalog); voce necatalogată → default_engine din config",
"tests/test_tts.py: mock httpx.ConnectError/5xx pe _synthesize_pockettts → apel automat pe _synthesize_supertonic + engine_used corect; input eronat (voce inexistentă) NU cade pe supertonic",
"tests/test_tts.py: regression test pentru retry recursiv lang='na' în Supertonic",
"tests/test_config.py verifică Config().set('tts.default_engine',...) persistă prin save()/reload()",
"source .venv/bin/activate && pytest tests/test_tts.py tests/test_config.py iese PASS"
],
"tags": [
"backend"
],
"dependsOn": [
"US-006"
],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "tests/test_tts.py (nou, 11 teste): rutare engine din catalog (pockettts/supertonic), fallback pe default_engine pentru voce necatalogată, fallback tehnic (ConnectError/5xx) pe Supertonic cu engine_used corect, eroare de conținut (fișier .safetensors lipsă) NU declanșează fallback, regression retry lang na->na (fără retry dublu). tests/test_config.py: test dedicat persistență tts.default_engine prin save()/reload() (peste acoperirea generică deja existentă). pytest tests/test_tts.py tests/test_config.py: 34 passed. Suită completă: 1055 passed, 22 preexisting failures neschimbate."
},
{
"id": "US-012",
"title": "Documentează comenzile TTS noi în TOOLS.md",
"description": "Actualizează personality/TOOLS.md cu comenzile noi (/voice engine, /voice addvoice, autocomplete voci) și cu noul engine pocket-tts + catalogul tts_voices.json.",
"priority": 120,
"acceptanceCriteria": [
"personality/TOOLS.md descrie /voice engine și /voice addvoice",
"Documentează engine-ul pocket-tts, catalogul tts_voices.json și fluxul de adăugare voce nouă",
"Nu contrazice comportamentul implementat (fallback, engine default din config)"
],
"tags": [
"docs"
],
"dependsOn": [
"US-007",
"US-008",
"US-009"
],
"requiresBrowserCheck": false,
"requiresDesignReview": false,
"passes": true,
"failed": false,
"blocked": false,
"retries": 0,
"failureReason": "",
"notes": "personality/TOOLS.md, secțiune Discord Voice extinsă cu subsecțiunea TTS: engine pocket-tts + Supertonic — motoare, rutare din tts_voices.json, /voice engine, /voice setvoice, /voice addvoice (venv separat .venv-pockettts, tools/pocket_tts_add_voice.py), /voice doctor (health-check), /audio. Comportament documentat (rutare din catalog, fallback tehnic vs eroare de conținut) verificat contra tools/tts.py și src/adapters/discord_voice.py — nu contrazice implementarea."
}
]
}

296
scripts/ralph/progress.txt Normal file
View File

@@ -0,0 +1,296 @@
# Ralph Progress Log
Started: 2026-07-11 09:56
Project: echo-core
---
## Iterație: 2026-07-11 10:03
### Story implementat: US-001 - Setup venv-pockettts și pocket-tts.service systemd unit (tags: infra)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv; niciun fișier Python schimbat de acest story)
- Lint: SKIP (ruff neinstalat în .venv; niciun fișier Python schimbat)
- Tests: PASS parțial — pytest tests/ rulează cu 22 eșecuri preexistente, nelegate de acest story (nu am schimbat cod Python); 1042 passed
- /review (infra): PASS — diff minimal (.gitignore +1 linie, launcher shell script nou); fără shell injection, fără secret leakage (hf_token citit din keyring, niciodată logat/hardcodat)
- Smoke test manual: curl -s http://127.0.0.1:7789/health -> 200; systemctl --user restart pocket-tts.service -> health revine 200 în ~3-6s; systemctl --user is-enabled -> enabled
### Learnings:
- Implementarea de bază (venv, launcher, systemd unit) fusese deja făcută într-o iterație anterioară care nu apucase să facă commit/mark passes — la începutul iterației verifică mereu starea reală (ls, systemctl status, curl) înainte să presupui că trebuie construit de la zero.
- Pattern existent: doar dashboard/echo-taskboard.service e trackuit în git ca template; supertonic-tts.service (alt serviciu existent) NU e trackuit — deci pocket-tts.service urmează același pattern, rămâne doar în ~/.config/systemd/user/, nu în repo.
- .venv al proiectului nu are ruff/mypy instalate — gate-urile de typecheck/lint din PRD sunt aspiraționale, nu configurate încă; pentru story-uri Python viitoare (US-002+) verifică disponibilitatea înainte să te bazezi pe fail/pass real.
### Next:
- US-002 (backend, hf_token în REQUIRED_SECRETS) — eligibil, dependsOn gol
- US-003 (db+backend, catalog tts_voices.json) — eligibil, dependsOn gol
- US-005 (backend, config.json tts section) — eligibil, dependsOn gol
---
## Iterație: 2026-07-11 (US-002)
### Story implementat: US-002 - Adaugă hf_token la verificarea de secrete în credential_store (tags: backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ rulează cu aceleași 22 eșecuri preexistente (neschimbate, confirmat prin git stash diff), 1043 passed (vs 1042 înainte, +1 test nou)
- /review (backend): PASS — diff trivial (o linie de date + teste actualizate), fără risc de corectitudine/securitate
### Learnings:
- REQUIRED_SECRETS e folosit doar de check_secrets() (CLI `eco secrets test` / `eco doctor`), nu blochează startup-ul aplicației — sigur de extins fără risc de regressie funcțională.
- Testele existente pentru check_secrets asertau egalitate exactă de dict, deci orice extindere a REQUIRED_SECRETS necesită update sincron al testelor (test_missing_required, test_present_required, test_all_present din TestCLISecretsTest).
- Cei 22 de eșecuri preexistenți (test_cli.py::TestDoctor::test_all_pass, test_discord.py, test_heartbeat.py, test_voice_normalize.py etc.) sunt independenți de acest story — verificat cu `git stash` înainte/după.
### Next:
- US-003 (db+backend, catalog tts_voices.json) — eligibil, dependsOn gol
- US-005 (backend, config.json tts section) — eligibil, dependsOn gol
---
## Iterație: 2026-07-11 (US-004)
### Story implementat: US-004 - Script de export voce pocket_tts_add_voice.py (tags: backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- /review (backend): PASS — script mic, single-responsibility, erori clare (wav lipsă / hf_token lipsă din keyring), fără shell injection / secret leakage (HF_TOKEN doar în os.environ al subprocesului, niciodată logat)
- Verificare manuală: .venv-pockettts/bin/python tools/pocket_tts_add_voice.py --help rulează curat fără a atinge .venv principal; _next_free_index() -> 4 pentru Marius/Paula (seed 1-3 deja ocupat), 1 pentru nume noi; toate cele 6 blob-uri models/voices/{marius,paula}-{1,2,3}.safetensors validate cu safetensors.torch.load_file (12 tensori fiecare, fără corupere)
### Learnings:
- Iterația anterioară (US-004, retries=1) a atins max_turns=30 în timp ce lucra pe US-003 și US-004 în aceeași sesiune (script scris, model încărcat, 6 voci exportate, catalog scris) dar nu a apucat să facă commit — la începutul iterației verifică mereu `git status`/`git log` înainte să presupui că trebuie reimplementat de la zero; codul + blob-urile deja produse pot fi validate în loc de regenerate (re-exportul ar fi reîncărcat modelul, costisitor).
- `tools/pocket_tts_add_voice.py` a ajuns commitat accidental în același commit cu US-003 (e3b5cdf) pentru că era deja `git add`-uit din sesiunea anterioară — commit-urile per-story nu sunt garantat izolate dacă lucrul anterior a lăsat fișiere staged; verifică `git diff --cached` înainte de commit ca să știi exact ce intră.
- `scripts/ralph/prd.json` (harness PRD-ul acestui proiect) și `cron/jobs.json` (timestamp-uri live ale schedulerului) nu trebuie commitate ca parte a unui story de cod — doar prd.json e commitat, dar separat, ca update de stare, nu amestecat în diff-ul de cod al story-ului.
### Next:
- US-005 (backend, config.json secțiune tts) — eligibil, dependsOn gol
- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true)
---
## Iterație: 2026-07-11 (US-005)
### Story implementat: US-005 - Adaugă secțiunea tts în config.json (tags: backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- /review (backend): review manual (skill-ul interactiv /review nu se pretează unei iterații autonome — cere AskUserQuestion) — diff e doar date (4 linii JSON), fără cod nou care citește tts.* (US-006 face asta), fără secrete, JSON valid
### Learnings:
- src/config.py::Config e deja complet generic (get/set/save/reload cu dot-notation pe orice cheie nouă) — adăugarea unei secțiuni noi în config.json nu cere nicio schimbare de cod, doar date. tests/test_config.py deja acoperă roundtrip-ul set→save→reload generic, deci AC-urile de persistență ale story-ului sunt satisfăcute fără teste noi.
- Skill-ul gstack `/review` e construit pentru revizuire PR interactivă (AskUserQuestion, gh pr, telemetry) — pentru iterații Ralph autonome cu diff-uri mici/triviale, un review manual (grep pentru cod care citește noua cheie, verificare JSON valid, verificare secrete) e suficient și evită blocarea pe input interactiv indisponibil.
### Next:
- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true)
- US-006 (backend+refactor, rutare engine tts.py) — NU eligibil încă, dependsOn include US-003 (passes:true) dar și US-005 (acum passes:true) — devine eligibil următoarea iterație
---
## Iterație: 2026-07-11 (US-006)
### Story implementat: US-006 - Refactor tools/tts.py — rutare engine, backend pocket-tts și fallback (tags: backend, refactor)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- /workflow:simplify (refactor): 4 agenți paraleli (reuse/simplification/efficiency/altitude) pe diff-ul tools/tts.py.
Aplicat 1 fix sigur: `engine_used` setat o singură dată în loc de trei literali duplicate.
Respinse ca false-positive (verificate contra AC-urilor explicite din PRD, nu doar "pare hardcodat"):
- voice=DEFAULT_VOICE, lang=DEFAULT_LANG hardcodate pe calea de fallback tehnic — AC-ul US-006 cere explicit
`_synthesize_supertonic(voice='M2', lang='ro')`; pocket-tts ignoră lang complet, deci la fallback nu există
niciun semnal de încredere pentru ce limbă Supertonic ar trebui folosită — design intenționat, nu bandaid.
- lookup catalog case-insensitive unificat — ar schimba comportamentul (matching pe nume gen "Marius 1")
fără cerință din AC, risc fără beneficiu clar în scope-ul acestui story.
Notat pentru follow-up (NU implementat, în afara scope-ului): `_load_voice_catalog()`/`_config_get()`/citirea
fișierului de voce clonată se repetă per-clauză în hot path-ul de streaming (src/voice/tts_stream.py) — un
cache per-sesiune ar elimina I/O redundant, dar necesită schimbări în afara tools/tts.py.
- /review (backend, manual — skill-ul interactiv nu se pretează unei iterații autonome): PASS — fără shell
injection, fără secret leakage, error handling scoped corect (ConnectError/TimeoutException/HTTPStatusError
separate de erorile de conținut 4xx), semnătura publică synthesize(text, voice, lang) neschimbată (verificat
caller-ul src/fast_commands.py::_tts_synthesize).
### Learnings:
- Codul pentru US-006 era deja scris (necommitat) dintr-o iterație anterioară care nu a apucat să facă commit —
la fel ca US-003/US-004, verifică mereu `git diff`/`git status` înainte să presupui reimplementare de la zero.
- Gate-urile `/simplify` (agenți automați) găsesc uneori "probleme" care sunt de fapt cerințe explicite din AC
(ex. valorile hardcodate de fallback voice='M2'/lang='ro') — verifică fiecare finding contra textului literal
al acceptance criteria din PRD înainte să aplici un fix, nu doar contra intuiției generale de cod curat.
- Findings de eficiență care ar necesita schimbări cross-file (aici: caching în src/voice/tts_stream.py pentru
hot path-ul de streaming) rămân notate ca follow-up, nu aplicate — story-ul e scopat strict la tools/tts.py.
### Next:
- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true)
- US-007 (ui+backend, /voice engine) — eligibil, dependsOn US-005+US-006 (ambele acum passes:true)
- US-008 (ui+backend, /voice addvoice) — eligibil, dependsOn US-004+US-006 (ambele acum passes:true)
- US-009 (ui+backend, autocomplete voci) — eligibil, dependsOn US-003+US-006 (ambele acum passes:true)
- US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (acum passes:true)
---
## Iterație: 2026-07-11 (US-007)
### Story implementat: US-007 - Comandă Discord /voice engine (tags: ui, backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- Gate `ui`: agent-browser/Playwright nu se aplică — /voice engine e o comandă slash Discord, nu o pagină
web pe techStack.port. În loc de screenshot, am validat wiring-ul comenzii într-un CommandTree izolat
(register() apelat direct, fără bot live) — comanda 'engine' apare cu choices=[pockettts, supertonic].
Am evitat intenționat un restart al `echo-core.service` (bot-ul de producție Discord/Telegram/WhatsApp
folosit activ de Marius) pentru un smoke-test end-to-end real în Discord, ca să nu întrerup serviciul
mid-day fără motiv — rămâne recomandare de test manual după următorul restart/deploy normal.
- /review (backend, manual): PASS — diff mic, urmează exact pattern-ul `/voice setvoice` existent
(defer ephemeral, Config().set+save, try/except pe save failure), fără shell injection, fără secret
leakage, API contract nou (nu atinge alte comenzi).
### Learnings:
- Pentru comenzi Discord (tag `ui` fără server web real pe `techStack.port`), gate-ul `/qa` din instrucțiuni
(agent-browser pe localhost) nu are sens literal — echo-core e un bot, nu o aplicație web. Verificare
alternativă validă: instanțiere izolată a CommandTree + register() pentru confirmare structurală, plus
review manual al codului. Nu presupune că un web server rulează pe `techStack.port` doar pentru că PRD-ul
are un port generic definit.
- `echo-core.service` rulează live direct din acest working tree, pe branch-ul `ralph/echo-improve` — este
bot-ul de producție folosit activ de Marius (Discord/Telegram/WhatsApp), nu un serviciu de test izolat.
Restart-ul lui e o acțiune cu blast radius asupra unui sistem shared/live — nu-l restart fără motiv clar
sau aprobare explicită, chiar dacă ar oferi o verificare end-to-end mai completă a comenzilor Discord noi.
- `_default_voice_for_engine()` a fost adăugat ca helper mic separat de `_get_default_voice()` existent —
catalogul `tts_voices.json` nu are un flag explicit "default per engine", deci am folosit convenția deja
stabilită în US-003 (voce predefinită pocket-tts = 'alba') + config-ul existent `voice.default_voice`
pentru Supertonic, în loc să introduc un câmp nou de config nefolosit în altă parte.
### Next:
- US-008 (ui+backend, /voice addvoice) — eligibil, dependsOn US-004+US-006 (ambele passes:true)
- US-009 (ui+backend, autocomplete voci) — eligibil, dependsOn US-003+US-006 (ambele passes:true)
- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true)
- US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (passes:true)
---
## Iterație: 2026-07-11 (US-008)
### Story implementat: US-008 - Comandă Discord /voice addvoice (tags: ui, backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- Gate `ui`: la fel ca US-007, /voice addvoice e o comandă slash Discord, nu o pagină web pe
techStack.port — agent-browser nu se aplică. Verificat wiring prin CommandTree izolat
(register() apelat direct, fără bot live): comanda 'addvoice' apare cu parametrii
nume:str și sample:Attachment. Nu am restart-uit echo-core.service (bot de producție live
folosit activ de Marius) pentru un test end-to-end real în Discord.
- /review (backend, manual): PASS — subprocess apelat cu argumente ca listă (fără shell=True),
deci fără risc de shell injection pe `--name` (nume introdus de utilizator); hf_token nu
apare niciodată în discord_voice.py (rămâne izolat în subprocesul pocket_tts_add_voice.py,
citit din keyring); systemctl start rulează în `finally` deci serviciul repornește chiar și
la timeout/eroare; fișierul temporar WAV e curățat în `finally` separat.
### Learnings:
- Codul pentru US-008 era deja scris (necommitat) dintr-o iterație anterioară care a atins
max_turns fără să apuce commit — a treia oară când se întâmplă acest pattern (după
US-003/US-004, US-006). Verifică mereu `git diff`/`git status` la începutul iterației
înainte să presupui reimplementare de la zero; aici codul era deja corect și complet,
a fost nevoie doar de verificare linie cu linie contra acceptance criteria, nu rescriere.
- Pattern-ul de gate `ui` pentru comenzi Discord (fără server web real) rămâne: verificare
structurală prin CommandTree izolat + review manual, stabilit deja la US-007 — reutilizat
aici fără modificări.
- `cron/jobs.json` are timestamp-uri live modificate de scheduler-ul care rulează concurent
cu acest proces Ralph — nu trebuie inclus în commit-ul de cod al story-ului (confirmă
learning-ul din iterația US-004).
### Next:
- US-009 (ui+backend, autocomplete voci) — eligibil, dependsOn US-003+US-006 (ambele passes:true)
- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true)
- US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (passes:true)
---
## Iterație: 2026-07-11 (US-009)
### Story implementat: US-009 - Autocomplete voci din catalog pentru /audio și /voice setvoice (tags: ui, backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- Gate `ui`: la fel ca US-007/US-008, comenzi slash Discord fără server web pe techStack.port —
agent-browser nu se aplică. Verificat structural prin `create_bot()` + `discord_voice.register()`
izolat (fără bot live): parametrul `voce` din `/audio` și `voice` din `/voice setvoice` au
`autocomplete` legat corect la `_voice_autocomplete` (verificat cu `repr(p.autocomplete)`, nu doar
`is not None` — primul test cu `is not None` dădea fals-pozitiv pe toți parametrii). Confirmat că
`tts.list_voice_names()` întoarce live cele 17 intrări din `tts_voices.json`, filtrate corect pe
`engine='supertonic'`/`'pockettts'`.
- /review (backend, manual): PASS — `_load_voice_catalog()` citește prin `src.jsonlock.read_locked`
(fără acces direct la fișier), fără subprocess/shell nou, fără secret leakage, import lazy
`tools/tts.py` identic cu pattern-ul deja folosit în `_tts_synthesize_preview` (US-008).
### Learnings:
- A patra oară (după US-003/US-004, US-006, US-008) când codul unui story era deja scris
(necommitat) dintr-o iterație anterioară care a atins max_turns — pattern consistent, verifică
mereu `git status`/`git diff` la începutul iterației înainte să presupui reimplementare de la zero.
- `discord.app_commands.CommandParameter.autocomplete` e `None` by default și devine `True`-ish
(bool) doar când verificat cu `bool(x)`/`is not None` pe un attribute care de fapt e mereu setat
la construcție internă — verificarea corectă e `repr(p.autocomplete)` care arată fie `None`, fie
numele funcției/`True` literal; testul inițial cu `is not None` a dat fals-pozitiv pe toți cei 3
parametri ai `/audio`, corectat verificând direct valoarea brută înainte de a trage concluzia.
- Catalogul `tts_voices.json` are deja chei case-sensitive exact ca vechile liste hardcodate
(`M1`-`M5`, `F1`-`F5` uppercase) — nu a fost nevoie de normalizare suplimentară de case în
`list_voice_names()`, doar filtrare pe `engine` din intrare.
### Next:
- US-010 (backend, /voice doctor + pocket-tts health) — eligibil, dependsOn US-001+US-002 (ambele passes:true)
- US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (passes:true)
- US-012 (docs, TOOLS.md) — NU eligibil, dependsOn US-007+US-008+US-009 (toate acum passes:true) —
devine eligibil următoarea iterație
---
## Rate limit la iter 14 — sleep 1800
## Iterație: 2026-07-11 (US-010)
### Story implementat: US-010 - Extinde /voice doctor cu health-check pocket-tts (tags: backend)
### Status: Complete
### Gates rulate:
- Typecheck: SKIP (mypy neinstalat în .venv)
- Lint: SKIP (ruff neinstalat în .venv)
- Tests: PASS — pytest tests/ aceleași 22 eșecuri preexistente, 1043 passed (neschimbat)
- /review (backend, manual): PASS — hf_token check raportează doar prezență (bool), niciodată
valoarea; URL-ul pocket-tts vine din config, nu din input utilizator; ambele checks noi sunt
în try/except separat, degradează la FAIL în loc să crape comanda; checks-urile existente
(libopus, voice load error) neatinse.
### Learnings:
- Prima iterație din acest lanț fără cod deja scris dintr-o sesiune anterioară — a fost nevoie
de implementare reală de la zero (spre deosebire de US-003/004/006/008/009 unde codul exista
deja necommitat). git status/diff la început a confirmat clar starea curată.
- Nu exista deja un health-check de rețea pentru Supertonic în /voice doctor (doar libopus +
voice_load_error) — AC-ul "verificarea Supertonic existentă rămâne funcțională" s-a interpretat
ca "nu rupe checks-urile existente", nu ca "adaugă un ping nou pentru Supertonic" (nu era cerut
explicit în AC, doar pentru pocket-tts).
- Verificat manual end-to-end contra serviciilor live (pocket-tts.service rulează din US-001,
hf_token e în keyring din US-002) — get_secret("hf_token") is not None -> True, GET
127.0.0.1:7789/health -> 200. Confirmă că integrarea reală funcționează, nu doar wiring-ul.
### Next:
- US-011 (backend, teste TTS) — eligibil, dependsOn US-006 (passes:true)
- US-012 (docs, TOOLS.md) — eligibil, dependsOn US-007+US-008+US-009 (toate passes:true)
---
## Iterație: 2026-07-11 (US-011, US-012 — finalizate manual după epuizarea bugetului ralph de 15 iterații)
### Story implementat: US-011 - Teste minime pentru rutare TTS și config (tags: backend)
### Status: Complete
### Gates rulate:
- Tests: PASS — pytest tests/test_tts.py tests/test_config.py: 34 passed (11 teste noi tts + 1 test nou config, restul preexistente)
- Suită completă: pytest tests/: 1055 passed, 22 preexisting failures (neschimbate față de toate iterațiile anterioare)
### Story implementat: US-012 - Documentează comenzile TTS noi în TOOLS.md (tags: docs)
### Status: Complete
### Gates rulate:
- Review manual: secțiune nouă în personality/TOOLS.md verificată propoziție cu propoziție contra tools/tts.py::synthesize și src/adapters/discord_voice.py (rutare din catalog, fallback tehnic vs eroare de conținut, /voice engine/setvoice/addvoice/doctor, /audio)
### Learnings:
- ralph.sh a epuizat bugetul de 15 iterații cu 10/12 povești complete (retry-uri repetate pe max-turns=30 la US-004/006/008/009 + o pauză de 30min pe rate limit) — cele două povești rămase (teste + docs) nu aveau nicio complexitate arhitecturală, doar volum de context (citit tools/tts.py, discord_voice.py, tts_voices.json), potrivite pentru finalizare directă fără un nou run ralph.
- _synthesize_pockettts distinge tehnic (ConnectError/timeout/5xx → raise _PocketTTSUnavailable → fallback) de eroare de conținut (fișier stat_path lipsă, 4xx → dict {ok:false} direct, fără fallback) — testat explicit ca regression guard, pentru că e ușor de stricat accidental dacă cineva mută un check de validare în interiorul try-ului.
### Next:
- Toate cele 12 povești din PRD sunt passes:true. Self-improve TTS (pocket-tts + Supertonic routing) e complet.

View File

@@ -39,6 +39,19 @@ from src.adapters.discord_views import (
logger = logging.getLogger("echo-core.discord")
_security_log = logging.getLogger("echo-core.security")
def _tts_voice_names() -> list[str]:
"""Nume de voci din tts_voices.json (import lazy din tools/tts.py, catalog live)."""
import sys as _sys
tools_dir = str(PROJECT_ROOT / "tools")
if tools_dir not in _sys.path:
_sys.path.insert(0, tools_dir)
try:
import tts as _tts_mod
return _tts_mod.list_voice_names()
except Exception:
return []
# Module-level config reference, set by create_bot()
_config: Config | None = None
@@ -908,26 +921,20 @@ def create_bot(config: Config) -> discord.Client:
except Exception:
return wav_path
async def _voice_autocomplete(
interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
current_low = (current or "").lower()
names = [n for n in _tts_voice_names() if current_low in n.lower()]
return [app_commands.Choice(name=n, value=n) for n in names[:25]]
@tree.command(name="audio", description="TTS: convertește text sau URL în voice note")
@app_commands.describe(
voce="Voce (M1-M5 masculin, F1-F5 feminin; default M2)",
voce="Voce din catalog (ex: M1-M5, F1-F5, 'Marius 1'; default M2)",
text_sau_url="Text direct, URL articol, sau gol pentru ultimul răspuns Echo",
rezumat="Dacă să facă Claude rezumat înainte de TTS (doar pentru URL)",
)
@app_commands.choices(
voce=[
app_commands.Choice(name="M1 — Masculin 1", value="M1"),
app_commands.Choice(name="M2 — Masculin 2 (default)", value="M2"),
app_commands.Choice(name="M3 — Masculin 3", value="M3"),
app_commands.Choice(name="M4 — Masculin 4", value="M4"),
app_commands.Choice(name="M5 — Masculin 5", value="M5"),
app_commands.Choice(name="F1 — Feminin 1", value="F1"),
app_commands.Choice(name="F2 — Feminin 2", value="F2"),
app_commands.Choice(name="F3 — Feminin 3", value="F3"),
app_commands.Choice(name="F4 — Feminin 4", value="F4"),
app_commands.Choice(name="F5 — Feminin 5", value="F5"),
]
)
@app_commands.autocomplete(voce=_voice_autocomplete)
async def audio_cmd(
interaction: discord.Interaction,
voce: str | None = None,

View File

@@ -15,10 +15,18 @@ heavy lifting to:
from __future__ import annotations
import asyncio
import io
import logging
import os
import re
import subprocess
import tempfile
import wave
from pathlib import Path
from typing import Optional
import discord
import httpx
from discord import app_commands
# Optional DAVE dep (mandatory at runtime when discord.py 2.7.1 is paired with
@@ -41,6 +49,13 @@ from src.voice._discord_voice_adapter import connect_voice
log = logging.getLogger("echo-core.discord.voice")
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
POCKET_TTS_VENV_PYTHON = PROJECT_ROOT / ".venv-pockettts" / "bin" / "python"
ADD_VOICE_SCRIPT = PROJECT_ROOT / "tools" / "pocket_tts_add_voice.py"
POCKET_TTS_SERVICE = "pocket-tts.service"
_MIN_ADDVOICE_SAMPLE_SECONDS = 3.0
_ADDVOICE_TIMEOUT_SECONDS = 300
# Per-guild voice session registry. Key = guild_id.
_voice_sessions: dict[int, VoiceSession] = {}
@@ -100,6 +115,60 @@ def _get_default_voice() -> str:
return "M2"
def _default_voice_for_engine(engine: str) -> str:
"""Vocea implicită asociată engine-ului (nu vocea implicită globală per-guild)."""
if engine == "pockettts":
return "alba"
return _get_default_voice()
def _systemctl_user(action: str, unit: str) -> None:
"""Best-effort `systemctl --user <action> <unit>` — nu ridică, doar loghează eșecul."""
try:
subprocess.run(
["systemctl", "--user", action, unit],
capture_output=True, text=True, timeout=15,
)
except Exception as e:
log.warning("systemctl --user %s %s failed: %s", action, unit, e)
_REGISTERED_VOICE_RE = re.compile(r"Registered voice '(.+?)' ->")
def _parse_registered_voice_name(stdout: str) -> Optional[str]:
m = _REGISTERED_VOICE_RE.search(stdout or "")
return m.group(1) if m else None
def _tts_voice_names() -> list[str]:
"""Nume de voci din tts_voices.json (import lazy din tools/tts.py, catalog live)."""
import sys as _sys
tools_dir = str(PROJECT_ROOT / "tools")
if tools_dir not in _sys.path:
_sys.path.insert(0, tools_dir)
try:
import tts as _tts_mod
return _tts_mod.list_voice_names()
except Exception:
return []
def _tts_synthesize_preview(text: str, voice: str) -> dict:
"""Import tools/tts.py (nu e package, sys.path trick) și generează un preview audio."""
import sys as _sys
tools_dir = str(PROJECT_ROOT / "tools")
if tools_dir not in _sys.path:
_sys.path.insert(0, tools_dir)
try:
import importlib
import tts as _tts_mod
importlib.reload(_tts_mod)
return _tts_mod.synthesize(text, voice=voice, lang="ro")
except Exception as e:
return {"ok": False, "error": f"{type(e).__name__}: {e}"}
def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_commands.Group:
"""Build the `/voice` slash command group and return it (caller registers)."""
voice_group = app_commands.Group(
@@ -246,20 +315,27 @@ def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_command
log.warning("Presence reset skipped", exc_info=True)
await interaction.followup.send("Plecat.", ephemeral=True)
_VOICE_CHOICES = [
app_commands.Choice(name=v, value=v)
for v in ("M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5")
]
async def _voice_autocomplete(
interaction: discord.Interaction, current: str
) -> list[app_commands.Choice[str]]:
current_low = (current or "").lower()
names = [n for n in _tts_voice_names() if current_low in n.lower()]
return [app_commands.Choice(name=n, value=n) for n in names[:25]]
@voice_group.command(name="setvoice", description="Schimbă vocea Echo (M1-M5 sau F1-F5)")
@voice_group.command(name="setvoice", description="Schimbă vocea Echo (din catalogul tts_voices.json)")
@app_commands.describe(voice="Voce nouă")
@app_commands.choices(voice=_VOICE_CHOICES)
@app_commands.autocomplete(voice=_voice_autocomplete)
async def setvoice(
interaction: discord.Interaction,
voice: app_commands.Choice[str],
voice: str,
) -> None:
await interaction.response.defer(ephemeral=True)
new_voice = voice.value
if voice not in _tts_voice_names():
await interaction.followup.send(
f"Voce necunoscută: {voice!r}. Alege din autocomplete.", ephemeral=True
)
return
new_voice = voice
# Live-swap on the active session if Echo is in voice on this guild.
guild_id = interaction.guild.id if interaction.guild else None
session = _voice_sessions.get(guild_id) if guild_id is not None else None
@@ -285,6 +361,148 @@ def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_command
msg = f"Default voce setată {new_voice}. Va intra în vigoare la următorul /voice join."
await interaction.followup.send(msg, ephemeral=True)
_ENGINE_CHOICES = [
app_commands.Choice(name="pocket-tts", value="pockettts"),
app_commands.Choice(name="Supertonic", value="supertonic"),
]
@voice_group.command(name="engine", description="Schimbă engine-ul TTS implicit (pockettts/supertonic)")
@app_commands.describe(engine="Engine TTS")
@app_commands.choices(engine=_ENGINE_CHOICES)
async def engine_cmd(
interaction: discord.Interaction,
engine: app_commands.Choice[str],
) -> None:
await interaction.response.defer(ephemeral=True)
new_engine = engine.value
try:
cfg = Config()
cfg.set("tts.default_engine", new_engine)
cfg.save()
except Exception as e:
log.warning("config save failed for tts.default_engine: %s", e)
await interaction.followup.send(
f"Eroare la salvarea engine-ului: {e}", ephemeral=True
)
return
default_voice = _default_voice_for_engine(new_engine)
await interaction.followup.send(
f"Engine TTS implicit setat pe **{new_engine}**. Voce implicită: {default_voice}.",
ephemeral=True,
)
@voice_group.command(name="addvoice", description="Adaugă o voce nouă clonată (pocket-tts) dintr-un sample WAV")
@app_commands.describe(nume="Nume voce (ex: Marius)", sample="Fișier WAV cu vocea (minim ~3s)")
async def addvoice(
interaction: discord.Interaction,
nume: str,
sample: discord.Attachment,
) -> None:
await interaction.response.defer(ephemeral=True)
nume = nume.strip()
if not nume:
await interaction.followup.send("Numele vocii nu poate fi gol.", ephemeral=True)
return
filename = sample.filename or ""
if not filename.lower().endswith(".wav"):
await interaction.followup.send(
"Sample-ul trebuie să fie un fișier .wav.", ephemeral=True
)
return
try:
content = await sample.read()
except Exception as e:
await interaction.followup.send(
f"Descărcare sample eșuată: {type(e).__name__}: {e}", ephemeral=True
)
return
try:
with wave.open(io.BytesIO(content), "rb") as wf:
duration = wf.getnframes() / float(wf.getframerate())
except (wave.Error, EOFError) as e:
await interaction.followup.send(f"Fișier WAV invalid: {e}", ephemeral=True)
return
if duration < _MIN_ADDVOICE_SAMPLE_SECONDS:
await interaction.followup.send(
f"Sample prea scurt ({duration:.1f}s) — minim {_MIN_ADDVOICE_SAMPLE_SECONDS:.0f}s.",
ephemeral=True,
)
return
if not POCKET_TTS_VENV_PYTHON.exists():
await interaction.followup.send(
f"venv pocket-tts lipsă: {POCKET_TTS_VENV_PYTHON}", ephemeral=True
)
return
await interaction.followup.send(
"Adaug voce, TTS indisponibil ~30s...", ephemeral=True
)
tmp_wav_path: Optional[Path] = None
try:
fd, tmp_name = tempfile.mkstemp(prefix="echo-addvoice-", suffix=".wav")
with open(fd, "wb") as f:
f.write(content)
tmp_wav_path = Path(tmp_name)
await asyncio.to_thread(_systemctl_user, "stop", POCKET_TTS_SERVICE)
try:
proc = await asyncio.to_thread(
subprocess.run,
[str(POCKET_TTS_VENV_PYTHON), str(ADD_VOICE_SCRIPT),
"--wav", str(tmp_wav_path), "--name", nume],
capture_output=True, text=True,
timeout=_ADDVOICE_TIMEOUT_SECONDS, cwd=str(PROJECT_ROOT),
)
except subprocess.TimeoutExpired:
await interaction.followup.send(
f"Export voce a depășit timeout-ul ({_ADDVOICE_TIMEOUT_SECONDS}s).",
ephemeral=True,
)
return
finally:
await asyncio.to_thread(_systemctl_user, "start", POCKET_TTS_SERVICE)
finally:
if tmp_wav_path is not None:
try:
tmp_wav_path.unlink(missing_ok=True)
except OSError:
pass
if proc.returncode != 0:
err = (proc.stderr or proc.stdout or "eroare necunoscută").strip()
await interaction.followup.send(
f"Export voce eșuat: {err[-500:]}", ephemeral=True
)
return
final_name = _parse_registered_voice_name(proc.stdout) or nume
preview = _tts_synthesize_preview(f"Salut, sunt vocea {final_name}.", final_name)
if preview.get("ok"):
preview_path = preview["path"]
try:
await interaction.followup.send(
f"Voce adăugată: **{final_name}**.",
file=discord.File(preview_path, filename="preview.wav"),
ephemeral=True,
)
finally:
try:
os.unlink(preview_path)
except OSError:
pass
else:
await interaction.followup.send(
f"Voce adăugată: **{final_name}** (preview audio eșuat: {preview.get('error')})",
ephemeral=True,
)
@voice_group.command(name="stop", description="Oprește audio-ul curent (golește coada TTS)")
async def stop_audio(interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True)
@@ -313,6 +531,23 @@ def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_command
checks.append(("libopus", False))
# warmup
checks.append(("voice load error", _voice_load_error is None))
# hf_token în keyring
try:
from src.credential_store import get_secret
checks.append(("hf_token (keyring)", get_secret("hf_token") is not None))
except Exception:
checks.append(("hf_token (keyring)", False))
# pocket-tts /health
pockettts_ok = False
try:
pockettts_url = Config().get("tts.pockettts_url", "http://127.0.0.1:7789")
async with httpx.AsyncClient(timeout=3.0) as client:
resp = await client.get(f"{pockettts_url}/health")
pockettts_ok = resp.status_code == 200
except Exception:
pockettts_ok = False
checks.append(("pocket-tts /health", pockettts_ok))
# Build response
lines = ["**Voice doctor:**"]
for label, ok in checks:

View File

@@ -6,7 +6,7 @@ import keyring
SERVICE = "echo-core"
# Required secrets that should exist for full functionality
REQUIRED_SECRETS = ["discord_token"]
REQUIRED_SECRETS = ["discord_token", "hf_token"]
def set_secret(name: str, value: str) -> None:

View File

@@ -712,11 +712,23 @@ Session:
# Audio / TTS
# ---------------------------------------------------------------------------
_VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
_AUDIO_PREFIX = "__AUDIO__:"
_MAX_TTS_CHARS = 3000
def _supertonic_voice_names() -> set[str]:
"""Nume de voci Supertonic din tts_voices.json (import lazy din tools/tts.py)."""
import sys as _sys
_tools_dir = str(TOOLS_DIR)
if _tools_dir not in _sys.path:
_sys.path.insert(0, _tools_dir)
try:
import tts as _tts_mod
return set(_tts_mod.list_voice_names(engine="supertonic"))
except Exception:
return {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
def cmd_audio(args: list[str]) -> str:
"""TTS via Supertonic. Returnează __AUDIO__:/cale sau text de eroare.
@@ -732,7 +744,7 @@ def cmd_audio(args: list[str]) -> str:
remaining = list(args)
# Detectare voce ca prim token
if remaining and remaining[0].upper() in _VOICES:
if remaining and remaining[0].upper() in _supertonic_voice_names():
voice = remaining[0].upper()
remaining = remaining[1:]

View File

@@ -0,0 +1,184 @@
# 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.

View File

@@ -133,6 +133,14 @@ class TestConfigSave:
assert raw["bot"]["name"] == "TestBot"
assert raw["new_key"] == "new_value"
def test_tts_default_engine_persists_through_save_and_reload(self, tmp_config):
cfg = Config(tmp_config)
cfg.set("tts.default_engine", "pockettts")
cfg.save()
cfg2 = Config(tmp_config)
assert cfg2.get("tts.default_engine") == "pockettts"
class TestConfigReload:
def test_reload_picks_up_external_changes(self, tmp_config):

View File

@@ -165,12 +165,18 @@ class TestListSecrets:
class TestCheckSecrets:
def test_missing_required(self, mock_keyring):
result = check_secrets()
assert result == {"discord_token": False}
assert result == {"discord_token": False, "hf_token": False}
def test_present_required(self, mock_keyring):
set_secret("discord_token", "tok-123")
set_secret("hf_token", "hf-456")
result = check_secrets()
assert result == {"discord_token": True}
assert result == {"discord_token": True, "hf_token": True}
def test_partial_required(self, mock_keyring):
set_secret("discord_token", "tok-123")
result = check_secrets()
assert result == {"discord_token": True, "hf_token": False}
# ===========================================================================
@@ -250,6 +256,7 @@ class TestCLISecretsTest:
def test_all_present(self, mock_keyring, capsys):
set_secret("discord_token", "tok")
set_secret("hf_token", "hf-tok")
with patch("sys.argv", ["echo", "secrets", "test"]):
cli_main()
out = capsys.readouterr().out

162
tests/test_tts.py Normal file
View File

@@ -0,0 +1,162 @@
"""Tests for tools/tts.py — rutare engine, fallback pockettts -> supertonic, retry lang=na."""
from unittest.mock import MagicMock, patch
import httpx
import pytest
from tools.tts import _PocketTTSUnavailable, _synthesize_pockettts, _synthesize_supertonic, synthesize
def _fake_response(status_code=200, content=b"RIFF....WAVE", text=""):
resp = MagicMock()
resp.status_code = status_code
resp.content = content
resp.text = text
if status_code >= 400:
resp.raise_for_status.side_effect = httpx.HTTPStatusError(
"error", request=MagicMock(), response=resp
)
else:
resp.raise_for_status = MagicMock()
return resp
class TestEngineRouting:
@patch("tools.tts._load_voice_catalog")
@patch("tools.tts._synthesize_pockettts")
def test_catalogued_voice_uses_pockettts(self, mock_pockettts, mock_catalog):
mock_catalog.return_value = {
"Marius 1": {"engine": "pockettts", "state_path": "models/voices/marius-1.safetensors"}
}
mock_pockettts.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10}
result = synthesize("salut", voice="Marius 1")
mock_pockettts.assert_called_once()
assert result["engine_used"] == "pockettts"
assert result["ok"] is True
@patch("tools.tts._load_voice_catalog")
@patch("tools.tts._synthesize_supertonic")
def test_catalogued_voice_uses_supertonic(self, mock_supertonic, mock_catalog):
mock_catalog.return_value = {"M1": {"engine": "supertonic"}}
mock_supertonic.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10}
result = synthesize("salut", voice="M1")
mock_supertonic.assert_called_once()
assert result["engine_used"] == "supertonic"
@patch("tools.tts._config_get")
@patch("tools.tts._load_voice_catalog")
@patch("tools.tts._synthesize_pockettts")
def test_uncatalogued_voice_falls_back_to_default_engine_config(
self, mock_pockettts, mock_catalog, mock_config_get
):
mock_catalog.return_value = {}
mock_config_get.return_value = "pockettts"
mock_pockettts.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10}
result = synthesize("salut", voice="voce-inexistenta")
mock_config_get.assert_called_with("tts.default_engine", "supertonic")
assert result["engine_used"] == "pockettts"
class TestPockettsFallback:
@patch("tools.tts._load_voice_catalog")
@patch("tools.tts._synthesize_supertonic")
@patch("tools.tts._synthesize_pockettts")
def test_connect_error_falls_back_to_supertonic(
self, mock_pockettts, mock_supertonic, mock_catalog
):
mock_catalog.return_value = {"Marius 1": {"engine": "pockettts"}}
mock_pockettts.side_effect = _PocketTTSUnavailable("connection refused")
mock_supertonic.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10}
result = synthesize("salut", voice="Marius 1")
mock_supertonic.assert_called_once()
assert result["engine_used"] == "supertonic"
assert result["ok"] is True
@patch("tools.tts._load_voice_catalog")
@patch("tools.tts._synthesize_supertonic")
@patch("tools.tts._synthesize_pockettts")
def test_5xx_falls_back_to_supertonic(self, mock_pockettts, mock_supertonic, mock_catalog):
mock_catalog.return_value = {"Marius 1": {"engine": "pockettts"}}
mock_pockettts.side_effect = _PocketTTSUnavailable("HTTP 503: unavailable")
mock_supertonic.return_value = {"ok": True, "path": "/tmp/x.wav", "size_bytes": 10}
result = synthesize("salut", voice="Marius 1")
mock_supertonic.assert_called_once()
assert result["engine_used"] == "supertonic"
@patch("tools.tts._load_voice_catalog")
@patch("tools.tts._synthesize_supertonic")
def test_content_error_does_not_fall_back_to_supertonic(self, mock_supertonic, mock_catalog):
"""Missing state_path file -> ok:false dict, not a raised _PocketTTSUnavailable — no fallback."""
mock_catalog.return_value = {
"Marius 1": {"engine": "pockettts", "state_path": "models/voices/does-not-exist.safetensors"}
}
result = synthesize("salut", voice="Marius 1")
mock_supertonic.assert_not_called()
assert result["ok"] is False
assert result["engine_used"] == "pockettts"
assert "lipsă" in result["error"]
@patch("httpx.post")
def test_synthesize_pockettts_raises_on_connect_error(self, mock_post):
mock_post.side_effect = httpx.ConnectError("connection refused")
with pytest.raises(_PocketTTSUnavailable):
_synthesize_pockettts("salut", {})
@patch("httpx.post")
def test_synthesize_pockettts_raises_on_5xx(self, mock_post):
mock_post.return_value = _fake_response(status_code=503, text="unavailable")
with pytest.raises(_PocketTTSUnavailable):
_synthesize_pockettts("salut", {})
@patch("httpx.post")
def test_synthesize_pockettts_returns_error_dict_on_4xx(self, mock_post):
mock_post.return_value = _fake_response(status_code=400, text="bad request")
result = _synthesize_pockettts("salut", {})
assert result["ok"] is False
assert "400" in result["error"]
class TestSupertonicLangNaRetry:
@patch("tools.tts._load_voice_catalog")
@patch("httpx.post")
def test_ro_failure_retries_with_lang_na(self, mock_post, mock_catalog):
mock_catalog.return_value = {}
def side_effect(url, json, timeout):
if json.get("lang") == "ro":
return _fake_response(status_code=500, text="ro synthesis failed")
return _fake_response(status_code=200, content=b"RIFF....WAVE")
mock_post.side_effect = side_effect
result = _synthesize_supertonic("salut", voice="M1", lang="ro")
assert result["ok"] is True
assert mock_post.call_count == 2
langs_requested = [call.kwargs["json"]["lang"] for call in mock_post.call_args_list]
assert langs_requested == ["ro", "na"]
@patch("tools.tts._load_voice_catalog")
@patch("httpx.post")
def test_na_failure_does_not_retry_again(self, mock_post, mock_catalog):
"""Regression: retry only happens once (ro -> na), na failure returns the error directly."""
mock_catalog.return_value = {}
mock_post.return_value = _fake_response(status_code=500, text="still failing")
result = _synthesize_supertonic("salut", voice="M1", lang="na")
assert result["ok"] is False
assert mock_post.call_count == 1

View File

@@ -0,0 +1,70 @@
#!/usr/bin/env python3
"""Trimite un fișier (audio, imagine, etc.) direct într-un canal Discord.
De ce există: `__AUDIO__:<path>` (folosit de fast_commands.py) e interceptat
de discord_bot.py DOAR când niciun bloc de text n-a fost deja streamat pe
canal în turul curent. Într-un răspuns conversațional normal (acest proces,
`claude_session.py`), fiecare bloc de text pe care Claude îl produce e
trimis live prin `on_text` înainte ca răspunsul final să fie evaluat —
deci prefixul `__AUDIO__:` ajunge mereu trimis ca text brut, nu convertit
în attachment. Singura cale de a atașa un fișier dintr-un răspuns Claude
obișnuit e apelul direct la REST API-ul Discord, cu tokenul botului.
CLI:
python3 tools/discord_send_file.py --channel <id> --file /path/audio.wav [--text "mesaj"]
Modul:
from tools.discord_send_file import send_file
send_file(channel_id, "/path/audio.wav", content="mesaj opțional")
Channel ID: dacă nu-l știi din conversație, citește `sessions/active.json`
(cheia e channel_id-ul; de obicei există o singură sesiune activă).
"""
import argparse
import mimetypes
import sys
import httpx
sys.path.insert(0, "/home/moltbot/echo-core")
from src.credential_store import get_secret
def send_file(channel_id: str, path: str, content: str | None = None, filename: str | None = None) -> dict:
"""Trimite un fișier direct pe canalul Discord dat, via REST API.
Returns: {"ok": True, "message_id": "..."} sau {"ok": False, "error": "..."}
"""
token = get_secret("discord_token")
if not token:
return {"ok": False, "error": "discord_token lipsește din keyring (echo-core)."}
filename = filename or path.rsplit("/", 1)[-1]
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
try:
with httpx.Client(timeout=30) as client, open(path, "rb") as f:
resp = client.post(
f"https://discord.com/api/v10/channels/{channel_id}/messages",
headers={"Authorization": f"Bot {token}"},
files={"file": (filename, f, mime)},
data={"content": content} if content else {},
)
if resp.status_code not in (200, 201):
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:300]}"}
return {"ok": True, "message_id": resp.json().get("id")}
except Exception as e:
return {"ok": False, "error": str(e)}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Trimite un fișier direct pe un canal Discord")
parser.add_argument("--channel", required=True, help="Channel ID Discord")
parser.add_argument("--file", required=True, help="Cale către fișierul de trimis")
parser.add_argument("--text", default=None, help="Text opțional care însoțește fișierul")
args = parser.parse_args()
result = send_file(args.channel, args.file, content=args.text)
print(result)
sys.exit(0 if result.get("ok") else 1)

98
tools/pocket_tts_add_voice.py Executable file
View File

@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Export a pocket-tts voice state from a WAV sample into tts_voices.json.
Must run with <.venv-pockettts>/bin/python (torch + pocket-tts installed
there), never with the main .venv:
.venv-pockettts/bin/python tools/pocket_tts_add_voice.py \\
--wav /path/to/sample.wav --name "Marius"
Determines the next free index for --name by scanning tts_voices.json (an
entry counts as free if it's absent, or present but its state_path file
doesn't exist yet on disk), exports the voice state to
models/voices/<slug>.safetensors, and registers it in tts_voices.json via
src.jsonlock.
"""
import argparse
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CATALOG_PATH = REPO_ROOT / "tts_voices.json"
VOICES_DIR = REPO_ROOT / "models" / "voices"
sys.path.insert(0, str(REPO_ROOT))
from src.credential_store import get_secret # noqa: E402
from src.jsonlock import read_locked, write_locked # noqa: E402
def _slugify(name: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
def _next_free_index(catalog: dict, name: str) -> int:
n = 1
while True:
entry = catalog.get(f"{name} {n}")
if entry is None:
return n
state_path = entry.get("state_path")
if state_path and not (REPO_ROOT / state_path).exists():
return n
n += 1
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wav", required=True, help="Path to source WAV sample")
parser.add_argument("--name", required=True, help="Base voice name, e.g. 'Marius'")
args = parser.parse_args()
wav_path = Path(args.wav).expanduser().resolve()
if not wav_path.exists():
print(f"[pocket_tts_add_voice] ERROR: wav not found: {wav_path}", file=sys.stderr)
return 1
import os
hf_token = get_secret("hf_token")
if not hf_token:
print(
"[pocket_tts_add_voice] ERROR: hf_token lipsește din keyring (service echo-core).",
file=sys.stderr,
)
return 1
os.environ["HF_TOKEN"] = hf_token
from pocket_tts import TTSModel, export_model_state
catalog = read_locked(str(CATALOG_PATH))
index = _next_free_index(catalog, args.name)
full_name = f"{args.name} {index}"
slug = _slugify(full_name)
VOICES_DIR.mkdir(parents=True, exist_ok=True)
export_path = VOICES_DIR / f"{slug}.safetensors"
print("[pocket_tts_add_voice] Loading pocket-tts model...")
model = TTSModel.load_model()
print(f"[pocket_tts_add_voice] Exporting voice state from {wav_path} -> {export_path}")
state = model.get_state_for_audio_prompt(audio_conditioning=str(wav_path), truncate=True)
export_model_state(state, str(export_path))
def _register(data: dict) -> dict:
data[full_name] = {
"engine": "pockettts",
"state_path": f"models/voices/{slug}.safetensors",
"owner": args.name,
}
return data
write_locked(str(CATALOG_PATH), _register)
print(f"[pocket_tts_add_voice] Registered voice '{full_name}' -> {export_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

22
tools/pocket_tts_env_launch.sh Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env bash
# Launcher for pocket-tts.service — resolves hf_token from keyring (needed to
# download the gated kyutai/pocket-tts weights) and execs the server.
set -uo pipefail
REPO_ROOT="/home/moltbot/echo-core"
VENV="$REPO_ROOT/.venv-pockettts"
cd "$REPO_ROOT"
HF_TOKEN="$(PYTHONPATH="$REPO_ROOT" "$VENV/bin/python3" -c "
from src.credential_store import get_secret
print(get_secret('hf_token') or '')
")"
if [ -z "$HF_TOKEN" ]; then
echo "[pocket-tts-launch] ERROR: hf_token lipsește din keyring (service echo-core). Setează-l cu: $REPO_ROOT/.venv/bin/python3 -c \"from src.credential_store import set_secret; set_secret('hf_token', '<token>')\"" >&2
exit 1
fi
export HF_TOKEN
exec "$VENV/bin/pocket-tts" serve --host 127.0.0.1 --port 7789

View File

@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""Text-to-speech via Supertonic local server.
"""Text-to-speech — rutează pe engine (Supertonic sau pocket-tts) pe baza catalogului de voci.
CLI:
python3 tools/tts.py --text "Salut Marius" [--voice M1] [--lang ro]
→ stdout: {"ok": true, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": 12345}
→ stdout: {"ok": true, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": 12345, "engine_used": "supertonic"}
→ stdout: {"ok": false, "error": "..."}
Module:
@@ -15,13 +15,22 @@ import argparse
import json
import sys
import tempfile
from pathlib import Path
import httpx
REPO_ROOT = Path(__file__).resolve().parent.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
SUPERTONIC_URL = "http://127.0.0.1:7788"
VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
# Safety net dacă tts_voices.json lipsește/e gol — normal, catalogul e sursa de adevăr.
_FALLBACK_SUPERTONIC_VOICES = {"M1", "M2", "M3", "M4", "M5", "F1", "F2", "F3", "F4", "F5"}
DEFAULT_VOICE = "M2"
DEFAULT_LANG = "ro"
DEFAULT_ENGINE = "supertonic"
_VOICES_CATALOG_PATH = REPO_ROOT / "tts_voices.json"
# Punctuation Supertonic synthesis rejects with HTTP 500 (Romanian curly quotes,
# smart dashes, ellipsis, angle quotes). Mapped to ASCII so a stray „foo" in
@@ -40,6 +49,10 @@ _TTS_PUNCT_MAP = {
_MAX_TTS_CHARS = 400
class _PocketTTSUnavailable(Exception):
"""pocket-tts server e nereachable sau eșuează server-side (5xx) — caller-ul trebuie să facă fallback pe Supertonic."""
def sanitize_for_supertonic(text: str) -> str:
"""Replace Unicode punctuation and strip chars that crash Supertonic's ONNX model."""
for src, dst in _TTS_PUNCT_MAP.items():
@@ -58,20 +71,60 @@ def sanitize_for_supertonic(text: str) -> str:
return text
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
def _load_voice_catalog() -> dict:
from src.jsonlock import read_locked
try:
return read_locked(str(_VOICES_CATALOG_PATH))
except FileNotFoundError:
return {}
def _catalog_entry(voice: str) -> dict | None:
catalog = _load_voice_catalog()
return catalog.get(voice) or catalog.get(voice.upper())
def list_voice_names(engine: str | None = None) -> list[str]:
"""Nume de voci din tts_voices.json, opțional filtrate pe `engine`.
Sursă live pentru autocomplete Discord (/audio, /voice setvoice) — o voce
nouă adăugată în catalog apare imediat, fără redeploy.
"""
catalog = _load_voice_catalog()
if engine is not None:
return sorted(name for name, entry in catalog.items() if entry.get("engine") == engine)
return sorted(catalog.keys())
def _config_get(key: str, default: str) -> str:
from src.config import Config
return Config().get(key, default)
def _write_temp_wav(content: bytes) -> dict:
try:
fd, path = tempfile.mkstemp(prefix="echo-tts-", suffix=".wav")
with open(fd, "wb") as f:
f.write(content)
return {"ok": True, "path": path, "size_bytes": len(content)}
except Exception as e:
return {"ok": False, "error": f"Scriere fișier: {e}"}
def _synthesize_supertonic(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
"""Call Supertonic server and save audio to a temp WAV file.
Returns:
{"ok": True, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": N}
{"ok": False, "error": "mesaj eroare"}
"""
if not text or not text.strip():
return {"ok": False, "error": "Text gol."}
text = sanitize_for_supertonic(text)
voice = voice.upper()
if voice not in VOICES:
supertonic_voices = set(list_voice_names(engine="supertonic")) or _FALLBACK_SUPERTONIC_VOICES
if voice not in supertonic_voices:
voice = DEFAULT_VOICE
try:
@@ -99,31 +152,94 @@ def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG)
body = e.response.text[:300]
# Fallback: dacă lang=ro eșuează, încearcă na (language-agnostic)
if lang != "na":
return synthesize(text, voice=voice, lang="na")
return _synthesize_supertonic(text, voice=voice, lang="na")
return {"ok": False, "error": f"HTTP {e.response.status_code}: {body}"}
except Exception as e:
return {"ok": False, "error": str(e)}
# Salvează în fișier temp
return _write_temp_wav(resp.content)
def _synthesize_pockettts(text: str, entry: dict) -> dict:
"""POST multipart la serverul pocket-tts (:7789/tts). Ignoră lang (modelul e monolingv per deployment).
`entry` e intrarea din tts_voices.json (deja rezolvată de caller — evită un al doilea read+lock
al catalogului). state_path (voce clonată) -> încarcă fișierul ca voice_wav. voice_url (voce
predefinită, ex. 'alba') -> trimite voice_url. Nici unul -> nu trimite voice_wav/voice_url
(serverul alege default-ul limbii).
Raises:
_PocketTTSUnavailable: eșec tehnic (connect error/timeout/5xx) — caller-ul face fallback.
"""
state_path = entry.get("state_path")
voice_url = entry.get("voice_url")
data = {"text": text}
files = None
if state_path:
full_path = REPO_ROOT / state_path
if not full_path.exists():
return {"ok": False, "error": f"Fișier voce lipsă: {state_path}"}
files = {"voice_wav": (full_path.name, full_path.read_bytes())}
elif voice_url:
data["voice_url"] = voice_url
try:
fd, path = tempfile.mkstemp(prefix="echo-tts-", suffix=".wav")
with open(fd, "wb") as f:
f.write(resp.content)
return {"ok": True, "path": path, "size_bytes": len(resp.content)}
except Exception as e:
return {"ok": False, "error": f"Scriere fișier: {e}"}
resp = httpx.post(
f"{_config_get('tts.pockettts_url', 'http://127.0.0.1:7789')}/tts",
data=data,
files=files,
timeout=60.0,
)
except (httpx.ConnectError, httpx.TimeoutException) as e:
raise _PocketTTSUnavailable(str(e)) from e
if resp.status_code >= 500:
raise _PocketTTSUnavailable(f"HTTP {resp.status_code}: {resp.text[:300]}")
if resp.status_code >= 400:
return {"ok": False, "error": f"HTTP {resp.status_code}: {resp.text[:300]}"}
return _write_temp_wav(resp.content)
def synthesize(text: str, voice: str = DEFAULT_VOICE, lang: str = DEFAULT_LANG) -> dict:
"""Rutează sinteza pe engine-ul rezolvat din catalog (fallback la tts.default_engine).
Returns:
{"ok": True, "path": "/tmp/echo-tts-xxx.wav", "size_bytes": N, "engine_used": "pockettts"}
{"ok": False, "error": "mesaj eroare", "engine_used": "..."}
"""
if not text or not text.strip():
return {"ok": False, "error": "Text gol."}
entry = _catalog_entry(voice) or {}
engine = entry.get("engine") or _config_get("tts.default_engine", DEFAULT_ENGINE)
engine_used = "supertonic"
if engine == "pockettts":
try:
result = _synthesize_pockettts(text, entry)
engine_used = "pockettts"
except _PocketTTSUnavailable:
result = _synthesize_supertonic(text, voice=DEFAULT_VOICE, lang=DEFAULT_LANG)
else:
result = _synthesize_supertonic(text, voice=voice, lang=lang)
result["engine_used"] = engine_used
return result
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Supertonic TTS CLI")
parser = argparse.ArgumentParser(description="Echo Core TTS CLI")
parser.add_argument("--text", required=True, help="Text de convertit în audio")
parser.add_argument(
"--voice", default=DEFAULT_VOICE,
help="Voce: M1-M5 (masculin) sau F1-F5 (feminin). Default: M1"
help="Voce din tts_voices.json (ex: M1-M5, F1-F5, 'Marius 1', 'alba'). Default: M2"
)
parser.add_argument(
"--lang", default=DEFAULT_LANG,
help="Limbă (ro, en, na). Default: ro. Fallback automat la na dacă ro eșuează."
help="Limbă (ro, en, na) — folosită doar pe calea Supertonic. Default: ro."
)
args = parser.parse_args()

66
tts_voices.json Normal file
View File

@@ -0,0 +1,66 @@
{
"M1": {
"engine": "supertonic"
},
"M2": {
"engine": "supertonic"
},
"M3": {
"engine": "supertonic"
},
"M4": {
"engine": "supertonic"
},
"M5": {
"engine": "supertonic"
},
"F1": {
"engine": "supertonic"
},
"F2": {
"engine": "supertonic"
},
"F3": {
"engine": "supertonic"
},
"F4": {
"engine": "supertonic"
},
"F5": {
"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"
},
"Paula 1": {
"engine": "pockettts",
"state_path": "models/voices/paula-1.safetensors",
"owner": "Paula"
},
"Paula 2": {
"engine": "pockettts",
"state_path": "models/voices/paula-2.safetensors",
"owner": "Paula"
},
"Paula 3": {
"engine": "pockettts",
"state_path": "models/voices/paula-3.safetensors",
"owner": "Paula"
},
"alba": {
"engine": "pockettts",
"voice_url": "alba"
}
}