Compare commits
15 Commits
738dc0181a
...
a043e2f177
| Author | SHA1 | Date | |
|---|---|---|---|
| a043e2f177 | |||
| 9ddad02879 | |||
| c1414616ad | |||
| 8700276816 | |||
| 504e12ac4e | |||
| 3d5c2a4ace | |||
| c7db236247 | |||
| 7ede30d156 | |||
| 90b1fbdffe | |||
| e14a2d7189 | |||
| 6e73887867 | |||
| e3b5cdf0e5 | |||
| 18c05ede39 | |||
| ed407b49a6 | |||
| 155d6cb9c1 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
54
memory/kb/projects/pocket-tts-integration-handoff-prompt.md
Normal file
54
memory/kb/projects/pocket-tts-integration-handoff-prompt.md
Normal 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".
|
||||
@@ -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
333
scripts/ralph/prd.json
Normal 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
296
scripts/ralph/progress.txt
Normal 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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:]
|
||||
|
||||
|
||||
184
tasks/pocket-tts-integration-plan.md
Normal file
184
tasks/pocket-tts-integration-plan.md
Normal 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.
|
||||
@@ -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):
|
||||
|
||||
@@ -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
162
tests/test_tts.py
Normal 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
|
||||
70
tools/discord_send_file.py
Normal file
70
tools/discord_send_file.py
Normal 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
98
tools/pocket_tts_add_voice.py
Executable 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
22
tools/pocket_tts_env_launch.sh
Executable 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
|
||||
152
tools/tts.py
152
tools/tts.py
@@ -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
66
tts_voices.json
Normal 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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user