Skip to main content
A voice agent is judged on how long the user waits for a reply to start, and on whether the reply says what you meant. Synthesis itself runs on our servers. Most of what you can change sits around it, in how you connect, how you send the text and how you write it. This page covers the choices that matter, and what each one saves.

Measure the time to first audio

Before tuning anything, find out where the wait is.
  • HTTP API. Every 200 carries a Server-Timing header. On a streamed response, its ttfa is our server’s own time from receiving the request to sending the first audio byte. Your client’s time to the first byte, minus ttfa, is the network, the connection setup and your client. When that difference is large, the connection and data zone habits below are the ones that help. See Response headers.
  • WebSocket API. Time a turn from the Generate you send to the first Audio frame, and time opening a session, from connecting to Begin, separately. The first is what a turn waits for on an open session. The second is what a turn adds when it opens its own. When the text streams in from an LLM, also time the first audio from the moment the first sentence is complete: that is the wait flushing each sentence shortens.
The SDKs do not expose Server-Timing, so measure the HTTP split with a plain client. httpx ships with the Python SDK:
The first run also opens the connection; the rest run on the kept connection. The difference between the client’s time and ttfa is what the habits below can remove.

Use one WebSocket session per conversation

A conversation belongs on the WebSocket API, with one session held open for the whole call and each reply a request on it. Text can be sent a token at a time as an LLM writes it, the server starts speaking once it holds complete sentences, and Cancel stops the request in flight when the user interrupts. The rest of this page assumes that shape. Choose an interface covers the decision itself, and when whole texts over HTTP or the OpenAI-compatible route fit better.

Open the session before the first reply

Opening a session costs a TCP and a TLS handshake, the WebSocket upgrade, then our server’s checks before Begin: the credential, the rate limit and the voice. A session opened per sentence pays all of it on every turn, and spends the sessions-per-minute limit on every turn as well. A turn on a session opened earlier pays none of it. Even close to the API, that setup delays the first audio of a turn that opens its own session, and the farther your client is from the API, the more the three round trips of a new WebSocket add. Right after Begin, the server loads the session’s voice. A voice the server has not used recently takes longer the first time, so a first turn sent right after Begin with such a voice waits a little longer for its first audio. Once the voice has loaded, a turn starts about as quickly as with a voice in recent use. A session opened ahead of the first reply gives the voice that time. Open the session when the call starts or the page loads, not when the first reply is ready, and keep it open between turns. The server closes a session after two minutes without a frame in either direction, or after your shorter inactivity_timeout, so send KeepAlive at least once per idle timeout while the caller is silent. Count the time your player spends on audio you already received as idle time too: audio arrives several times faster than it plays, so a long reply can leave the socket quiet while it is still playing. KeepAlive only restarts the idle timer, so sending it more often makes nothing faster, and a tight loop closes the session with 3010. A session ends at Begin.expires_at, at most an hour after it opened. Open its replacement in a pause before then, send the following turns on it once its Begin arrives, and Terminate the old one, so no turn waits for a reconnect. One session per conversation is enough: an idle session still holds resources on our servers, so do not keep spares open in reserve.
The integrations do this for you. LiveKit opens the session when you call tts.prewarm() at the start of the call, and Pipecat connects when the pipeline starts.

Reuse one TLS context in Python

With the raw websockets package, websockets.connect builds a new TLS context for every connection unless you give it one. On Python linked against OpenSSL 3.0, such as the system Python of Ubuntu 24.04, building one takes noticeable CPU time, up to tens of milliseconds on a busy machine. Build one when your process starts and pass it to every connection. The SDKs and HTTP clients such as httpx build their context once, when the client is created, which is one more reason to keep one client per process.
With a shared context, a turn that opens its own session no longer waits for a new context to be built before its first audio.

Keep HTTP connections open

If you send whole replies over HTTP, the same idea applies to the connection. Build one HTTP client, or one OpenAI client, per process, and send every request through it. A new client starts with no open connection, and in Python it also builds a new TLS context. The API keeps an idle connection open for an hour, but common clients close it much sooner: Python’s httpx and httpx2, which the OpenAI Python SDK is built on, after 5 seconds, and Node’s built-in fetch after 4. A voice agent whose replies are further apart than that opens a new connection, a TCP and a TLS handshake, before nearly every reply. Raise the client’s idle limit above your longest pause between replies; 2 minutes covers most conversations.
For the OpenAI SDKs, Pipecat and LiveKit, see OpenAI compatibility. The first request through a client still opens a connection. Open it while nobody is waiting, for example when a call starts, with any cheap request through the same client. HEAD /v1/openai/models takes no credential and synthesizes nothing:
The connection then stays open for the client’s idle limit, so open it shortly before the first reply rather than at application startup, unless you raised the limit as above. Every reconnect costs two round trips before the request can leave, with any client: a few milliseconds close to the API, and more the farther your client is from it.

Send text as it is written, and flush each sentence

Send each piece of text with Generate as soon as you have it, and keep the space at the edge of each piece, since pieces are joined exactly as sent. The first audio of a reply cannot come before its first sentence has arrived, so text held back on the client delays it by as much. Send Flush at the end of each sentence as it completes, starting with the first. A Flush starts that sentence’s audio at once, so the reply starts right after its first sentence is complete. Without one, the server waits until it holds enough complete sentences, so a short first sentence such as Sure, I can help with that. waits for the sentence after it, and a reply sent in one piece after the LLM finishes waits for the whole reply. At the pace an LLM writes, either can add close to a second before the reply starts. Never flush per token, or in the middle of a sentence. A request that ends mid-sentence is synthesized on its own, which produces choppy prosody, and a per-token loop exceeds the control-frame limit and closes the session. Always flush when a reply is complete: without it, the end of the reply is held until more text arrives, and a short reply is never spoken at all. With the SDKs, a speak() per sentence, or a flush() after the last generate() of each sentence, does the same. See Generate and Limits. Keep the first sentence of a reply short. With a flush per sentence, the audio starts once the first sentence is complete, so a short opening starts it sooner than a long one with the same content.

Stream raw PCM in the format your player needs

A streamed response sends its first byte with the first audio. A buffered one, "stream": false on the HTTP API, sends nothing until the whole clip is ready, which is right for a file and wrong for a conversation. MP3 starts every streamed response with the encoder’s delay, about 1,100 samples of silence, which is about 46 ms at 24 kHz, 69 ms at 16 kHz and 138 ms at 8 kHz, and its first byte also comes a little later than PCM’s. For a voice agent, stream pcm_s16le, which is pcm on the OpenAI-compatible route, where MP3 is the default. The service’s native output is 24 kHz 16-bit PCM. Every other rate or codec is converted on the server before it reaches you, which adds a few milliseconds at most, so set the format your playback path needs explicitly rather than converting it yourself. 16 kHz pcm_s16le is the common choice when the audio feeds a pipeline that already runs at 16 kHz, and 8 kHz pcm_mulaw or pcm_alaw matches a carrier media stream directly. A rate above 24 kHz is resampled up from the native output, so it carries no more detail and only adds bytes to send; ask for 44.1 or 48 kHz only when your output device needs it. Read from the socket continuously and buffer the audio locally. Your player, not the socket, should be what waits. See Audio formats on WebSocket API basics, and the HTTP API’s WAV and MP3 containers.

Use the nearest data zone

tts.us.assemblyai.com runs in us-east-1 (N. Virginia) and tts.eu.assemblyai.com in eu-north-1 (Stockholm). Choose the zone your data residency requires first, and the zone that holds your cloned voices, since a voice is only usable in the zone it was created in. With no such requirement, choose the zone nearer to the machine that calls the API. If the same agent also streams the caller’s audio to Streaming Speech-to-Text, use the same data zone for both and run the agent near it, since every turn makes round trips to both. See Cloud endpoints and data residency. Every request costs at least one network round trip to the zone, and a new connection costs more before the request can leave: two round trips for HTTPS (TCP, then TLS 1.3) and three for a WebSocket (TCP, TLS and the upgrade). A round trip takes longer the farther your client is from its zone, so the farther it is, the more the connection habits above are worth.

In a browser

A page opens each session with a temporary token, so a connection first waits for the page to ask your server and for your server to mint the token, which is a request of its own to our API. A token minted ahead of time takes that wait off the session’s open.
  • Keep a spare token. Mint it on an early hint that a session will be needed, such as the pointer entering the text box, and only when the page will have to open a session, such as after the idle session closed. While a session is open, a spare goes unused and only sits in the page as a live credential. Drop it well before its expires_in_seconds runs out, and give every connection a token of its own.
  • Reopen on intent. A page that lets an idle session close and reopens it when the user presses Speak makes that click wait for the token, the handshakes and Begin. Reopen when the user focuses the text box instead, while they type, and the click finds the session open. A click that comes before the reopened session’s Begin should keep its text and speak it when Begin arrives.

Write the text the way it should be read

Before synthesis, the server rewrites the forms that identify themselves into the words they are read as, and drops what is written but never said, in every language. For most sentences that is what you want: write currencies, phone numbers, addresses and dates the way they are normally written, and they are read the way they are normally said.
  • Read in their spoken form: amounts with a currency sign, dates written with their year, phone numbers, addresses, units, email addresses and URLs. $25.50 is read as twenty five dollars and fifty cents, and (415) 555-0132 in groups of digits. An English numeric date such as 03/04/2026 is read month first, as March fourth.
  • Also read out in English: common written shorthand and technical strings. p. 12 is read as page 12, w/ as with and press * as press star, and a file path, a MAC address or a 0x hex number symbol by symbol.
  • Dropped: emoji, Markdown and HTML markup, footnote marks such as the [3] in as the study showed[3], and decorative symbols.
  • Left to the model: anything else, such as a number with no format that says what it is, like 4321 or 1,234. The model reads it on its own.
Where the reading matters, make it explicit.
  • Use full sentences with punctuation. The model takes pace and tone from it. Text with no punctuation may not be read in the sentences you expect, and a question mark, an exclamation mark or a comma each change the delivery.
  • Spell out a reading the model could get wrong. Put dashes between digits to read a number digit by digit, as in a card number, and write it with commas to read it as a whole number. Put dashes between the letters of an uncommon acronym. Write out a date or an amount in words when the written form is ambiguous, such as a numeric date meant day first or one written without its year.
  • Remove anything that should not be spoken. Stage directions are read aloud: square brackets become parentheses, so [pause] is read as the word pause, not as a pause instruction.
  • Send complete sentences per request. A request that ends mid-sentence is spoken on its own, without the context of the sentence it belongs to, which is the same reason not to flush per token.
See the Prompting guide for the full normalization table and the punctuation the model responds to.

Set the language of the text

language decides the language the session speaks and the model it runs on. Set it to the language of the text you send: the API does not detect it. When you leave it out, the session speaks the language its voice was recorded in, which is English for the built-in voices such as aai-jane and for a session that names no voice. assemblyai-tts-1 speaks every voice in all six languages, so any voice can read your text once language matches it, though results are usually worse than in the voice’s own language: for the best results, choose a voice whose own language is the language of your text. Text in another language than the session’s comes out mispronounced. Read configuration.voice_id and configuration.language back from Begin to confirm what the session resolved, defaults included. See Voices.
  • Message frames: when the server starts speaking a Generate, and what Flush, Cancel and KeepAlive do.
  • Limits: text, session and rate limits, and how each one reports a breach.
  • Prompting guide: pacing, tone and normalization.
  • WebSocket API basics: the encodings and sample rates, and playing raw PCM from Begin’s sample rate.
  • OpenAI compatibility: keeping connections with the OpenAI SDKs, Pipecat and LiveKit.
  • HTTP API: streamed and buffered responses, audio formats and Server-Timing.