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 writes a WAV file at the end. That file is a side effect of frames already handed to the caller, not the thing being waited for.

npm install ws
speak.mjsjs
import fs from "node:fs";
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: "maichi",
      format: "pcm_s16le",
      sample_rate: SAMPLE_RATE,
    }),
  },
).then((response) => {
  if (!response.ok) throw new Error(`session refused: ${response.status}`);
  return response.json();
});

// 2 ── Open it.
const socket = new WebSocket(session.url);
const chunks = [];
let firstAudioAt = null;
const startedAt = Date.now();

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.
    if (data.length <= HEADER_BYTES) return;
    if (firstAudioAt === null) {
      firstAudioAt = Date.now();
      console.log(`first audio in ${firstAudioAt - startedAt} ms`);
    }
    chunks.push(data.subarray(HEADER_BYTES));
    return;
  }

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

// 5 ── Close ends the billing. This one closes itself on context_done above.
socket.on("close", (code, reason) => {
  if (code !== 1000) console.error(`closed ${code}: ${reason}`);
  const pcm = Buffer.concat(chunks);
  fs.writeFileSync("out.wav", Buffer.concat([wavHeader(pcm.length), pcm]));
  console.log(`wrote out.wav, ${(pcm.length / 2 / SAMPLE_RATE).toFixed(2)}s`);
});

/** Mono 16-bit PCM WAV header. */
function wavHeader(dataBytes) {
  const header = Buffer.alloc(44);
  header.write("RIFF", 0);
  header.writeUInt32LE(36 + dataBytes, 4);
  header.write("WAVEfmt ", 8);
  header.writeUInt32LE(16, 16);          // PCM chunk size
  header.writeUInt16LE(1, 20);           // format: PCM
  header.writeUInt16LE(1, 22);           // channels: mono
  header.writeUInt32LE(SAMPLE_RATE, 24);
  header.writeUInt32LE(SAMPLE_RATE * 2, 28); // byte rate
  header.writeUInt16LE(2, 32);           // block align
  header.writeUInt16LE(16, 34);          // bits per sample
  header.write("data", 36);
  header.writeUInt32LE(dataBytes, 40);
  return header;
}

The same in Python#

import asyncio
import json
import os
import struct
import wave

import httpx
import websockets

API_KEY = os.environ["ZEROWEIGHT_API_KEY"]
SAMPLE_RATE = 24000
HEADER_BYTES = 12


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": "maichi",
                "format": "pcm_s16le",
                "sample_rate": SAMPLE_RATE,
            },
        )
        response.raise_for_status()
        session = response.json()

    audio = bytearray()

    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
                audio.extend(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"))

    with wave.open("out.wav", "wb") as out:
        out.setnchannels(1)
        out.setsampwidth(2)
        out.setframerate(SAMPLE_RATE)
        out.writeframes(bytes(audio))

    print(f"wrote out.wav, {len(audio) / 2 / SAMPLE_RATE:.2f}s")


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.