Speak in realtime

Realtime TTS speaks text while it's still being written. You open a WebSocket session, send text in pieces as it arrives — for example, from a model streaming tokens — and the API sends speech back as it's ready. The listener hears the first words before the text is complete.

One session supports multiple sequential generations: finish one, start the next, without reconnecting.

i
Information
Understand the lifecycle

Understand the lifecycle

A session is one WebSocket connection that goes through four stages, always in the same order. Your application sends the text, and the API sends the audio back while the text still flows.

At each stage, your application does the following:

StageYou sendYou receiveWhat your application does
OpenA connection with the model and your API keysession.created, with the default settingsWait for it before you send anything else.
Configuresession.update, with your settingssession.updated, with the settings the session appliesCheck the values. They're fixed for the session until you update again.
Send textinput_text.append, one text delta at a timespeech.audio.delta, with each chunk of audioDecode each chunk and pass it to your player, or append it to the file you write.
Finishinput_text.flush to end a generation, or input_text.end to end the sessionspeech.audio.done, with usage for the generationStart the next generation after flush, or close the connection after end.

If the API rejects a message, it sends an error event at that stage instead. See Common issues.

Connect to a session

Connect to a session

Open a WebSocket connection to the realtime TTS endpoint, with the model in the query string and your API key in the Authorization header:

wss://api.mistral.ai/v1/audio/speech/realtime?model=voxtral-mini-tts-3-0
Authorization: Bearer $MISTRAL_API_KEY

The server responds with session.created, with the default settings:

{
  "type": "session.created",
  "session": {
    "model": "voxtral-mini-tts-3-0-13",
    "voice": "voice_447",
    "streaming_mode": "responsive",
    "response_format": "wav"
  }
}
Configure the session

Configure the session

Send a session.update message to change settings for subsequent generations. The server acknowledges with session.updated:

{
  "type": "session.update",
  "session": {
    "voice": "voice_0",
    "streaming_mode": "long_form",
    "response_format": "mp3"
  }
}
Session fields

Session fields

FieldTypeDefaultUse it to
voiceString, a preset voice IDvoice_447Pick a ready-made voice. See Voices.
ref_audioString, base64-encoded audioNot setReproduce a voice from a short recording. Provide this or voice, not both. See Voice cloning.
streaming_modeStringresponsiveresponsive (lower latency) or long_form (higher quality for longer text).
response_formatStringwavpcm, wav, mp3, flac, opus. See Audio formats.
Note

Update session settings before input starts, or after all pending generations finish with no unflushed input. Updating during an active generation is a recoverable error: the update is rejected, and the connection stays open.

Send text and receive audio

Send text and receive audio

Send text in pieces with input_text.append. Each message carries only the new delta — spaces are not inserted automatically, so include them in the text you send.

You sendWhat it does
input_text.appendAppends new text to the current generation. Send only the new delta.
input_text.flushFinishes the current generation's input. Optionally include a final text delta. The connection stays open for the next generation.
input_text.endFinishes the input, drains queued generations, and closes the connection (code 1000). Does not accept a text field.

The server sends audio back as it's ready:

You receiveFieldsSent when
speech.audio.deltaaudio_data (base64)A chunk of audio is ready. Decode each delta separately and concatenate the bytes in order.
speech.audio.doneusageAll audio for the current generation has been sent.
errorerror.message, error.codeThe API rejects a message or a generation fails. A generation failure closes the connection.

Audio can arrive before flush or end once enough text is available. The server does not automatically flush on pauses, spaces, or punctuation — you decide when a generation is complete.

Here is a full session with two generations:

Client: WebSocket connect
Server: session.created

Client: {"type":"session.update","session":{"voice":"voice_0"}}
Server: session.updated

Client: {"type":"input_text.append","text":"Hello "}
Client: {"type":"input_text.append","text":"world."}
Client: {"type":"input_text.flush"}
Server: speech.audio.delta ...
Server: speech.audio.done

Client: {"type":"input_text.append","text":"Goodbye."}
Client: {"type":"input_text.end"}
Server: speech.audio.delta ...
Server: speech.audio.done
Server: WebSocket close 1000

The following script runs the same session, saves each generation's audio to a separate file, and measures the time to first audio:

import asyncio
import base64
import json
import os
import time
from pathlib import Path

import websockets

MODEL = "voxtral-mini-tts-3-0"
URL = f"wss://api.mistral.ai/v1/audio/speech/realtime?model={MODEL}"
HEADERS = {"Authorization": f"Bearer {os.environ['MISTRAL_API_KEY']}"}


async def receive_audio(ws, label, start):
    """Receive audio for one generation. Returns the raw bytes."""
    chunks = []
    first_audio = None
    while True:
        msg = json.loads(await ws.recv())
        if msg["type"] == "speech.audio.delta":
            if first_audio is None:
                first_audio = time.perf_counter() - start
                print(f"  [{label}] First audio after {first_audio:.2f}s")
            chunks.append(base64.b64decode(msg["audio_data"]))
        elif msg["type"] == "speech.audio.done":
            print(f"  [{label}] Done ({sum(len(c) for c in chunks)} bytes)")
            return b"".join(chunks)
        elif msg["type"] == "error":
            raise RuntimeError(msg["error"]["message"])


async def main():
    start = time.perf_counter()
    async with websockets.connect(URL, additional_headers=HEADERS) as ws:
        # Wait for session.created
        msg = json.loads(await ws.recv())
        assert msg["type"] == "session.created"
        print(f"Connected ({time.perf_counter() - start:.2f}s)")

        # Configure the session
        await ws.send(json.dumps({
            "type": "session.update",
            "session": {"voice": "voice_447", "response_format": "wav"},
        }))
        msg = json.loads(await ws.recv())
        assert msg["type"] == "session.updated"

        # Generation 1: send text in pieces, then flush
        await ws.send(json.dumps({"type": "input_text.append", "text": "Hello "}))
        await ws.send(json.dumps({"type": "input_text.append", "text": "world. "}))
        await ws.send(json.dumps({
            "type": "input_text.append",
            "text": "This is a test of the realtime TTS API.",
        }))
        await ws.send(json.dumps({"type": "input_text.flush"}))
        audio1 = await receive_audio(ws, "gen1", start)
        Path("realtime_gen1.wav").write_bytes(audio1)

        # Generation 2: send text, then end (closes the connection)
        await ws.send(json.dumps({"type": "input_text.append", "text": "Goodbye."}))
        await ws.send(json.dumps({"type": "input_text.end"}))
        audio2 = await receive_audio(ws, "gen2", start)
        Path("realtime_gen2.wav").write_bytes(audio2)

    print(f"Total: {time.perf_counter() - start:.2f}s")
    print("Saved realtime_gen1.wav and realtime_gen2.wav")


asyncio.run(main())
Finish a generation

Finish a generation

After a flush, the next input_text.append starts a new generation. Session settings persist, but the previous generation's model context does not. speech.audio.done means all audio for that generation has been sent — not that the client has finished playing it.

After end, keep receiving until the server closes the connection. The server drains any queued generations, sends their audio, then closes with code 1000.

Caution

Each speech.audio.delta contains base64 audio in audio_data. Decode each delta separately and concatenate the bytes in order. WAV output has one header per generation; individual deltas are not standalone files.

Common issues

Common issues

These issues are specific to realtime sessions. For errors on the request itself, such as an unknown voice or an invalid format, see Troubleshooting.

SymptomCauseFix
No audio arrivesYou sent input_text.append but never flush or endThe server doesn't auto-flush. Send flush when a generation's text is complete, or end to finish and close.
Audio arrives in the wrong orderYou decoded or wrote chunks out of orderDecode every speech.audio.delta and write the bytes in the order they arrived.
The connection closes mid-generationA generation failedThe server emits error and closes on failure. Check the error message, then open a new session.
session.update is rejectedYou sent it during an active generationUpdate settings before input starts, or after speech.audio.done with no unflushed input.
The saved WAV file doesn't playYou concatenated multiple generations into one fileEach generation has its own WAV header. Save each generation to a separate file, or use a format without headers such as pcm.
Going further

Going further