feat(voice): Pas 7 — discord_voice.py slash group + discord_bot wiring (CONVERGENCE)
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
296
src/adapters/discord_voice.py
Normal file
296
src/adapters/discord_voice.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user