voice_stt.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. """Réception Discord : une phrase par personne, envoyée au serveur Whisper."""
  2. import asyncio
  3. import audioop
  4. import logging
  5. import threading
  6. import time
  7. import wave
  8. from io import BytesIO
  9. from collections import Counter
  10. import discord
  11. import httpx
  12. from davey import MediaType
  13. from discord.ext import voice_recv
  14. logger = logging.getLogger(__name__)
  15. class STTSink(voice_recv.AudioSink):
  16. def __init__(self, on_phrase, stt_url):
  17. super().__init__()
  18. self.on_phrase = on_phrase
  19. self.stt_url = stt_url
  20. self.loop = asyncio.get_running_loop()
  21. self.lock = threading.Lock()
  22. self.buffers = {}
  23. self.busy = False
  24. self.ignored_speakers = {}
  25. self.decoders = {}
  26. self.closed = False
  27. self.last_error = 0
  28. self.stats_lock = threading.Lock()
  29. self.stats = Counter(audio=0, rtcp=0, pcm=0, audible=0, phrases=0, stt=0)
  30. self.last_diagnostic = time.monotonic()
  31. # Uniquement le transfert vers le worker, aucune phrase en attente.
  32. self.queue = asyncio.Queue(maxsize=1)
  33. self.flush_task = self.loop.create_task(self.flush_phrases())
  34. self.worker_task = self.loop.create_task(self.transcribe_phrases())
  35. self.flush_task.add_done_callback(self.task_finished)
  36. self.worker_task.add_done_callback(self.task_finished)
  37. def task_finished(self, task):
  38. if not task.cancelled() and task.exception() is not None:
  39. logger.error("Tâche vocale arrêtée : %s erreur=%s",
  40. task.get_coro().__name__, type(task.exception()).__name__)
  41. def wants_opus(self):
  42. # L'extension ne déchiffre pas encore DAVE : le faire avant Opus.
  43. return True
  44. def count(self, name):
  45. with self.stats_lock:
  46. self.stats[name] += 1
  47. def log_diagnostic(self, reason='10s'):
  48. with self.stats_lock:
  49. stats = dict(self.stats)
  50. voice = getattr(self, 'voice_client', None)
  51. connection = getattr(voice, '_connection', None)
  52. session = getattr(connection, 'dave_session', None)
  53. logger.info(
  54. "Diagnostic vocal (%s, compteurs cumulés) : %s file=%d dave_version=%s dave_ready=%s",
  55. reason, stats, self.queue.qsize(),
  56. getattr(connection, 'dave_protocol_version', None), getattr(session, 'ready', False),
  57. )
  58. @voice_recv.AudioSink.listener()
  59. def on_rtcp_packet(self, packet, guild):
  60. self.count('rtcp')
  61. @voice_recv.AudioSink.listener()
  62. def on_voice_member_speaking_start(self, member):
  63. self.count('speaking_start')
  64. def receiver_stopped(self, error):
  65. if error:
  66. logger.error("Thread de réception vocale arrêté : %s", type(error).__name__)
  67. else:
  68. logger.info("Thread de réception vocale arrêté normalement")
  69. def write(self, user, data):
  70. if self.closed:
  71. return
  72. self.count('audio')
  73. for ignored, reason in ((user is None, 'unknown_user'),
  74. (getattr(user, 'bot', False), 'bot'),
  75. (not data.opus, 'empty_opus'),
  76. (data.packet.is_silence(), 'silence_packet')):
  77. if ignored:
  78. self.count(reason)
  79. return
  80. stage = 'DAVE'
  81. try:
  82. connection = self.voice_client._connection
  83. opus = bytes(data.opus)
  84. if connection.dave_protocol_version:
  85. if connection.dave_session is None or not connection.dave_session.ready:
  86. self.count('dave_not_ready')
  87. return
  88. opus = connection.dave_session.decrypt(user.id, MediaType.audio, opus)
  89. stage = 'Opus'
  90. with self.lock:
  91. if self.closed:
  92. return
  93. decoder = self.decoders.get(user.id)
  94. if decoder is None:
  95. decoder = self.decoders[user.id] = discord.opus.Decoder()
  96. pcm = decoder.decode(opus, fec=False)
  97. self.count('pcm')
  98. self.add_pcm(user, pcm, time.monotonic())
  99. except Exception as exc:
  100. self.count(stage + '_errors')
  101. # Ne jamais journaliser les paquets audio ou les clés de session.
  102. if time.monotonic() - self.last_error > 30:
  103. logger.warning("Réception vocale impossible à l'étape %s : %s", stage, type(exc).__name__)
  104. self.last_error = time.monotonic()
  105. def add_pcm(self, user, pcm, now):
  106. """Appelé sous le verrou ; PCM Discord = 48 kHz, stéréo, 16 bits."""
  107. audible = audioop.rms(pcm, 2) >= 200
  108. self.count('audible' if audible else 'below_threshold')
  109. last_ignored = self.ignored_speakers.get(user.id)
  110. if self.busy or (last_ignored is not None and now - last_ignored < 0.8):
  111. if audible:
  112. self.ignored_speakers[user.id] = now
  113. self.count('busy_ignored')
  114. return
  115. self.ignored_speakers.pop(user.id, None)
  116. if user.id not in self.buffers:
  117. if not audible:
  118. return
  119. self.buffers[user.id] = [user, bytearray(), now]
  120. buffer = self.buffers[user.id]
  121. # Plafond de 20 secondes même si la boucle asyncio est occupée.
  122. available = 48000 * 2 * 2 * 20 - len(buffer[1])
  123. buffer[1].extend(pcm[:available])
  124. if audible:
  125. buffer[2] = now
  126. async def flush_phrases(self):
  127. while True:
  128. await asyncio.sleep(0.1)
  129. now = time.monotonic()
  130. if now - self.last_diagnostic >= 10:
  131. self.log_diagnostic()
  132. self.last_diagnostic = now
  133. ready = []
  134. with self.lock:
  135. if self.busy:
  136. continue
  137. for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
  138. if now - last_voice >= 0.8 or len(pcm) >= 48000 * 4 * 20:
  139. del self.buffers[user_id]
  140. if len(pcm) >= 48000 * 4 * 0.25:
  141. ready.append((user, bytes(pcm)))
  142. self.busy = True
  143. # Oublier aussi les phrases déjà commencées par les autres.
  144. self.ignored_speakers.update(
  145. {uid: buffer[2] for uid, buffer in self.buffers.items()})
  146. self.buffers.clear()
  147. break
  148. else:
  149. self.count('too_short')
  150. for item in ready:
  151. try:
  152. self.queue.put_nowait(item)
  153. self.count('phrases')
  154. logger.info("Phrase vocale sélectionnée : durée=%.2fs transfert=%d",
  155. len(item[1]) / (48000 * 4), self.queue.qsize())
  156. except asyncio.QueueFull:
  157. self.count('queue_full')
  158. logger.warning("File vocale pleine : une phrase a été ignorée")
  159. async def transcribe_phrases(self):
  160. # Rester occupé jusqu'à la fin de la transcription ET de la réponse.
  161. async with httpx.AsyncClient(timeout=60) as http:
  162. while not self.closed:
  163. user, pcm = await self.queue.get()
  164. stage = 'STT'
  165. started = time.monotonic()
  166. try:
  167. audio = BytesIO()
  168. with wave.open(audio, 'wb') as wav:
  169. wav.setnchannels(2)
  170. wav.setsampwidth(2)
  171. wav.setframerate(48000)
  172. wav.writeframes(pcm)
  173. self.count('stt')
  174. logger.info("Envoi STT : durée audio=%.2fs", len(pcm) / (48000 * 4))
  175. response = await http.post(
  176. self.stt_url,
  177. files={'file': ('phrase.wav', audio.getvalue(), 'audio/wav')},
  178. )
  179. response.raise_for_status()
  180. text = response.json()['text'].strip()
  181. logger.info("Retour STT : HTTP=%d durée=%.2fs caractères=%d",
  182. response.status_code, time.monotonic() - started, len(text))
  183. if not text:
  184. self.count('empty_transcription')
  185. if text and not self.closed:
  186. stage = 'réponse'
  187. await self.on_phrase(user, text)
  188. except asyncio.CancelledError:
  189. raise
  190. except Exception as exc:
  191. self.count(stage + '_errors')
  192. status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
  193. logger.warning("Traitement vocal impossible : étape=%s erreur=%s HTTP=%s durée=%.2fs",
  194. stage, type(exc).__name__, status, time.monotonic() - started)
  195. finally:
  196. with self.lock:
  197. self.busy = False
  198. self.queue.task_done()
  199. @voice_recv.AudioSink.listener()
  200. def on_voice_member_disconnect(self, member, ssrc):
  201. if member:
  202. with self.lock:
  203. self.buffers.pop(member.id, None)
  204. self.decoders.pop(member.id, None)
  205. self.ignored_speakers.pop(member.id, None)
  206. def cleanup(self):
  207. # La bibliothèque appelle cleanup depuis son thread de réception.
  208. with self.lock:
  209. if self.closed:
  210. return
  211. self.closed = True
  212. self.buffers.clear()
  213. self.decoders.clear()
  214. self.ignored_speakers.clear()
  215. self.log_diagnostic('arrêt')
  216. if not self.loop.is_closed():
  217. self.loop.call_soon_threadsafe(self.flush_task.cancel)
  218. self.loop.call_soon_threadsafe(self.worker_task.cancel)
  219. async def close(self):
  220. self.cleanup()
  221. self.flush_task.cancel()
  222. self.worker_task.cancel()
  223. await asyncio.gather(self.flush_task, self.worker_task, return_exceptions=True)
  224. while not self.queue.empty():
  225. self.queue.get_nowait()
  226. self.queue.task_done()