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. OpenAI's names (tts-1, tts-1-hd, gpt-4o-mini-tts) map to the default engine; vieneu-v3 / vieneu-v4 pick one explicitly. Any other value is accepted and ignored, as before — except a vieneu-… name for an engine that does not exist, which is rejected. |
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 emotion and inline cues instead. |
sample_rate | — | VieNeu extension: 8000, 16000, 22050, 24000, 44100 or 48000. |
emotion | — | VieNeu extension: natural (default) or storytelling. |
aiRefine | — | VieNeu extension, default false — see Billing. |
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=v3] → { "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 unknownvieneu-…model, an unsupportedresponse_format,stream_formatwith a format that cannot be streamed, or asample_ratethat contradicts the format.403 insufficient_quota— the key's token grant is empty or expired. (Not 402: VieNeu reports an exhausted grant as 403 and a daily/weekly cap as 429. Both carryinsufficient_quotaso an OpenAI client can branch on the type rather than the status.)422 content_policy_violation— refused by moderation (only whenaiRefineis on).429 rate_limit_exceeded— throttled, or the token quota window was hit.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).
Every response carries X-Request-Id; quote it in a support request.
Billing
Tokens are deducted from the API key's grant by the submitted character count, so the cost of a call is predictable from the request alone. A failed synthesis is refunded automatically.
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, voiceclone, SRT dubbing — have no OpenAI equivalent. Use the native/v1/*endpoints.