Beta/superviseur/voice.py

355 lines
16 KiB
Python
Raw Normal View History

2026-02-06 22:23:20 +01:00
import discord
import logging
import asyncio
import os
import time
from typing import Dict, Optional, List
from .whisper_worker import WhisperWorker
logger = logging.getLogger('Superviseur')
# Note: This requires discord-ext-voice-recv for actual audio capture.
try:
from discord.ext import voice_recv
HAS_VOICE_RECV = True
_BaseSink = voice_recv.AudioSink
except ImportError:
HAS_VOICE_RECV = False
_BaseSink = object # Fallback to prevent crash
class BetaAudioSink(_BaseSink):
"""Custom sink that performs manual decoding to handle Opus errors gracefully."""
def __init__(self, voice_manager):
super().__init__()
self.manager = voice_manager
self.user_buffers = {} # {id: {"name": str, "data": bytearray, "decoder": Decoder}}
def wants_opus(self) -> bool:
"""Request Opus packets to handle decoding manually and safely."""
return True
def write(self, user, data):
if user is None: return
u_id = user.id
# Initialisation du décodeur pour ce sujet si besoin
if u_id not in self.user_buffers:
logger.debug(f"◈ AudioSink: Initializing resilient decoder for {user.display_name}")
self.user_buffers[u_id] = {
"name": user.display_name,
"data": bytearray(),
"decoder": discord.opus.Decoder()
}
# Décodage manuel avec capture d'erreur
try:
# data.opus contient le paquet brut car wants_opus = True
pcm_data = self.user_buffers[u_id]["decoder"].decode(data.opus)
if pcm_data:
self.user_buffers[u_id]["data"].extend(pcm_data)
except discord.opus.OpusError:
# On ignore silencieusement les paquets corrompus (souvent au début)
pass
except Exception as e:
logger.error(f"◈ AudioSink Error: Unexpected decoding failure: {e}")
def cleanup(self):
self.user_buffers.clear()
logger.debug("◈ AudioSink: Session resources released.")
class VoiceManager:
"""Manages tactical voice channel connections and audio monitoring."""
HAS_VOICE_RECV = HAS_VOICE_RECV
def __init__(self, bot):
self.bot = bot
self.active_connections: Dict[int, discord.VoiceClient] = {}
self.current_channel: Optional[discord.VoiceChannel] = None
self.whisper = WhisperWorker(device="cpu") # Stable direct fallback
self.is_monitoring = False
self._monitoring_task: Optional[asyncio.Task] = None
self.session_buffer: List[Dict[str, Any]] = [] # [{"user": name, "text": str, "timestamp": ts}]
self.session_start = 0
self.active_sink: Optional[BetaAudioSink] = None
async def initialize(self):
"""Lazy load Whisper to preserve startup time."""
# Run in executor to not block bot ready
loop = asyncio.get_event_loop()
loop.run_in_executor(None, self.whisper.initialize)
async def join_channel(self, channel: discord.VoiceChannel):
"""Securely join a voice channel with permission checking."""
try:
# Physical authorization check
me = channel.guild.me
perms = channel.permissions_for(me)
if not perms.connect or not perms.view_channel:
logger.error(f"◈ REJECTION: Missing permissions for '{channel.name}' (CONNECT: {perms.connect}, VIEW: {perms.view_channel})")
await self.bot.introspection_log(
"DEPLOYMENT FAILURE",
f"Permissions insuffisantes pour `{channel.name}`. Système incapable de se déployer.",
discord.Color.red()
)
return
active_vc = channel.guild.voice_client
# Si on est déjà connecté
if active_vc and active_vc.channel == channel:
# Mais qu'on n'est pas dans la bonne classe ou que le monitoring n'est pas lancé
if HAS_VOICE_RECV and not isinstance(active_vc, voice_recv.VoiceRecvClient):
logger.info("◈ SYSTEM: Upgrading Voice Client to RecvClient.")
await self.leave_current()
elif self.is_monitoring:
return # Tout est déjà opérationnel
else:
# On continue pour lancer le monitoring sur le vc existant
vc = active_vc
else:
if active_vc:
await self.leave_current()
logger.info(f"◈ SYSTEM: Joining Voice Channel '{channel.name}' (Priority Acquisition)")
await self.bot.introspection_log(
"VOICE DEPLOYMENT",
f"Déploiment tactique dans `{channel.name}` (Priorité identifiée).",
discord.Color.blue()
)
# Utilisation du client spécialisé pour la réception audio
connect_cls = voice_recv.VoiceRecvClient if HAS_VOICE_RECV else discord.VoiceClient
vc = await channel.connect(cls=connect_cls)
self.active_connections[channel.guild.id] = vc
self.current_channel = channel
if HAS_VOICE_RECV:
# Start the ear protocol
self.is_monitoring = True
self.session_buffer = []
self.session_start = time.time()
self.active_sink = BetaAudioSink(self)
self._monitoring_task = self.bot.loop.create_task(self._start_listening(vc))
except Exception as e:
logger.error(f"Failed to join voice channel: {e}")
async def _start_listening(self, vc):
"""Internal loop to capture and transcribe audio chunks."""
logger.info("◈ SYSTEM: Ear Protocol Engaged. Monitoring Voice Stream.")
# On attache le sink
try:
vc.listen(self.active_sink)
except Exception as e:
logger.error(f"Failed to start listening sink: {e}")
return
import wave
import tempfile
while self.is_monitoring and vc.is_connected():
await asyncio.sleep(7) # Process chunks every 7s (faster response)
# Rotation des buffers
current_buffers = self.active_sink.user_buffers
self.active_sink.user_buffers = {}
if not current_buffers:
logger.debug("◈ Voice Processor: No audio data in current buffers.")
continue
for u_id, buffer_data in current_buffers.items():
data_len = len(buffer_data["data"])
logger.debug(f"◈ Voice Processor: Processing {data_len} bytes from {buffer_data['name']}")
if data_len < 5000: # On monte le seuil à ~50ms pour filtrer les bruits de fond
continue
# Conversion PCM brut -> WAV (Whisper en a besoin)
# Discord envoie du 48kHz Stereo 16-bit PCM
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
tmp_path = tmp.name
with wave.open(tmp_path, 'wb') as wav_file:
wav_file.setnchannels(2)
wav_file.setsampwidth(2)
wav_file.setframerate(48000)
wav_file.writeframes(buffer_data["data"])
# Transcription
text = await self.whisper.transcribe(tmp_path)
if text and len(text.strip()) > 3:
logger.debug(f"◈ Logged Voice Insight: {buffer_data['name']} -> {text}")
self.session_buffer.append({
"user": buffer_data["name"],
"text": text,
"ts": time.time()
})
# NEW: Real-time Voice Trigger Detection
trigger_names = ["bêta", "beta", "béta", "béat", "vêta", "veta"]
if any(name in text.lower() for name in trigger_names):
logger.info(f"◈ SYSTEM: Voice trigger MATCH detected in VOC: '{text}'")
logger.info(f"◈ SYSTEM: Launching voice interaction bridge for {buffer_data['name']}")
# We run it in a task to not block the voice processing loop
# Note: we pass None for channel since bot.py will now use DMs
self.bot.loop.create_task(
self.bot.handle_voice_interaction(
user_name=buffer_data["name"],
user_id=u_id,
content=text,
channel=None
)
)
# Cleanup
try: os.remove(tmp_path)
except: pass
async def generate_session_report(self, advanced: bool = False, custom_buffer=None):
"""Analyze accumulated transcriptions and send a clinical summary.
If advanced=True, uses the heavy LLM for a deep analysis.
"""
buffer = custom_buffer if custom_buffer is not None else self.session_buffer
if not buffer:
return "Aucune donnée vocale significative n'a été capturée durant cette session."
# ANALYSE DE TENSION (Mots-clés ou BSI bas)
is_tense = False
conflit_keywords = ["fdp", "tg", "nique", "pute", "connard", "merde", "salope"]
tense_count = 0
for item in buffer:
if any(k in item['text'].lower() for k in conflit_keywords):
tense_count += 1
if tense_count > 3: # Seuil de tension
is_tense = True
self.bot.metrics["daily_tense_sessions"] = self.bot.metrics.get("daily_tense_sessions", 0) + 1
logger.warning(f"◈ SYSTEM: Tense voice session detected in {self.current_channel}")
# Concatenate for analysis
full_transcript = "\n".join([f"{item['user']}: {item['text']}" for item in buffer])
channel_name = self.current_channel.name if self.current_channel else "N/A"
duration = int((time.time() - (self.session_start if hasattr(self, 'session_start') else time.time())) / 60)
if not advanced:
# Rapport périodique simple
report = [
f"**◈ VOICE SESSION STATUS (Périodique) ◈**",
f"Salon : `{channel_name}`",
f"Durée actuelle : `{duration} minutes`",
f"Extraits récents :\n"
]
# On prend les 10 derniers segments
for item in self.session_buffer[-10:]:
report.append(f"- **{item['user']}** : \"{item['text'][:100]}\"")
return "\n".join(report)
# RAPPORT AVANCÉ (Fin de session) via HEAVY LLM
prompt = (
f"PROTOCOLE D'ANALYSE VOCALE FINALE\n"
f"Salon : {channel_name}\n"
f"Durée totale : {duration} minutes\n"
f"Flux de données capturé :\n---\n{full_transcript}\n---\n\n"
f"VOTRE MISSION :\n"
f"En tant que Bêta, analysez l'intégralité de ce flux. Produisez un rapport clinique structuré incluant :\n"
f"1. RÉSUMÉ EXÉCUTIF (Synthèse des échanges)\n"
f"2. IDENTIFICATION DES SUJETS (Comportements et attitudes)\n"
f"3. POINTS DE VIGILANCE (Alertes Relevant ou insights stratégiques)\n"
f"4. CONCLUSION (État de l'écosystème)\n\n"
f"IMPORTANT: Pour cette mission spécifique, produisez un rapport en TEXTE BRUT structuré. "
f"IGNOREZ la consigne habituelle de format JSON. Ne mettez AUCUN bloc JSON."
f"Le ton doit être froid, professionnel et factuel."
)
try:
logger.info(f"◈ SYSTEM: Generating advanced report for {channel_name} using Heavy LLM...")
# Utilisation du manager LLM du bot
payload = {
"model": self.bot.ollama_model,
"prompt": prompt,
"system": self.bot.system_prompt,
"stream": False
}
# Note: call_ollama returns the accumulated string
analysis = await self.bot.llm.call_ollama(payload)
# Nettoyage si jamais l'IA renvoie du JSON superflu (Bêta n'est pas censé mais on sait jamais)
json_str = self.bot.llm.extract_json_actions(analysis)
if json_str:
try:
data = json.loads(json_str)
if isinstance(data, list): data = data[0]
analysis = data.get('response', analysis)
except: pass
return analysis
except Exception as e:
logger.error(f"◈ SYSTEM: Failed to generate advanced voice report: {e}")
return f"ERREUR ANALYSE IA GÉNÉRALE : {e}\n\n[TRANSCRIPT DE SECOURS]\n{full_transcript[:1800]}..."
async def leave_current(self, send_report=True):
"""Disconnect immediately and handle report generation in the background."""
if not self.active_connections and not self.current_channel:
return
# 1. Capture current session data for background reporting
captured_buffer = list(self.session_buffer) if self.session_buffer else []
was_monitoring = self.is_monitoring
# 2. CLEAR STATE IMMEDIATELY (Responsiveness)
self.session_buffer = []
self.is_monitoring = False
if self._monitoring_task:
self._monitoring_task.cancel()
# 3. DISCONNECT IMMEDIATELY
for guild_id, vc in list(self.active_connections.items()):
try:
await vc.disconnect()
except Exception as e:
logger.error(f"Error disconnecting from voice: {e}")
del self.active_connections[guild_id]
self.current_channel = None
logger.info("◈ SYSTEM: Voice Connection Terminated (Resource Preservation)")
# 4. Background Report Generation
if was_monitoring and send_report and captured_buffer:
# We use a separate task to avoid blocking the caller (who might be waiting to join another channel)
self.bot.loop.create_task(self._process_background_report(captured_buffer))
async def _process_background_report(self, buffer):
"""Internal helper to generate report without blocking the voice client."""
try:
# Temporarily restore buffer for the report generation call
# Note: generate_session_report uses self.session_buffer, so we might need to adapt it
# or pass the buffer to it.
# Let's check generate_session_report signature.
report_text = await self.generate_session_report(advanced=True, custom_buffer=buffer)
# 1. Envoi au canal d'introspection
await self.bot.introspection_log("FINAL VOICE SESSION ANALYSIS", report_text, discord.Color.blue())
# 2. Envoi direct aux Admins
for admin_id in self.bot.admin_ids:
admin = self.bot.get_user(admin_id) or await self.bot.fetch_user(admin_id)
if admin:
await self.bot.messaging.send_embed(admin, "FINAL VOICE SESSION ANALYSIS", report_text, color=discord.Color.blue())
except Exception as e:
logger.error(f"◈ SYSTEM: Background report generation failed: {e}")
await self.bot.introspection_log(
"VOICE TERMINATION",
"Connexion vocale terminée. Libération des ressources audio.",
discord.Color.dark_grey()
)