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:
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
Fornur-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: avoice_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,languageandnormalize_textidentical across the pieces. Changing any of them mid-document is audible.
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.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:
inputis stored to 4,000 characters. An input near the 4,096-character request cap comes back withtext_truncated: true, andcharacter_countstill 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.
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.