voice_stt.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  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. import discord
  10. import httpx
  11. from davey import MediaType
  12. from discord.ext import voice_recv
  13. logger = logging.getLogger(__name__)
  14. class STTSink(voice_recv.AudioSink):
  15. def __init__(self, on_phrase, stt_url):
  16. super().__init__()
  17. self.on_phrase = on_phrase
  18. self.stt_url = stt_url
  19. self.loop = asyncio.get_running_loop()
  20. self.lock = threading.Lock()
  21. self.buffers = {}
  22. self.decoders = {}
  23. self.closed = False
  24. self.last_error = 0
  25. self.queue = asyncio.Queue(maxsize=8)
  26. self.flush_task = self.loop.create_task(self.flush_phrases())
  27. self.worker_task = self.loop.create_task(self.transcribe_phrases())
  28. def wants_opus(self):
  29. # L'extension ne déchiffre pas encore DAVE : le faire avant Opus.
  30. return True
  31. def write(self, user, data):
  32. if self.closed or user is None or user.bot or not data.opus or data.packet.is_silence():
  33. return
  34. try:
  35. connection = self.voice_client._connection
  36. opus = bytes(data.opus)
  37. if connection.dave_protocol_version:
  38. if connection.dave_session is None or not connection.dave_session.ready:
  39. return
  40. opus = connection.dave_session.decrypt(user.id, MediaType.audio, opus)
  41. with self.lock:
  42. if self.closed:
  43. return
  44. decoder = self.decoders.get(user.id)
  45. if decoder is None:
  46. decoder = self.decoders[user.id] = discord.opus.Decoder()
  47. pcm = decoder.decode(opus, fec=False)
  48. self.add_pcm(user, pcm, time.monotonic())
  49. except Exception as exc:
  50. # Ne jamais journaliser les paquets audio ou les clés de session.
  51. if time.monotonic() - self.last_error > 30:
  52. logger.warning("Réception vocale impossible : %s", type(exc).__name__)
  53. self.last_error = time.monotonic()
  54. def add_pcm(self, user, pcm, now):
  55. """Appelé sous le verrou ; PCM Discord = 48 kHz, stéréo, 16 bits."""
  56. audible = audioop.rms(pcm, 2) >= 200
  57. if user.id not in self.buffers:
  58. if not audible:
  59. return
  60. self.buffers[user.id] = [user, bytearray(), now]
  61. buffer = self.buffers[user.id]
  62. # Plafond de 20 secondes même si la boucle asyncio est occupée.
  63. available = 48000 * 2 * 2 * 20 - len(buffer[1])
  64. buffer[1].extend(pcm[:available])
  65. if audible:
  66. buffer[2] = now
  67. async def flush_phrases(self):
  68. while True:
  69. await asyncio.sleep(0.1)
  70. now = time.monotonic()
  71. ready = []
  72. with self.lock:
  73. for user_id, (user, pcm, last_voice) in list(self.buffers.items()):
  74. if now - last_voice >= 0.8 or len(pcm) >= 48000 * 4 * 20:
  75. del self.buffers[user_id]
  76. if len(pcm) >= 48000 * 4 * 0.25:
  77. ready.append((user, bytes(pcm)))
  78. for item in ready:
  79. try:
  80. self.queue.put_nowait(item)
  81. except asyncio.QueueFull:
  82. logger.warning("File vocale pleine : une phrase a été ignorée")
  83. async def transcribe_phrases(self):
  84. # Une seule réponse à la fois pour conserver l'ordre des phrases.
  85. async with httpx.AsyncClient(timeout=60) as http:
  86. while not self.closed:
  87. user, pcm = await self.queue.get()
  88. try:
  89. audio = BytesIO()
  90. with wave.open(audio, 'wb') as wav:
  91. wav.setnchannels(2)
  92. wav.setsampwidth(2)
  93. wav.setframerate(48000)
  94. wav.writeframes(pcm)
  95. response = await http.post(
  96. self.stt_url,
  97. files={'file': ('phrase.wav', audio.getvalue(), 'audio/wav')},
  98. )
  99. response.raise_for_status()
  100. text = response.json()['text'].strip()
  101. if text and not self.closed:
  102. await self.on_phrase(user, text)
  103. except asyncio.CancelledError:
  104. raise
  105. except Exception as exc:
  106. logger.warning("Traitement vocal impossible : %s", type(exc).__name__)
  107. finally:
  108. self.queue.task_done()
  109. @voice_recv.AudioSink.listener()
  110. def on_voice_member_disconnect(self, member, ssrc):
  111. if member:
  112. with self.lock:
  113. self.buffers.pop(member.id, None)
  114. self.decoders.pop(member.id, None)
  115. def cleanup(self):
  116. # La bibliothèque appelle cleanup depuis son thread de réception.
  117. with self.lock:
  118. self.closed = True
  119. self.buffers.clear()
  120. self.decoders.clear()
  121. if not self.loop.is_closed():
  122. self.loop.call_soon_threadsafe(self.flush_task.cancel)
  123. self.loop.call_soon_threadsafe(self.worker_task.cancel)
  124. async def close(self):
  125. self.cleanup()
  126. self.flush_task.cancel()
  127. self.worker_task.cancel()
  128. await asyncio.gather(self.flush_task, self.worker_task, return_exceptions=True)
  129. while not self.queue.empty():
  130. self.queue.get_nowait()
  131. self.queue.task_done()