https://api.echonova.aiDevelopers
Stream speech as it is generated
Keep a WebSocket open, send text, and play PCM audio while it is still being generated, with sentence-by-sentence replies, barge-in with cancel, billing, limits and a complete Python client.
The Text to Speech stream sends audio while it is still being generated, so a listener hears the first words long before the whole reply is ready. One socket stays open for a whole conversation and takes one text after another. This page shows how to connect, play the audio as it arrives, feed a reply sentence by sentence, and stop speaking when the listener interrupts. Every message and field is on Stream text to speech.
When to stream
POST /v1/text-to-speech/sync |
Stream | |
|---|---|---|
| What you get | A link to a finished WAV file | Raw 16-bit mono PCM frames while they are generated |
| First audio | When the whole text is spoken | After the first few seconds of speech are generated |
| Texts per request | One | Any number, one at a time, on one socket |
| Stopping midway | Not possible | cancel, at the next chunk boundary |
| Price | 1 credit per character | 1 credit per character |
Stream when someone is waiting to hear the reply: a voice agent, a phone call, a live assistant. For audio you store or serve later, the synchronous endpoint gives you a file and a job.
How a session works
you server
|-- upgrade: Authorization --------------->|
|-- {"type":"synthesize", ...} ----------->|
|<-------------------------------- start |
|<---------------- binary PCM frames ... |
|<-------------------------------- done |
|-- {"type":"synthesize", ...} ----------->| the next text, same socket
|<-------------------------------- start |
|<---------------- binary PCM frames ... |
|-- {"type":"cancel"} -------------------->| the listener interrupts
|<---------------------------- cancelled |What you need
- An API key whose services include Text to Speech, or that has no service restriction. See Get an API key.
- A
voiceIdfrom List available voices. - A server. Browsers cannot set the
Authorizationheader on a WebSocket, so a web page cannot connect. Stream on your server and pass the audio on to your player; never put the key in a page. - Credits on the workspace. See Credits.
- For the Python client below: Python 3.9 or later and
pip install "websockets>=14".
Step 1: Connect
Open a WebSocket to wss://api.echonova.ai/v1/text-to-speech/stream with
Authorization: Bearer YOUR_API_KEY on the upgrade request. The stream
takes no query parameters. When something is wrong before the upgrade, the
server answers with plain HTTP and the usual error envelope, and nothing is
charged; see Errors.
Open the socket before you need it, for example when a call starts, and keep it for the whole conversation. Reusing it saves the connection time on every reply and counts as one of your concurrent streams.
Step 2: Send a synthesize message
{ "type": "synthesize", "requestId": "reply-1", "text": "أهلاً بك، كيف يمكنني مساعدتك اليوم؟", "voiceId": "66e5ef3a9c1d2b4f8a7e6c01" }| Field | Required | Value |
|---|---|---|
type |
Yes | synthesize |
text |
Yes | 1 to 500 characters. |
voiceId |
Yes | A voice from the voice list. The voice fixes the dialect. |
requestId |
No | Your id for this text, up to 64 characters, echoed on start, done, cancelled and error. |
speed |
No | 0.5 to 2, default 1. |
cfg |
No | 1 to 5, default 2. |
numSteps |
No | 16, 32, 48, 64 or 128, default 48. |
tashkeel |
No | Adds the vowel marks to Arabic text before it is read. Fusha voices only. Default false. |
normalize |
No | Reads numbers, dates, money and the like as a speaker of the dialect says them. Default true. |
The fields mean what they mean on Generate speech synchronously; see Text to Speech voices and options.
Only one text is spoken at a time on a socket. A synthesize sent while
another is still running cancels the running one first, exactly like
cancel followed by synthesize.
Step 3: Play the audio as it arrives
The server answers with start, then binary frames, then done:
{ "type": "start", "requestId": "reply-1", "sampleRate": 24000, "encoding": "pcm_s16le", "channels": 1 }Each binary frame is 16-bit signed little-endian mono PCM at the
sampleRate from start. That is 24 kHz today, and synthesize takes no
rate of its own, but read it from start rather than hard-coding it. Play the frames in the order they arrive, or
append them to a file. Audio is generated a few seconds of speech at a time,
so frames come in bursts: give your player a small buffer, a few hundred
milliseconds, so it does not run dry between bursts.
{ "type": "done", "requestId": "reply-1", "durationSeconds": 2.9, "characters": 36, "creditsConsumed": 36 }done means every frame of that text has been sent. Binary frames carry no
requestId; the frames between a start and its done or cancelled
belong to that request.
With the sounddevice package, for example, playback is a stream you write
the frames to:
import sounddevice # pip install sounddevice
player = sounddevice.RawOutputStream(samplerate=24000, channels=1, dtype="int16")
player.start()
# for every binary frame received:
player.write(frame)Step 4: Speak a reply sentence by sentence
When the text comes from a language model, do not wait for the whole reply.
Cut it into sentences as it is generated and send each sentence as its own
synthesize, so the listener hears the first sentence while the model is
still writing the rest.
Send the next sentence only after the done of the one before. A
synthesize sent earlier cancels the sentence still being spoken. Keep a
queue on your side:
- When a sentence is complete (it ends with
.,?,!,؟or a line break), add it to the queue. - When the socket is idle, send the first sentence in the queue.
- On
done, send the next one.
Recommended
- One
synthesizeper sentence or short paragraph, each under 500 characters, sent one after the other on one socket.
Less effective
- One
synthesizefor every few words. Each piece is spoken on its own, so the voice loses the rhythm of the sentence. - A new socket for every sentence. Each one adds a connection and counts against the limit of concurrent streams.
Step 5: Stop speaking when the listener interrupts
When the listener starts talking over the reply, send:
{ "type": "cancel" }The server stops the synthesis and answers with
{"type":"cancelled","requestId":"reply-1"}. No frame for that request
follows cancelled.
Cancelling takes effect at the next chunk boundary: a few frames already
generated can still arrive between your cancel and cancelled. Stop your
player and clear its buffer at once, on your side, and drop every frame
until cancelled arrives. Then send the next synthesize, or send it right
away: a new synthesize cancels the running one too.
A cancelled synthesis is charged in full. The charge is taken when the
synthesis starts, for every character of its text, whatever part of it
was spoken. Sentence-sized messages keep what an interruption wastes small.
Complete Python client
This client sends one text, collects the PCM frames and writes them to
speech.wav. The same client in JavaScript, TypeScript, Go and C# is on
Stream text to speech.
import asyncio
import json
import wave
import websockets # pip install "websockets>=14"
URL = "wss://api.echonova.ai/v1/text-to-speech/stream"
API_KEY = "YOUR_API_KEY"
async def main():
pcm = bytearray()
rate = 24000
async with websockets.connect(
URL, additional_headers={"Authorization": f"Bearer {API_KEY}"}
) as ws:
await ws.send(json.dumps({
"type": "synthesize",
"requestId": "reply-1",
"text": "أهلاً بك، كيف يمكنني مساعدتك اليوم؟",
"voiceId": "66e5ef3a9c1d2b4f8a7e6c01",
}))
while True:
message = await ws.recv()
if isinstance(message, bytes): # a binary frame of PCM16
pcm.extend(message)
continue
event = json.loads(message)
if event["type"] == "start":
rate = event["sampleRate"]
elif event["type"] == "done":
print("seconds:", event["durationSeconds"], "credits:", event["creditsConsumed"])
break
elif event["type"] in ("error", "cancelled"):
raise SystemExit(f"{event['type']}: {event.get('message', '')}")
with wave.open("speech.wav", "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(rate)
wav.writeframes(bytes(pcm))
asyncio.run(main())Errors on an open socket
An error message carries code, message and, when it concerns one
text, its requestId:
code |
What happened | What to do |
|---|---|---|
validation_failed |
A field of synthesize is missing or out of range. |
Fix the message and send it again. The socket stays open. |
voice_not_found |
No voice has this voiceId. |
Take an id from the voice list. |
invalid_message |
A text frame was not valid JSON or had an unknown type. |
The socket closes with 1008. Fix the message and reconnect. |
frame_too_large |
A frame was larger than 64 KiB. | The socket closes with 1008, or 1009 with no message over 1 MiB. Send shorter text. |
insufficient_balance |
The workspace ran out of credits. | The socket closes with 4002. Add credits. |
spending_limit |
The key reached its Credit Limit. | The socket closes with 4029. Raise the limit. |
upstream_error |
Speech generation failed. | Send the text again. The socket stays open. |
Every close code is listed on Errors.
Billing
Each synthesize costs 1 credit per character of text, spaces included,
the same rate as the other Text to Speech endpoints. The credits are
charged when the synthesis starts, before the first frame, and done
repeats the amount in creditsConsumed. A cancelled synthesis, by cancel
or by a newer synthesize, is still charged. If the synthesis fails before
any audio is sent, the charge is refunded. A key's Credit Limit applies
to every synthesize. See Credits.
Limits
| Limit | Value |
|---|---|
| Text length | 500 characters per synthesize |
| Syntheses at a time | 1 per socket; a new synthesize cancels the current one |
| Session length | 30 minutes |
| Idle timeout | 120 seconds without any frame; send a ping to keep a quiet socket open |
| Concurrent streams | 10 open streams per workspace, Speech to Text and Text to Speech together |
| Connection rate | 100 connection attempts per minute per key |
See Rate limits for how the stream limits are counted.
Troubleshooting
My second sentence cut off the first
A synthesize sent before the previous one's done cancels it. Queue the
sentences and send each one on the previous done, as in Step 4.
Frames keep arriving after I sent cancel
The synthesis stops at the next chunk boundary, so a few frames can follow
your cancel. Drop every frame until cancelled arrives.
The audio plays too fast, too slow or as noise
Play it at the sampleRate from start, as 16-bit signed little-endian
mono samples. A player set to another rate, to stereo or to 8-bit misreads
the frames.
The socket closes after two minutes of silence
The idle timeout is 120 seconds without any frame. Send
{"type":"ping"} every minute or so while the conversation is quiet; the
server answers with pong.
FAQ
Can I get MP3 or Opus instead of PCM?
No. The stream sends raw 16-bit mono PCM. Encode it yourself if you need another format, or use Generate speech synchronously for a WAV file.
Can one socket use several voices?
Yes. voiceId belongs to each synthesize, so every text can use another
voice, one text at a time.
Can I connect from a browser?
No. A browser cannot set the Authorization header on a WebSocket, and the
key must never reach a web page. Stream on your server and pass the audio
on.