Endpoint TTS tương thích OpenAI
API công khai gốc của VieNeu chạy bất đồng bộ (POST /v1/tts trả về một jobId
để bạn poll). POST /v1/audio/speech là bản thay thế nguyên chỗ cho endpoint
cùng tên của OpenAI: trỏ một SDK OpenAI vào base URL của VieNeu, đưa nó khoá
VieNeu của bạn, và nó chạy mà không cần sửa gì thêm.
Chuyện này quan trọng hơn vẻ ngoài của nó. Rất nhiều phần mềm đã nói được đúng
một endpoint này — Open WebUI, SillyTavern, LobeChat, LiteLLM, plugin OpenAI của
LiveKit và của Pipecat, cùng vô số script lẻ — và tất cả đều cho phép đặt
base_url riêng. Hỗ trợ đúng hình dạng request này chính là thứ khiến VieNeu
dùng được trong chúng mà không cần plugin, không cần adapter và không tốn công
gì ở phía chúng tôi.
Endpoint
POST /api/v1/audio/speech
Authorization: Bearer <VieNeu API key> # vn_sk_... (X-API-Key also accepted)
Content-Type: application/json
Trả về byte audio ngay trong phản hồi — không cần poll.
Thân request
| Trường | OpenAI | Hành vi ở VieNeu |
|---|---|---|
input | bắt buộc | văn bản cần tổng hợp ✅ |
model | bắt buộc | chọn engine — và từ 2026-09-24 chỉ còn một engine, v4. Các tên của OpenAI (tts-1, tts-1-hd, gpt-4o-mini-tts), vieneu và vieneu-v4 đều trỏ về nó. Mọi giá trị khác vẫn được nhận rồi bỏ qua, như trước — trừ một tên vieneu-… trỏ tới engine không tồn tại hoặc đã ngừng, giá trị đó bị từ chối: vieneu-v3 trả 400 kèm Model 'vieneu-v3' is retired. Engine "v3" was retired on 2026-09-24. …. |
voice | alloy, … | một voice id của VieNeu — liệt kê bằng GET /v1/audio/voices. Tên giọng của OpenAI không được ánh xạ. Bỏ trống để dùng giọng mặc định. |
response_format | mp3 (mặc định) | mp3 (mặc định), wav, opus, pcm, cộng thêm ulaw của VieNeu. aac và flac trả về 400. |
speed | 0.25–4.0 | nhận trọn dải của OpenAI rồi kẹp về 0.5–2.0, khoảng mà engine còn giữ được chất lượng. Một giá trị hợp lệ theo OpenAI không bao giờ bị báo lỗi. |
stream_format | audio | sse | hỗ trợ cho pcm và ulaw — xem Streaming. |
instructions | (gpt-4o-mini-tts) | được nhận rồi bỏ qua. Hãy dùng thẻ biểu cảm đặt thẳng trong văn bản thay cho nó. |
sample_rate | — | phần mở rộng của VieNeu: 8000, 16000, 22050, 24000, 44100 hoặc 48000. |
emotion | — | phần mở rộng của VieNeu còn sót lại từ v3: natural (mặc định) hoặc storytelling. Được nhận rồi bỏ qua trên v4 — xem bên dưới. |
aiRefine | — | phần mở rộng của VieNeu, mặc định false — xem Tính phí. |
emotion không còn làm gì nữaemotion vốn là tham số của v3, mà v3 đã ngừng phục vụ trên Cloud API từ
2026-09-24. v4 — giờ là engine duy nhất — không có kiểu đọc nào cả. Nó thể
hiện được ba thẻ biểu cảm đặt thẳng trong văn bản — [cười], [thở dài] và
[hắng giọng] — còn mọi thẻ khác thì bị xoá khỏi văn bản của bạn chứ không
được đọc lên. Một request đặt "emotion": "storytelling" vẫn được nhận, vẫn bị
tính phí, và vẫn đọc bằng giọng bình thường; đó không phải lỗi, nên không có gì
trong phản hồi báo cho bạn biết trường đó đã rơi vào khoảng không. Nêu
"model": "vieneu-v3" để đòi lại hành vi cũ thì là 400.
GET /v1/emotion-tags trả về đúng những gì engine thực sự thể hiện được, và nó
không cần API key. Không truyền engine thì nó trả lời cho engine mặc định —
v4 — tức là ba thẻ ở trên và một danh sách kiểu đọc rỗng; ?engine=v3 là 400.
Hãy hỏi nó thay vì đoán, nhất là khi bạn đang mang theo một danh sách thẻ viết
cho v3.
Định dạng
pcm và ulaw không có header: chuỗi byte không mang theo sample rate, nên
hãy đọc nó từ header phản hồi X-Sample-Rate.
Về tần số: pcm mặc định 24000, khớp với tài liệu của OpenAI, để client nào
tuân thủ đúng contract của họ sẽ phát ở đúng tốc độ. Mọi thứ còn lại mặc định
48000. opus luôn là 48 kHz (bản thân Opus vốn vậy) còn ulaw luôn 8 kHz, đúng
thứ một đường điện thoại cần — truyền vào một sample_rate mâu thuẫn thì bị từ
chối chứ không bị lặng lẽ bỏ qua.
mp3 và opus cần có
ffmpegtrên worker phục vụ request. Một node ra đời trước nó sẽ trả 503 kèm tên định dạng, thay vì trả về một thứ không phải định dạng bạn yêu cầu. Ngoại lệ duy nhất là request không hề nêu định dạng: vì mp3 là mặc định của chúng tôi chứ không phải lựa chọn của bạn, những request đó lùi về wav thay vì báo lỗi.
Ví dụ
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
SDK Python của OpenAI (nguyên bản, trỏ vào 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")
Điện thoại (mu-law 8 kHz)
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
Đặt stream_format để bắt đầu nhận audio ngay khi nó còn đang được sinh ra,
thay vì đợi trọn cả tệp.
audio— các byte về theo chunked transfer encoding. Cứ nối chúng lại; kết quả đúng bằng đoạn audio bạn nhận được nếu lấy nguyên khối.sse— Server-Sent Events. Sự kiệnspeech.audio.deltamang audio dạng base64; luồng kết thúc bằng đúng mộtspeech.audio.done(audio đã trọn vẹn) hoặcspeech.audio.error(thì không).
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 là bằng chứng duy nhất cho thấy audio còn nguyên vẹn.
Nếu một worker chết giữa chừng thì luồng dừng lại, mà ngoài sự kiện đó ra, một
luồng bị cắt cụt không tài nào phân biệt được với một luồng vốn dĩ ngắn. Khi sự
kiện đó không tới, hãy bỏ đoạn audio đi — request được hoàn token tự động.
Streaming chỉ có cho pcm và ulaw
Audio phát theo luồng được sinh theo từng chunk và mỗi chunk được mã hoá độc lập, nên ghép chúng lại chỉ cho ra kết quả hợp lệ với những định dạng không có header:
| Định dạng | Stream được | Vì sao không |
|---|---|---|
pcm, ulaw | ✅ | mẫu thô; nối lại chính là phát |
wav | ❌ | chunk nào cũng lặp lại header 44 byte của nó ở giữa tệp |
mp3 | ❌ | mỗi chunk lại áp lại độ trễ của bộ mã hoá — đo được khoảng 24 ms im lặng chèn vào mỗi mối nối, kèm một tiếng "cụp" |
opus | ❌ | mỗi chunk là một luồng Ogg hoàn chỉnh; hầu hết trình duyệt chỉ phát cái đầu tiên |
Yêu cầu một định dạng không stream được kèm stream_format sẽ nhận về 400, thay
vì được giao một đoạn audio thủng lỗ chỗ. Muốn một tệp mp3 hay opus hoàn
chỉnh thì bỏ stream_format đi — phản hồi đồng bộ không dính vấn đề nào trong
số này.
Danh sách sẽ nới ra khi worker giữ được một bộ mã hoá duy nhất suốt cả luồng; hiện tại nó khởi tạo mới cho từng chunk.
Giọng đọc
GET /api/v1/audio/voices[?engine=v4] → { "voices": ["Ngọc Lan", …] }
Endpoint đi kèm, dành cho các client tương thích OpenAI — chúng tìm đúng route
này để đổ vào ô chọn giọng của mình. GET /v1/voices mới là bản đầy đủ hơn —
tên, giới tính, vùng miền, và cả những giọng bạn tự nhân bản.
Lỗi
Trả về theo hình dạng của OpenAI:
{ "error": { "message": "...", "type": "invalid_request_error", "param": "input", "code": null } }
400 invalid_request_error— thiếuinput, một modelvieneu-…không tồn tại hoặc đã ngừng (vieneu-v3, từ 2026-09-24), mộtresponse_formatkhông được hỗ trợ,stream_formatđi kèm định dạng không stream được, hoặc mộtsample_ratemâu thuẫn với định dạng.401 authentication_error— key thiếu, sai dạng hoặc đã bị thu hồi.403 insufficient_quota— token grant của khoá đã cạn hoặc hết hạn. Không phải 402: không chỗ nào trên/v1trả402cả. Ở đây chuyện "hết tiền" tách ra làm hai status, và chính chỗ tách đó mới là thứ đáng rẽ nhánh: grant cạn hoặc hết hạn là403 insufficient_quotavà tự nó không hết, còn trần theo ngày hoặc theo tuần đã dùng hết là429 rate_limit_exceededvà sẽ tự hết. Nêninsufficient_quotanghĩa là nạp thêm hoặc gia hạn, cònrate_limit_exceedednghĩa là chờ.422 content_policy_violation— bị kiểm duyệt từ chối (chỉ khiaiRefinebật).429 rate_limit_exceeded— bị giới hạn tần suất, hoặc một trần token theo ngày/tuần đã dùng hết. Envelope này không phân biệt được hai thứ đó: các route gốc gọi tên cái trần trongcodevà nói khi nào nó nhả ra trongresetAt, mà cả hai trường ấy đều không sống sót khi chuyển sang hình dạng của OpenAI. Xem Lỗi.503— không có worker nào cho engine được yêu cầu, hoặc không worker nào mã hoá được định dạng được yêu cầu.500 api_error— tổng hợp thất bại (số token đã trừ được hoàn lại).
Một lưu ý về 403. Cái 403 hạn mức ở trên là do chính handler gán type. Còn
một 403 phát sinh trước khi handler chạy — một guard từ chối vì gói của bạn
không có tính năng đó — thì được gán type chỉ theo status, và phép ánh xạ ấy gộp
401 với 403 thành authentication_error. Vậy nên insufficient_quota thì
luôn là chuyện tiền, còn authentication_error có thể là chuyện khoá, cũng có
thể là chuyện gói.
Mọi phản hồi đều mang X-Request-Id; hãy kèm nó khi gửi yêu cầu hỗ trợ. Body thì
không lặp lại giá trị đó — envelope của OpenAI không có trường nào cho nó — nên
hãy đọc từ header.
Tính phí
Token bị trừ theo số ký tự gửi lên, nên chi phí một lời gọi có thể biết trước chỉ từ chính request. Gói của API key trả trước; khi gói đó không trả được, một gói khác còn hoạt động của tài khoản trả trọn lời gọi. Tổng hợp thất bại thì được hoàn token tự động, về đúng gói đã trả.
Ở đây aiRefine mặc định false, khác với web app vốn bật sẵn. Khi tắt,
văn bản được tổng hợp đúng như lúc gửi lên: không kiểm duyệt bằng AI, không
chuẩn hoá cách đọc, không phụ phí, và bớt được một vòng gọi model nên bớt độ
trễ. Đặt true để có hành vi của web app — công thức, từ viết tắt và tiếng Anh
lẫn trong câu đều đọc đúng, nội dung được kiểm — và bị tính kèm phụ phí AI.
Phần chuẩn bị văn bản tất định (đánh vần công thức hoá học, hạ CHỮ HOA, lớp
phoneme sea-g2p) vẫn chạy trong cả hai trường hợp. aiRefine chỉ chi phối lời
gọi AI.
Khác biệt so với OpenAI
voicecần một voice id của VieNeu, không phải tên giọng của OpenAI.aacvàflackhông được hỗ trợ;ulawvàsample_ratelà phần thêm vào.stream_formatchỉ phủpcmvàulaw, không phải mọi định dạng.speednằm ngoài 0.5–2.0 bị kẹp lại chứ không được giữ đúng nguyên giá trị.- Những tính năng phong phú hơn của VieNeu —
dialoguenhiều người nói,dub, lồng tiếng theo SRT — không có thứ tương đương bên OpenAI. Hãy dùng các endpoint gốc/v1/*. Giọng nhân bản thì tạo trên Studio web rồi dùng như mộtvoiceIdbình thường ởPOST /v1/tts(không dùng được ở route này — idclone_…gửi vào đây là 400).