Skip to main content

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​

FieldOpenAIVieNeu behavior
inputrequiredthe text to synthesize ✅
modelrequiredselects 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. ….
voicealloy, …a VieNeu voice id — list them with GET /v1/audio/voices. OpenAI's voice names are not mapped. Omit for the default voice.
response_formatmp3 (default)mp3 (default), wav, opus, pcm, plus VieNeu's ulaw. aac and flac return 400.
speed0.25–4.0accepted 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_formataudio | ssesupported 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 more

emotion 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 ffmpeg on 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.delta events carry base64 audio; the stream ends with exactly one speech.audio.done (the audio is complete) or speech.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:

FormatStreamableWhy 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 — missing input, an unknown or retired vieneu-… model (vieneu-v3, since 2026-09-24), an unsupported response_format, stream_format with a format that cannot be streamed, or a sample_rate that 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 /v1 answers 402 at all. Being out of credit splits across two statuses here, and the split is the part worth branching on: an exhausted or expired grant is 403 insufficient_quota and will not clear on its own, while a spent daily or weekly cap is 429 rate_limit_exceeded and will. So insufficient_quota means top up or renew; rate_limit_exceeded means wait.
  • 422 content_policy_violation — refused by moderation (only when aiRefine is 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 in code and say when it lifts in resetAt, 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​

  • voice expects a VieNeu voice id, not an OpenAI voice name.
  • aac and flac are not supported; ulaw and sample_rate are additions.
  • stream_format covers pcm and ulaw only, not every format.
  • speed outside 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 ordinary voiceId on POST /v1/tts (not on this route — a clone_… id here is a 400).