voice_stt.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. """Réception Discord : une phrase par personne, envoyée au serveur Whisper."""
  2. import asyncio
  3. import audioop
  4. import logging
  5. import os
  6. import threading
  7. import time
  8. import wave
  9. from io import BytesIO
  10. from collections import Counter
  11. import discord
  12. import httpx
  13. from davey import MediaType
  14. from discord.ext import voice_recv
  15. logger = logging.getLogger(__name__)
  16. class STTSink(voice_recv.AudioSink):
  17. def __init__(self, on_phrase, stt_url, on_transcription=None, on_batch_status=None):
  18. super().__init__()
  19. self.phrase_silence = float(os.getenv('VOICE_PHRASE_SILENCE', '0.5'))
  20. self.reply_silence = float(os.getenv('VOICE_REPLY_SILENCE', '1.0'))
  21. if not (0.25 <= self.phrase_silence <= self.reply_silence <= 5):
  22. raise ValueError('Silences vocaux attendus : 0.25 <= phrase <= réponse <= 5 secondes')
  23. self.on_phrase = on_phrase
  24. self.on_transcription = on_transcription
  25. self.on_batch_status = on_batch_status
  26. self.pending_receipts = []
  27. self.pending = []
  28. self.pending_event = asyncio.Event()
  29. self.stt_url = stt_url
  30. self.loop = asyncio.get_running_loop()
  31. self.lock = threading.Lock()
  32. self.buffers = {}
  33. self.decoders = {}
  34. self.closed = False
  35. self.responding = False
  36. self.last_stt_seconds = None
  37. self.last_reply_seconds = None
  38. self.last_voice = 0.0
  39. self.last_error = 0
  40. self.stats_lock = threading.Lock()
  41. self.stats = Counter(audio=0, rtcp=0, pcm=0, audible=0, phrases=0, stt=0)
  42. self.last_diagnostic = time.monotonic()
  43. # File audio bornée si Whisper ne suit plus le rythme du vocal.
  44. self.queue = asyncio.Queue(maxsize=32)
  45. self.flush_task = self.loop.create_task(self.flush_phrases())
  46. self.worker_task = self.loop.create_task(self.transcribe_phrases())
  47. self.reply_task = self.loop.create_task(self.respond_batches())
  48. self.reply_task.add_done_callback(self.task_finished)
  49. self.flush_task.add_done_callback(self.task_finished)
  50. self.worker_task.add_done_callback(self.task_finished)
  51. def task_finished(self, task):
  52. if not task.cancelled() and task.exception() is not None:
  53. logger.error("Tâche vocale arrêtée : %s erreur=%s",
  54. task.get_coro().__name__, type(task.exception()).__name__)
  55. def wants_opus(self):
  56. # L'extension ne déchiffre pas encore DAVE : le faire avant Opus.
  57. return True
  58. def count(self, name):
  59. with self.stats_lock:
  60. self.stats[name] += 1
  61. def log_diagnostic(self, reason='10s'):
  62. with self.stats_lock:
  63. stats = dict(self.stats)
  64. voice = getattr(self, 'voice_client', None)
  65. connection = getattr(voice, '_connection', None)
  66. session = getattr(connection, 'dave_session', None)
  67. logger.info(
  68. "Diagnostic vocal (%s, compteurs cumulés) : %s file=%d dave_version=%s dave_ready=%s",
  69. reason, stats, self.queue.qsize(),
  70. getattr(connection, 'dave_protocol_version', None), getattr(session, 'ready', False),
  71. )
  72. @voice_recv.AudioSink.listener()
  73. def on_rtcp_packet(self, packet, guild):
  74. self.count('rtcp')
  75. @voice_recv.AudioSink.listener()
  76. def on_voice_member_speaking_start(self, member):
  77. self.count('speaking_start')
  78. def receiver_stopped(self, error):
  79. if error:
  80. logger.error("Thread de réception vocale arrêté : %s", type(error).__name__)
  81. else:
  82. logger.info("Thread de réception vocale arrêté normalement")
  83. def write(self, user, data):
  84. if self.closed:
  85. return
  86. self.count('audio')
  87. for ignored, reason in ((user is None, 'unknown_user'),
  88. (getattr(user, 'bot', False), 'bot'),
  89. (not data.opus, 'empty_opus'),
  90. (data.packet.is_silence(), 'silence_packet')):
  91. if ignored:
  92. self.count(reason)
  93. return
  94. stage = 'DAVE'
  95. try:
  96. connection = self.voice_client._connection
  97. opus = bytes(data.opus)
  98. if connection.dave_protocol_version:
  99. if connection.dave_session is None or not connection.dave_session.ready:
  100. self.count('dave_not_ready')
  101. return
  102. opus = connection.dave_session.decrypt(user.id, MediaType.audio, opus)
  103. stage = 'Opus'
  104. with self.lock:
  105. if self.closed:
  106. return
  107. decoder = self.decoders.get(user.id)
  108. if decoder is None:
  109. decoder = self.decoders[user.id] = discord.opus.Decoder()
  110. pcm = decoder.decode(opus, fec=False)
  111. self.count('pcm')
  112. self.add_pcm(user, pcm, time.monotonic())
  113. except Exception as exc:
  114. self.count(stage + '_errors')
  115. # Ne jamais journaliser les paquets audio ou les clés de session.
  116. if time.monotonic() - self.last_error > 30:
  117. logger.warning("Réception vocale impossible à l'étape %s : %s", stage, type(exc).__name__)
  118. self.last_error = time.monotonic()
  119. def add_pcm(self, user, pcm, now):
  120. """Appelé sous le verrou ; PCM Discord = 48 kHz, stéréo, 16 bits."""
  121. audible = audioop.rms(pcm, 2) >= 200
  122. self.count('audible' if audible else 'below_threshold')
  123. if user.id not in self.buffers:
  124. if not audible:
  125. return
  126. self.buffers[user.id] = [user, bytearray(), now]
  127. buffer = self.buffers[user.id]
  128. # Plafond de 20 secondes même si la boucle asyncio est occupée.
  129. available = 48000 * 2 * 2 * 20 - len(buffer[1])
  130. buffer[1].extend(pcm[:available])
  131. if audible:
  132. buffer[2] = now
  133. self.last_voice = now
  134. async def flush_phrases(self):
  135. while True:
  136. await asyncio.sleep(0.1)
  137. now = time.monotonic()
  138. if now - self.last_diagnostic >= 10:
  139. self.log_diagnostic()
  140. self.last_diagnostic = now
  141. ready = []
  142. with self.lock:
  143. for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
  144. if now - last_voice >= self.phrase_silence or len(pcm) >= 48000 * 4 * 20:
  145. del self.buffers[user_id]
  146. if len(pcm) >= 48000 * 4 * 0.25:
  147. ready.append((user, bytes(pcm)))
  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. # La transcription continue pendant les réponses du LLM.
  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. self.last_stt_seconds = time.monotonic() - started
  182. logger.info("Retour STT : HTTP=%d durée=%.2fs caractères=%d",
  183. response.status_code, time.monotonic() - started, len(text))
  184. if not text:
  185. self.count('empty_transcription')
  186. if text and not self.closed:
  187. receipt = None
  188. try:
  189. stage = 'affichage'
  190. if self.on_transcription:
  191. receipt = await self.on_transcription(user, text)
  192. finally:
  193. if not self.closed:
  194. self.pending.append((user, text))
  195. self.pending_receipts.append(receipt)
  196. self.pending_event.set()
  197. except asyncio.CancelledError:
  198. raise
  199. except Exception as exc:
  200. self.count(stage + '_errors')
  201. status = exc.response.status_code if isinstance(exc, httpx.HTTPStatusError) else None
  202. logger.warning("Traitement vocal impossible : étape=%s erreur=%s HTTP=%s durée=%.2fs",
  203. stage, type(exc).__name__, status, time.monotonic() - started)
  204. finally:
  205. self.queue.task_done()
  206. async def respond_batches(self):
  207. while not self.closed:
  208. await self.pending_event.wait()
  209. # L'affichage STT continue ; seule la réponse attend le silence global.
  210. while not self.closed:
  211. await self.queue.join()
  212. with self.lock:
  213. quiet = time.monotonic() - self.last_voice >= self.reply_silence
  214. buffered = bool(self.buffers)
  215. if quiet and not buffered and self.queue.empty():
  216. break
  217. await asyncio.sleep(0.1)
  218. if self.closed:
  219. return
  220. self.pending_event.clear()
  221. batch, self.pending = self.pending, []
  222. receipts, self.pending_receipts = self.pending_receipts, []
  223. if not batch:
  224. continue
  225. started = time.monotonic()
  226. self.responding = True
  227. try:
  228. await self.report_status(receipts, 'processing')
  229. await self.on_phrase(batch)
  230. await self.report_status(receipts, 'done')
  231. except asyncio.CancelledError:
  232. raise
  233. except Exception as exc:
  234. await self.report_status(receipts, 'error')
  235. logger.warning("Réponse au lot vocal impossible : %s", type(exc).__name__)
  236. finally:
  237. self.last_reply_seconds = time.monotonic() - started
  238. self.responding = False
  239. async def report_status(self, receipts, state):
  240. if self.on_batch_status:
  241. try:
  242. await self.on_batch_status(receipts, state)
  243. except Exception as exc:
  244. logger.warning('Mise à jour du statut impossible : %s', type(exc).__name__)
  245. @voice_recv.AudioSink.listener()
  246. def on_voice_member_disconnect(self, member, ssrc):
  247. if member:
  248. with self.lock:
  249. self.buffers.pop(member.id, None)
  250. self.decoders.pop(member.id, None)
  251. def cleanup(self):
  252. # La bibliothèque appelle cleanup depuis son thread de réception.
  253. with self.lock:
  254. if self.closed:
  255. return
  256. self.closed = True
  257. self.buffers.clear()
  258. self.decoders.clear()
  259. self.pending.clear()
  260. self.pending_receipts.clear()
  261. self.log_diagnostic('arrêt')
  262. if not self.loop.is_closed():
  263. self.loop.call_soon_threadsafe(self.flush_task.cancel)
  264. self.loop.call_soon_threadsafe(self.worker_task.cancel)
  265. self.loop.call_soon_threadsafe(self.reply_task.cancel)
  266. async def close(self):
  267. self.cleanup()
  268. self.flush_task.cancel()
  269. self.worker_task.cancel()
  270. self.reply_task.cancel()
  271. await asyncio.gather(self.flush_task, self.worker_task, self.reply_task, return_exceptions=True)
  272. while not self.queue.empty():
  273. self.queue.get_nowait()
  274. self.queue.task_done()