TTS
Streaming (WebSocket)
The realtime protocol: every message, in both directions.
One socket, JSON text frames in both directions, and audio as binary. The connection is opened with a URL from the session route. See Authentication for why it is not opened directly.
wss://<gateway>/v1/streamSession properties#
Everything that describes the session (the voice, the format, the sample rate, the sampling knobs) is a query parameter on that URL, put there by the session route from the body you posted. Which means all of it is immutable for the life of the connection. Changing a voice means opening a second socket; there is no message that changes one mid-stream.
wss://<gateway>/v1/stream
?token=zwst_1… the short-lived connect token
&voice_id=maichi a built-in voice, a system voice, or one you cloned
&format=pcm_s16le the wire format of the audio frames
&sample_rate=24000 ask explicitly: a player built at the wrong rate is silently wrong
&normalize_text=true expand digits, dates and acronyms before synthesis
&word_timestamps=true opt in to the timestamps messageContexts#
A context_id is one utterance, or one turn of an agent. It is a queue: several synthesize messages on the same context are synthesised in order, and their audio streams out as one continuous timeline with monotonic presentation timestamps. A listener cannot hear where one ended and the next began.
That is the mechanism a voice agent is built on. Send a sentence the moment your language model has finished writing it, rather than waiting for the paragraph, and the model starts speaking while the rest is still being written.
One sentence per synthesize beats one paragraph. The splitter packs sentences toward a 15-second budget rather than emitting one per sentence, so a whole paragraph can arrive as a single long segment, and prefill time, which grows with segment length, sits directly on the time-to-first-audio path.
Sending#
All client messages are JSON text frames.
synthesizeclient → serverSubmit text on a context. The server segments it and synthesises the parts in order.
typestringrequired"synthesize"context_idstringrequiredtextstringrequiredsegment_mode"auto" | "none"optionaldefault "auto"auto lets the server split the text. none says the text is exactly one segment. Over the model’s input limit that fails the connection with 4003 segment_too_long.{
"type": "synthesize",
"context_id": "turn-7",
"text": "Xin chào. Tôi có thể giúp gì cho bạn?",
"segment_mode": "auto"
}endclient → serverNo more text is coming for this context. Optional, but send it: it lets the server release the slot immediately rather than waiting for a timeout, and on a metered socket a timeout is money.
{ "type": "end", "context_id": "turn-7" }cancelclient → serverBarge-in. Stop this context now, drop everything still queued on it, and free the slot. This is what you send when the user starts talking over the agent.
{ "type": "cancel", "context_id": "turn-7" }Audio frames for a cancelled context can still arrive for a moment afterwards, because they were already in flight. Drop frames whose context you have cancelled rather than assuming the stream stops at the same instant your message is sent.
configclient → serverAdjust delivery for a context that has not started yet.
{ "type": "config", "context_id": "turn-8", "speed": 1.1 }pingclient → serverAn application-level heartbeat, answered with pong. Send one every 20 seconds: intermediaries kill idle sockets, and you want to discover that before your user speaks rather than during.
{ "type": "ping" }Receiving#
Audio frameserver → clientA binary frame: a 12-byte header followed by the audio payload in the format the session asked for. Not base64 inside JSON, which costs a third of the bandwidth and a parse on every chunk.
byte 0 version (0x01)
byte 1 flags: bit 0 is set on the final chunk of a context
bytes 2-3 context ordinal (uint16, in the order contexts were seen)
bytes 4-7 chunk sequence (uint32, per context)
bytes 8-11 presentation timestamp in ms (uint32, per context)
bytes 12+ audio payload: mono 16-bit little-endian PCM at sample_rateRead the samples out explicitly little-endian. Aliasing the buffer with an Int16Array takes the platform’s byte order, and the wire format is pcm_s16le whatever the platform happens to be.
context_startserver → clientThe server has accepted a context and begun work on it.
{ "type": "context_start", "context_id": "turn-7" }segment_startserver → clientOne segment of a context has been accepted, carrying queue_depth, the gateway’s backlog for that context.
This is the signal worth wiring up. A growing queue_depth means you are feeding segments faster than they can be synthesised, which is what tells an agent to trim its response rather than keep talking into a queue the listener will wait through.
{ "type": "segment_start", "context_id": "turn-7", "queue_depth": 2 }timestampsserver → clientWord timings, when the session was opened with word_timestamps=true. They are context-relative: the offset of every preceding segment is already folded in, so lipsync and captions never do the arithmetic.
{
"type": "timestamps",
"context_id": "turn-7",
"words": ["Xin", "chào"],
"start": [0.04, 0.31],
"end": [0.29, 0.72]
}segment_doneserver → clientEvery frame of one segment has been delivered. The next segment follows seamlessly.
{ "type": "segment_done", "context_id": "turn-7" }context_doneserver → clientThe context is finished and its audio is complete. This is the message that ends a turn, and it arrives after the final audio frame, so it is safe to treat as the end of the utterance.
{ "type": "context_done", "context_id": "turn-7" }errorserver → clientSomething went wrong mid-stream. The socket may survive it; a fatal problem arrives as a close code instead. See Errors and retries.
{ "type": "error", "context_id": "turn-7", "message": "…" }pong and resumed also arrive as JSON. resumed means a worker was lost and the gateway rebound the session to another one. See recovery. Neither needs handling; ignore any message type you do not recognise rather than treating it as an error, so a new one can be added without breaking your client.
A turn, end to end#
client server
│
├─ synthesize context_id=turn-7 ───────▶
│ ◀───────── context_start
│ ◀───────── segment_start queue_depth=0
├─ synthesize context_id=turn-7 ───────▶ (the next sentence, queued)
│ ◀───────── ▪ audio frame seq=0 pts=0
├─ end context_id=turn-7 ───────▶
│ ◀───────── ▪ audio frame seq=1 pts=40
│ ◀───────── segment_done
│ ◀───────── segment_start queue_depth=0
│ ◀───────── ▪ audio frame seq=2 pts=980
│ ◀───────── segment_done
│ ◀───────── context_done
│
├─ (next turn on the same socket, or close 1000)Rules worth following#
- Reuse one socket for a whole conversation. Reconnecting per turn costs 40–100 ms of handshake on every utterance, which is the same order as the latency this API exists to deliver.
- Consume audio as it arrives. Each session’s outbound queue is bounded at about two seconds. A client that stops reading has frames dropped, and after a sustained run the socket closes with
1008. - Heartbeat every 20 seconds. Silent sockets get killed by intermediaries.
- Close when the conversation ends. Billing runs on open-socket time, silence included.
Ordering guarantees#
| Within | Guaranteed |
|---|---|
| One context | Audio frames arrive in sequence order, with monotonic timestamps. |
| One context | Segments are synthesised and delivered in the order their text was submitted. |
| Across contexts | None. Use the context ordinal in the frame header to route audio, not arrival order. |
