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

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ườngOpenAIHành vi ở VieNeu
inputbắt buộcvăn bản cần tổng hợp ✅
modelbắt buộcchọ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. ….
voicealloy, …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_formatmp3 (mặc định)mp3 (mặc định), wav, opus, pcm, cộng thêm ulaw của VieNeu. aac và flac trả về 400.
speed0.25–4.0nhậ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_formataudio | ssehỗ 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ữa

emotion 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ó ffmpeg trê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ện speech.audio.delta mang audio dạng base64; luồng kết thúc bằng đúng một speech.audio.done (audio đã trọn vẹn) hoặc speech.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ạngStream đượcVì 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ếu input, một model vieneu-… không tồn tại hoặc đã ngừng (vieneu-v3, từ 2026-09-24), một response_format không được hỗ trợ, stream_format đi kèm định dạng không stream được, hoặc một sample_rate mâ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 /v1 trả 402 cả. Ở đâ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_quota và 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_exceeded và sẽ tự hết. Nên insufficient_quota nghĩa là nạp thêm hoặc gia hạn, còn rate_limit_exceeded nghĩa là chờ.
  • 422 content_policy_violation — bị kiểm duyệt từ chối (chỉ khi aiRefine bậ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 trong code và nói khi nào nó nhả ra trong resetAt, 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​

  • voice cần một voice id của VieNeu, không phải tên giọng của OpenAI.
  • aac và flac không được hỗ trợ; ulaw và sample_rate là phần thêm vào.
  • stream_format chỉ phủ pcm và ulaw, không phải mọi định dạng.
  • speed nằ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 — dialogue nhiề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ột voiceId bình thường ở POST /v1/tts (không dùng được ở route này — id clone_… gửi vào đây là 400).