From 155d6cb9c180aa9266ce531866bb7fbce499ebcd Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Sat, 11 Jul 2026 09:54:36 +0000 Subject: [PATCH] Update TOOLS.md, cron jobs, KB index; add discord file sender + pocket-tts plan Pre-existing work committed before starting Ralph self-improvement run on ralph/echo-improve branch, so that branch's diff stays isolated to the pocket-tts integration. Co-Authored-By: Claude Sonnet 5 --- cron/jobs.json | 20 +- memory/kb/index.json | 21 +- memory/kb/index.md | 2 +- memory/kb/projects/index.md | 5 +- .../pocket-tts-integration-handoff-prompt.md | 54 +++++ personality/TOOLS.md | 6 + tasks/pocket-tts-integration-plan.md | 184 ++++++++++++++++++ tools/discord_send_file.py | 70 +++++++ 8 files changed, 345 insertions(+), 17 deletions(-) create mode 100644 memory/kb/projects/pocket-tts-integration-handoff-prompt.md create mode 100644 tasks/pocket-tts-integration-plan.md create mode 100644 tools/discord_send_file.py diff --git a/cron/jobs.json b/cron/jobs.json index 86f05fc..a8a92df 100644 --- a/cron/jobs.json +++ b/cron/jobs.json @@ -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", diff --git a/memory/kb/index.json b/memory/kb/index.json index 3c4dff4..81bf00b 100644 --- a/memory/kb/index.json +++ b/memory/kb/index.json @@ -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, diff --git a/memory/kb/index.md b/memory/kb/index.md index 63498d8..447adf7 100644 --- a/memory/kb/index.md +++ b/memory/kb/index.md @@ -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 diff --git a/memory/kb/projects/index.md b/memory/kb/projects/index.md index ae48705..1de9ed5 100644 --- a/memory/kb/projects/index.md +++ b/memory/kb/projects/index.md @@ -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 diff --git a/memory/kb/projects/pocket-tts-integration-handoff-prompt.md b/memory/kb/projects/pocket-tts-integration-handoff-prompt.md new file mode 100644 index 0000000..b6e04e8 --- /dev/null +++ b/memory/kb/projects/pocket-tts-integration-handoff-prompt.md @@ -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__:` — 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 --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 **` `** (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". diff --git a/personality/TOOLS.md b/personality/TOOLS.md index 246380f..d3f2796 100644 --- a/personality/TOOLS.md +++ b/personality/TOOLS.md @@ -70,6 +70,12 @@ - **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. +### Discord — trimite fișier direct (audio, imagini) +- **Script:** `python3 tools/discord_send_file.py --channel --file /path --text "mesaj opțional"` +- **De ce nu merge `__AUDIO__:`:** 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 diff --git a/tasks/pocket-tts-integration-plan.md b/tasks/pocket-tts-integration-plan.md new file mode 100644 index 0000000..ee43618 --- /dev/null +++ b/tasks/pocket-tts-integration-plan.md @@ -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=` 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 `** — comandă nouă, setează `config.tts.default_engine`, persistă. Răspunde cu confirmare + vocea default curentă a engine-ului ales. +2. **`/voice addvoice `** — 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 --name ""` (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. diff --git a/tools/discord_send_file.py b/tools/discord_send_file.py new file mode 100644 index 0000000..c8198a9 --- /dev/null +++ b/tools/discord_send_file.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +"""Trimite un fișier (audio, imagine, etc.) direct într-un canal Discord. + +De ce există: `__AUDIO__:` (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 --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)