Parcourir la source

Garder les tests vocaux en local et retirer VOICE.md

Andy Esnard il y a 6 heures
Parent
commit
442d6adbab
4 fichiers modifiés avec 7 ajouts et 514 suppressions
  1. 7 1
      .gitignore
  2. 0 54
      VOICE.md
  3. 0 288
      test_voice_stt.py
  4. 0 171
      test_voice_tts.py

+ 7 - 1
.gitignore

@@ -8,4 +8,10 @@ conversation_history.json
 .env
 
 .vscode
-devfile.yaml
+devfile.yaml
+
+# Tests conserves uniquement en local
+/test_voice_stt.py
+/test_voice_tts.py
+__pycache__/
+*.pyc

+ 0 - 54
VOICE.md

@@ -1,54 +0,0 @@
-# Vocal Discord
-
-- `!join`, `!join texte` ou `!join text` : transcriptions et réponses écrites.
-- `!join audio` : écoute et réponses parlées ; pas de transcription ni de réponse de conversation dans le salon texte. Les confirmations de commandes restent textuelles.
-- `!quit` : annule les traitements et la lecture, puis déconnecte le bot.
-- Pour changer de mode : `!quit`, puis la commande de connexion souhaitée.
-
-Les transcriptions écrites sont modifiées : ⚪ en attente, 🟠 traitement du lot,
-🟢 réponse envoyée, 🔴 échec. Chaque phrase conserve sa référence de message,
-y compris les phrases identiques ou regroupées pendant une réponse.
-
-Le service TTS est interchangeable via `TTS_URL` (défaut
-`http://kokoro-tts:8000/v1/audio/speech`) et `TTS_VOICE` (`ff_siwis`).
-Contrat : POST JSON `input`, `voice`, réponse WAV PCM16 mono/stéréo ;
-GET `/healthz` pour vérifier le service avant connexion audio.
-
-La réponse complète du LLM est découpée en morceaux de 80 caractères maximum,
-avec un premier morceau limité à 48 caractères pour commencer la lecture plus tôt.
-Les coupures privilégient la ponctuation puis les espaces. Cela peut modifier la
-prosodie et ajouter des pauses ; le gain réel doit être mesuré après déploiement.
-Les emojis Unicode, emojis Discord et alias comme `:hap:` sont retirés uniquement
-du texte envoyé au TTS. Une réponse contenant seulement des emojis reste silencieuse.
-Le bot commence à lire la première phrase sans attendre la synthèse des suivantes.
-Une phrase est préchargée pendant la lecture. Il ne s'agit pas de streaming des
-tokens du LLM. Le TTS CPU ne traite qu'une requête à la fois.
-
-Réglages de latence (variables du bot, secondes pour les silences) :
-
-| Variable | Défaut | Rôle |
-| --- | --- | --- |
-| `VOICE_PHRASE_SILENCE` | `0.5` | Silence avant envoi à Whisper, auparavant 0.8 s |
-| `VOICE_REPLY_SILENCE` | `1.0` | Silence global avant réponse, auparavant 2 s |
-| `TTS_FIRST_CHUNK_CHARS` | `48` | Taille maximale du premier morceau parlé |
-| `TTS_CHUNK_CHARS` | `80` | Taille maximale des morceaux suivants |
-
-Le silence global est compté depuis la dernière parole, pas ajouté après le STT.
-La réponse attend toujours les transcriptions en cours et les locuteurs encore
-actifs. Une pause de 0.5 s peut découper une intervention : augmenter ce seuil si
-Whisper perd du contexte. Le bot attend toujours la fin de sa propre lecture avant
-de répondre au lot suivant. Aucun changement de modèle ni de précision Whisper.
-Les logs `Premier audio prêt` mesurent le temps entre entrée dans le TTS et premier
-WAV disponible, sans inclure STT/LLM, lecture ou transport Discord.
-
-Pour retrouver les seuils et tailles précédents : `VOICE_PHRASE_SILENCE=0.8`,
-`VOICE_REPLY_SILENCE=2.0`, `TTS_FIRST_CHUNK_CHARS=240`, `TTS_CHUNK_CHARS=240`.
-La préférence pour les pauses aux virgules reste active. Un revert de la PR restaure
-exactement le comportement précédent. Aucun changement du trigger OpenShift requis.
-
-Le modèle/voix Kokoro-82M ff_siwis est chargé dans un pod séparé sur bureau,
-avec 2 CPU réservés, limite de 4 CPU et 4 GiB RAM ; aucun GPU demandé.
-Les poids sont inclus dans l'image pour un démarrage sans accès à Hugging Face.
-
-Tests : `python -m unittest test_voice_stt test_voice_tts`.
-Les tests ne connectent pas le bot à Discord et ne publient aucun message réel.

+ 0 - 288
test_voice_stt.py

@@ -1,288 +0,0 @@
-"""Tests hors Discord : python -m unittest test_voice_stt."""
-import asyncio
-import ast
-import time
-import unittest
-import wave
-from io import BytesIO
-from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, Mock, patch
-
-import httpx
-import discord
-from voice_stt import STTSink
-
-
-class VoiceTests(unittest.IsolatedAsyncioTestCase):
-    async def asyncSetUp(self):
-        self.reply = AsyncMock()
-        self.sink = STTSink(self.reply, 'http://stt/transcribe')
-        self.user = SimpleNamespace(id=1, bot=False)
-        self.pcm = b'\x00\x10' * (48000 * 2 // 2)  # 0,5 s stéréo
-
-    async def asyncTearDown(self):
-        await self.sink.close()
-
-    async def test_silence_and_separate_speakers(self):
-        self.sink.worker_task.cancel()
-        with self.sink.lock:
-            self.sink.add_pcm(self.user, bytes(len(self.pcm)), time.monotonic())
-            self.assertFalse(self.sink.buffers)
-            self.sink.add_pcm(self.user, self.pcm, time.monotonic() - 2)
-            self.sink.add_pcm(SimpleNamespace(id=2), self.pcm, time.monotonic())
-        await asyncio.sleep(0.15)
-        user, pcm = self.sink.queue.get_nowait()
-        self.sink.queue.task_done()
-        self.assertEqual(user.id, 1)
-        self.assertEqual(pcm, self.pcm)
-        self.assertIn(2, self.sink.buffers)
-
-    async def test_audio_is_bounded_and_short_noise_ignored(self):
-        self.sink.worker_task.cancel()
-        with self.sink.lock:
-            self.sink.add_pcm(self.user, self.pcm * 50, time.monotonic())
-            self.assertEqual(len(self.sink.buffers[1][1]), 48000 * 4 * 20)
-            self.sink.add_pcm(SimpleNamespace(id=2), self.pcm[:1000], time.monotonic() - 2)
-        await asyncio.sleep(0.15)
-        self.assertEqual(self.sink.queue.qsize(), 1)
-        self.assertNotIn(2, self.sink.buffers)
-
-    async def test_dave_decrypted_before_opus(self):
-        session = SimpleNamespace(ready=True, decrypt=Mock(return_value=b'opus'))
-        self.sink._voice_client = SimpleNamespace(_connection=SimpleNamespace(
-            dave_protocol_version=1, dave_session=session))
-        data = SimpleNamespace(opus=b'encrypted', packet=SimpleNamespace(is_silence=lambda: False))
-        decoder = Mock()
-        decoder.decode.return_value = self.pcm
-        with patch('voice_stt.discord.opus.Decoder', return_value=decoder):
-            self.sink.write(self.user, data)
-        session.decrypt.assert_called_once()
-        decoder.decode.assert_called_once_with(b'opus', fec=False)
-        self.assertEqual(bytes(self.sink.buffers[1][1]), self.pcm)
-
-    async def test_http_recovery_and_wav_format(self):
-        await self.sink.close()
-        post = AsyncMock(side_effect=[
-            httpx.ConnectError('offline'),
-            httpx.Response(200, json={'text': 'Bonjour'}, request=httpx.Request('POST', 'http://stt')),
-        ])
-        http = AsyncMock()
-        http.__aenter__.return_value.post = post
-        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')])
-            payload = post.call_args.kwargs['files']['file'][1]
-            with wave.open(BytesIO(payload)) as wav:
-                self.assertEqual((wav.getnchannels(), wav.getsampwidth(), wav.getframerate()), (2, 2, 48000))
-                self.assertEqual(wav.readframes(wav.getnframes()), self.pcm)
-
-    async def test_transcriptions_continue_and_next_reply_groups_speakers(self):
-        await self.sink.close()
-        started, release, second = asyncio.Event(), asyncio.Event(), asyncio.Event()
-        displayed = AsyncMock()
-        batches = []
-
-        async def reply(batch):
-            batches.append(batch)
-            if len(batches) == 1:
-                started.set()
-                await release.wait()
-            else:
-                second.set()
-
-        http = AsyncMock()
-        http.__aenter__.return_value.post.side_effect = [
-            httpx.Response(200, json={'text': text}, request=httpx.Request('POST', 'http://stt'))
-            for text in ('Bonjour', 'Minecraft ?', 'Avec des mods')
-        ]
-        other = SimpleNamespace(id=2)
-        with patch('voice_stt.httpx.AsyncClient', return_value=http):
-            self.sink = STTSink(reply, 'http://stt/transcribe', on_transcription=displayed)
-            self.sink.queue.put_nowait((self.user, self.pcm))
-            await asyncio.wait_for(started.wait(), 2)
-            self.sink.queue.put_nowait((other, self.pcm))
-            self.sink.queue.put_nowait((self.user, self.pcm))
-            await asyncio.wait_for(self.sink.queue.join(), 2)
-            self.assertEqual(displayed.await_count, 3)
-            self.assertEqual(len(batches), 1)
-            with self.sink.lock:
-                self.sink.add_pcm(other, self.pcm, time.monotonic())
-                self.assertIn(2, self.sink.buffers)
-            release.set()
-            await asyncio.wait_for(second.wait(), 4)
-            self.assertEqual(batches[1], [(other, 'Minecraft ?'), (self.user, 'Avec des mods')])
-            self.assertFalse(self.sink.pending)
-
-    async def test_reply_waits_for_global_silence(self):
-        with self.sink.lock:
-            self.sink.last_voice = time.monotonic()
-        self.sink.pending.append((self.user, 'Bonjour'))
-        self.sink.pending_event.set()
-        await asyncio.sleep(0.2)
-        self.reply.assert_not_awaited()
-        with self.sink.lock:
-            self.sink.last_voice = time.monotonic() - 2.1
-        await asyncio.sleep(0.2)
-        self.reply.assert_awaited_once_with([(self.user, 'Bonjour')])
-
-    async def test_reply_can_start_before_old_two_second_threshold(self):
-        self.sink.last_voice = time.monotonic() - 1.1
-        self.sink.pending.append((self.user, 'Bonjour'))
-        self.sink.pending_event.set()
-        await asyncio.sleep(0.15)
-        self.reply.assert_awaited_once_with([(self.user, 'Bonjour')])
-
-    async def test_silence_settings_restore_previous_behavior(self):
-        await self.sink.close()
-        with patch.dict('os.environ', VOICE_PHRASE_SILENCE='0.8', VOICE_REPLY_SILENCE='2.0'):
-            self.sink = STTSink(self.reply, 'http://stt/transcribe')
-        self.sink.last_voice = time.monotonic() - 1.1
-        self.sink.pending.append((self.user, 'Bonjour'))
-        self.sink.pending_event.set()
-        await asyncio.sleep(0.15)
-        self.reply.assert_not_awaited()
-        self.assertEqual(self.sink.phrase_silence, 0.8)
-
-    async def test_close_discards_pending_audio(self):
-        self.sink.queue.put_nowait((self.user, self.pcm))
-        await self.sink.close()
-        self.assertTrue(self.sink.closed)
-        self.assertTrue(self.sink.queue.empty())
-        self.assertTrue(self.sink.flush_task.done())
-        self.assertTrue(self.sink.worker_task.done())
-        self.assertTrue(self.sink.reply_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_voice_limits_history_and_disables_thinking_only_for_voice(self):
-        tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
-        function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
-                        and n.name == 'call_openai_api')
-        response = SimpleNamespace(choices=[SimpleNamespace(message=SimpleNamespace(
-            tool_calls=None, content='Bonjour'))])
-        create = AsyncMock(return_value=response)
-        history = [{'role': 'user', 'content': str(i)} for i in range(10)]
-        namespace = dict(transform_emote=lambda text, output: text, PERSONALITY_PROMPT='Personnalité',
-                         conversation_history=history, search_web=AsyncMock(return_value=None),
-                         WEB_SEARCH_ENABLED=True, MODEL='test', TEMPERATURE=1, logger=Mock(),
-                         openai_client=SimpleNamespace(chat=SimpleNamespace(
-                             completions=SimpleNamespace(create=create))),
-                         add_to_conversation_history=AsyncMock())
-        exec(compile(ast.Module(body=[function], type_ignores=[]), '<api>', 'exec'), namespace)
-        await namespace['call_openai_api']('Bonjour', 'Alice', voice=True)
-        args = create.call_args.kwargs
-        self.assertEqual(args['messages'][1:-1], history[-5:])
-        self.assertEqual(args['extra_body']['options']['think'], False)
-        self.assertIn('une phrase courte', args['messages'][0]['content'])
-        self.assertIn('search_web', args['messages'][0]['content'])
-        await namespace['call_openai_api']('Bonjour', 'Alice')
-        args = create.call_args.kwargs
-        self.assertEqual(args['messages'][1:-1], history)
-        self.assertNotIn('extra_body', args)
-
-    async def test_status_connected_and_disconnected(self):
-        tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
-        function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
-                        and n.name == 'handle_status')
-        namespace = dict(BOT_VERSION='test', MODEL='gemma', discord=discord)
-        exec(compile(ast.Module(body=[function], type_ignores=[]), '<status>', 'exec'), namespace)
-        message = SimpleNamespace(guild=None, channel=SimpleNamespace(send=AsyncMock()))
-        await namespace['handle_status'](message)
-        self.assertIn('déconnecté', message.channel.send.call_args.args[0])
-        sink = SimpleNamespace(responding=True, queue=asyncio.Queue(), pending=[1, 2],
-                               last_stt_seconds=0.5, last_reply_seconds=None)
-        message.guild = SimpleNamespace(voice_client=SimpleNamespace(sink=sink, is_connected=lambda: True))
-        await namespace['handle_status'](message)
-        text = message.channel.send.call_args.args[0]
-        self.assertIn('transcriptions en attente : 2', text)
-        self.assertIn('0.5 s', text)
-        self.assertIn('pas encore mesuré', text)
-
-    async def test_join_replies_in_command_channel_and_quit_closes_sink(self):
-        # Charger uniquement la fonction : aucun token ni connexion Discord.
-        tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
-        function = next(node for node in tree.body if isinstance(node, ast.AsyncFunctionDef)
-                        and node.name == 'handle_voice_command')
-        http = AsyncMock()
-        http.__aenter__.return_value.get.return_value = Mock()
-        sink = SimpleNamespace(close=AsyncMock(), receiver_stopped=Mock(), log_diagnostic=Mock())
-        make_sink = Mock(return_value=sink)
-        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, me=SimpleNamespace(voice=None)),
-            author=SimpleNamespace(voice=SimpleNamespace(channel=channel)),
-            channel=SimpleNamespace(send=AsyncMock(), typing=AsyncMock()),
-        )
-        message.channel.typing = Mock(return_value=AsyncMock())
-        call_api = AsyncMock(return_value=SimpleNamespace(
-            choices=[SimpleNamespace(message=SimpleNamespace(content='Réponse'))]))
-        namespace = dict(
-            VOICE_ENABLED=True, STT_URL='http://stt/transcribe',
-            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, **kwargs: [text],
-            discord=discord, logger=Mock(),
-        )
-        exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), namespace)
-        await namespace['handle_voice_command'](message)
-        voice.listen.assert_called_once_with(sink, after=sink.receiver_stopped)
-        callback = make_sink.call_args.args[0]
-        receipt = await make_sink.call_args.kwargs['on_transcription'](SimpleNamespace(display_name='Alice'), 'Bonjour')
-        update = make_sink.call_args.kwargs['on_batch_status']
-        await update([receipt], 'processing')
-        self.assertEqual(receipt[0][0].edit.call_args.kwargs['content'], '🟠 > 🎙 Alice : Bonjour')
-        await callback([(SimpleNamespace(display_name='Alice'), 'Bonjour')])
-        call_api.assert_awaited_once_with('Alice : Bonjour', 'Conversation vocale', voice=True)
-        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': []})
-        await update([receipt], 'done')
-        self.assertEqual(receipt[0][0].edit.call_args.kwargs['content'], '🟢 > 🎙 Alice : Bonjour')
-        message.content = '!quit'
-        message.guild.voice_client = voice
-        await namespace['handle_voice_command'](message)
-        sink.close.assert_awaited_once()
-        voice.disconnect.assert_awaited_once_with(force=True)
-
-
-if __name__ == '__main__':
-    unittest.main()

+ 0 - 171
test_voice_tts.py

@@ -1,171 +0,0 @@
-import asyncio
-import ast
-import unittest
-import wave
-from io import BytesIO
-from pathlib import Path
-from types import SimpleNamespace
-from unittest.mock import AsyncMock, Mock, patch
-
-import discord
-from voice_tts import WavSource, speech_chunks, play_wav, speak
-from voice_stt import STTSink
-
-
-class AudioTests(unittest.IsolatedAsyncioTestCase):
-    def test_short_first_chunk_preserves_all_words(self):
-        text = 'Je peux te répondre rapidement, puis poursuivre avec les détails pendant la lecture du début.'
-        chunks = list(speech_chunks(text, limit=80, first_limit=48))
-        self.assertEqual(chunks[0], 'Je peux te répondre rapidement,')
-        self.assertEqual(' '.join(chunks), text)
-        self.assertLessEqual(len(chunks[0]), 48)
-        self.assertTrue(all(len(chunk) <= 80 for chunk in chunks))
-
-    async def test_next_synthesis_overlaps_playback_and_is_cancelled(self):
-        second_started = asyncio.Event()
-        second_cancelled = asyncio.Event()
-        first_played = asyncio.Event()
-        requests = []
-
-        class Response:
-            async def __aenter__(self):
-                return self
-
-            async def __aexit__(self, *args):
-                pass
-
-            def raise_for_status(self):
-                pass
-
-            async def aiter_bytes(self):
-                if len(requests) == 2:
-                    second_started.set()
-                    try:
-                        await asyncio.Event().wait()
-                    finally:
-                        second_cancelled.set()
-                yield b'wav'
-
-        def stream(*args, **kwargs):
-            requests.append(kwargs['json']['input'])
-            return Response()
-
-        async def play(*args):
-            first_played.set()
-            await second_started.wait()
-            await asyncio.Event().wait()
-
-        http = AsyncMock()
-        http.__aenter__.return_value.stream = Mock(side_effect=stream)
-        with patch('voice_tts.httpx.AsyncClient', return_value=http), patch('voice_tts.play_wav', side_effect=play):
-            task = asyncio.create_task(speak(Mock(), 'Bonjour. Voici la suite.', 'http://tts'))
-            try:
-                await asyncio.wait_for(second_started.wait(), 1)
-                self.assertTrue(first_played.is_set())
-                self.assertEqual(requests, ['Bonjour.', 'Voici la suite.'])
-            finally:
-                task.cancel()
-                with self.assertRaises(asyncio.CancelledError):
-                    await task
-            self.assertTrue(second_cancelled.is_set())
-
-    def test_speech_removes_unicode_sequences_and_custom_aliases(self):
-        text = 'Salut :hap: 😊 ! Ça va ❤️ 👍🏽 👩‍💻 🇫🇷 1️⃣ :desu: <:minou:123> <a:eh:456> ?'
-        self.assertEqual(list(speech_chunks(text)), ['Salut !', 'Ça va ?'])
-
-    def test_speech_preserves_french_numbers_and_times(self):
-        text = 'Écoute : rendez-vous à 12:30:45, pour 42 € et 20 %.'
-        self.assertEqual(list(speech_chunks(text)), [text])
-
-    async def test_emoji_only_response_is_silent(self):
-        voice = Mock()
-        with patch('voice_tts.httpx.AsyncClient') as factory:
-            factory.return_value = AsyncMock()
-            await speak(voice, '😊 :hap: 👩‍💻', 'http://tts')
-            factory.return_value.__aenter__.return_value.stream.assert_not_called()
-        voice.play.assert_not_called()
-
-    def test_chunks_are_bounded_and_remove_discord_markup(self):
-        chunks = list(speech_chunks('Bonjour ! [Lis ceci](https://example.org) <:hap:123> ' + 'mot ' * 200))
-        self.assertTrue(all(0 < len(c) <= 240 for c in chunks))
-        self.assertNotIn('https', ' '.join(chunks))
-        self.assertNotIn('<:hap', ' '.join(chunks))
-        self.assertEqual(chunks[0], 'Bonjour !')
-
-    def wav(self):
-        buf = BytesIO()
-        with wave.open(buf, 'wb') as wav:
-            wav.setnchannels(1)
-            wav.setsampwidth(2)
-            wav.setframerate(24000)
-            wav.writeframes(b'\0\1' * 2400)
-        return buf.getvalue()
-
-    def test_discord_frames_are_stereo_48khz_20ms(self):
-        source = WavSource(self.wav())
-        frames = []
-        while frame := source.read():
-            frames.append(frame)
-        self.assertEqual(len(frames), 5)
-        self.assertTrue(all(len(frame) == 3840 for frame in frames))
-        source.cleanup()
-
-    async def test_cancel_stops_playback(self):
-        voice = SimpleNamespace(play=Mock(), stop=Mock())
-        task = asyncio.create_task(play_wav(voice, self.wav()))
-        await asyncio.sleep(0)
-        task.cancel()
-        with self.assertRaises(asyncio.CancelledError):
-            await task
-        voice.stop.assert_called_once()
-
-    async def test_batch_statuses_track_success_failure_and_duplicate_phrases(self):
-        statuses = AsyncMock()
-        reply = AsyncMock(side_effect=[None, RuntimeError('LLM unavailable')])
-        sink = STTSink(reply, 'http://unused', on_batch_status=statuses)
-        try:
-            sink.pending = [('Alice', 'Bonjour'), ('Alice', 'Bonjour')]
-            sink.pending_receipts = ['message-1', 'message-2']
-            sink.pending_event.set()
-            await asyncio.sleep(0.1)
-            self.assertEqual(statuses.call_args_list[0].args, (['message-1', 'message-2'], 'processing'))
-            self.assertEqual(statuses.call_args_list[1].args, (['message-1', 'message-2'], 'done'))
-            sink.pending = [('Alice', 'Autre phrase')]
-            sink.pending_receipts = ['message-3']
-            sink.pending_event.set()
-            await asyncio.sleep(0.1)
-            self.assertEqual(statuses.call_args_list[-1].args, (['message-3'], 'error'))
-        finally:
-            await sink.close()
-
-    async def test_audio_mode_does_not_publish_conversation(self):
-        tree = ast.parse(Path(__file__).with_name('chatbot.py').read_text(encoding='utf-8'))
-        function = next(n for n in tree.body if isinstance(n, ast.AsyncFunctionDef)
-                        and n.name == 'handle_voice_command')
-        http = AsyncMock()
-        http.__aenter__.return_value.get.return_value = Mock()
-        sink = SimpleNamespace(close=AsyncMock(), receiver_stopped=Mock(), log_diagnostic=Mock())
-        factory = Mock(return_value=sink)
-        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 audio',
-            guild=SimpleNamespace(voice_client=None, me=SimpleNamespace(voice=None)),
-            author=SimpleNamespace(voice=SimpleNamespace(channel=channel)),
-            channel=SimpleNamespace(send=AsyncMock()))
-        speak = AsyncMock()
-        ns = dict(VOICE_ENABLED=True, STT_URL='http://stt/transcribe', TTS_URL='http://tts/v1/audio/speech',
-                  TTS_VOICE='ff_siwis', voice_command_lock=asyncio.Lock(),
-                  httpx=SimpleNamespace(AsyncClient=Mock(return_value=http)),
-                  voice_recv=SimpleNamespace(VoiceRecvClient=object), STTSink=factory,
-                  call_openai_api=AsyncMock(return_value=SimpleNamespace(choices=[
-                      SimpleNamespace(message=SimpleNamespace(content='Bonjour Alice.'))])),
-                  filter_message=lambda x: x, speak=speak, discord=discord, logger=Mock())
-        exec(compile(ast.Module(body=[function], type_ignores=[]), '<commands>', 'exec'), ns)
-        await ns['handle_voice_command'](message)
-        self.assertIsNone(factory.call_args.kwargs['on_transcription'])
-        message.channel.send.reset_mock()
-        await factory.call_args.args[0]([(SimpleNamespace(display_name='Alice'), 'Salut')])
-        speak.assert_awaited_once_with(voice, 'Bonjour Alice.', ns['TTS_URL'], 'ff_siwis')
-        message.channel.send.assert_not_awaited()