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.
If the text is already complete, use Speech generation or Stream speech instead. To choose between the modes, see Offline and realtime audio.
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:
| Stage | You send | You receive | What your application does |
|---|---|---|---|
| Open | A connection with the model and your API key | session.created, with the default settings | Wait for it before you send anything else. |
| Configure | session.update, with your settings | session.updated, with the settings the session applies | Check the values. They're fixed for the session until you update again. |
| Send text | input_text.append, one text delta at a time | speech.audio.delta, with each chunk of audio | Decode each chunk and pass it to your player, or append it to the file you write. |
| Finish | input_text.flush to end a generation, or input_text.end to end the session | speech.audio.done, with usage for the generation | Start 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
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_KEYThe 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
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
| Field | Type | Default | Use it to |
|---|---|---|---|
voice | String, a preset voice ID | voice_447 | Pick a ready-made voice. See Voices. |
ref_audio | String, base64-encoded audio | Not set | Reproduce a voice from a short recording. Provide this or voice, not both. See Voice cloning. |
streaming_mode | String | responsive | responsive (lower latency) or long_form (higher quality for longer text). |
response_format | String | wav | pcm, wav, mp3, flac, opus. See Audio formats. |
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 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 send | What it does |
|---|---|
input_text.append | Appends new text to the current generation. Send only the new delta. |
input_text.flush | Finishes the current generation's input. Optionally include a final text delta. The connection stays open for the next generation. |
input_text.end | Finishes 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 receive | Fields | Sent when |
|---|---|---|
speech.audio.delta | audio_data (base64) | A chunk of audio is ready. Decode each delta separately and concatenate the bytes in order. |
speech.audio.done | usage | All audio for the current generation has been sent. |
error | error.message, error.code | The 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 1000The 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
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.
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
These issues are specific to realtime sessions. For errors on the request itself, such as an unknown voice or an invalid format, see Troubleshooting.
| Symptom | Cause | Fix |
|---|---|---|
| No audio arrives | You sent input_text.append but never flush or end | The 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 order | You decoded or wrote chunks out of order | Decode every speech.audio.delta and write the bytes in the order they arrived. |
| The connection closes mid-generation | A generation failed | The server emits error and closes on failure. Check the error message, then open a new session. |
session.update is rejected | You sent it during an active generation | Update settings before input starts, or after speech.audio.done with no unflushed input. |
| The saved WAV file doesn't play | You concatenated multiple generations into one file | Each generation has its own WAV header. Save each generation to a separate file, or use a format without headers such as pcm. |