"""Synthèse HTTP interchangeable et lecture Discord, sans fichiers temporaires.""" import asyncio import audioop import re import wave from io import BytesIO import discord import emoji import httpx def speech_chunks(text, limit=240): text = re.sub(r'```.*?```', ' Bloc de code omis. ', text, flags=re.S) text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text) text = re.sub(r'https?://\S+||<[@#][!&]?\d+>', ' ', text) # Le prompt peut émettre des alias personnalisés (:hap:, :desu:, etc.). # Exiger une lettre évite de confondre ces alias avec une heure 12:30:45. text = re.sub(r':[A-Za-z_][A-Za-z0-9_+\-]*:', ' ', text) text = emoji.replace_emoji(text, replace=' ') text = re.sub(r'[*_`~#>|]', '', text) text = re.sub(r'[^\S\n]+', ' ', text) for sentence in re.split(r'(?<=[.!?…])\s+|\n+', text.strip()): while sentence: end = len(sentence) if len(sentence) <= limit else sentence.rfind(' ', 0, limit + 1) if end <= 0: end = limit chunk, sentence = sentence[:end].strip(), sentence[end:].strip() if chunk: yield chunk class WavSource(discord.AudioSource): def __init__(self, payload): with wave.open(BytesIO(payload), 'rb') as wav: if wav.getsampwidth() != 2 or wav.getnchannels() not in (1, 2): raise ValueError('Le TTS doit retourner du WAV PCM 16 bits mono/stéréo') pcm = wav.readframes(wav.getnframes()) if wav.getnchannels() == 1: pcm = audioop.tostereo(pcm, 2, 1, 1) pcm, _ = audioop.ratecv(pcm, 2, 2, wav.getframerate(), 48000, None) self.audio = BytesIO(pcm) def read(self): frame = self.audio.read(3840) # Discord : 20 ms, 48 kHz, stéréo, PCM16. return frame.ljust(3840, b'\0') if frame else b'' def cleanup(self): self.audio.close() async def play_wav(voice, payload): loop = asyncio.get_running_loop() done = loop.create_future() source = WavSource(payload) def complete(error): if not done.done(): if error: done.set_exception(error) else: done.set_result(None) try: voice.play(source, after=lambda error: loop.call_soon_threadsafe(complete, error)) await done finally: if not done.done() or done.cancelled(): voice.stop() source.cleanup() async def speak(voice, text, url, voice_name='ff_siwis'): chunks = iter(speech_chunks(text)) async with httpx.AsyncClient(timeout=60) as http: async def synthesize(chunk): async with http.stream('POST', url, json={'input': chunk, 'voice': voice_name}) as response: response.raise_for_status() payload = bytearray() async for data in response.aiter_bytes(): payload.extend(data) if len(payload) > 8 * 1024 * 1024: raise ValueError('Réponse audio trop volumineuse') return bytes(payload) first = next(chunks, None) if first is None: return # Une réponse uniquement composée d'emojis reste silencieuse. pending = asyncio.create_task(synthesize(first)) try: while pending is not None: payload = await pending following = next(chunks, None) # Une seule phrase préchargée pendant la lecture de la précédente. pending = asyncio.create_task(synthesize(following)) if following else None await play_wav(voice, payload) finally: if pending is not None: pending.cancel() await asyncio.gather(pending, return_exceptions=True)