|
@@ -1,7 +1,10 @@
|
|
|
"""Synthèse HTTP interchangeable et lecture Discord, sans fichiers temporaires."""
|
|
"""Synthèse HTTP interchangeable et lecture Discord, sans fichiers temporaires."""
|
|
|
import asyncio
|
|
import asyncio
|
|
|
import audioop
|
|
import audioop
|
|
|
|
|
+import logging
|
|
|
|
|
+import os
|
|
|
import re
|
|
import re
|
|
|
|
|
+import time
|
|
|
import wave
|
|
import wave
|
|
|
from io import BytesIO
|
|
from io import BytesIO
|
|
|
|
|
|
|
@@ -9,8 +12,13 @@ import discord
|
|
|
import emoji
|
|
import emoji
|
|
|
import httpx
|
|
import httpx
|
|
|
|
|
|
|
|
|
|
+logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
-def speech_chunks(text, limit=240):
|
|
|
|
|
|
|
+
|
|
|
|
|
+def speech_chunks(text, limit=240, first_limit=None):
|
|
|
|
|
+ if limit < 1 or (first_limit is not None and first_limit < 1):
|
|
|
|
|
+ raise ValueError('La taille des morceaux doit être positive')
|
|
|
|
|
+ first = True
|
|
|
text = re.sub(r'```.*?```', ' Bloc de code omis. ', text, flags=re.S)
|
|
text = re.sub(r'```.*?```', ' Bloc de code omis. ', text, flags=re.S)
|
|
|
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
|
|
text = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', text)
|
|
|
text = re.sub(r'https?://\S+|<a?:\w+:\d+>|<[@#][!&]?\d+>', ' ', text)
|
|
text = re.sub(r'https?://\S+|<a?:\w+:\d+>|<[@#][!&]?\d+>', ' ', text)
|
|
@@ -22,11 +30,18 @@ def speech_chunks(text, limit=240):
|
|
|
text = re.sub(r'[^\S\n]+', ' ', text)
|
|
text = re.sub(r'[^\S\n]+', ' ', text)
|
|
|
for sentence in re.split(r'(?<=[.!?…])\s+|\n+', text.strip()):
|
|
for sentence in re.split(r'(?<=[.!?…])\s+|\n+', text.strip()):
|
|
|
while sentence:
|
|
while sentence:
|
|
|
- end = len(sentence) if len(sentence) <= limit else sentence.rfind(' ', 0, limit + 1)
|
|
|
|
|
|
|
+ budget = min(limit, first_limit) if first and first_limit else limit
|
|
|
|
|
+ end = len(sentence) if len(sentence) <= budget else sentence.rfind(' ', 0, budget + 1)
|
|
|
|
|
+ # Favoriser une pause naturelle, sans fabriquer un minuscule morceau.
|
|
|
|
|
+ if len(sentence) > budget:
|
|
|
|
|
+ pauses = list(re.finditer(r'[,;:]\s', sentence[:budget + 1]))
|
|
|
|
|
+ if pauses and pauses[-1].start() >= budget // 2:
|
|
|
|
|
+ end = pauses[-1].start() + 1
|
|
|
if end <= 0:
|
|
if end <= 0:
|
|
|
- end = limit
|
|
|
|
|
|
|
+ end = budget
|
|
|
chunk, sentence = sentence[:end].strip(), sentence[end:].strip()
|
|
chunk, sentence = sentence[:end].strip(), sentence[end:].strip()
|
|
|
if chunk:
|
|
if chunk:
|
|
|
|
|
+ first = False
|
|
|
yield chunk
|
|
yield chunk
|
|
|
|
|
|
|
|
|
|
|
|
@@ -71,7 +86,13 @@ async def play_wav(voice, payload):
|
|
|
|
|
|
|
|
|
|
|
|
|
async def speak(voice, text, url, voice_name='ff_siwis'):
|
|
async def speak(voice, text, url, voice_name='ff_siwis'):
|
|
|
- chunks = iter(speech_chunks(text))
|
|
|
|
|
|
|
+ first_chars = int(os.getenv('TTS_FIRST_CHUNK_CHARS', '48'))
|
|
|
|
|
+ chunk_chars = int(os.getenv('TTS_CHUNK_CHARS', '80'))
|
|
|
|
|
+ if not (16 <= first_chars <= chunk_chars <= 240):
|
|
|
|
|
+ raise ValueError('Tailles TTS attendues : 16 <= premier <= suivants <= 240')
|
|
|
|
|
+ chunks = iter(speech_chunks(text, limit=chunk_chars, first_limit=first_chars))
|
|
|
|
|
+ started = time.monotonic()
|
|
|
|
|
+ first_playback = True
|
|
|
async with httpx.AsyncClient(timeout=60) as http:
|
|
async with httpx.AsyncClient(timeout=60) as http:
|
|
|
async def synthesize(chunk):
|
|
async def synthesize(chunk):
|
|
|
async with http.stream('POST', url, json={'input': chunk, 'voice': voice_name}) as response:
|
|
async with http.stream('POST', url, json={'input': chunk, 'voice': voice_name}) as response:
|
|
@@ -93,6 +114,9 @@ async def speak(voice, text, url, voice_name='ff_siwis'):
|
|
|
following = next(chunks, None)
|
|
following = next(chunks, None)
|
|
|
# Une seule phrase préchargée pendant la lecture de la précédente.
|
|
# Une seule phrase préchargée pendant la lecture de la précédente.
|
|
|
pending = asyncio.create_task(synthesize(following)) if following else None
|
|
pending = asyncio.create_task(synthesize(following)) if following else None
|
|
|
|
|
+ if first_playback:
|
|
|
|
|
+ logger.info('Premier audio prêt : délai TTS=%.3fs', time.monotonic() - started)
|
|
|
|
|
+ first_playback = False
|
|
|
await play_wav(voice, payload)
|
|
await play_wav(voice, payload)
|
|
|
finally:
|
|
finally:
|
|
|
if pending is not None:
|
|
if pending is not None:
|