OpenAI-compatible TTS endpoint
VieNeu's native public API is asynchronous (POST /v1/tts returns a jobId you
poll). POST /v1/audio/speech is a drop-in for OpenAI's endpoint of the same
name: point an OpenAI SDK at VieNeu's base URL, give it your VieNeu key, and it
works without any other change.
This matters more than it looks. A large amount of software already speaks this
one endpoint — Open WebUI, SillyTavern, LobeChat, LiteLLM, LiveKit's and
Pipecat's OpenAI plugins, a long tail of scripts — and all of them accept a
custom base_url. Supporting this shape is what makes VieNeu usable in them with
no plugin, no adapter and no work on our side.
Endpoint
POST /api/v1/audio/speech
Authorization: Bearer <VieNeu API key> # vn_sk_... (X-API-Key also accepted)
Content-Type: application/json
Returns the audio bytes synchronously — no polling.
Request body
| Field | OpenAI | VieNeu behavior |
|---|---|---|
input | required | the text to synthesize ✅ |
model | required | selects the engine — and since 2026-09-24 there is one, v4. OpenAI's names (tts-1, tts-1-hd, gpt-4o-mini-tts), vieneu and vieneu-v4 all resolve to it. Any other value is accepted and ignored, as before — except a vieneu-… name for an engine that does not exist or has been retired, which is rejected: vieneu-v3 answers 400 with Model 'vieneu-v3' is retired. Engine "v3" was retired on 2026-09-24. …. |
voice | alloy, … | a VieNeu voice id — list them with GET /v1/audio/voices. OpenAI's voice names are not mapped. Omit for the default voice. |
response_format | mp3 (default) | mp3 (default), wav, opus, pcm, plus VieNeu's ulaw. aac and flac return 400. |
speed | 0.25–4.0 | accepted across OpenAI's full range and clamped to 0.5–2.0, where the engine holds quality. A valid OpenAI value never returns an error. |
stream_format | audio | sse | supported for pcm and ulaw — see Streaming. |
instructions | (gpt-4o-mini-tts) | accepted and ignored. Use inline cue tags instead. |
sample_rate | — | VieNeu extension: 8000, 16000, 22050, 24000, 44100 or 48000. |
emotion | — | VieNeu extension left over from v3: natural (default) or storytelling. Accepted and ignored on v4 — see below. |
aiRefine | — | VieNeu extension, default false — see Billing. |
emotion does nothing any moreemotion was a v3 parameter, and v3 was retired from the cloud API on
2026-09-24. v4 — now the only engine — has no reading styles. It renders
three inline cue tags — [cười], [thở dài] and [hắng giọng] — and
deletes every other tag from your text rather than voicing it. A request
that sets "emotion": "storytelling" is accepted, billed, and read in the
ordinary voice; it is not an error, so nothing in the response tells you the
field went nowhere. Naming "model": "vieneu-v3" to get the old behaviour back
is a 400.
GET /v1/emotion-tags returns what the engine can actually render, and it needs
no API key. With no engine it answers for the default — v4 — so the reply is
the three cues above and an empty list of styles; ?engine=v3 is a 400. Ask it
rather than assuming, especially if you are carrying a tag list written against
v3.
Formats
pcm and ulaw are headerless: the bytes carry no sample rate, so read it
from the X-Sample-Rate response header.
Rates: pcm defaults to 24000, matching what OpenAI documents so a client
following their contract plays it at the right speed. Everything else defaults to
48000. opus is always 48 kHz (Opus itself is) and ulaw always 8 kHz, which is
what a phone line wants — passing a conflicting sample_rate is rejected rather
than quietly ignored.
mp3 and opus need
ffmpegon the worker that serves the request. A node that predates it answers 503 naming the format, rather than returning something that is not the format you asked for. The one exception is a request that never named a format: since mp3 is our default rather than your choice, those fall back to wav instead of failing.
Examples
curl
curl https://api.vieneu.io/api/v1/audio/speech \
-H "Authorization: Bearer $VIENEU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "model": "tts-1", "input": "Xin chào, đây là VieNeu.", "voice": "Ngọc Lan" }' \
--output speech.mp3
OpenAI Python SDK (unmodified, pointed at VieNeu)
from openai import OpenAI
client = OpenAI(api_key="vn_sk_...", base_url="https://api.vieneu.io/api/v1")
resp = client.audio.speech.create(
model="tts-1",
voice="Ngọc Lan", # a VieNeu voice id, from GET /v1/audio/voices
input="Xin chào, đây là VieNeu.",
)
resp.stream_to_file("speech.mp3")
Telephony (8 kHz mu-law)
curl https://api.vieneu.io/api/v1/audio/speech \
-H "Authorization: Bearer $VIENEU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "input": "Xin chào", "response_format": "ulaw" }' \
--output line.raw # raw G.711 mu-law, 8000 Hz, no header
Streaming
Set stream_format to start receiving audio while it is still being generated,
instead of waiting for the whole file.
audio— the bytes arrive as chunked transfer encoding. Append them; the result is the same audio you would have got in one piece.sse— Server-Sent Events.speech.audio.deltaevents carry base64 audio; the stream ends with exactly onespeech.audio.done(the audio is complete) orspeech.audio.error(it is not).
with client.audio.speech.with_streaming_response.create(
model="tts-1", voice="Ngọc Lan", input="…", response_format="pcm",
extra_body={"stream_format": "audio"},
) as resp:
resp.stream_to_file("speech.pcm") # raw s16le, 24 kHz
speech.audio.done is the only proof the audio is whole. If a worker dies
mid-generation the stream stops, and a truncated stream is otherwise
indistinguishable from a short one. When that event does not arrive, discard the
audio — the request is refunded automatically.
Streaming is pcm and ulaw only
Streamed audio is generated in chunks and each chunk is encoded independently, so joining them only produces a valid result for the headerless formats:
| Format | Streamable | Why not |
|---|---|---|
pcm, ulaw | ✅ | raw samples; concatenation is playback |
wav | ❌ | every chunk repeats its 44-byte header mid-file |
mp3 | ❌ | each chunk re-applies the encoder's delay — measured at ~24 ms of inserted silence per seam, plus a click |
opus | ❌ | each chunk is a complete Ogg stream; most browsers play only the first one |
Asking for a non-streamable format with stream_format returns 400 rather than
shipping audio with gaps in it. For a complete mp3 or opus file, drop
stream_format — the synchronous response has none of these problems.
This will widen once the worker can hold a single encoder open across a whole stream; today it starts a fresh one per chunk.
Voices
GET /api/v1/audio/voices[?engine=v4] → { "voices": ["Ngọc Lan", …] }
A companion to this endpoint for OpenAI-compatible clients, which look for this
route to fill their voice picker. GET /v1/voices is the richer version — names,
gender, region, and your own cloned voices.
Errors
Returned in OpenAI's shape:
{ "error": { "message": "...", "type": "invalid_request_error", "param": "input", "code": null } }
400 invalid_request_error— missinginput, an unknown or retiredvieneu-…model (vieneu-v3, since 2026-09-24), an unsupportedresponse_format,stream_formatwith a format that cannot be streamed, or asample_ratethat contradicts the format.401 authentication_error— the key is missing, malformed or revoked.403 insufficient_quota— the key's token grant is exhausted or expired. Not 402: nothing on/v1answers402at all. Being out of credit splits across two statuses here, and the split is the part worth branching on: an exhausted or expired grant is403 insufficient_quotaand will not clear on its own, while a spent daily or weekly cap is429 rate_limit_exceededand will. Soinsufficient_quotameans top up or renew;rate_limit_exceededmeans wait.422 content_policy_violation— refused by moderation (only whenaiRefineis on).429 rate_limit_exceeded— the rate limiter throttled you, or a daily or weekly token cap is spent. This envelope cannot tell those two apart: the native routes name the cap incodeand say when it lifts inresetAt, and neither field survives the translation into OpenAI's shape. See Errors.503— no worker for the requested engine, or none that can encode the requested format.500 api_error— synthesis failed (the token charge is refunded).
One caveat on 403. The quota 403 above is typed by the handler itself. A
403 raised before the handler runs — a guard refusing a feature your plan
does not include — is typed from the status alone, which folds 401 and 403
together as authentication_error. So insufficient_quota always means money,
but authentication_error can mean either the key or the plan.
Every response carries X-Request-Id; quote it in a support request. The body
does not repeat it — OpenAI's envelope has no field for it, so read the header.
Billing
Tokens are deducted by the submitted character count, so the cost of a call is predictable from the request alone. The API key's own plan pays first; when it cannot, another active plan on your account pays the whole call. A failed synthesis is refunded automatically, to the plan that paid.
aiRefine defaults to false here, unlike the web app, where it is on. With
it off the text is synthesized as submitted: no AI moderation, no pronunciation
normalization, no surcharge, and one less model round-trip of latency. Set it to
true to get the web app's behaviour — formulas, acronyms and mixed-in English
read correctly, content checked — billed with the AI surcharge.
Deterministic text preparation (chemistry spelling, ALL-CAPS folding, the sea-g2p
phoneme layer) runs either way. aiRefine controls only the AI call.
Differences from OpenAI
voiceexpects a VieNeu voice id, not an OpenAI voice name.aacandflacare not supported;ulawandsample_rateare additions.stream_formatcoverspcmandulawonly, not every format.speedoutside 0.5–2.0 is clamped rather than honoured exactly.- The richer VieNeu features — multi-speaker
dialogue,dub, SRT dubbing — have no OpenAI equivalent. Use the native/v1/*endpoints. Cloned voices are made in the web Studio and then used as an ordinaryvoiceIdonPOST /v1/tts(not on this route — aclone_…id here is a 400).