TTS

Quickstart

From an API key to audio playing, in two calls.

From nothing to audio playing. Two calls: one HTTPS request that hands you a socket to open, and the socket itself.

Before you start#

  • An API plan, Starter or Pro. There is no free streaming tier.
  • An API key from the API Keys page.
  • Somewhere to keep that key that is not a browser. See Authentication.

The shape of it#

  1. 1

    Ask for a session

    POST /api/v1/realtime/stream/session with your key. You get back a wss:// URL carrying a token that lives about thirty seconds.

  2. 2

    Open the socket

    Set binaryType to arraybuffer. Audio arrives as binary frames; everything else is JSON.

  3. 3

    Send text

    A synthesize message per sentence as it becomes available, then one end when there is no more.

  4. 4

    Play frames as they land

    Strip the 12-byte header, feed the PCM to your player. Do not wait for context_done. Waiting for it is the one thing that throws away everything this API is for.

  5. 5

    Close when the conversation ends

    The meter runs on the open socket, silence included.

A complete Node example#

Runs on a server, holds the key directly, and plays each frame through your speakers the moment it lands, so you hear the first syllable while the rest of the sentence is still being synthesised. Playback goes through ffplay, which reads raw PCM from stdin and behaves the same on macOS, Windows and Linux.

brew install ffmpeg
npm install ws

export ZEROWEIGHT_API_KEY="zw_..."
node speak.mjs
speak.mjsjs
import { spawn } from "node:child_process";
import WebSocket from "ws";

const API_KEY = process.env.ZEROWEIGHT_API_KEY;
const SAMPLE_RATE = 24000;
const HEADER_BYTES = 12;

// 1 ── Ask for somewhere to connect. Your key travels in this header and
//      nowhere else; what comes back carries a token that expires in seconds.
const session = await fetch(
  "https://api.zeroweight.ai/api/v1/realtime/stream/session",
  {
    method: "POST",
    headers: { "Content-Type": "application/json", "X-ZW-Api-Key": API_KEY },
    body: JSON.stringify({
      voice_id: "ngocanh",
      format: "pcm_s16le",
      sample_rate: SAMPLE_RATE,
    }),
  },
).then((response) => {
  if (!response.ok) throw new Error(`session refused: ${response.status}`);
  return response.json();
});

// A player that reads mono 16-bit PCM from stdin and plays it as it arrives.
// Raw PCM has nothing to probe, so skip the analysis ffplay would otherwise
// buffer for before it starts playing.
const player = spawn(
  "ffplay",
  [
    "-nodisp", "-autoexit", "-loglevel", "error",
    "-probesize", "32", "-analyzeduration", "0",
    "-f", "s16le", "-sample_rate", String(SAMPLE_RATE),
    "-i", "-",
  ],
  { stdio: ["pipe", "ignore", "inherit"] },
);
player.on("error", () => {
  console.error("ffplay not found: install ffmpeg and make sure it is on PATH");
  process.exit(1);
});
player.on("exit", () => console.log("playback finished"));

// 2 ── Open it.
const socket = new WebSocket(session.url);
let finished = false;

socket.on("open", () => {
  // 3 ── One context is one utterance. Several synthesize messages on the same
  //      context_id queue seamlessly, which is how you feed an LLM's output
  //      sentence by sentence.
  socket.send(JSON.stringify({
    type: "synthesize",
    context_id: "turn-1",
    text: "Xin chào. Tôi có thể giúp gì cho bạn?",
    segment_mode: "auto",
  }));
  // Optional but recommended: it lets the gateway release the slot without
  // waiting for a timeout.
  socket.send(JSON.stringify({ type: "end", context_id: "turn-1" }));
});

socket.on("message", (data, isBinary) => {
  if (isBinary) {
    // 4 ── 12-byte header, then mono 16-bit little-endian PCM. Straight to
    //      the speakers; nothing waits for the rest of the utterance.
    if (data.length <= HEADER_BYTES) return;
    player.stdin.write(data.subarray(HEADER_BYTES));
    return;
  }

  const message = JSON.parse(data.toString());
  if (message.type === "context_done") {
    finished = true;
    socket.close(1000);
  }
  if (message.type === "error") console.error(message);
});

// 5 ── Close ends the billing. This one closes itself on context_done above;
//      ending stdin lets ffplay finish what it holds and exit.
socket.on("close", (code, reason) => {
  // After context_done the gateway may drop the line rather than answer the
  // close (1006); the audio is already complete, so only an early close matters.
  if (!finished) console.error(`closed ${code}: ${reason}`);
  player.stdin.end();
});

The same in Python#

Playback goes through sounddevice. Its wheels bundle PortAudio on macOS and Windows, so pip install is all either needs; Linux needs the system library. Frames go into a buffer that the audio thread drains on its own clock, so a slow speaker never holds up the socket.

python3 -m pip install httpx websockets sounddevice

export ZEROWEIGHT_API_KEY="zw_..."
python3 speak.py
import asyncio
import json
import os
import threading

import httpx
import sounddevice as sd
import websockets

API_KEY = os.environ["ZEROWEIGHT_API_KEY"]
SAMPLE_RATE = 24000
HEADER_BYTES = 12
# Held before starting playback. Below ~100 ms a hiccup in delivery is audible
# as a gap; much above it and you have spent the latency streaming saves.
JITTER_BYTES = SAMPLE_RATE * 2 * 120 // 1000


class StreamPlayer:
    """Plays mono 16-bit PCM as it arrives, on PortAudio's own thread."""

    def __init__(self) -> None:
        self._buffer = bytearray()
        self._lock = threading.Lock()
        self._closing = False
        self.done = threading.Event()
        self._stream = sd.RawOutputStream(
            samplerate=SAMPLE_RATE,
            channels=1,
            dtype="int16",
            callback=self._fill,
            finished_callback=self.done.set,
        )

    def push(self, pcm: bytes) -> None:
        with self._lock:
            self._buffer.extend(pcm)
            ready = len(self._buffer) >= JITTER_BYTES
        if ready and not self._stream.active:
            self._stream.start()

    def finish(self) -> None:
        """No more audio is coming: play out what is buffered, then stop."""
        self._closing = True
        if not self._stream.active:
            self._stream.start()

    def _fill(self, outdata, frames, timing, status) -> None:
        wanted = len(outdata)
        with self._lock:
            chunk = bytes(self._buffer[:wanted])
            del self._buffer[:wanted]
        outdata[: len(chunk)] = chunk
        # Short means an underrun mid-stream, or the end of the utterance.
        if len(chunk) < wanted:
            outdata[len(chunk):] = b"\x00" * (wanted - len(chunk))
            if self._closing:
                raise sd.CallbackStop


async def main() -> None:
    async with httpx.AsyncClient(timeout=10) as client:
        response = await client.post(
            "https://api.zeroweight.ai/api/v1/realtime/stream/session",
            headers={"X-ZW-Api-Key": API_KEY},
            json={
                "voice_id": "ngocanh",
                "format": "pcm_s16le",
                "sample_rate": SAMPLE_RATE,
            },
        )
        response.raise_for_status()
        session = response.json()

    player = StreamPlayer()

    async with websockets.connect(session["url"]) as socket:
        await socket.send(json.dumps({
            "type": "synthesize",
            "context_id": "turn-1",
            "text": "Xin chào. Tôi có thể giúp gì cho bạn?",
            "segment_mode": "auto",
        }))
        await socket.send(json.dumps({"type": "end", "context_id": "turn-1"}))

        async for frame in socket:
            if isinstance(frame, bytes):
                # version u8 · flags u8 · context u16 · sequence u32 · pts u32
                if len(frame) <= HEADER_BYTES:
                    continue
                player.push(frame[HEADER_BYTES:])
                continue

            message = json.loads(frame)
            if message["type"] == "context_done":
                break
            if message["type"] == "error":
                raise RuntimeError(message.get("message", "stream error"))

    # The socket is closed and billing has stopped; let the tail play out.
    player.finish()
    await asyncio.to_thread(player.done.wait)
    print("playback finished")


asyncio.run(main())

Playing it in a browser#

In a browser the point is not to collect the audio but to hear it immediately. Feed each chunk into an AudioContext behind a small jitter buffer, and schedule each buffer against the one before it so the seams are inaudible.

client/player.tsts
const SAMPLE_RATE = 24000;
const HEADER_BYTES = 12;
/** Held before starting playback. Below ~100 ms a hiccup in delivery is
 *  audible as a gap; much above it and you have spent the latency you paid
 *  the streaming API to save. */
const JITTER_MS = 120;

export class StreamPlayer {
  private context = new AudioContext({ sampleRate: SAMPLE_RATE });
  /** When the next buffer should start, on the context's own clock. */
  private playhead = 0;

  /** One binary frame off the socket. */
  push(frame: ArrayBuffer): void {
    if (frame.byteLength <= HEADER_BYTES) return;

    // Read out explicitly little-endian: a typed-array view takes the
    // platform's byte order, and the wire format is pcm_s16le regardless.
    const view = new DataView(frame);
    const count = (frame.byteLength - HEADER_BYTES) >> 1;
    const buffer = this.context.createBuffer(1, count, SAMPLE_RATE);
    const samples = buffer.getChannelData(0);
    for (let i = 0; i < count; i++) {
      samples[i] = view.getInt16(HEADER_BYTES + i * 2, true) / 0x8000;
    }

    const source = this.context.createBufferSource();
    source.buffer = buffer;
    source.connect(this.context.destination);

    // Behind the clock means we underran: restart the buffer rather than
    // scheduling into the past, which plays everything at once.
    const now = this.context.currentTime;
    if (this.playhead < now) this.playhead = now + JITTER_MS / 1000;
    source.start(this.playhead);
    this.playhead += buffer.duration;
  }
}

const player = new StreamPlayer();
const socket = new WebSocket(session.url);
socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
  if (typeof event.data !== "string") player.push(event.data);
};

Prefer to see it before you build it? The Playground streams through the same gateway on your own key, so the latency it reports is the latency your integration gets.

Next#

  • The protocol: every message, in both directions.
  • Voices: the eight built in, and using your own clones.
  • Errors and retries: what each close code means, and which ones are worth retrying.