voice_tts.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100
  1. """Synthèse HTTP interchangeable et lecture Discord, sans fichiers temporaires."""
  2. import asyncio
  3. import audioop
  4. import re
  5. import wave
  6. from io import BytesIO
  7. import discord
  8. import emoji
  9. import httpx
  10. def speech_chunks(text, limit=240):
  11. text = re.sub(r'```.*?```', ' Bloc de code omis. ', text, flags=re.S)
  12. text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
  13. text = re.sub(r'https?://\S+|<a?:\w+:\d+>|<[@#][!&]?\d+>', ' ', text)
  14. # Le prompt peut émettre des alias personnalisés (:hap:, :desu:, etc.).
  15. # Exiger une lettre évite de confondre ces alias avec une heure 12:30:45.
  16. text = re.sub(r':[A-Za-z_][A-Za-z0-9_+\-]*:', ' ', text)
  17. text = emoji.replace_emoji(text, replace=' ')
  18. text = re.sub(r'[*_`~#>|]', '', text)
  19. text = re.sub(r'[^\S\n]+', ' ', text)
  20. for sentence in re.split(r'(?<=[.!?…])\s+|\n+', text.strip()):
  21. while sentence:
  22. end = len(sentence) if len(sentence) <= limit else sentence.rfind(' ', 0, limit + 1)
  23. if end <= 0:
  24. end = limit
  25. chunk, sentence = sentence[:end].strip(), sentence[end:].strip()
  26. if chunk:
  27. yield chunk
  28. class WavSource(discord.AudioSource):
  29. def __init__(self, payload):
  30. with wave.open(BytesIO(payload), 'rb') as wav:
  31. if wav.getsampwidth() != 2 or wav.getnchannels() not in (1, 2):
  32. raise ValueError('Le TTS doit retourner du WAV PCM 16 bits mono/stéréo')
  33. pcm = wav.readframes(wav.getnframes())
  34. if wav.getnchannels() == 1:
  35. pcm = audioop.tostereo(pcm, 2, 1, 1)
  36. pcm, _ = audioop.ratecv(pcm, 2, 2, wav.getframerate(), 48000, None)
  37. self.audio = BytesIO(pcm)
  38. def read(self):
  39. frame = self.audio.read(3840) # Discord : 20 ms, 48 kHz, stéréo, PCM16.
  40. return frame.ljust(3840, b'\0') if frame else b''
  41. def cleanup(self):
  42. self.audio.close()
  43. async def play_wav(voice, payload):
  44. loop = asyncio.get_running_loop()
  45. done = loop.create_future()
  46. source = WavSource(payload)
  47. def complete(error):
  48. if not done.done():
  49. if error:
  50. done.set_exception(error)
  51. else:
  52. done.set_result(None)
  53. try:
  54. voice.play(source, after=lambda error: loop.call_soon_threadsafe(complete, error))
  55. await done
  56. finally:
  57. if not done.done() or done.cancelled():
  58. voice.stop()
  59. source.cleanup()
  60. async def speak(voice, text, url, voice_name='ff_siwis'):
  61. chunks = iter(speech_chunks(text))
  62. async with httpx.AsyncClient(timeout=60) as http:
  63. async def synthesize(chunk):
  64. async with http.stream('POST', url, json={'input': chunk, 'voice': voice_name}) as response:
  65. response.raise_for_status()
  66. payload = bytearray()
  67. async for data in response.aiter_bytes():
  68. payload.extend(data)
  69. if len(payload) > 8 * 1024 * 1024:
  70. raise ValueError('Réponse audio trop volumineuse')
  71. return bytes(payload)
  72. first = next(chunks, None)
  73. if first is None:
  74. return # Une réponse uniquement composée d'emojis reste silencieuse.
  75. pending = asyncio.create_task(synthesize(first))
  76. try:
  77. while pending is not None:
  78. payload = await pending
  79. following = next(chunks, None)
  80. # Une seule phrase préchargée pendant la lecture de la précédente.
  81. pending = asyncio.create_task(synthesize(following)) if following else None
  82. await play_wav(voice, payload)
  83. finally:
  84. if pending is not None:
  85. pending.cancel()
  86. await asyncio.gather(pending, return_exceptions=True)