Base URL https://api.echonova.ai

Developers

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 voiceId from List available voices.
  • A server. Browsers cannot set the Authorization header 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:

  1. When a sentence is complete (it ends with ., ?, !, ؟ or a line break), add it to the queue.
  2. When the socket is idle, send the first sentence in the queue.
  3. On done, send the next one.

Recommended

  • One synthesize per sentence or short paragraph, each under 500 characters, sent one after the other on one socket.

Less effective

  • One synthesize for 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.