Procházet zdrojové kódy

Citer les transcriptions et ignorer les phrases pendant le traitement vocal

Andy Esnard před 3 hodinami
rodič
revize
2d610dd61a
3 změnil soubory, kde provedl 81 přidání a 6 odebrání
  1. 5 1
      chatbot.py
  2. 51 2
      test_voice_stt.py
  3. 25 3
      voice_stt.py

+ 5 - 1
chatbot.py

@@ -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)