瀏覽代碼

Reduire la latence de debut des reponses vocales

Andy Esnard 13 小時之前
父節點
當前提交
fd5078de85
共有 5 個文件被更改,包括 135 次插入 和 7 次删除
  1. 26 1
      VOICE.md
  2. 18 0
      test_voice_stt.py
  3. 56 0
      test_voice_tts.py
  4. 7 2
      voice_stt.py
  5. 28 4
      voice_tts.py

+ 26 - 1
VOICE.md

@@ -14,13 +14,38 @@ Le service TTS est interchangeable via `TTS_URL` (défaut
 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.
+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.

+ 18 - 0
test_voice_stt.py

@@ -130,6 +130,24 @@ class VoiceTests(unittest.IsolatedAsyncioTestCase):
         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()

+ 56 - 0
test_voice_tts.py

@@ -13,6 +13,62 @@ 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 ?'])

+ 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: