3 Komitmen 90eb4be434 ... 01649b94e5

Pembuat SHA1 Pesan Tanggal
  Andy Esnard 01649b94e5 Passer en version 3.1.3 16 jam lalu
  Andy Esnard 2d610dd61a Citer les transcriptions et ignorer les phrases pendant le traitement vocal 16 jam lalu
  penta 2fe7b06067 Merge branch 'codex' of penta/satoko-chatbot into master 16 jam lalu
3 mengubah file dengan 82 tambahan dan 7 penghapusan
  1. 6 2
      chatbot.py
  2. 51 2
      test_voice_stt.py
  3. 25 3
      voice_stt.py

+ 6 - 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.2"
+BOT_VERSION = "3.1.3"
 
 # Vérifier la version de Discord
 if not sys.version_info[:2] >= (3, 15):
@@ -562,6 +562,10 @@ async def handle_voice_command(message):
         async def respond_to_voice(user, text):
             async with message.channel.typing():
                 try:
+                    heard = discord.utils.escape_markdown(text).replace('\n', ' ')
+                    name = discord.utils.escape_markdown(user.display_name)
+                    for part in split_message(f"🎙 {name} : {heard}", max_length=1900):
+                        await message.channel.send('> ' + part, allowed_mentions=discord.AllowedMentions.none())
                     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
@@ -588,7 +592,7 @@ async def handle_voice_command(message):
                         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.")
+            await message.channel.send("🎧 J'écoute le vocal et réponds ici à une phrase à la fois. Pendant ma réponse, les autres phrases sont ignorées. !quit pour arrêter.")
         except Exception as exc:
             logger.warning("Connexion vocale impossible : %s", type(exc).__name__)
             if sink:

+ 51 - 2
test_voice_stt.py

@@ -36,7 +36,8 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
         self.sink.queue.task_done()
         self.assertEqual(user.id, 1)
         self.assertEqual(pcm, self.pcm)
-        self.assertIn(2, self.sink.buffers)
+        self.assertFalse(self.sink.buffers)
+        self.assertTrue(self.sink.busy)
 
     async def test_audio_is_bounded_and_short_noise_ignored(self):
         self.sink.worker_task.cancel()
@@ -72,6 +73,7 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
         with patch('voice_stt.httpx.AsyncClient', return_value=http):
             self.sink = STTSink(self.reply, 'http://stt/transcribe')
             self.sink.queue.put_nowait((self.user, self.pcm))
+            await asyncio.wait_for(self.sink.queue.join(), 2)
             self.sink.queue.put_nowait((self.user, self.pcm))
             await asyncio.wait_for(self.sink.queue.join(), 2)
             self.reply.assert_awaited_once_with(self.user, 'Bonjour')
@@ -80,6 +82,50 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
                 self.assertEqual((wav.getnchannels(), wav.getsampwidth(), wav.getframerate()), (2, 2, 48000))
                 self.assertEqual(wav.readframes(wav.getnframes()), self.pcm)
 
+    async def test_no_backlog_during_stt_and_reply(self):
+        await self.sink.close()
+        stt_started, release_stt = asyncio.Event(), asyncio.Event()
+        reply_started, release_reply = asyncio.Event(), asyncio.Event()
+
+        async def post(*args, **kwargs):
+            stt_started.set()
+            await release_stt.wait()
+            return httpx.Response(200, json={'text': 'Bonjour'},
+                                  request=httpx.Request('POST', 'http://stt'))
+
+        async def reply(*args):
+            reply_started.set()
+            await release_reply.wait()
+
+        http = AsyncMock()
+        http.__aenter__.return_value.post.side_effect = post
+        self.reply.side_effect = reply
+        other = SimpleNamespace(id=2)
+        with patch('voice_stt.httpx.AsyncClient', return_value=http):
+            self.sink = STTSink(self.reply, 'http://stt/transcribe')
+            with self.sink.lock:
+                self.sink.add_pcm(self.user, self.pcm, time.monotonic() - 2)
+                self.sink.add_pcm(other, self.pcm, time.monotonic() - 2)
+            await asyncio.wait_for(stt_started.wait(), 2)
+            with self.sink.lock:
+                self.sink.add_pcm(other, self.pcm, time.monotonic())
+            release_stt.set()
+            await asyncio.wait_for(reply_started.wait(), 2)
+            with self.sink.lock:
+                self.sink.add_pcm(other, self.pcm, time.monotonic())
+            self.assertTrue(self.sink.queue.empty())
+            self.assertFalse(self.sink.buffers)
+            release_reply.set()
+            await asyncio.wait_for(self.sink.queue.join(), 2)
+            self.assertFalse(self.sink.busy)
+            self.reply.assert_awaited_once_with(self.user, 'Bonjour')
+            with self.sink.lock:
+                now = time.monotonic()
+                self.sink.add_pcm(other, self.pcm, now)
+                self.assertFalse(self.sink.buffers)  # Ne pas récupérer la fin d'une phrase ignorée.
+                self.sink.add_pcm(other, self.pcm, now + 1)
+                self.assertIn(2, self.sink.buffers)
+
     async def test_close_discards_pending_audio(self):
         self.sink.queue.put_nowait((self.user, self.pcm))
         await self.sink.close()
@@ -145,7 +191,7 @@ class CommandTests(unittest.IsolatedAsyncioTestCase):
             voice_command_lock=asyncio.Lock(), httpx=SimpleNamespace(AsyncClient=Mock(return_value=http)),
             voice_recv=SimpleNamespace(VoiceRecvClient=object), STTSink=make_sink,
             call_openai_api=call_api, transform_emote=lambda text, output: text,
-            filter_message=lambda text: text, split_message=lambda text: [text],
+            filter_message=lambda text: text, split_message=lambda text, **kwargs: [text],
             discord=discord, logger=Mock(),
         )
         exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), namespace)
@@ -155,6 +201,9 @@ class CommandTests(unittest.IsolatedAsyncioTestCase):
         await callback(SimpleNamespace(display_name='Alice'), 'Bonjour')
         call_api.assert_awaited_once_with('Bonjour', 'Alice')
         self.assertEqual(message.channel.send.call_args.args[0], 'Réponse')
+        quote = message.channel.send.call_args_list[-2]
+        self.assertEqual(quote.args[0], '> 🎙 Alice : Bonjour')
+        self.assertEqual(quote.kwargs['allowed_mentions'].to_dict(), {'parse': []})
         message.content = '!quit'
         message.guild.voice_client = voice
         await namespace['handle_voice_command'](message)

+ 25 - 3
voice_stt.py

@@ -24,13 +24,16 @@ class STTSink(voice_recv.AudioSink):
         self.loop = asyncio.get_running_loop()
         self.lock = threading.Lock()
         self.buffers = {}
+        self.busy = False
+        self.ignored_speakers = {}
         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)
+        # Uniquement le transfert vers le worker, aucune phrase en attente.
+        self.queue = asyncio.Queue(maxsize=1)
         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)
@@ -116,6 +119,13 @@ class STTSink(voice_recv.AudioSink):
         """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')
+        last_ignored = self.ignored_speakers.get(user.id)
+        if self.busy or (last_ignored is not None and now - last_ignored < 0.8):
+            if audible:
+                self.ignored_speakers[user.id] = now
+            self.count('busy_ignored')
+            return
+        self.ignored_speakers.pop(user.id, None)
         if user.id not in self.buffers:
             if not audible:
                 return
@@ -136,25 +146,33 @@ class STTSink(voice_recv.AudioSink):
                 self.last_diagnostic = now
             ready = []
             with self.lock:
+                if self.busy:
+                    continue
                 for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
                     if now - last_voice >= 0.8 or len(pcm) >= 48000 * 4 * 20:
                         del self.buffers[user_id]
                         if len(pcm) >= 48000 * 4 * 0.25:
                             ready.append((user, bytes(pcm)))
+                            self.busy = True
+                            # Oublier aussi les phrases déjà commencées par les autres.
+                            self.ignored_speakers.update(
+                                {uid: buffer[2] for uid, buffer in self.buffers.items()})
+                            self.buffers.clear()
+                            break
                         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",
+                    logger.info("Phrase vocale sélectionnée : durée=%.2fs transfert=%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):
-        # Une seule réponse à la fois pour conserver l'ordre des phrases.
+        # Rester occupé jusqu'à la fin de la transcription ET de la réponse.
         async with httpx.AsyncClient(timeout=60) as http:
             while not self.closed:
                 user, pcm = await self.queue.get()
@@ -190,6 +208,8 @@ class STTSink(voice_recv.AudioSink):
                     logger.warning("Traitement vocal impossible : étape=%s erreur=%s HTTP=%s durée=%.2fs",
                                    stage, type(exc).__name__, status, time.monotonic() - started)
                 finally:
+                    with self.lock:
+                        self.busy = False
                     self.queue.task_done()
 
     @voice_recv.AudioSink.listener()
@@ -198,6 +218,7 @@ class STTSink(voice_recv.AudioSink):
             with self.lock:
                 self.buffers.pop(member.id, None)
                 self.decoders.pop(member.id, None)
+                self.ignored_speakers.pop(member.id, None)
 
     def cleanup(self):
         # La bibliothèque appelle cleanup depuis son thread de réception.
@@ -207,6 +228,7 @@ class STTSink(voice_recv.AudioSink):
             self.closed = True
             self.buffers.clear()
             self.decoders.clear()
+            self.ignored_speakers.clear()
         self.log_diagnostic('arrêt')
         if not self.loop.is_closed():
             self.loop.call_soon_threadsafe(self.flush_task.cancel)