voice_tts.py 4.9 KB

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