Initialisation du repository de Beta
This commit is contained in:
commit
14985f6dbb
9469 changed files with 1903273 additions and 0 deletions
354
superviseur/voice.py
Normal file
354
superviseur/voice.py
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
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()
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue