소스 검색

Ajouter le mode vocal TTS et filtrer les emojis prononces

Andy Esnard 12 시간 전
부모
커밋
0cec775d05
14개의 변경된 파일과 698개의 추가작업 그리고 21개의 파일을 삭제
  1. 29 0
      VOICE.md
  2. 59 16
      chatbot.py
  3. 1 0
      requirements.txt
  4. 8 3
      test_voice_stt.py
  5. 115 0
      test_voice_tts.py
  6. 13 0
      tts-service/Dockerfile
  7. 34 0
      tts-service/README.md
  8. 74 0
      tts-service/app.py
  9. 28 0
      tts-service/check_service.py
  10. 118 0
      tts-service/openshift.yaml
  11. 97 0
      tts-service/requirements-deployed.txt
  12. 4 0
      tts-service/requirements.txt
  13. 18 2
      voice_stt.py
  14. 100 0
      voice_tts.py

+ 29 - 0
VOICE.md

@@ -0,0 +1,29 @@
+# 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.

+ 59 - 16
chatbot.py

@@ -23,6 +23,7 @@ from charset_normalizer import from_bytes
 from discord.ext import tasks
 from discord.ext import voice_recv
 from voice_stt import STTSink
+from voice_tts import speak
 
 # Charger les variables d'environnement depuis le fichier .env
 load_dotenv()
@@ -45,13 +46,15 @@ WEB_SEARCH_ENABLED = os.getenv('WEB_SEARCH_ENABLED', 'true').strip().lower() ==
 WEB_SEARCH_RESULT_COUNT = int(os.getenv('WEB_SEARCH_RESULT_COUNT', '3'))
 VOICE_ENABLED = os.getenv('VOICE_ENABLED', 'false').strip().lower() == 'true'
 STT_URL = os.getenv('STT_URL', 'http://whisper-stt-chatbot:8000/transcribe')
+TTS_URL = os.getenv('TTS_URL', 'http://kokoro-tts:8000/v1/audio/speech')
+TTS_VOICE = os.getenv('TTS_VOICE', 'ff_siwis')
 if WEB_SEARCH_RESULT_COUNT < 1:
     raise ValueError('WEB_SEARCH_RESULT_COUNT doit être supérieur ou égal à 1')
 
 # Initialiser le client OpenAI asynchrone ici
 openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY, base_url=URL_OPENAI_API)
 
-BOT_VERSION = "3.3.1"
+BOT_VERSION = "3.4.0"
 
 # Vérifier la version de Discord
 if not sys.version_info[:2] >= (3, 15):
@@ -565,6 +568,13 @@ async def handle_status(message):
 
 
 async def handle_voice_command(message):
+    command = message.content.strip().lower().split()
+    mode = command[1] if len(command) == 2 else 'texte'
+    if mode == 'text':
+        mode = 'texte'
+    if len(command) > 2 or mode not in ('texte', 'audio'):
+        await message.channel.send('Utilisation : !join texte, !join audio ou !quit.')
+        return
     if not VOICE_ENABLED:
         await message.channel.send("Le vocal est désactivé (VOICE_ENABLED=false).")
         return
@@ -591,40 +601,73 @@ async def handle_voice_command(message):
         async def display_transcription(user, text):
             heard = discord.utils.escape_markdown(text).replace('\n', ' ')
             name = discord.utils.escape_markdown(user.display_name)
+            receipts = []
             for part in split_message(f"🎙 {name} : {heard}", max_length=1900):
-                await message.channel.send('> ' + part, allowed_mentions=discord.AllowedMentions.none())
+                sent = await message.channel.send('⚪ > ' + part, allowed_mentions=discord.AllowedMentions.none())
+                receipts.append((sent, part))
+            return receipts
+
+        async def update_transcriptions(receipts, state):
+            led = {'processing': '🟠', 'done': '🟢', 'error': '🔴'}[state]
+            for receipt in receipts:
+                for sent, part in receipt or []:
+                    try:
+                        await sent.edit(content=led + ' > ' + part,
+                                        allowed_mentions=discord.AllowedMentions.none())
+                    except discord.HTTPException:
+                        logger.warning('Impossible de modifier une transcription vocale')
 
         async def respond_to_voice(batch):
             text = "\n".join(f"{user.display_name} : {phrase}" for user, phrase in batch)
-            async with message.channel.typing():
-                try:
-                    logger.info("Vocal : transcription transmise au LLM (%d caractères)", len(text))
-                    result = await call_openai_api(text, "Conversation vocale", voice=True)
-                    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."):
+            try:
+                logger.info("Vocal : transcription transmise au LLM (%d caractères), mode=%s", len(text), mode)
+                result = await call_openai_api(text, "Conversation vocale", voice=True)
+                reply = filter_message(result.choices[0].message.content or '') if result else ''
+                if not reply:
+                    raise RuntimeError('Réponse du modèle vide')
+                if mode == 'audio':
+                    await speak(voice, reply, TTS_URL, TTS_VOICE)
+                else:
+                    for part in split_message(transform_emote(reply, True)):
                         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__)
+                logger.info('Réponse vocale terminée, mode=%s', mode)
+            except Exception as exc:
+                logger.warning("Réponse au vocal impossible : %s", type(exc).__name__)
+                if mode == 'texte':
                     await message.channel.send("Désolé, je n'ai pas pu répondre à cette phrase.")
+                else:
+                    # Aucun contenu de conversation publié dans le salon texte.
+                    try:
+                        await speak(voice, "Désolée, je n'ai pas pu répondre.", TTS_URL, TTS_VOICE)
+                    except Exception:
+                        logger.warning('Annonce audio indisponible')
+                raise
 
         sink = None
         try:
             async with httpx.AsyncClient(timeout=5) as stt_client:
                 health = await stt_client.get(STT_URL.rsplit('/', 1)[0] + '/healthz')
                 health.raise_for_status()
+                if mode == 'audio':
+                    health = await stt_client.get(TTS_URL.rsplit('/v1/', 1)[0] + '/healthz')
+                    health.raise_for_status()
             voice = await message.author.voice.channel.connect(
                 cls=voice_recv.VoiceRecvClient, self_deaf=False,
             )
-            sink = STTSink(respond_to_voice, STT_URL, on_transcription=display_transcription)
+            sink = STTSink(respond_to_voice, STT_URL,
+                           on_transcription=display_transcription if mode == 'texte' else None,
+                           on_batch_status=update_transcriptions if mode == 'texte' else None)
+            sink.mode = mode
             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 à une phrase à la fois. Les transcriptions s'affichent au fil des phrases ; celles reçues pendant ma réponse seront regroupées pour la suivante. !quit pour arrêter.")
+            await message.channel.send(
+                "🔊 Je réponds dans le vocal, sans transcription ici. !quit pour arrêter."
+                if mode == 'audio' else
+                "🎧 Mode texte : ⚪ en attente · 🟠 en traitement · 🟢 traité · 🔴 échec. !quit pour arrêter.")
         except Exception as exc:
             logger.warning("Connexion vocale impossible : %s", type(exc).__name__)
             if sink:
@@ -632,7 +675,7 @@ async def handle_voice_command(message):
             voice = message.guild.voice_client
             if voice:
                 await voice.disconnect(force=True)
-            await message.channel.send("Impossible de démarrer le vocal. Vérifie le service STT, mes permissions et les logs du bot.")
+            await message.channel.send("Impossible de démarrer le vocal. Vérifie les services STT/TTS, mes permissions et les logs du bot.")
 
 
 @client_discord.event
@@ -662,7 +705,7 @@ async def on_message(message):
     if user_text.lower() == '!status':
         await handle_status(message)
         return
-    if user_text.lower() in ('!join', '!quit'):
+    if user_text.lower().split(maxsplit=1)[0:1] in (['!join'], ['!quit']):
         await handle_voice_command(message)
         return
     file_content = None

+ 1 - 0
requirements.txt

@@ -1,5 +1,6 @@
 urllib3==2.6.3
 httpx==0.28.1
+emoji==2.15.0
 openai==2.21.0
 discord.py[voice]==2.7.1
 discord-ext-voice-recv==0.5.2a179

+ 8 - 3
test_voice_stt.py

@@ -189,7 +189,7 @@ class CommandTests(unittest.IsolatedAsyncioTestCase):
         args = create.call_args.kwargs
         self.assertEqual(args['messages'][1:-1], history[-5:])
         self.assertEqual(args['extra_body']['options']['think'], False)
-        self.assertIn('une à trois phrases', args['messages'][0]['content'])
+        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
@@ -247,13 +247,18 @@ class CommandTests(unittest.IsolatedAsyncioTestCase):
         await namespace['handle_voice_command'](message)
         voice.listen.assert_called_once_with(sink, after=sink.receiver_stopped)
         callback = make_sink.call_args.args[0]
-        await make_sink.call_args.kwargs['on_transcription'](SimpleNamespace(display_name='Alice'), 'Bonjour')
+        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.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)

+ 115 - 0
test_voice_tts.py

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

+ 13 - 0
tts-service/Dockerfile

@@ -0,0 +1,13 @@
+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"]

+ 34 - 0
tts-service/README.md

@@ -0,0 +1,34 @@
+# 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.

+ 74 - 0
tts-service/app.py

@@ -0,0 +1,74 @@
+"""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}'})

+ 28 - 0
tts-service/check_service.py

@@ -0,0 +1,28 @@
+"""À 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')

+ 118 - 0
tts-service/openshift.yaml

@@ -0,0 +1,118 @@
+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

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

@@ -0,0 +1,97 @@
+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

+ 4 - 0
tts-service/requirements.txt

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

+ 18 - 2
voice_stt.py

@@ -17,10 +17,12 @@ logger = logging.getLogger(__name__)
 
 
 class STTSink(voice_recv.AudioSink):
-    def __init__(self, on_phrase, stt_url, on_transcription=None):
+    def __init__(self, on_phrase, stt_url, on_transcription=None, on_batch_status=None):
         super().__init__()
         self.on_phrase = on_phrase
         self.on_transcription = on_transcription
+        self.on_batch_status = on_batch_status
+        self.pending_receipts = []
         self.pending = []
         self.pending_event = asyncio.Event()
         self.stt_url = stt_url
@@ -192,13 +194,15 @@ class STTSink(voice_recv.AudioSink):
                     if not text:
                         self.count('empty_transcription')
                     if text and not self.closed:
+                        receipt = None
                         try:
                             stage = 'affichage'
                             if self.on_transcription:
-                                await self.on_transcription(user, text)
+                                receipt = await self.on_transcription(user, text)
                         finally:
                             if not self.closed:
                                 self.pending.append((user, text))
+                                self.pending_receipts.append(receipt)
                                 self.pending_event.set()
                 except asyncio.CancelledError:
                     raise
@@ -226,20 +230,31 @@ class STTSink(voice_recv.AudioSink):
                 return
             self.pending_event.clear()
             batch, self.pending = self.pending, []
+            receipts, self.pending_receipts = self.pending_receipts, []
             if not batch:
                 continue
             started = time.monotonic()
             self.responding = True
             try:
+                await self.report_status(receipts, 'processing')
                 await self.on_phrase(batch)
+                await self.report_status(receipts, 'done')
             except asyncio.CancelledError:
                 raise
             except Exception as exc:
+                await self.report_status(receipts, 'error')
                 logger.warning("Réponse au lot vocal impossible : %s", type(exc).__name__)
             finally:
                 self.last_reply_seconds = time.monotonic() - started
                 self.responding = False
 
+    async def report_status(self, receipts, state):
+        if self.on_batch_status:
+            try:
+                await self.on_batch_status(receipts, state)
+            except Exception as exc:
+                logger.warning('Mise à jour du statut impossible : %s', type(exc).__name__)
+
     @voice_recv.AudioSink.listener()
     def on_voice_member_disconnect(self, member, ssrc):
         if member:
@@ -256,6 +271,7 @@ class STTSink(voice_recv.AudioSink):
             self.buffers.clear()
             self.decoders.clear()
             self.pending.clear()
+            self.pending_receipts.clear()
         self.log_diagnostic('arrêt')
         if not self.loop.is_closed():
             self.loop.call_soon_threadsafe(self.flush_task.cancel)

+ 100 - 0
voice_tts.py

@@ -0,0 +1,100 @@
+"""Synthèse HTTP interchangeable et lecture Discord, sans fichiers temporaires."""
+import asyncio
+import audioop
+import re
+import wave
+from io import BytesIO
+
+import discord
+import emoji
+import httpx
+
+
+def speech_chunks(text, limit=240):
+    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)
+    # Le prompt peut émettre des alias personnalisés (:hap:, :desu:, etc.).
+    # Exiger une lettre évite de confondre ces alias avec une heure 12:30:45.
+    text = re.sub(r':[A-Za-z_][A-Za-z0-9_+\-]*:', ' ', text)
+    text = emoji.replace_emoji(text, replace=' ')
+    text = re.sub(r'[*_`~#>|]', '', text)
+    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)
+            if end <= 0:
+                end = limit
+            chunk, sentence = sentence[:end].strip(), sentence[end:].strip()
+            if chunk:
+                yield chunk
+
+
+class WavSource(discord.AudioSource):
+    def __init__(self, payload):
+        with wave.open(BytesIO(payload), 'rb') as wav:
+            if wav.getsampwidth() != 2 or wav.getnchannels() not in (1, 2):
+                raise ValueError('Le TTS doit retourner du WAV PCM 16 bits mono/stéréo')
+            pcm = wav.readframes(wav.getnframes())
+            if wav.getnchannels() == 1:
+                pcm = audioop.tostereo(pcm, 2, 1, 1)
+            pcm, _ = audioop.ratecv(pcm, 2, 2, wav.getframerate(), 48000, None)
+        self.audio = BytesIO(pcm)
+
+    def read(self):
+        frame = self.audio.read(3840)  # Discord : 20 ms, 48 kHz, stéréo, PCM16.
+        return frame.ljust(3840, b'\0') if frame else b''
+
+    def cleanup(self):
+        self.audio.close()
+
+
+async def play_wav(voice, payload):
+    loop = asyncio.get_running_loop()
+    done = loop.create_future()
+    source = WavSource(payload)
+
+    def complete(error):
+        if not done.done():
+            if error:
+                done.set_exception(error)
+            else:
+                done.set_result(None)
+
+    try:
+        voice.play(source, after=lambda error: loop.call_soon_threadsafe(complete, error))
+        await done
+    finally:
+        if not done.done() or done.cancelled():
+            voice.stop()
+        source.cleanup()
+
+
+async def speak(voice, text, url, voice_name='ff_siwis'):
+    chunks = iter(speech_chunks(text))
+    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:
+                response.raise_for_status()
+                payload = bytearray()
+                async for data in response.aiter_bytes():
+                    payload.extend(data)
+                    if len(payload) > 8 * 1024 * 1024:
+                        raise ValueError('Réponse audio trop volumineuse')
+                return bytes(payload)
+
+        first = next(chunks, None)
+        if first is None:
+            return  # Une réponse uniquement composée d'emojis reste silencieuse.
+        pending = asyncio.create_task(synthesize(first))
+        try:
+            while pending is not None:
+                payload = await pending
+                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
+                await play_wav(voice, payload)
+        finally:
+            if pending is not None:
+                pending.cancel()
+                await asyncio.gather(pending, return_exceptions=True)