#5 Version 3.1.1 — diagnostics du vocal Discord

Unito
penta ha unito 1 commit da penta/codex a penta/master 6 ore fa
3 ha cambiato i file con 124 aggiunte e 9 eliminazioni
  1. 9 2
      chatbot.py
  2. 33 4
      test_voice_stt.py
  3. 82 3
      voice_stt.py

+ 9 - 2
chatbot.py

@@ -51,7 +51,7 @@ if WEB_SEARCH_RESULT_COUNT < 1:
 # Initialiser le client OpenAI asynchrone ici
 openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
 
-BOT_VERSION = "3.1.0"
+BOT_VERSION = "3.1.1"
 
 # Vérifier la version de Discord
 if not sys.version_info[:2] >= (3, 15):
@@ -562,11 +562,13 @@ async def handle_voice_command(message):
         async def respond_to_voice(user, text):
             async with message.channel.typing():
                 try:
+                    logger.info("Vocal : transcription transmise au LLM (%d caractères)", len(text))
                     result = await call_openai_api(text, user.display_name)
                     reply = result.choices[0].message.content if result else None
                     reply = transform_emote(filter_message(reply or ''), True)
                     for part in split_message(reply or "Désolé, le modèle ne répond pas pour le moment."):
                         await message.channel.send(part, allowed_mentions=discord.AllowedMentions.none())
+                    logger.info("Vocal : réponse envoyée dans le salon texte (réponse modèle=%s)", bool(reply))
                 except Exception as exc:
                     logger.warning("Réponse au vocal impossible : %s", type(exc).__name__)
                     await message.channel.send("Désolé, je n'ai pas pu répondre à cette phrase.")
@@ -580,7 +582,12 @@ async def handle_voice_command(message):
                 cls=voice_recv.VoiceRecvClient, self_deaf=False,
             )
             sink = STTSink(respond_to_voice, STT_URL)
-            voice.listen(sink)
+            voice.listen(sink, after=sink.receiver_stopped)
+            state = message.guild.me.voice
+            logger.info("Écoute vocale démarrée : connecté=%s écoute=%s self_deaf=%s server_deaf=%s",
+                        voice.is_connected(), voice.is_listening(),
+                        getattr(state, 'self_deaf', None), getattr(state, 'deaf', None))
+            sink.log_diagnostic('connexion')
             await message.channel.send("🎧 J'écoute le vocal et réponds ici après chaque prise de parole. !quit pour arrêter.")
         except Exception as exc:
             logger.warning("Connexion vocale impossible : %s", type(exc).__name__)

+ 33 - 4
test_voice_stt.py

@@ -89,6 +89,34 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
         self.assertTrue(self.sink.worker_task.done())
         self.reply.assert_not_awaited()
 
+    async def test_diagnostics_distinguish_ignored_audio_and_dave_failure(self):
+        packet = SimpleNamespace(is_silence=lambda: False)
+        data = SimpleNamespace(opus=b'PRIVATE_AUDIO', packet=packet)
+        session = SimpleNamespace(ready=False, decrypt=Mock(side_effect=ValueError('PRIVATE_KEY')))
+        self.sink._voice_client = SimpleNamespace(_connection=SimpleNamespace(
+            dave_protocol_version=1, dave_session=session))
+        self.sink.on_rtcp_packet(None, None)
+        self.sink.write(None, data)
+        self.sink.write(self.user, data)
+        session.ready = True
+        with self.assertLogs('voice_stt', level='INFO') as logs:
+            self.sink.write(self.user, data)
+            self.sink.log_diagnostic()
+        self.assertEqual(self.sink.stats['audio'], 3)
+        for reason in ('rtcp', 'unknown_user', 'dave_not_ready', 'DAVE_errors'):
+            self.assertEqual(self.sink.stats[reason], 1)
+        self.assertEqual(self.sink.stats['pcm'], 0)
+        self.assertNotIn('PRIVATE_AUDIO', str(logs.output))
+        self.assertNotIn('PRIVATE_KEY', str(logs.output))
+
+    async def test_diagnostics_without_any_audio_and_final_report(self):
+        with self.assertLogs('voice_stt', level='INFO') as logs:
+            self.sink.log_diagnostic()
+            await self.sink.close()
+            await self.sink.close()
+        self.assertIn("'audio': 0", str(logs.output))
+        self.assertEqual(sum('arrêt' in line for line in logs.output), 1)
+
 
 class CommandTests(unittest.IsolatedAsyncioTestCase):
     async def test_join_replies_in_command_channel_and_quit_closes_sink(self):
@@ -98,13 +126,14 @@ class CommandTests(unittest.IsolatedAsyncioTestCase):
                         and node.name == 'handle_voice_command')
         http = AsyncMock()
         http.__aenter__.return_value.get.return_value = Mock()
-        sink = SimpleNamespace(close=AsyncMock())
+        sink = SimpleNamespace(close=AsyncMock(), receiver_stopped=Mock(), log_diagnostic=Mock())
         make_sink = Mock(return_value=sink)
-        voice = SimpleNamespace(sink=sink, listen=Mock(), disconnect=AsyncMock())
+        voice = SimpleNamespace(sink=sink, listen=Mock(), disconnect=AsyncMock(),
+                                is_connected=lambda: True, is_listening=lambda: True)
         channel = SimpleNamespace(connect=AsyncMock(return_value=voice))
         voice.channel = channel
         message = SimpleNamespace(
-            content='!join', guild=SimpleNamespace(voice_client=None),
+            content='!join', guild=SimpleNamespace(voice_client=None, me=SimpleNamespace(voice=None)),
             author=SimpleNamespace(voice=SimpleNamespace(channel=channel)),
             channel=SimpleNamespace(send=AsyncMock(), typing=AsyncMock()),
         )
@@ -121,7 +150,7 @@ class CommandTests(unittest.IsolatedAsyncioTestCase):
         )
         exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), namespace)
         await namespace['handle_voice_command'](message)
-        voice.listen.assert_called_once_with(sink)
+        voice.listen.assert_called_once_with(sink, after=sink.receiver_stopped)
         callback = make_sink.call_args.args[0]
         await callback(SimpleNamespace(display_name='Alice'), 'Bonjour')
         call_api.assert_awaited_once_with('Bonjour', 'Alice')

+ 82 - 3
voice_stt.py

@@ -6,6 +6,7 @@ import threading
 import time
 import wave
 from io import BytesIO
+from collections import Counter
 
 import discord
 import httpx
@@ -26,24 +27,75 @@ class STTSink(voice_recv.AudioSink):
         self.decoders = {}
         self.closed = False
         self.last_error = 0
+        self.stats_lock = threading.Lock()
+        self.stats = Counter(audio=0, rtcp=0, pcm=0, audible=0, phrases=0, stt=0)
+        self.last_diagnostic = time.monotonic()
         self.queue = asyncio.Queue(maxsize=8)
         self.flush_task = self.loop.create_task(self.flush_phrases())
         self.worker_task = self.loop.create_task(self.transcribe_phrases())
+        self.flush_task.add_done_callback(self.task_finished)
+        self.worker_task.add_done_callback(self.task_finished)
+
+    def task_finished(self, task):
+        if not task.cancelled() and task.exception() is not None:
+            logger.error("Tâche vocale arrêtée : %s erreur=%s",
+                         task.get_coro().__name__, type(task.exception()).__name__)
 
     def wants_opus(self):
         # L'extension ne déchiffre pas encore DAVE : le faire avant Opus.
         return True
 
+    def count(self, name):
+        with self.stats_lock:
+            self.stats[name] += 1
+
+    def log_diagnostic(self, reason='10s'):
+        with self.stats_lock:
+            stats = dict(self.stats)
+        voice = getattr(self, 'voice_client', None)
+        connection = getattr(voice, '_connection', None)
+        session = getattr(connection, 'dave_session', None)
+        logger.info(
+            "Diagnostic vocal (%s, compteurs cumulés) : %s file=%d dave_version=%s dave_ready=%s",
+            reason, stats, self.queue.qsize(),
+            getattr(connection, 'dave_protocol_version', None), getattr(session, 'ready', False),
+        )
+
+    @voice_recv.AudioSink.listener()
+    def on_rtcp_packet(self, packet, guild):
+        self.count('rtcp')
+
+    @voice_recv.AudioSink.listener()
+    def on_voice_member_speaking_start(self, member):
+        self.count('speaking_start')
+
+    def receiver_stopped(self, error):
+        if error:
+            logger.error("Thread de réception vocale arrêté : %s", type(error).__name__)
+        else:
+            logger.info("Thread de réception vocale arrêté normalement")
+
     def write(self, user, data):
-        if self.closed or user is None or user.bot or not data.opus or data.packet.is_silence():
+        if self.closed:
             return
+        self.count('audio')
+        for ignored, reason in ((user is None, 'unknown_user'),
+                                (getattr(user, 'bot', False), 'bot'),
+                                (not data.opus, 'empty_opus'),
+                                (data.packet.is_silence(), 'silence_packet')):
+            if ignored:
+                self.count(reason)
+                return
+        stage = 'DAVE'
         try:
             connection = self.voice_client._connection
             opus = bytes(data.opus)
             if connection.dave_protocol_version:
                 if connection.dave_session is None or not connection.dave_session.ready:
+                    self.count('dave_not_ready')
                     return
                 opus = connection.dave_session.decrypt(user.id, MediaType.audio, opus)
+            stage = 'Opus'
             with self.lock:
                 if self.closed:
                     return
@@ -51,16 +103,19 @@ class STTSink(voice_recv.AudioSink):
                 if decoder is None:
                     decoder = self.decoders[user.id] = discord.opus.Decoder()
                 pcm = decoder.decode(opus, fec=False)
+                self.count('pcm')
                 self.add_pcm(user, pcm, time.monotonic())
         except Exception as exc:
+            self.count(stage + '_errors')
             # Ne jamais journaliser les paquets audio ou les clés de session.
             if time.monotonic() - self.last_error > 30:
-                logger.warning("Réception vocale impossible : %s", type(exc).__name__)
+                logger.warning("Réception vocale impossible à l'étape %s : %s", stage, type(exc).__name__)
                 self.last_error = time.monotonic()
 
     def add_pcm(self, user, pcm, now):
         """Appelé sous le verrou ; PCM Discord = 48 kHz, stéréo, 16 bits."""
         audible = audioop.rms(pcm, 2) >= 200
+        self.count('audible' if audible else 'below_threshold')
         if user.id not in self.buffers:
             if not audible:
                 return
@@ -76,6 +131,9 @@ class STTSink(voice_recv.AudioSink):
         while True:
             await asyncio.sleep(0.1)
             now = time.monotonic()
+            if now - self.last_diagnostic >= 10:
+                self.log_diagnostic()
+                self.last_diagnostic = now
             ready = []
             with self.lock:
                 for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
@@ -83,10 +141,16 @@ class STTSink(voice_recv.AudioSink):
                         del self.buffers[user_id]
                         if len(pcm) >= 48000 * 4 * 0.25:
                             ready.append((user, bytes(pcm)))
+                        else:
+                            self.count('too_short')
             for item in ready:
                 try:
                     self.queue.put_nowait(item)
+                    self.count('phrases')
+                    logger.info("Phrase vocale en file : durée=%.2fs file=%d",
+                                len(item[1]) / (48000 * 4), self.queue.qsize())
                 except asyncio.QueueFull:
+                    self.count('queue_full')
                     logger.warning("File vocale pleine : une phrase a été ignorée")
 
     async def transcribe_phrases(self):
@@ -94,6 +158,8 @@ class STTSink(voice_recv.AudioSink):
         async with httpx.AsyncClient(timeout=60) as http:
             while not self.closed:
                 user, pcm = await self.queue.get()
+                stage = 'STT'
+                started = time.monotonic()
                 try:
                     audio = BytesIO()
                     with wave.open(audio, 'wb') as wav:
@@ -101,18 +167,28 @@ class STTSink(voice_recv.AudioSink):
                         wav.setsampwidth(2)
                         wav.setframerate(48000)
                         wav.writeframes(pcm)
+                    self.count('stt')
+                    logger.info("Envoi STT : durée audio=%.2fs", len(pcm) / (48000 * 4))
                     response = await http.post(
                         self.stt_url,
                         files={'file': ('phrase.wav', audio.getvalue(), 'audio/wav')},
                     )
                     response.raise_for_status()
                     text = response.json()['text'].strip()
+                    logger.info("Retour STT : HTTP=%d durée=%.2fs caractères=%d",
+                                response.status_code, time.monotonic() - started, len(text))
+                    if not text:
+                        self.count('empty_transcription')
                     if text and not self.closed:
+                        stage = 'réponse'
                         await self.on_phrase(user, text)
                 except asyncio.CancelledError:
                     raise
                 except Exception as exc:
-                    logger.warning("Traitement vocal impossible : %s", type(exc).__name__)
+                    self.count(stage + '_errors')
+                    status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
+                    logger.warning("Traitement vocal impossible : étape=%s erreur=%s HTTP=%s durée=%.2fs",
+                                   stage, type(exc).__name__, status, time.monotonic() - started)
                 finally:
                     self.queue.task_done()
 
@@ -126,9 +202,12 @@ class STTSink(voice_recv.AudioSink):
     def cleanup(self):
         # La bibliothèque appelle cleanup depuis son thread de réception.
         with self.lock:
+            if self.closed:
+                return
             self.closed = True
             self.buffers.clear()
             self.decoders.clear()
+        self.log_diagnostic('arrêt')
         if not self.loop.is_closed():
             self.loop.call_soon_threadsafe(self.flush_task.cancel)
             self.loop.call_soon_threadsafe(self.worker_task.cancel)