Streaming
Get the first sample while the rest is still being synthesized, over chunked HTTP or a WebSocket.
Batch holds every chunk until the vendor says it is done, so the caller waits out the whole utterance before the first sample. Streaming forwards each chunk as it decodes.
There are two transports. They share one engine — the same ranking, the same failover, the same decoder, the same PCM. What differs is when the text can arrive, and which providers can serve it.
| Chunked HTTP | WebSocket | |
|---|---|---|
| Endpoint | POST /v1/audio/speech/stream | GET wss://api.speko.ai/v1/synthesize/stream |
| Text | Fixed at request time | Pushed in as you write it |
| Needs | Any HTTP client | A WebSocket client |
| Providers | Every provider that can deliver progressive audio | The four that speak a WebSocket TTS protocol |
| Accents | Yes | No — no accent voice sits behind a WebSocket vendor |
| Reach for it when | You have the sentence | An LLM is still writing it |
If you already have the text, use chunked HTTP. It gives the same first-sample latency with none of the protocol, and it reaches far more of the board — including every accent voice.
Chunked HTTP
POST https://api.speko.ai/v1/audio/speech/streaminput and text are both accepted, on this route and on /v1/synthesize/stream, so a body you already have works unchanged.
import os
import time
import httpx
started = time.monotonic()
with httpx.stream(
"POST",
"https://api.speko.ai/v1/audio/speech/stream",
headers={"Authorization": f"Bearer {os.environ['SPEKO_API_KEY']}"},
json={"model": "auto", "input": "Your appointment is confirmed for Friday."},
timeout=None,
) as response:
response.raise_for_status()
print(response.headers["x-route"])
first = True
for chunk in response.iter_bytes(): # 24 kHz mono 16-bit little-endian
if first:
print(f"first sample after {(time.monotonic() - started) * 1000:.0f} ms")
first = False
play(chunk)const started = performance.now();
const response = await fetch('https://api.speko.ai/v1/audio/speech/stream', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.SPEKO_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ model: 'auto', input: 'Your appointment is confirmed for Friday.' }),
});
if (!response.ok) throw new Error(`${response.status} ${await response.text()}`);
console.log(response.headers.get('x-route'));
let first = true;
for await (const chunk of response.body as AsyncIterable<Uint8Array>) {
if (first) {
console.log(`first sample after ${Math.round(performance.now() - started)} ms`);
first = false;
}
play(chunk);
}The response is 200 with Content-Type: audio/pcm;rate=24000 and no Content-Length — the body is chunked. x-route, x-route-reason, and x-speko-failover-count describe the routing decision.
The official OpenAI SDKs cannot reach this route. Both build the path /audio/speech
from your base URL, so client.audio.speech.create(...) always calls
batch no matter what you pass. Streaming needs a plain HTTP call.
With curl, pass -N — without it curl buffers the body and a working stream looks
like a batch response.
x-speko-first-byte-ms reports the router's own wait on the upstream, not your first sample — the two differ by the network leg to you. Measure first-sample latency on your side, as both samples above do.
WebSocket
Use this when the text is still being produced. You open on the first clause instead of waiting for the last one, so the first sample lands earlier even though the per-byte behaviour matches chunked HTTP.
wss://api.speko.ai/v1/synthesize/streamSession shape, the 5-second config window, ephemeral browser tokens and failover behaviour are identical on both realtime routes — see the realtime contract. Here you push {"type":"text"} frames as often as you like and receive binary PCM.
import json
import os
import websockets
async with websockets.connect(
"wss://api.speko.ai/v1/synthesize/stream",
additional_headers={
"Authorization": f"Bearer {os.environ['SPEKO_API_KEY']}",
"X-Speko-Allow": "soniox:tts-rt-v1,gradium:default",
},
subprotocols=["speko.realtime.v1"],
) as socket:
await socket.send(json.dumps({"type": "config", "language": "en", "text": "Your appointment "}))
print(await socket.recv()) # {"type":"ready","provider":"Soniox",...}
async for clause in llm_clauses():
await socket.send(json.dumps({"type": "text", "text": clause}))
await socket.send(json.dumps({"type": "end"}))
async for message in socket:
if isinstance(message, bytes): # 24 kHz mono 16-bit little-endian
play(message)import WebSocket from 'ws';
const socket = new WebSocket('wss://api.speko.ai/v1/synthesize/stream', ['speko.realtime.v1'], {
headers: {
Authorization: `Bearer ${process.env.SPEKO_API_KEY}`,
'X-Speko-Allow': 'soniox:tts-rt-v1,gradium:default',
},
});
socket.on('open', () => {
socket.send(JSON.stringify({ type: 'config', language: 'en', text: 'Your appointment ' }));
});
socket.on('message', (raw: Buffer, isBinary: boolean) => {
if (isBinary) return play(raw);
const event = JSON.parse(raw.toString());
if (event.type === 'ready') {
socket.send(JSON.stringify({ type: 'text', text: 'is confirmed for Friday.' }));
socket.send(JSON.stringify({ type: 'end' }));
}
if (event.type === 'error') console.error(event.code, event.message);
});The config frame
| Field | Type | Default | Meaning |
|---|---|---|---|
type | "config" | — | Required. Any other value is rejected. |
language | BCP 47 tag | key's policy | Picks the board. A region subtag is read and ignored — no accent voice is reachable here. |
voice | string | provider default | Provider voice id. |
model | string | auto | A candidate filter, not a vendor string. |
speed | number | — | Honored on the providers that accept it. |
text | string | — | Opening text, so a one-utterance caller needs a single frame. |
{ "type": "config", "language": "en", "voice": "Adrian", "text": "Your appointment " }On a provider that cannot take incremental text, text here is not optional — it is the only place the words can go.
Frames you receive
{"type": "ready", "provider": "Soniox", "model": "tts-rt-v1", "format": "pcm_s16le", "sampleRate": 24000}
{"type": "error", "code": "UPSTREAM", "message": "Streaming TTS could not start."}
{"type": "end"}Everything else is a binary frame of PCM. {"type":"end"} arrives once, last.
One synthesis per connection. When the vendor signals done the router sends end and
closes the socket, so the text for a single utterance can arrive in pieces but the socket
is not reused for the next one. A synthesis is capped at 120 seconds.
Errors
On top of the three shared codes, this route adds four:
| Code | Cause |
|---|---|
UNSUPPORTED_MODEL | The named model matches no routable candidate. |
NO_PROVIDER | Selection matched nothing under your constraints. |
TIMEOUT | The synthesis passed 120 seconds. |
UPSTREAM | As shared, and additionally when a provider drops mid-stream. |
Before the upgrade you get ordinary HTTP statuses instead, and on the HTTP transport the same failures are statuses you can catch: 400 for a bad body, 502 upstream_error, 503 no_streaming_tts_provider.
Which provider answers
The two transports draw from different candidate sets.
Chunked HTTP reaches every provider that can deliver progressive audio — the same board batch ranks, minus any vendor that only answers with one finished blob. x-route names the winner. This is the transport that can serve an accent voice.
The WebSocket reaches the four providers that speak a WebSocket TTS protocol. The ready frame names the winner.
Only two of them accept text after the session opens. Push a text frame at the other two and the router answers UPSTREAM with "This provider accepts all text in the config frame."
| Candidate | Default voice | Text after ready |
|---|---|---|
soniox:tts-rt-v1 | Adrian | Yes |
gradium:default | YTpq7expH9539ERJ | Yes |
smallest:lightning_v3.1 | olivia | No — all text in the config frame |
hume:octave-2 | Colton Rivers | No — all text in the config frame |
So if you are streaming LLM tokens, pin the two that can take them: X-Speko-Allow: soniox:tts-rt-v1,gradium:default. The samples above do.
An X-Speko-Allow naming no provider the transport can serve is refused rather than substituted. On chunked HTTP that is 503 no_streaming_tts_provider. On the WebSocket the upgrade itself fails, so the client sees a handshake error and close code 1002 with no readable body. Check a pin against GET /v1/routing/preview before shipping it.
One case substitutes instead of refusing: a steered accent above its text budget. See languages and accents.
Audio format
pcm only, on both transports — raw 24 kHz mono 16-bit signed little-endian.
A wav header carries the total byte count in its first 44 bytes, so it cannot be written before the audio is complete, and mp3 and opus would need an encoder in the audio path. Asking for any of them returns 400 naming what is supported. Use batch when you need a wav file.
Failover on chunked HTTP
Failover stops once audio flows on both transports. On chunked HTTP, truncation is your only signal.
The router ends the body abnormally rather than cleanly, so a client that reads to EOF
and ignores transport errors cannot tell a finished utterance from a failed one, and
will play the fragment. Check for a read error, or use the WebSocket, which says
{"type":"error"} before it closes.