diff --git a/scripts/ralph/prd.json b/scripts/ralph/prd.json index 0cba530..8dd4d03 100644 --- a/scripts/ralph/prd.json +++ b/scripts/ralph/prd.json @@ -186,12 +186,12 @@ ], "requiresBrowserCheck": true, "requiresDesignReview": false, - "passes": false, + "passes": true, "failed": false, "blocked": false, "retries": 0, "failureReason": "", - "notes": "" + "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", @@ -214,12 +214,12 @@ ], "requiresBrowserCheck": true, "requiresDesignReview": false, - "passes": false, + "passes": true, "failed": false, "blocked": false, - "retries": 0, + "retries": 1, "failureReason": "", - "notes": "" + "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", diff --git a/scripts/ralph/progress.txt b/scripts/ralph/progress.txt index 64f233b..10357c0 100644 --- a/scripts/ralph/progress.txt +++ b/scripts/ralph/progress.txt @@ -125,3 +125,81 @@ Project: echo-core - 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) +--- diff --git a/src/adapters/discord_voice.py b/src/adapters/discord_voice.py index d331155..906811c 100644 --- a/src/adapters/discord_voice.py +++ b/src/adapters/discord_voice.py @@ -15,7 +15,14 @@ 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 @@ -41,6 +48,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] = {} @@ -107,6 +121,40 @@ def _default_voice_for_engine(engine: str) -> str: return _get_default_voice() +def _systemctl_user(action: str, unit: str) -> None: + """Best-effort `systemctl --user ` — 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_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( @@ -322,6 +370,118 @@ def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_command 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)