Speko Docs
TTS

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 HTTPWebSocket
EndpointPOST /v1/audio/speech/streamGET wss://api.speko.ai/v1/synthesize/stream
TextFixed at request timePushed in as you write it
NeedsAny HTTP clientA WebSocket client
ProvidersEvery provider that can deliver progressive audioThe four that speak a WebSocket TTS protocol
AccentsYesNo — no accent voice sits behind a WebSocket vendor
Reach for it whenYou have the sentenceAn 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/stream

input 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/stream

Session 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

FieldTypeDefaultMeaning
type"config"Required. Any other value is rejected.
languageBCP 47 tagkey's policyPicks the board. A region subtag is read and ignored — no accent voice is reachable here.
voicestringprovider defaultProvider voice id.
modelstringautoA candidate filter, not a vendor string.
speednumberHonored on the providers that accept it.
textstringOpening 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:

CodeCause
UNSUPPORTED_MODELThe named model matches no routable candidate.
NO_PROVIDERSelection matched nothing under your constraints.
TIMEOUTThe synthesis passed 120 seconds.
UPSTREAMAs 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."

CandidateDefault voiceText after ready
soniox:tts-rt-v1AdrianYes
gradium:defaultYTpq7expH9539ERJYes
smallest:lightning_v3.1oliviaNo — all text in the config frame
hume:octave-2Colton RiversNo — 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.

On this page