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