"""Discord voice slash commands (Pas 7 — CONVERGENCE wiring). Registers the `/voice` slash command group on the existing CommandTree and exposes an async `warmup_models()` for eager model load at bot startup. Owns nothing in `src/voice/*` — purely the Discord-facing wiring. Defers heavy lifting to: - ``src.voice.pipeline.VoiceSession`` — per-guild session state machine - ``src.voice.pipeline.EchoVoiceSink`` — discord-ext-voice-recv sink - ``src.voice.tts_stream.TTSQueue`` / ``EchoStreamingAudioSource`` - ``src.voice._discord_voice_adapter.connect_voice`` """ 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 import httpx from discord import app_commands # Optional DAVE dep (mandatory at runtime when discord.py 2.7.1 is paired with # Discord voice gateway v=8; tolerated missing in tests / dev environments). try: import davey _HAS_DAVE = True except ImportError: _HAS_DAVE = False from src.config import Config from src.voice.pipeline import ( VoiceSession, EchoVoiceSink, _get_whisper_model, _get_silero_vad, ) from src.voice.tts_stream import TTSQueue, EchoStreamingAudioSource 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] = {} # Set if model warmup failed; surfaces as ephemeral error on /voice join. _voice_load_error: Optional[str] = None # Reference to the eager warmup task created in on_ready, so /voice join can # await it if the user is faster than the background load. _models_warmup_future: Optional[asyncio.Task] = None async def warmup_models() -> None: """Eager model load — called from `on_ready()` as a background task. Runs the (synchronous, blocking) model loaders on a worker thread so the event loop stays responsive. On failure, sets `_voice_load_error` instead of raising, so `/voice join` can degrade gracefully. """ global _voice_load_error try: if not discord.opus.is_loaded(): discord.opus.load_opus("libopus.so.0") if _HAS_DAVE: log.info("DAVE protocol v%d available (davey %s)", davey.DAVE_PROTOCOL_VERSION, davey.__version__) await asyncio.to_thread(_get_whisper_model) await asyncio.to_thread(_get_silero_vad) log.info("Voice models warm") except Exception as e: _voice_load_error = f"{type(e).__name__}: {e}" log.error("Voice models load failed: %s", _voice_load_error) def _get_whitelist() -> set[int]: """Read `voice.allowed_user_ids` from config and coerce to int set. Re-reads config from disk to pick up any runtime edits between bot start and /voice join. """ try: raw = Config().get("voice.allowed_user_ids", []) except Exception: raw = [] out: set[int] = set() for v in raw or []: try: out.add(int(v)) except (TypeError, ValueError): continue return out def _get_default_voice() -> str: try: return Config().get("voice.default_voice", "M2") or "M2" except Exception: return "M2" def _default_voice_for_engine(engine: str) -> str: """Vocea implicită asociată engine-ului (nu vocea implicită globală per-guild).""" if engine == "pockettts": return "alba" 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_voice_names() -> list[str]: """Nume de voci din tts_voices.json (import lazy din tools/tts.py, catalog live).""" 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 tts as _tts_mod return _tts_mod.list_voice_names() except Exception: return [] 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( name="voice", description="Echo Core voice channel" ) @voice_group.command(name="join", description="Echo intră în voice channel-ul tău") async def join(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) if _voice_load_error: await interaction.followup.send( f"Voice unavailable: {_voice_load_error}", ephemeral=True ) return if _models_warmup_future is not None and not _models_warmup_future.done(): try: await _models_warmup_future except Exception as e: await interaction.followup.send( f"Voice unavailable: {type(e).__name__}: {e}", ephemeral=True ) return user = interaction.user if not isinstance(user, discord.Member) or user.voice is None or user.voice.channel is None: await interaction.followup.send( "Intră într-un voice channel întâi.", ephemeral=True ) return channel = user.voice.channel whitelist = _get_whitelist() if user.id not in whitelist: await interaction.followup.send( "Nu ești pe whitelist voice.", ephemeral=True ) return # Reject double-join on the same guild. guild_id = channel.guild.id if guild_id in _voice_sessions: await interaction.followup.send( "Sunt deja în voice pe acest server. Folosește /voice leave întâi.", ephemeral=True, ) return # Connect try: vc = await connect_voice(channel) except Exception as e: log.exception("connect_voice failed") await interaction.followup.send( f"Conectare eșuată: {type(e).__name__}: {e}", ephemeral=True ) return # Build TTS queue + session ttsq = TTSQueue(voice_id=_get_default_voice(), lang="ro") ttsq.start() try: session = VoiceSession( text_channel_id=int(interaction.channel.id), voice_channel_id=int(channel.id), guild_id=guild_id, voice_client=vc, record_enabled=False, mirror_enabled=True, whitelist=whitelist, ttsq=ttsq, bot=bot, loop=asyncio.get_running_loop(), ) except Exception as e: log.exception("VoiceSession construction failed") ttsq.stop() try: await vc.disconnect(force=True) except Exception: pass await interaction.followup.send( f"Sesiune voice eșuată: {type(e).__name__}: {e}", ephemeral=True ) return _voice_sessions[guild_id] = session # Start TTS streaming source for the entire session. Chain the # wake-up beep via `after=` so streaming takes over when beep ends. def _start_stream(error: Optional[Exception] = None) -> None: if error is not None: log.warning("Beep playback ended with error: %s", error) try: vc.play(EchoStreamingAudioSource(ttsq)) log.info("TTS streaming source attached") except Exception: log.exception("EchoStreamingAudioSource attach failed") try: vc.play( discord.FFmpegPCMAudio("assets/voice/beep_200ms.wav"), after=_start_stream, ) except Exception: log.warning("Beep playback skipped, starting stream directly", exc_info=True) _start_stream() # Attach sink try: bot_user_id = int(bot.user.id) if bot.user is not None else 0 sink = EchoVoiceSink(session=session, bot_user_id=bot_user_id) vc.listen(sink) except Exception as e: log.exception("Sink attach failed") _voice_sessions.pop(guild_id, None) try: session.cleanup("sink_attach_failed") except Exception: pass await interaction.followup.send( f"Atașare sink eșuată: {type(e).__name__}: {e}", ephemeral=True ) return # Presence try: await bot.change_presence(activity=discord.Activity( type=discord.ActivityType.listening, name=f"{user.display_name} în #{channel.name}", )) except Exception: log.warning("Presence update skipped", exc_info=True) await interaction.followup.send( f"În voce în #{channel.name}.", ephemeral=True ) @voice_group.command(name="leave", description="Echo iese din voice channel") async def leave(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) guild_id = interaction.guild.id if interaction.guild else None session = _voice_sessions.pop(guild_id, None) if guild_id is not None else None if session is None: await interaction.followup.send( "Nu sunt în niciun voice channel aici.", ephemeral=True ) return try: session.cleanup("user_leave") except Exception: log.exception("session.cleanup raised") try: await bot.change_presence(activity=None) except Exception: log.warning("Presence reset skipped", exc_info=True) await interaction.followup.send("Plecat.", ephemeral=True) async def _voice_autocomplete( interaction: discord.Interaction, current: str ) -> list[app_commands.Choice[str]]: current_low = (current or "").lower() names = [n for n in _tts_voice_names() if current_low in n.lower()] return [app_commands.Choice(name=n, value=n) for n in names[:25]] @voice_group.command(name="setvoice", description="Schimbă vocea Echo (din catalogul tts_voices.json)") @app_commands.describe(voice="Voce nouă") @app_commands.autocomplete(voice=_voice_autocomplete) async def setvoice( interaction: discord.Interaction, voice: str, ) -> None: await interaction.response.defer(ephemeral=True) if voice not in _tts_voice_names(): await interaction.followup.send( f"Voce necunoscută: {voice!r}. Alege din autocomplete.", ephemeral=True ) return new_voice = voice # Live-swap on the active session if Echo is in voice on this guild. guild_id = interaction.guild.id if interaction.guild else None session = _voice_sessions.get(guild_id) if guild_id is not None else None live_swapped = False if session is not None and session.ttsq is not None: session.ttsq.voice_id = new_voice live_swapped = True # Persist as the new default for future sessions. try: cfg = Config() cfg.set("voice.default_voice", new_voice) cfg.save() except Exception as e: log.warning("config save failed for new default voice: %s", e) await interaction.followup.send( f"Voce schimbată live ({new_voice}), dar config-ul nu s-a salvat: {e}", ephemeral=True, ) return if live_swapped: msg = f"Vocea schimbată **live** pe {new_voice}. Următoarea frază va folosi vocea nouă." else: msg = f"Default voce setată {new_voice}. Va intra în vigoare la următorul /voice join." await interaction.followup.send(msg, ephemeral=True) _ENGINE_CHOICES = [ app_commands.Choice(name="pocket-tts", value="pockettts"), app_commands.Choice(name="Supertonic", value="supertonic"), ] @voice_group.command(name="engine", description="Schimbă engine-ul TTS implicit (pockettts/supertonic)") @app_commands.describe(engine="Engine TTS") @app_commands.choices(engine=_ENGINE_CHOICES) async def engine_cmd( interaction: discord.Interaction, engine: app_commands.Choice[str], ) -> None: await interaction.response.defer(ephemeral=True) new_engine = engine.value try: cfg = Config() cfg.set("tts.default_engine", new_engine) cfg.save() except Exception as e: log.warning("config save failed for tts.default_engine: %s", e) await interaction.followup.send( f"Eroare la salvarea engine-ului: {e}", ephemeral=True ) return default_voice = _default_voice_for_engine(new_engine) await interaction.followup.send( f"Engine TTS implicit setat pe **{new_engine}**. Voce implicită: {default_voice}.", 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) guild_id = interaction.guild.id if interaction.guild else None session = _voice_sessions.get(guild_id) if guild_id is not None else None if session is None or session.ttsq is None: await interaction.followup.send("Nu sunt în voice.", ephemeral=True) return try: session.ttsq.clear() log.info("voice stop: TTS queue cleared by user %s", interaction.user) except Exception as e: log.warning("voice stop: ttsq.clear failed: %s", e) await interaction.followup.send(f"Eroare la oprire: {e}", ephemeral=True) return await interaction.followup.send("Audio oprit.", ephemeral=True) @voice_group.command(name="doctor", description="Verifică voice stack") async def doctor(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) checks: list[tuple[str, bool]] = [] # libopus try: checks.append(("libopus", bool(discord.opus.is_loaded()))) except Exception: checks.append(("libopus", False)) # warmup checks.append(("voice load error", _voice_load_error is None)) # hf_token în keyring try: from src.credential_store import get_secret checks.append(("hf_token (keyring)", get_secret("hf_token") is not None)) except Exception: checks.append(("hf_token (keyring)", False)) # pocket-tts /health pockettts_ok = False try: pockettts_url = Config().get("tts.pockettts_url", "http://127.0.0.1:7789") async with httpx.AsyncClient(timeout=3.0) as client: resp = await client.get(f"{pockettts_url}/health") pockettts_ok = resp.status_code == 200 except Exception: pockettts_ok = False checks.append(("pocket-tts /health", pockettts_ok)) # Build response lines = ["**Voice doctor:**"] for label, ok in checks: lines.append(f"{'OK' if ok else 'FAIL'} — {label}") if _voice_load_error: lines.append(f" details: {_voice_load_error}") await interaction.followup.send("\n".join(lines), ephemeral=True) # --- /voice mirror on|off --- mirror_group = app_commands.Group( name="mirror", description="Text mirror", parent=voice_group ) @mirror_group.command(name="on", description="Activează text mirror în canal") async def mirror_on(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) guild_id = interaction.guild.id if interaction.guild else None s = _voice_sessions.get(guild_id) if guild_id is not None else None if s is None: await interaction.followup.send("Nu sunt în voice.", ephemeral=True) return s.mirror_enabled = True await interaction.followup.send("Mirror ON.", ephemeral=True) @mirror_group.command(name="off", description="Dezactivează text mirror") async def mirror_off(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) guild_id = interaction.guild.id if interaction.guild else None s = _voice_sessions.get(guild_id) if guild_id is not None else None if s is None: await interaction.followup.send("Nu sunt în voice.", ephemeral=True) return s.mirror_enabled = False await interaction.followup.send("Mirror OFF.", ephemeral=True) # --- /voice record on|off --- record_group = app_commands.Group( name="record", description="KB recording", parent=voice_group ) @record_group.command(name="on", description="Activează înregistrare în KB") async def record_on(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) guild_id = interaction.guild.id if interaction.guild else None s = _voice_sessions.get(guild_id) if guild_id is not None else None if s is None: await interaction.followup.send("Nu sunt în voice.", ephemeral=True) return s.record_enabled = True await interaction.followup.send("Record ON.", ephemeral=True) @record_group.command(name="off", description="Dezactivează înregistrare") async def record_off(interaction: discord.Interaction) -> None: await interaction.response.defer(ephemeral=True) guild_id = interaction.guild.id if interaction.guild else None s = _voice_sessions.get(guild_id) if guild_id is not None else None if s is None: await interaction.followup.send("Nu sunt în voice.", ephemeral=True) return s.record_enabled = False await interaction.followup.send("Record OFF.", ephemeral=True) return voice_group