Chuyển tới nội dung chính

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. 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.
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 emotion and inline cues instead.
sample_rateVieNeu extension: 8000, 16000, 22050, 24000, 44100 or 48000.
emotionVieNeu extension: natural (default) or storytelling.
aiRefineVieNeu 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 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, ulawraw samples; concatenation is playback
wavevery chunk repeats its 44-byte header mid-file
mp3each chunk re-applies the encoder's delay — measured at ~24 ms of inserted silence per seam, plus a click
opuseach 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 — missing input, an unknown vieneu-… model, an unsupported response_format, stream_format with a format that cannot be streamed, or a sample_rate that 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 carry insufficient_quota so an OpenAI client can branch on the type rather than the status.)
  • 422 content_policy_violation — refused by moderation (only when aiRefine is 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

  • 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, voice clone, SRT dubbing — have no OpenAI equivalent. Use the native /v1/* endpoints.