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:
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
---
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user