Sfoglia il codice sorgente

Merge branch 'codex' of penta/satoko-chatbot into master

penta 13 ore fa
parent
commit
e0fc2d5ab9

+ 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 - 29
VOICE.md

@@ -1,29 +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 phrases de 240 caractères maximum.
-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.
-
-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.

+ 1 - 1
chatbot.py

@@ -54,7 +54,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.4.0"
+BOT_VERSION = "3.4.1"
 
 # Vérifier la version de Discord
 if not sys.version_info[:2] >= (3, 15):

+ 0 - 270
test_voice_stt.py

@@ -1,270 +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_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 - 115
test_voice_tts.py

@@ -1,115 +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_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()

+ 0 - 13
tts-service/Dockerfile

@@ -1,13 +0,0 @@
-FROM python:3.12-slim
-WORKDIR /app
-ENV PYTHONUNBUFFERED=1 HF_HOME=/opt/models HOME=/tmp OMP_NUM_THREADS=4 MKL_NUM_THREADS=4
-RUN apt-get update && apt-get install -y --no-install-recommends espeak-ng libsndfile1 && rm -rf /var/lib/apt/lists/*
-COPY requirements.txt .
-RUN pip install --no-cache-dir torch==2.7.1 --index-url https://download.pytorch.org/whl/cpu && pip install --no-cache-dir -r requirements.txt
-# Modèle et voix embarqués : aucun téléchargement au démarrage du pod.
-RUN python -c "from kokoro import KPipeline; p=KPipeline(lang_code='f', device='cpu'); list(p('Bonjour.', voice='ff_siwis'))" && chmod -R a+rX /opt/models
-ENV HF_HUB_OFFLINE=1
-COPY app.py .
-USER 1001
-EXPOSE 8000
-CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

+ 0 - 34
tts-service/README.md

@@ -1,34 +0,0 @@
-# Synthèse vocale Kokoro sur CPU
-
-Service interne `kokoro-tts:8000`, voix française féminine `ff_siwis`.
-Les poids sont embarqués dans l'image ; le modèle est chargé et préchauffé au
-démarrage. Aucun GPU n'est demandé. Le manifeste cible le nœud
-`bureau.local.pentou.ovh` dans le namespace `localai` (à adapter ailleurs).
-
-Depuis la racine du dépôt :
-
-```sh
-oc apply -f tts-service/openshift.yaml
-oc start-build kokoro-tts -n localai --from-dir=tts-service --follow
-```
-
-Après réussite du build, mettre à jour le Deployment `kokoro-tts`, conteneur
-`tts`, avec le digest indiqué par l'ImageStreamTag `kokoro-tts:cpu`, puis vérifier
-son rollout. Le manifeste initial utilise le tag `cpu` ; il ne configure pas
-de déclencheur automatique pour les builds suivants.
-
-API : POST `/v1/audio/speech`, JSON `input` (1 à 300 caractères), `voice`
-(`ff_siwis`), réponse WAV PCM16 mono 24 kHz. GET `/healthz` devient disponible
-après préchauffage. Une génération à la fois ; HTTP 429 si occupé.
-
-`check_service.py` peut être exécuté via stdin dans le pod Satoko. Il vérifie
-la connectivité, le format WAV, la validation de voix et mesure la génération
-de trois phrases sans publier sur Discord.
-
-Mesures sur Ryzen 9 5950X, limite 4 CPU : 1,44 / 2,53 / 2,69 secondes de requête
-pour 3,13 / 5,75 / 5,53 secondes d'audio. Elles excluent STT, LLM et Discord.
-`requirements-deployed.txt` est l'inventaire de l'image testée ; le Dockerfile
-installe PyTorch CPU explicitement puis les dépendances de `requirements.txt`.
-
-La NetworkPolicy autorise seulement les pods `app=satoko-chatbot` du même
-namespace à accéder au service. Aucune route publique n'est créée.

+ 0 - 74
tts-service/app.py

@@ -1,74 +0,0 @@
-"""Service local Kokoro CPU : API WAV compatible avec le client vocal."""
-import asyncio
-import logging
-import os
-import time
-from contextlib import asynccontextmanager
-from io import BytesIO
-
-import numpy as np
-import soundfile as sf
-import torch
-from fastapi import FastAPI, HTTPException
-from fastapi.responses import Response
-from kokoro import KPipeline
-from pydantic import BaseModel, Field
-
-logger = logging.getLogger('uvicorn.error')
-pipeline = None
-generation_lock = asyncio.Lock()
-
-
-def generate(text, voice):
-    with torch.inference_mode():
-        chunks = [result.audio.cpu().numpy() for result in pipeline(text, voice=voice)]
-    if not chunks:
-        raise ValueError('Aucun audio généré')
-    audio = np.concatenate(chunks)
-    output = BytesIO()
-    sf.write(output, audio, 24000, format='WAV', subtype='PCM_16')
-    return output.getvalue(), len(audio) / 24000
-
-
-@asynccontextmanager
-async def lifespan(app):
-    global pipeline
-    torch.set_num_threads(int(os.getenv('TTS_THREADS', '4')))
-    torch.set_num_interop_threads(1)
-    pipeline = KPipeline(lang_code='f', repo_id='hexgrad/Kokoro-82M', device='cpu')
-    await asyncio.to_thread(generate, 'Bonjour, je suis prête.', 'ff_siwis')
-    yield
-
-
-app = FastAPI(lifespan=lifespan)
-
-
-class SpeechRequest(BaseModel):
-    input: str = Field(min_length=1, max_length=300)
-    voice: str = 'ff_siwis'
-
-
-@app.get('/healthz')
-def health():
-    return {'ready': pipeline is not None, 'model': 'Kokoro-82M', 'voice': 'ff_siwis', 'device': 'cpu'}
-
-
-@app.post('/v1/audio/speech')
-async def speech(request: SpeechRequest):
-    if request.voice != 'ff_siwis' or not request.input.strip():
-        raise HTTPException(422, 'Voix inconnue ou texte vide')
-    if generation_lock.locked():
-        raise HTTPException(429, 'Synthèse occupée')
-    async with generation_lock:
-        started = time.monotonic()
-        # Shield : ne pas libérer le verrou pendant une génération annulée par le client.
-        task = asyncio.create_task(asyncio.to_thread(generate, request.input, request.voice))
-        try:
-            payload, duration = await asyncio.shield(task)
-        except asyncio.CancelledError:
-            await task
-            raise
-        elapsed = time.monotonic() - started
-        logger.info('Synthèse : caractères=%d génération=%.3fs audio=%.3fs', len(request.input), elapsed, duration)
-        return Response(payload, media_type='audio/wav', headers={
-            'X-Generation-Seconds': f'{elapsed:.3f}', 'X-Audio-Seconds': f'{duration:.3f}'})

+ 0 - 28
tts-service/check_service.py

@@ -1,28 +0,0 @@
-"""À exécuter depuis le pod Satoko ; aucun appel à Discord."""
-import io
-import json
-import time
-import wave
-import httpx
-
-with httpx.Client(timeout=90) as http:
-    health = http.get('http://kokoro-tts:8000/healthz')
-    health.raise_for_status()
-    print(health.json())
-    for text in (
-        'Bonjour, je suis prête à discuter avec vous.',
-        'Tu peux maintenant me parler dans le salon vocal, et je te répondrai directement à voix haute.',
-        'Les trois serveurs fonctionnent correctement. Nous pouvons continuer notre partie tranquillement.',
-    ):
-        started = time.monotonic()
-        response = http.post('http://kokoro-tts:8000/v1/audio/speech', json={'input': text, 'voice': 'ff_siwis'})
-        response.raise_for_status()
-        with wave.open(io.BytesIO(response.content)) as wav:
-            assert wav.getframerate() == 24000 and wav.getnchannels() == 1 and wav.getsampwidth() == 2
-            duration = wav.getnframes() / wav.getframerate()
-        print(json.dumps({'characters': len(text), 'request_seconds': round(time.monotonic()-started, 3),
-                          'generation_seconds': response.headers.get('X-Generation-Seconds'),
-                          'audio_seconds': round(duration, 3)}, ensure_ascii=False))
-    invalid = http.post('http://kokoro-tts:8000/v1/audio/speech', json={'input': 'bonjour', 'voice': '../unknown'})
-    assert invalid.status_code == 422
-    print('WAV et validation de voix : OK')

+ 0 - 118
tts-service/openshift.yaml

@@ -1,118 +0,0 @@
-apiVersion: image.openshift.io/v1
-kind: ImageStream
-metadata:
-  name: kokoro-tts
-  namespace: localai
----
-apiVersion: build.openshift.io/v1
-kind: BuildConfig
-metadata:
-  name: kokoro-tts
-  namespace: localai
-spec:
-  runPolicy: Serial
-  source:
-    type: Binary
-    binary: {}
-  strategy:
-    type: Docker
-    dockerStrategy: {}
-  output:
-    to:
-      kind: ImageStreamTag
-      name: kokoro-tts:cpu
-  resources:
-    requests:
-      cpu: '1'
-      memory: 2Gi
-    limits:
-      cpu: '4'
-      memory: 6Gi
----
-apiVersion: apps/v1
-kind: Deployment
-metadata:
-  name: kokoro-tts
-  namespace: localai
-spec:
-  replicas: 1
-  strategy:
-    type: Recreate
-  selector:
-    matchLabels:
-      app: kokoro-tts
-  template:
-    metadata:
-      labels:
-        app: kokoro-tts
-    spec:
-      nodeSelector:
-        kubernetes.io/hostname: bureau.local.pentou.ovh
-      automountServiceAccountToken: false
-      containers:
-        - name: tts
-          image: image-registry.openshift-image-registry.svc:5000/localai/kokoro-tts:cpu
-          ports:
-            - containerPort: 8000
-          resources:
-            requests:
-              cpu: '2'
-              memory: 2Gi
-            limits:
-              cpu: '4'
-              memory: 4Gi
-          securityContext:
-            allowPrivilegeEscalation: false
-            capabilities:
-              drop: [ALL]
-            runAsNonRoot: true
-            seccompProfile:
-              type: RuntimeDefault
-          startupProbe:
-            httpGet:
-              path: /healthz
-              port: 8000
-            periodSeconds: 5
-            failureThreshold: 60
-          readinessProbe:
-            httpGet:
-              path: /healthz
-              port: 8000
-            periodSeconds: 5
-          livenessProbe:
-            httpGet:
-              path: /healthz
-              port: 8000
-            periodSeconds: 20
-            timeoutSeconds: 5
----
-apiVersion: v1
-kind: Service
-metadata:
-  name: kokoro-tts
-  namespace: localai
-spec:
-  selector:
-    app: kokoro-tts
-  ports:
-    - port: 8000
-      targetPort: 8000
----
-apiVersion: networking.k8s.io/v1
-kind: NetworkPolicy
-metadata:
-  name: kokoro-tts-ingress
-  namespace: localai
-spec:
-  podSelector:
-    matchLabels:
-      app: kokoro-tts
-  policyTypes: [Ingress]
-  ingress:
-    - from:
-        - podSelector:
-            matchLabels:
-              app: satoko-chatbot
-      ports:
-        - protocol: TCP
-          port: 8000

+ 0 - 97
tts-service/requirements-deployed.txt

@@ -1,97 +0,0 @@
-addict==2.4.0
-annotated-doc==0.0.5
-annotated-types==0.8.0
-anyio==4.16.0
-attrs==26.1.0
-babel==2.18.0
-blis==1.3.4
-catalogue==2.0.10
-certifi==2026.10.23
-cffi==2.1.1
-charset-normalizer==3.5.2
-click==8.5.0
-cloudpathlib==0.26.0
-cloudpickle==3.1.2
-confection==1.3.4
-csvw==4.1.1
-curated-tokenizers==0.0.10
-curated-transformers==0.1.1
-cymem==2.0.16
-dlinfo==2.0.0
-docopt==0.6.2
-espeakng-loader==0.2.4
-fastapi==0.115.12
-filelock==4.2.4
-fsspec==2026.9.0
-h11==0.16.0
-hf-xet==1.8.0
-httpcore==1.0.9
-httpcore2==2.16.0
-httpx==0.28.1
-httpx2==2.16.0
-huggingface_hub==2.4.0
-idna==3.20
-isodate==0.7.2
-Jinja2==3.1.6
-joblib==1.6.0
-jsonschema==4.26.0
-jsonschema-specifications==2025.9.1
-kokoro==0.9.4
-language-tags==1.3.2
-loguru==0.7.3
-markdown-it-py==4.2.0
-MarkupSafe==3.0.3
-mdurl==0.1.2
-misaki==0.9.4
-mpmath==1.3.0
-murmurhash==1.0.16
-networkx==3.7
-num2words==0.5.14
-numpy==2.5.4
-packaging==26.3
-phonemizer-fork==3.3.2
-preshed==3.0.15
-pycparser==3.12
-pydantic==2.14.2
-pydantic_core==2.50.2
-Pygments==2.21.0
-pyparsing==3.3.3
-python-dateutil==4.0.0
-PyYAML==6.0.3
-rdflib==7.6.0
-referencing==0.37.0
-regex==2026.9.29
-requests==2.34.2
-rfc3986==1.5.0
-rich==15.0.0
-rpds-py==2026.9.1
-safetensors==0.8.0
-segments==2.4.0
-setuptools==78.1.0
-shellingham==1.5.4
-smart_open==9.0.2
-soundfile==0.13.1
-spacy==3.8.18
-spacy-curated-transformers==0.3.1
-spacy-legacy==3.0.12
-spacy-loggers==1.0.5
-srsly==2.5.6
-starlette==0.46.2
-sympy==1.14.0
-termcolor==3.3.0
-thinc==8.3.14
-tokenizers==0.23.3
-torch==2.7.1+cpu
-tqdm==4.70.1
-transformers==5.22.1
-truststore==0.10.5
-typer==0.27.3
-typing-inspection==0.4.5
-typing_extensions==4.16.0
-tzdata==2026.6
-uritemplate==4.2.0
-urllib3==2.8.0
-uvicorn==0.34.2
-wasabi==1.1.3
-weasel==1.0.1
-wrapt==2.5.2

+ 0 - 4
tts-service/requirements.txt

@@ -1,4 +0,0 @@
-kokoro==0.9.4
-soundfile==0.13.1
-fastapi==0.115.12
-uvicorn==0.34.2

+ 7 - 2
voice_stt.py

@@ -2,6 +2,7 @@
 import asyncio
 import audioop
 import logging
+import os
 import threading
 import time
 import wave
@@ -19,6 +20,10 @@ logger = logging.getLogger(__name__)
 class STTSink(voice_recv.AudioSink):
     def __init__(self, on_phrase, stt_url, on_transcription=None, on_batch_status=None):
         super().__init__()
+        self.phrase_silence = float(os.getenv('VOICE_PHRASE_SILENCE', '0.5'))
+        self.reply_silence = float(os.getenv('VOICE_REPLY_SILENCE', '1.0'))
+        if not (0.25 <= self.phrase_silence <= self.reply_silence <= 5):
+            raise ValueError('Silences vocaux attendus : 0.25 <= phrase <= réponse <= 5 secondes')
         self.on_phrase = on_phrase
         self.on_transcription = on_transcription
         self.on_batch_status = on_batch_status
@@ -150,7 +155,7 @@ class STTSink(voice_recv.AudioSink):
             ready = []
             with self.lock:
                 for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
-                    if now - last_voice >= 0.8 or len(pcm) >= 48000 * 4 * 20:
+                    if now - last_voice >= self.phrase_silence or len(pcm) >= 48000 * 4 * 20:
                         del self.buffers[user_id]
                         if len(pcm) >= 48000 * 4 * 0.25:
                             ready.append((user, bytes(pcm)))
@@ -221,7 +226,7 @@ class STTSink(voice_recv.AudioSink):
             while not self.closed:
                 await self.queue.join()
                 with self.lock:
-                    quiet = time.monotonic() - self.last_voice > 2.0
+                    quiet = time.monotonic() - self.last_voice >= self.reply_silence
                     buffered = bool(self.buffers)
                 if quiet and not buffered and self.queue.empty():
                     break

+ 28 - 4
voice_tts.py

@@ -1,7 +1,10 @@
 """Synthèse HTTP interchangeable et lecture Discord, sans fichiers temporaires."""
 import asyncio
 import audioop
+import logging
+import os
 import re
+import time
 import wave
 from io import BytesIO
 
@@ -9,8 +12,13 @@ import discord
 import emoji
 import httpx
 
+logger = logging.getLogger(__name__)
 
-def speech_chunks(text, limit=240):
+
+def speech_chunks(text, limit=240, first_limit=None):
+    if limit < 1 or (first_limit is not None and first_limit < 1):
+        raise ValueError('La taille des morceaux doit être positive')
+    first = True
     text = re.sub(r'```.*?```', ' Bloc de code omis. ', text, flags=re.S)
     text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
     text = re.sub(r'https?://\S+|<a?:\w+:\d+>|<[@#][!&]?\d+>', ' ', text)
@@ -22,11 +30,18 @@ def speech_chunks(text, limit=240):
     text = re.sub(r'[^\S\n]+', ' ', text)
     for sentence in re.split(r'(?<=[.!?…])\s+|\n+', text.strip()):
         while sentence:
-            end = len(sentence) if len(sentence) <= limit else sentence.rfind(' ', 0, limit + 1)
+            budget = min(limit, first_limit) if first and first_limit else limit
+            end = len(sentence) if len(sentence) <= budget else sentence.rfind(' ', 0, budget + 1)
+            # Favoriser une pause naturelle, sans fabriquer un minuscule morceau.
+            if len(sentence) > budget:
+                pauses = list(re.finditer(r'[,;:]\s', sentence[:budget + 1]))
+                if pauses and pauses[-1].start() >= budget // 2:
+                    end = pauses[-1].start() + 1
             if end <= 0:
-                end = limit
+                end = budget
             chunk, sentence = sentence[:end].strip(), sentence[end:].strip()
             if chunk:
+                first = False
                 yield chunk
 
 
@@ -71,7 +86,13 @@ async def play_wav(voice, payload):
 
 
 async def speak(voice, text, url, voice_name='ff_siwis'):
-    chunks = iter(speech_chunks(text))
+    first_chars = int(os.getenv('TTS_FIRST_CHUNK_CHARS', '48'))
+    chunk_chars = int(os.getenv('TTS_CHUNK_CHARS', '80'))
+    if not (16 <= first_chars <= chunk_chars <= 240):
+        raise ValueError('Tailles TTS attendues : 16 <= premier <= suivants <= 240')
+    chunks = iter(speech_chunks(text, limit=chunk_chars, first_limit=first_chars))
+    started = time.monotonic()
+    first_playback = True
     async with httpx.AsyncClient(timeout=60) as http:
         async def synthesize(chunk):
             async with http.stream('POST', url, json={'input': chunk, 'voice': voice_name}) as response:
@@ -93,6 +114,9 @@ async def speak(voice, text, url, voice_name='ff_siwis'):
                 following = next(chunks, None)
                 # Une seule phrase préchargée pendant la lecture de la précédente.
                 pending = asyncio.create_task(synthesize(following)) if following else None
+                if first_playback:
+                    logger.info('Premier audio prêt : délai TTS=%.3fs', time.monotonic() - started)
+                    first_playback = False
                 await play_wav(voice, payload)
         finally:
             if pending is not None: