feat: US-008 - Comandă Discord /voice addvoice

- /voice addvoice <nume> <sample:Attachment>: validează wav+durată min 3s,
  oprește pocket-tts.service, exportă vocea prin pocket_tts_add_voice.py
  în venv separat, repornește serviciul, răspunde cu numele final + preview audio
- gates rulate: tests PASS (1043 passed, 22 preexistente neschimbate), review backend manual PASS
This commit is contained in:
2026-07-11 10:38:15 +00:00
parent c7db236247
commit 3d5c2a4ace
3 changed files with 243 additions and 5 deletions

View File

@@ -186,12 +186,12 @@
], ],
"requiresBrowserCheck": true, "requiresBrowserCheck": true,
"requiresDesignReview": false, "requiresDesignReview": false,
"passes": false, "passes": true,
"failed": false, "failed": false,
"blocked": false, "blocked": false,
"retries": 0, "retries": 0,
"failureReason": "", "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", "id": "US-008",
@@ -214,12 +214,12 @@
], ],
"requiresBrowserCheck": true, "requiresBrowserCheck": true,
"requiresDesignReview": false, "requiresDesignReview": false,
"passes": false, "passes": true,
"failed": false, "failed": false,
"blocked": false, "blocked": false,
"retries": 0, "retries": 1,
"failureReason": "", "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", "id": "US-009",

View File

@@ -125,3 +125,81 @@ Project: echo-core
- US-009 (ui+backend, autocomplete voci) — eligibil, dependsOn US-003+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) - 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)
---

View File

@@ -15,7 +15,14 @@ heavy lifting to:
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import io
import logging import logging
import os
import re
import subprocess
import tempfile
import wave
from pathlib import Path
from typing import Optional from typing import Optional
import discord import discord
@@ -41,6 +48,13 @@ from src.voice._discord_voice_adapter import connect_voice
log = logging.getLogger("echo-core.discord.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. # Per-guild voice session registry. Key = guild_id.
_voice_sessions: dict[int, VoiceSession] = {} _voice_sessions: dict[int, VoiceSession] = {}
@@ -107,6 +121,40 @@ def _default_voice_for_engine(engine: str) -> str:
return _get_default_voice() 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_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: def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_commands.Group:
"""Build the `/voice` slash command group and return it (caller registers).""" """Build the `/voice` slash command group and return it (caller registers)."""
voice_group = app_commands.Group( voice_group = app_commands.Group(
@@ -322,6 +370,118 @@ def register(tree: app_commands.CommandTree, bot: discord.Client) -> app_command
ephemeral=True, 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)") @voice_group.command(name="stop", description="Oprește audio-ul curent (golește coada TTS)")
async def stop_audio(interaction: discord.Interaction) -> None: async def stop_audio(interaction: discord.Interaction) -> None:
await interaction.response.defer(ephemeral=True) await interaction.response.defer(ephemeral=True)