From 13931db9533555b5dbddb04bf199054a6199e142 Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Wed, 27 May 2026 14:55:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(voice):=20Pas=207=20=E2=80=94=20discord=5F?= =?UTF-8?q?voice.py=20slash=20group=20+=20discord=5Fbot=20wiring=20(CONVER?= =?UTF-8?q?GENCE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/adapters/discord_voice.py (NEW, ~280 linii): - /voice slash group cu subcommands: join, leave, doctor, mirror on|off, record on|off - warmup_models() async — eager faster-whisper + silero-vad load la on_ready pe background task - _voice_load_error guard — /voice join responds ephemeral graceful dacă models load fail - _voice_sessions: dict[int, VoiceSession] keyed pe guild_id - _get_whitelist() re-reads config la fiecare apel — runtime edits la voice.allowed_user_ids fără bot restart - Double-join guard, try/except graceful pe connect/listen/play/presence - /voice doctor surfaces _voice_load_error + libopus state ephemeral - await interaction.response.defer(ephemeral=True) în orice voice command (Discord 3s timeout pattern din CLAUDE.md) src/adapters/discord_bot.py — 3 surgical edits: - Linia 115: intents.voice_states = True (după intents.message_content) - Liniile 963-966: import + register_voice(tree, client) + tree.add_command(voice_group), după /audio body - Liniile 1126-1130: discord_voice._models_warmup_future = asyncio.create_task(discord_voice.warmup_models()) la end of on_ready Adapted la pipeline.py API actual (channel_id int nu str, kw-only args după *, EchoVoiceSink(session, bot_user_id) signature, loop kwarg mandatory pentru cross-thread bot.change_presence). Smoke import OK. test_discord.py 61 pass / 4 fail (pre-existing pe master, verificat via git stash). test_voice_session_cleanup 5/5 + test_voice_adapter_contract 22/22. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/adapters/discord_bot.py | 11 ++ src/adapters/discord_voice.py | 296 ++++++++++++++++++++++++++++++++++ 2 files changed, 307 insertions(+) create mode 100644 src/adapters/discord_voice.py diff --git a/src/adapters/discord_bot.py b/src/adapters/discord_bot.py index c5d0b9d..20730a8 100644 --- a/src/adapters/discord_bot.py +++ b/src/adapters/discord_bot.py @@ -112,6 +112,7 @@ def create_bot(config: Config) -> discord.Client: intents = discord.Intents.default() intents.message_content = True + intents.voice_states = True client = discord.Client(intents=intents) tree = app_commands.CommandTree(client) @@ -958,6 +959,11 @@ def create_bot(config: Config) -> discord.Client: else: await interaction.followup.send(result or "Eroare TTS.") + # Voice slash group (Pas 7) + from src.adapters.discord_voice import register as register_voice + voice_group = register_voice(tree, client) + tree.add_command(voice_group) + # --- Ralph commands (autonomous project execution) --- async def _autocomplete_by_status( @@ -1118,6 +1124,11 @@ def create_bot(config: Config) -> discord.Client: from datetime import datetime, timezone client._ready_at = datetime.now(timezone.utc) logger.info("Echo Core online as %s", client.user) + # Voice models eager warmup (Pas 7) + from src.adapters import discord_voice + discord_voice._models_warmup_future = asyncio.create_task( + discord_voice.warmup_models() + ) async def _handle_chat(message: discord.Message) -> None: """Process a chat message through the router and send the response.""" diff --git a/src/adapters/discord_voice.py b/src/adapters/discord_voice.py new file mode 100644 index 0000000..da693b2 --- /dev/null +++ b/src/adapters/discord_voice.py @@ -0,0 +1,296 @@ +"""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 logging +from typing import Optional + +import discord +from discord import app_commands + +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 +from src.voice._discord_voice_adapter import connect_voice + +log = logging.getLogger("echo-core.discord.voice") + +# 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: + 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 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( + channel_id=channel.id, + guild_id=guild_id, + voice_client=vc, + text_channel=interaction.channel, + 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 + # Wake-up beep + try: + vc.play(discord.FFmpegPCMAudio("assets/voice/beep_200ms.wav")) + except Exception: + log.warning("Beep playback skipped", exc_info=True) + # 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) + + @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)) + # 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