Aller au contenu principal

Pipeline LLM vers TTS streaming

Connecter un LLM a la synthese vocale en temps reel

Le cas d’usage le plus puissant du WebSocket TTS est le chaining avec un LLM en streaming. Le LLM genere du texte token par token, chaque token est envoye au TTS via WebSocket, et l’audio est joue au fur et a mesure. L’utilisateur entend la reponse pendant que le LLM continue a reflechir.

Architecture du pipeline

Le flux de donnees traverse trois etapes :

Utilisateur -> LLM (streaming) -> WebSocket TTS -> Audio (lecture)
   question      tokens texte       audio.delta      haut-parleur

Chaque etape fonctionne en streaming, ce qui minimise la latence percue. L’utilisateur n’attend pas que le LLM ait fini de generer toute sa reponse pour commencer a l’entendre.

Implementation en Python

Voici un pipeline complet qui connecte l’API Chat Completions de Grok au TTS WebSocket :

import asyncio
import websockets
import json
import base64
import aiohttp

async def llm_to_speech(api_key, prompt, voice="rex", language="fr"):
    # Etape 1 : Connexion WebSocket TTS
    tts_uri = f"wss://api.x.ai/v1/tts?language={language}&voice={voice}"
    tts_headers = {"Authorization": f"Bearer {api_key}"}

    audio_chunks = []

    async with websockets.connect(tts_uri, extra_headers=tts_headers) as tts_ws:

        # Etape 2 : Appel LLM en streaming
        async with aiohttp.ClientSession() as session:
            async with session.post(
                "https://api.x.ai/v1/chat/completions",
                headers={
                    "Authorization": f"Bearer {api_key}",
                    "Content-Type": "application/json"
                },
                json={
                    "model": "grok-3",
                    "messages": [{"role": "user", "content": prompt}],
                    "stream": True
                }
            ) as llm_response:

                # Etape 3 : Chaque token LLM -> delta TTS
                buffer = ""
                async for line in llm_response.content:
                    line = line.decode().strip()
                    if not line.startswith("data: "):
                        continue
                    if line == "data: [DONE]":
                        break

                    chunk = json.loads(line[6:])
                    token = chunk["choices"][0].get("delta", {}).get("content", "")

                    if token:
                        buffer += token
                        # Envoyer par phrases completes
                        if any(c in token for c in ".!?\n"):
                            await tts_ws.send(json.dumps({
                                "type": "text.delta",
                                "delta": buffer
                            }))
                            buffer = ""

                # Envoyer le reste du buffer
                if buffer:
                    await tts_ws.send(json.dumps({
                        "type": "text.delta",
                        "delta": buffer
                    }))

                await tts_ws.send(json.dumps({"type": "text.done"}))

        # Etape 4 : Collecter l'audio
        async for msg in tts_ws:
            data = json.loads(msg)
            if data["type"] == "audio.delta":
                audio_chunks.append(base64.b64decode(data["delta"]))
            elif data["type"] == "audio.done":
                break

    return b"".join(audio_chunks)

Strategie de buffering

Envoyer chaque token individuellement au TTS n’est pas optimal. Le moteur de synthese produit un meilleur resultat quand il dispose de contexte. Trois strategies de buffering :

Par phrase

Attendez un delimiteur de phrase (., !, ?, retour a la ligne) avant d’envoyer :

if any(c in token for c in ".!?\n"):
    await tts_ws.send(json.dumps({
        "type": "text.delta", "delta": buffer
    }))
    buffer = ""

C’est le meilleur compromis entre qualite et latence.

Par taille

Envoyez quand le buffer atteint une certaine taille (par exemple 200 caracteres) :

if len(buffer) >= 200:
    await tts_ws.send(json.dumps({
        "type": "text.delta", "delta": buffer
    }))
    buffer = ""

Plus simple mais peut couper au milieu d’un mot.

Par temporisation

Envoyez apres un delai sans nouveau token (utile quand le LLM reflechit) :

last_token_time = time.time()

# Si plus de 500ms sans token, envoyer le buffer
if time.time() - last_token_time > 0.5 and buffer:
    await tts_ws.send(...)
    buffer = ""

Lecture audio en temps reel

Pour lire l’audio au fur et a mesure (pas seulement le collecter), utilisez une queue asynchrone :

import asyncio

audio_queue = asyncio.Queue()

async def receive_audio(tts_ws):
    """Recoit les chunks audio et les met en queue."""
    async for msg in tts_ws:
        data = json.loads(msg)
        if data["type"] == "audio.delta":
            audio_bytes = base64.b64decode(data["delta"])
            await audio_queue.put(audio_bytes)
        elif data["type"] == "audio.done":
            await audio_queue.put(None)  # Sentinelle de fin
            break

async def play_audio():
    """Lit les chunks audio depuis la queue."""
    while True:
        chunk = await audio_queue.get()
        if chunk is None:
            break
        # Jouer le chunk (pyaudio, sounddevice, etc.)
        play_chunk(chunk)

En executant receive_audio et play_audio en parallele, la lecture commence des le premier chunk recu.

Gestion des erreurs dans le pipeline

Le pipeline peut echouer a trois endroits : le LLM, le TTS, ou le reseau. Chaque point doit etre gere :

async def robust_pipeline(api_key, prompt):
    try:
        audio = await llm_to_speech(api_key, prompt)
        return audio
    except aiohttp.ClientError:
        print("Erreur LLM - verifiez votre connexion")
    except websockets.ConnectionClosed:
        print("Connexion TTS perdue - reconnexion necessaire")
    except json.JSONDecodeError:
        print("Reponse invalide - format inattendu")
    except Exception as e:
        print(f"Erreur inattendue: {e}")
    return None

Points cles a retenir

  • Le pipeline LLM streaming vers TTS WebSocket offre la latence minimale
  • Bufferisez par phrase pour le meilleur compromis qualite/latence
  • Utilisez une queue asynchrone pour la lecture audio en temps reel
  • Gerez les erreurs a chaque etape du pipeline
  • Le texte total est illimite via WebSocket (deltas de 15 000 caracteres max)
  • La limite de 50 sessions concurrentes s’applique a l’ensemble de l’equipe