Streaming voice in real time.
Stream synthesised speech sentence by sentence over a WebSocket, so a conversational agent can start speaking the first sentence while the rest of the text is still being synthesised. This is live now for any API key.
The streaming endpoint below is live at wss://api.celestlabs.ai/v1/stream. The non-streaming REST endpoint POST /v1/synthesize returns the whole clip in a single response; reach for the WebSocket when you want playback to begin before the full passage is rendered. You can hear streaming end to end in the live demo.
WebSocket endpoint Live
WSSwss://api.celestlabs.ai/v1/stream
Open a WebSocket and send a single JSON message carrying your API key and the text. The server synthesises the passage one sentence at a time and streams each sentence back as a binary WAV frame, then sends a final JSON summary and closes the connection. It's one request per connection, open a fresh connection for the next utterance.
The request frame
Your first (and only) message is JSON. token and text are required; everything else falls back to the defaults shown.
{
"token": "sk_live_...", // your API key, required
"text": "First sentence. Second sentence.", // required
"voice": "meera", // optional, default "meera"
"language": "en", // optional, default "en"
"steps": 8, // optional, 1–64, default 8
"speed": 1.05, // optional, 0–3, default 1.05
"idempotency_key": "req-42" // optional, echoed back as request_id
}
What the server streams back
After your request frame, the server sends a sequence of frames on the same socket:
- Binary frames, one per sentence-chunk, each a complete, self-contained WAV file (RIFF header followed by 16-bit PCM, mono, 44.1 kHz). Decode or play each frame as it arrives; the first lands while the later sentences are still being synthesised.
- A final text frame (JSON), once every sentence has been sent, summarising the request:
json
{ "done": true, "request_id": "req-42", "chars": 33, "audio_ms": 2480 }
The server then closes the connection normally (code 1000). Because each binary frame is an independent WAV, the simplest browser pattern is to decodeAudioData each one and schedule the buffers back to back.
Audio format
Every binary frame is a self-contained WAV, 16-bit PCM, mono, 44.1 kHz, the model's native rate. There is no format negotiation on the socket. If you need μ-law / 8 kHz for telephony or Opus for WebRTC, transcode the PCM client-side (or resample once at your edge).
Close codes
Errors are signalled by the WebSocket close code, not an error frame. Read it from the close event to decide whether to retry.
| Code | Meaning |
|---|---|
1000 | Normal close, sent after the done frame |
4401 | Missing or invalid API key (token) |
1003 | Malformed first frame, or empty text |
4400 | Unknown voice or unsupported language |
4403 | API key not scoped for that voice or language |
4402 | Free-tier character allowance exhausted |
4429 | Rate limit or concurrency slot exceeded, back off and retry |
1011 | Server-side configuration error |
Full example
Open the socket, send one JSON frame, and play each WAV frame the moment it arrives. The browser needs no dependencies; the Node and Python samples use one small library each.
const ctx = new AudioContext(); let nextStart = ctx.currentTime + 0.05; const ws = new WebSocket("wss://api.celestlabs.ai/v1/stream"); ws.binaryType = "arraybuffer"; ws.addEventListener("open", () => { ws.send(JSON.stringify({ token: "sk_live_...", text: "Streaming voice, sentence by sentence. Each plays as it arrives.", voice: "meera", })); }); ws.addEventListener("message", async (evt) => { if(typeof evt.data === "string"){ // final { done: true, ... } if(JSON.parse(evt.data).done) ws.close(); return; } // each binary frame is a complete WAV, decode and schedule it const buf = await ctx.decodeAudioData(evt.data); const src = ctx.createBufferSource(); src.buffer = buf; src.connect(ctx.destination); src.start(nextStart); nextStart += buf.duration; });
import WebSocket from "ws"; import fs from "node:fs"; const ws = new WebSocket("wss://api.celestlabs.ai/v1/stream"); ws.on("open", () => { ws.send(JSON.stringify({ token: process.env.CELESTLABS_API_KEY, text: "Hello from Node, streamed sentence by sentence.", voice: "meera", })); }); let n = 0; ws.on("message", (data, isBinary) => { if(isBinary){ fs.writeFileSync(`sentence_${n++}.wav`, data); // each frame is a playable WAV } else { const msg = JSON.parse(data.toString()); if(msg.done){ console.log(`done: ${msg.chars} chars, ${msg.audio_ms} ms`); ws.close(); } } });
import asyncio, io, json, os, wave import websockets, sounddevice as sd async def stream(): async with websockets.connect("wss://api.celestlabs.ai/v1/stream") as ws: await ws.send(json.dumps({ "token": os.environ["CELESTLABS_API_KEY"], "text": "Hello from Python, streamed sentence by sentence.", "voice": "meera", })) out = sd.RawOutputStream(samplerate=44100, channels=1, dtype="int16") out.start() async for msg in ws: if isinstance(msg, bytes): # a complete WAV per sentence w = wave.open(io.BytesIO(msg), "rb") out.write(w.readframes(w.getnframes())) elif json.loads(msg).get("done"): break out.stop() asyncio.run(stream())
Throughput & streaming
The model synthesises faster than real time on a CPU, no GPU required, at a real-time factor of about 0.18, roughly 5.5× faster than playback. Because audio streams sentence by sentence, the first sentence starts playing while later sentences are still being synthesised, so perceived wait time stays short on long passages. The live demo on the home page shows the real timing end to end.
Pair streaming with your LLM's streaming output. As each reply (or each finished sentence) comes back from the LLM, open a stream and send it; audio for the first sentence arrives while the rest is still being synthesised, keeping the conversation moving. Open one stream per turn.
Barge-in & interruption
For natural conversational agents you need to stop speaking the moment the user starts talking. The flow is:
- Your VAD or ASR detects user speech.
- Close the WebSocket. The server stops synthesising the remaining sentences as soon as the connection drops, there's no separate cancel message to send.
- Stop playing audio frames already buffered on the client.
- Start the next turn on a fresh connection.
Billing
Streaming is metered by the number of characters synthesised, exactly like the REST endpoint and at the same per-character price. Usage for a stream is recorded once, when it completes.