Read this text aloud, in this voice. One request, POST /v1/audio/speech, and the audio comes back in the body. Before you start: put a key in EESI_API_KEY — see Authentication — and install a client if you want one, from SDKs. Neither SDK is published to PyPI or npm yet, so both install from a checkout.
ev_1a2b3c4d stands for a real voice id. Get one from GET /v1/voices — built-in and cloned voices both look like this.

The request

response_format is not a transcode

The schema accepts OpenAI’s six values, and a model serves only the ones it advertises. nur-tts-v1 advertises mp3, wav and pcm, so asking it for opus, aac or flac is a 400 naming the formats it does serve — not a conversion:
Read formats from GET /v1/audio/models rather than trusting the enum. If you need opus, transcode the wav yourself.

A wrong voice is refused, not ignored

The synthesis model conditions the speaker on reference audio, not on the string in voice. A name it does not recognize used to be dropped upstream, and the utterance came back in the default voice with a 200 — the hardest failure on this route to diagnose, because nothing said anything was wrong. So the gateway checks the name first. voice must be a voice id (ev_…), an OpenAI voice name, or auto for the model’s own default, and anything else is a 400 unknown_voice before synthesis starts. Passing a voice’s display name (“Nur”) rather than its id lands here. That gate checks the shape of the name, not your library. An ev_… string passes on its prefix alone, and the id is looked up later, when the reference clip is fetched. A typo therefore lands a different error: ev_deadbeef comes back as a 400 whose message is “Unknown voice” and the id you sent, and whose code is null. Two gates, two 400s — do not expect unknown_voice to cover both.
An OpenAI voice name such as alloy does not give you OpenAI’s alloy. It maps to auto, the serving model’s default speaker. Name a voice_id when you care which voice you get.

Incremental speech

For nur-tts-v1, combine response_format: "pcm" with stream_format: "sse" to receive audio as phrases are synthesized. Each speech.audio.delta carries base64 PCM16 audio: signed little-endian, 24 kHz, mono. Play consecutive deltas in order; speech.audio.done closes the stream. Closing the connection cancels remaining synthesis. MP3, WAV and other file formats still finish synthesis before sending the encoded file. Using SSE with those formats changes framing only. The default stream_format: "audio" returns raw response bytes for file download. Short requests reduce the amount of work before playback. Phrase streaming also lets playback begin before a longer request finishes, but reference-voice preparation and synthesis still affect first-audio latency. See Latency.

Narrating something long

The 4,096-character cap is per request, so a chapter is several requests. Split on sentence boundaries, synthesize each piece, and concatenate the results. The voice holds across the seam because it is not resampled per request: a voice_id resolves to the same stored reference clip every time, so segment nine sounds like segment one. That is the whole reason voices are a resource rather than a string. Two things to keep straight when you split:
  • Split at punctuation, not at 4,096 characters. A piece that starts mid-clause is read with the wrong intonation, and no amount of stitching hides it.
  • Keep speed, voice, language and normalize_text identical across the pieces. Changing any of them mid-document is audible.
There is a worked version of this in Stream speech to a file.

What comes back with the audio

Two headers are worth reading. One is on every response; the other is on every response that produced a history row. Read the header rather than assuming it. A client that indexes by generation id needs a path for the response that has none.

What a generation leaves behind

Where retention is on, each request leaves an entry, so a take you liked can be replayed and downloaded rather than regenerated from memory.
The entry is written before the audio is uploaded, so has_audio is false for a moment after a request finishes, and stays false when the deployment keeps no audio or the upload failed. The row is still a true record of what was said, so it is listed either way. voice_name is denormalized onto the row, which is what keeps history readable after a voice is renamed or deleted. Two caps apply to what the row holds, and both are flagged rather than silent:
  • input is stored to 4,000 characters. An input near the 4,096-character request cap comes back with text_truncated: true, and character_count still counts what you sent.
  • Audio is stored to 25 MB. You receive every byte; the stored copy is cut, and the row says audio_truncated: true. A short stored clip is never a short generation.
Fetching the audio needs the generation id and your key: ids appear in storage keys, so holding one is never on its own enough to read the audio.

Cost, limits and failures

The failures you will actually meet: An unreachable backend is a 502, not a 503. Every 502 on this route means the gateway could not get a usable answer out of the model server, and none of them is caused by your request — including the redirect the gateway refuses to follow, upstream_redirect_refused. Branch on the status, then on code. A request that fails one of the gates above is refused before any audio is generated, so it costs nothing. A request that is cut off mid-stream is billed for the audio that was produced, because the GPU pass was already spent. The whole utterance is generated before the first byte, so a long paragraph under load can outlast an SDK’s default budget and raise RequestTimeoutError. In Python that budget is the 60-second read timeout: raise it with EESIClient(timeout=180), where a float covers every phase. In TypeScript the binding default is connectTimeoutMs, ten seconds to response headers — and no headers are sent until the audio exists — so raising readTimeoutMs alone changes nothing. Pass new EESIClient({ connectTimeoutMs: 180_000, readTimeoutMs: 180_000 }), or split the text. Nothing is retried automatically for you: a POST that failed on the wire may still have reached the server.

Next

Voices

Why a voice is a resource, and how to get a voice_id.

Story

A script with a cast, rendered block by block through this endpoint.

Stream speech to a file

Narrate something long without holding it in memory.

Synthetic audio marking

What every response declares about itself.

Authentication

Where an sk-eesi-… key comes from, and what a bad one looks like.