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

Streaming

Văn bản dài mất bao lâu để tổng hợp thì phải chờ đúng bấy nhiêu. Streaming cho phép bắt đầu phát ngay từ câu đầu tiên — thường là 1–2 giây với các gói thường, hoặc một trần thoả thuận riêng với Enterprise — thay vì đợi đến câu cuối cùng.

Engine — và cái giá đi kèm

Streaming chạy trên v4. Xưa nay vẫn vậy — v3 dồn các mảnh về cuối rồi trả một lượt, nên yêu cầu stream chỉ định nó bị từ chối thay vì được phục vụ dưới một lời hứa time-to-first-audio mà nó không giữ được — và từ 2026-09-24 v4 là engine duy nhất còn lại trên Cloud API, nên chẳng còn gì để chọn: bỏ trống engine, hoặc gửi v4. Nêu v3 trên route này giờ nhận đúng lỗi 400 như trên mọi route khác: Engine "v3" was retired on 2026-09-24. Use engine "v4" and a voice from GET /v1/voices?engine=v4 — the two catalogues share no ids.

Giá là giá của v4. Một stream tính tiền theo số ký tự gửi lên nhân hệ số của v4 — hiện là 3×, nhân thêm globalMultiplier toàn nền tảng — y hệt một job POST /v1/tts cho cùng đoạn văn bản. Phép so cũ — stream theo mức v4 đặt cạnh job chạy v3 ở 1,5× — không còn nghĩa gì nữa, vì mức 1,5× đã biến mất khỏi mọi route; nếu bạn từng tính ngân sách theo nó, hãy tính lại. GET /v1/engines trả về hệ số đang có hiệu lực, không cần key — xem Engine.

Điều này áp dụng cho cả giọng nhân bản. Từ 2026-08-28, các bản nhân bản đều đăng ký trên v4, nên chúng stream như mọi giọng v4 khác; bản cũ đăng ký trên v3 thì phải đăng ký lại trước đã. Xem Giọng nhân bản.

Có hai endpoint streaming, chọn cái nào là tuỳ vào ai đang đọc các byte đó:

  • POST /v1/audio/speech kèm stream_format — audio thuần hoặc SSE, đúng thứ mà một client OpenAI vốn đã biết đọc. Hãy bắt đầu từ đây.
  • POST /v1/tts/stream — stream chia khung gốc của VieNeu. Dùng nó khi bạn muốn mỗi mảnh đến nơi như một đơn vị giải mã độc lập, hoặc khi bạn muốn có tín hiệu khẳng định rằng stream đã kết thúc trọn vẹn.

Stream gốc của VieNeu​

curl -N -X POST https://api.vieneu.io/api/v1/tts/stream \
-H "Authorization: Bearer $VIENEU_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "text": "…", "voiceId": "Ngọc Lan" }' \
--output stream.bin

Thân phản hồi là một chuỗi các khung có tiền tố độ dài:

[4-byte big-endian uint32 = N][N bytes of audio]   … repeated …
[4-byte big-endian uint32 = 0] ← end of stream

Đọc độ dài, đọc đúng bấy nhiêu byte, rồi lặp lại. Mặc định mỗi payload là một tệp WAV trọn vẹn, nên bạn có thể đưa thẳng một khung cho bộ giải mã mà không phải chờ phần còn lại.

Các header phản hồi cho bạn biết mình thực sự nhận được gì:

HeaderÝ nghĩa
X-Sample-RateTần số lấy mẫu của audio, tính bằng Hz
X-Output-FormatKiểu mã hoá: wav, mp3, opus, pcm hoặc ulaw
X-Stream-Formatlen32-wav-chunks, hoặc len32-frames với các kiểu mã hoá khác

Khung độ dài 0 mới là điểm mấu chốt​

Một stream chạy hết sẽ gửi khung cuối cùng có độ dài 0. Một stream bị đứt vì lỗi giữa chừng thì đơn giản là dừng lại, không có khung đó.

Thiếu nó nghĩa là stream đã bị cắt ngang: hãy bỏ phần audio đi. Bạn không bị tính tiền cho một stream chưa từng gửi khung ấy — tiền được hoàn tự động.

Nhưng riêng dấu kết thúc thì chưa phải là thành công. Một stream có thể về đủ mà vẫn không mang theo tiếng nói nào — một lượt sinh không ra gì sẽ gửi các nhịp tim (xem bên dưới) rồi kết thúc sạch sẽ. Chúng tôi cũng không tính tiền những ca đó, nên bên gọi nào coi dấu kết thúc là đủ sẽ ghi nhận một lần thành công, lưu lại một tệp im lặng, rồi cuối tháng ngồi đối soát với một hoá đơn chưa từng thu tiền lần ấy.

Điều kiện chúng tôi dùng để tính tiền, và cũng là điều kiện bạn nên dùng, gồm cả hai vế:

dấu kết thúc stream đã về, VÀ có ít nhất một khung mang mẫu âm thanh.

Khung nhịp tim​

Một stream lặng đi một lúc sẽ gửi một nhịp tim: một tệp WAV hợp lệ không chứa mẫu nào, 44 byte trên đường truyền. Việc duy nhất của nó là giữ cho kết nối không bị bất cứ thứ gì nằm giữa hai bên ngắt đi vì im lìm quá lâu — một proxy, một gateway của công ty, hay NAT của nhà mạng di động.

Nhịp tim chỉ xuất hiện trên stream WAV (X-Stream-Format: len32-wav-chunks), nhiều nhất một cái mỗi X-Stream-Heartbeat giây, và chỉ khi bộ tổng hợp chưa sinh thêm gì mới. Một stream chảy bình thường không bao giờ gửi nhịp tim.

Bạn bắt buộc phải bỏ qua chúng. Cái bẫy nằm ở chỗ nhịp tim là một tệp WAV đúng chuẩn, nên bộ giải mã sẽ không từ chối nó — decodeAudioData trả về buffer rỗng chứ không ném lỗi, còn ghi khung đó ra tệp thì để lại một header 44 byte lạc lõng giữa đoạn audio của bạn. Kiểm tra khung có khác rỗng hay không là chưa đủ; phải hỏi xem nó có mang mẫu âm thanh hay không:

def has_samples(frame: bytes) -> bool:
"""False for a heartbeat: a valid WAV whose `data` chunk is empty."""
if not frame:
return False # nothing there at all
if len(frame) < 12 or frame[:4] != b"RIFF" or frame[8:12] != b"WAVE":
return True # not a WAV we can read — assume audio
off = 12
while off + 8 <= len(frame):
chunk_id = frame[off:off + 4]
size = int.from_bytes(frame[off + 4:off + 8], "little")
if chunk_id == b"data":
return size > 0
off += 8 + size + (size % 2) # RIFF chunks are word-aligned
return True

Hãy duyệt qua từng chunk thay vì giả định chunk data bắt đầu ở byte 36 — header 44 byte là trường hợp phổ biến, không phải một quy tắc.

Khi không đọc được header, hãy coi khung đó là audio. Đoán sai theo hướng này thì bạn chỉ mất công với một khung lạ; đoán sai theo hướng ngược lại thì bạn vứt đi đúng đoạn tiếng nói mà người nghe đang chờ.

Các stream codec thô (pcm, ulaw, mp3, opus — X-Stream-Format: len32-frames) không bao giờ mang nhịp tim, vì nhét một tệp WAV giả vào giữa bitstream của codec chẳng khác nào nhét rác vào đó. Nếu bạn stream các định dạng ấy, mọi khung đều là audio.

Định dạng​

Truyền outputFormat (và sampleRate nếu muốn) để đổi kiểu mã hoá của payload:

{ "text": "…", "voiceId": "Ngọc Lan", "outputFormat": "ulaw" }
Định dạngGhi chú
wavMặc định. Mỗi khung là một tệp trọn vẹn, 48 kHz.
pcmSigned 16-bit little-endian thô, không có header — đọc tần số lấy mẫu từ X-Sample-Rate.
ulawG.711 mu-law thô, luôn 8 kHz. Đúng thứ một đường điện thoại cần.
mp3, opusCó hỗ trợ, nhưng đọc cảnh báo bên dưới.

Các giá trị sampleRate hợp lệ là 8000, 16000, 22050, 24000, 44100 và 48000. Cách chia khung không đổi dù mã hoá kiểu gì — nên dấu kết thúc độ dài 0 mang đúng một ý nghĩa trong mọi định dạng.

Khung mp3 và opus không ghép liền được

Mỗi khung được mã hoá thành một tệp độc lập, nên hãy giải mã từng khung riêng — đừng nối chúng lại.

Nối mp3 lại sẽ sinh thêm khoảng 24 ms im lặng ở mỗi mối nối (độ trễ của bộ mã hoá, lặp lại ở từng khung) kèm một tiếng cạch nghe rõ. Nối opus lại thì thành một chuỗi nhiều stream Ogg hoàn chỉnh: ffmpeg đọc được, còn phần lớn trình duyệt dừng ở khung đầu tiên.

Nếu bạn muốn một thân mp3 hoặc opus liền mạch thay vì từng khung, hãy yêu cầu trọn tệp mà không streaming — đường đồng bộ mã hoá một lượt và không sinh ra hiện tượng nào kể trên. Vì vậy stream_format của /v1/audio/speech chỉ nhận pcm và ulaw.

Một stream kết thúc như thế nào​

Năm kết cục, và không phải cái nào cũng là lỗi. Phần tích hợp phía bạn nên phân biệt được chúng, vì ba trong số đó nghĩa là hãy thử lại, còn hai thì không.

Bạn thấy gìNghĩa là gìCó tính tiền?
Các khung, rồi một khung độ dài 0, và có ít nhất một khung mang mẫuThành côngcó
Các khung, rồi một khung độ dài 0, nhưng mọi khung đều là nhịp timTổng hợp không ra gìkhông — được hoàn
Thân phản hồi dừng ngang, không có khung độ dài 0Bị cắt giữa chừng khi đang sinhkhông — được hoàn
503 kèm "code": "STREAM_BUSY"Mọi node đều khoẻ nhưng năng lực streaming đã dùng hếtkhông — không thu gì cả
502Mọi node của engine này đều hỏngkhông — được hoàn

STREAM_BUSY mang theo "fallback": "generate", và đó là một chỉ dẫn thật sự: đường POST /v1/tts chạy qua hàng đợi có năng lực riêng và sẽ nhận việc ngay lúc này. Thử lại stream ngay lập tức thì thường là không được.

Một stream đã tới được chỗ chúng tôi nhưng suốt 120 giây không sinh ra audio nào sẽ bị bỏ ở phía máy chủ và kết thúc mà không có khung độ dài 0 — tức là nó rơi vào dòng bị cắt giữa chừng ở bảng trên. Cái trần đó tồn tại để một node bị kẹt không thể giữ kết nối của bạn mở vô thời hạn.

Nếu client của bạn ngắt kết nối​

Bạn vẫn bị tính tiền. Đóng kết nối giữa chừng — người dùng bấm Dừng, timeout ở phía bạn, một worker của chính bạn chết — vẫn tính tiền cho yêu cầu đó. Bạn đã nhận audio; chúng tôi đã sinh ra nó.

Đây là chủ ý, và là trường hợp duy nhất mà không có khung độ dài 0 lại không đồng nghĩa với hoàn tiền. Nếu bạn thử lại sau khi huỷ ngang, hãy tính ngân sách cho cả hai lần.

Giới hạn​

Giới hạnGiá trịKhi vượt
Số stream đồng thời4429, code: STREAM_CONCURRENCY
Số yêu cầu tổng hợp mỗi phút300429, tôn trọng Retry-After
Độ dài văn bản50 000 ký tự400

Mức đồng thời được tính trên token grant đứng sau API key của bạn, không phải trên bản thân key, nên nhiều key cấp trong cùng một tài khoản thường chia nhau bốn suất chứ không phải mỗi key có bốn suất riêng. Suất được giữ chỗ trước khi động đến số dư: một stream bị từ chối ở đây không tốn gì.

Route stream có trần kết nối chặt hơn nhiều

POST /v1/tts/stream khớp vào một location nginx của riêng nó — chính là location tắt buffering phản hồi, thiếu nó thì streaming mất sạch ý nghĩa. Location đó không thừa hưởng hạn mức 300 kết nối mỗi địa chỉ khá rộng rãi của /api/v1; nó rơi về mức chung của cả server là 20 kết nối đồng thời trên mỗi IP nguồn, và mức 500 yêu cầu/phút mỗi địa chỉ thay vì 3000.

Một stream giữ kết nối mở suốt quá trình tổng hợp, nên 20 suất đó bị giữ chứ không quay vòng. Hai mươi stream đồng thời từ một địa chỉ đi ra là con số hoàn toàn thực tế với một hệ tích hợp bận rộn, và stream thứ hai mươi mốt bị nginx từ chối bằng một 503 trơ trụi — một trang lỗi HTML, không Retry-After, không X-RateLimit-*, và không có chút JSON nào như STREAM_BUSY mang theo. Chính sự thiếu vắng đó giúp bạn phân biệt hai loại 503: của chúng tôi có thân phản hồi và một "fallback" để bạn hành động theo, của lớp biên thì không có gì cả. Nó cũng được tính chung cho tất cả những ai ở sau địa chỉ đó, không riêng bạn. Xem Giới hạn tần suất.

Giọng nhân bản​

Truyền một id dạng clone_… vào voiceId là nó stream y như mọi giọng khác — cùng các khung ấy, cùng dấu kết thúc ấy, cùng bộ header ấy:

{ "text": "…", "voiceId": "clone_9f2c1e04-…" }

Các id này lấy từ GET /v1/voices kèm API key của bạn (chúng nằm chung danh sách với catalogue, gắn nhãn "kind": "cloned"). Còn bản thân giọng thì bạn tạo trên Studio web tại vieneu.io/#/clone — API không còn tạo giọng nữa — và dùng được ở đây ngay khi vừa lưu. Cả bản nhân bản của chính bạn lẫn bản mà quản trị viên đã đưa lên catalogue đều dùng được.

Giá bằng đúng một giọng có sẵn. Streaming tính tiền theo số ký tự gửi lên × hệ số của engine, còn nhân bản không cộng thêm hệ số nào của riêng nó. Phí tạo giọng thu ở Studio chứ không thu ở đây, nên một stream bằng giọng clone_… tốn đúng bằng cùng đoạn văn bản đọc bằng giọng catalogue.

Các bản nhân bản đăng ký trước 2026-08-28

Mọi bản nhân bản tạo từ 2026-08-28 trở đi đều đăng ký trên v4, và chúng stream đúng như mô tả ở trên. Bản đăng ký trước đó thì được cắt cho v3, mà v3 đã ngừng phục vụ trên Cloud API từ 2026-09-24: giọng như vậy không còn xuất hiện trong GET /v1/voices và không dựng tiếng được qua API trên bất kỳ route nào — engine duy nhất đọc được đoạn mẫu của nó chính là engine giờ trả 400. Hãy đăng ký lại trên Studio; id clone_… mới sẽ stream như mọi giọng v4 khác. Audio bạn đã tạo bằng giọng cũ trước ngày ngừng không bị ảnh hưởng.

Có hai loại lỗi đáng phân biệt, và cả hai đều về dưới dạng 400 trước khi bất cứ khoản nào bị tính:

Thông báoChuyện gì đã xảy ra
Cloned voice "…" was not found among your voices.Sai id, hoặc bản nhân bản thuộc về tài khoản khác
Cloned voice "…" is no longer available.Giọng vẫn còn nhưng đoạn mẫu tham chiếu đã mất — thường là bị xoá ngay giữa lúc yêu cầu đang chạy

Bộ giải mã tham chiếu — Python​

import struct, requests

def stream_frames(text, voice, api_key, output_format="wav"):
"""Yield each audio frame. Raises if the stream was truncated."""
resp = requests.post(
"https://api.vieneu.io/api/v1/tts/stream",
headers={"Authorization": f"Bearer {api_key}"},
json={"text": text, "voiceId": voice, "outputFormat": output_format},
stream=True,
)
resp.raise_for_status()
print("sample rate:", resp.headers.get("X-Sample-Rate"))

buf, complete, any_audio = bytearray(), False, False
for chunk in resp.iter_content(chunk_size=8192):
buf.extend(chunk)
# A frame may span chunks, and several may arrive in one.
while len(buf) >= 4:
(length,) = struct.unpack(">I", buf[:4])
if length == 0: # end-of-stream marker
complete = True
del buf[:4]
continue
if len(buf) < 4 + length: # frame not all here yet
break
frame = bytes(buf[4:4 + length])
del buf[:4 + length]
if has_samples(frame): # skip heartbeats — see above
any_audio = True
yield frame

if not complete:
raise RuntimeError("stream truncated — discard this audio")
if not any_audio:
# Kết thúc sạch nhưng không một mẫu nào: chúng tôi cũng không tính tiền
# ca này. Coi nó là thành công là ghi sổ lệch với hoá đơn.
raise RuntimeError("stream carried no audio — not billed, do not save")

Bộ giải mã tham chiếu — JavaScript​

/** False for a heartbeat: a valid WAV whose `data` chunk is empty. */
function hasSamples(frame) {
if (frame.length === 0) return false; // nothing there at all
if (frame.length < 12) return true; // too short to read — assume audio
const dv = new DataView(frame.buffer, frame.byteOffset, frame.byteLength);
const tag = (o) => String.fromCharCode(...frame.subarray(o, o + 4));
if (tag(0) !== 'RIFF' || tag(8) !== 'WAVE') return true; // not WAV — assume audio
let off = 12;
while (off + 8 <= frame.length) {
const size = dv.getUint32(off + 4, true);
if (tag(off) === 'data') return size > 0;
off += 8 + size + (size % 2); // RIFF chunks are word-aligned
}
return true;
}

async function* streamFrames(text, voice, apiKey, outputFormat = 'wav') {
const resp = await fetch('https://api.vieneu.io/api/v1/tts/stream', {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ text, voiceId: voice, outputFormat }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);

const reader = resp.body.getReader();
let buf = new Uint8Array(0);
let complete = false;
let anyAudio = false;

for (;;) {
const { done, value } = await reader.read();
if (done) break;

const next = new Uint8Array(buf.length + value.length);
next.set(buf);
next.set(value, buf.length);
buf = next;

for (;;) {
if (buf.length < 4) break;
const length = new DataView(buf.buffer, buf.byteOffset, 4).getUint32(0);
if (length === 0) { // end-of-stream marker
complete = true;
buf = buf.subarray(4);
continue;
}
if (buf.length < 4 + length) break;
const frame = buf.slice(4, 4 + length);
buf = buf.subarray(4 + length);
if (hasSamples(frame)) { anyAudio = true; yield frame; } // skip heartbeats
}
}

if (!complete) throw new Error('stream truncated — discard this audio');
// Kết thúc sạch nhưng không một mẫu nào: chúng tôi cũng không tính tiền ca
// này. Coi nó là thành công là ghi sổ lệch với hoá đơn.
if (!anyAudio) throw new Error('stream carried no audio — not billed, do not save');
}

Với cách chia khung WAV mặc định, mỗi khung sinh ra là một tệp hoàn chỉnh — trong trình duyệt bạn có thể đưa thẳng chúng cho decodeAudioData rồi xếp kết quả vào hàng đợi. Chính lớp chắn hasSamples ở trên làm cho việc đó an toàn: thiếu nó, một nhịp tim sẽ giải mã ra buffer rỗng và lặng lẽ chen vào hàng đợi.

Streaming kiểu OpenAI​

Nếu bạn gọi từ một client OpenAI, hãy bỏ qua hẳn phần chia khung:

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

stream_format: "audio" trả cho bạn các byte audio dưới dạng chunked transfer encoding — cứ nối tiếp vào là bạn có trọn tệp. stream_format: "sse" trả về Server-Sent Events: các sự kiện speech.audio.delta mang audio dạng base64, kết thúc bằng đúng một speech.audio.done hoặc speech.audio.error.

Cũng như với stream gốc, speech.audio.done mới là bằng chứng cho thấy audio còn nguyên vẹn; nếu nó không bao giờ tới, hãy bỏ những gì bạn đã nhận — bạn không bị tính tiền.

stream_format chỉ nhận pcm và ulaw, vì lý do đã nêu ở cảnh báo bên trên: các định dạng còn lại không thể nối lại thành một kết quả phát được. Muốn có tệp mp3 hoặc opus hoàn chỉnh, hãy gửi một yêu cầu thường, không streaming.

Nói thật về độ trễ​

Với các gói thường — từ Starter tới Hội viên, tất cả những gói đi chung một lane — âm thanh đầu tiên về sau khoảng 1–2 giây tính trọn đường đi. Chừng đó là đủ nhanh cho đọc thành tiếng, lồng tiếng, câu thoại IVR và những trợ lý chấp nhận một nhịp chờ trước khi cất tiếng. Nó không thuộc nhóm 200–300 ms mà các agent hội thoại thời gian thực nghiêm ngặt đòi hỏi, và không có cách chỉnh nào ở phía client kéo nó xuống được: cái chờ là hàng đợi cộng mảnh mở đầu, không phải đường truyền của bạn.

Enterprise là ngoại lệ, bằng hợp đồng chứ không bằng may rủi. Gói đó chạy trên node GPU riêng với một lane không chia cho ai, nên trần độ trễ trở thành thứ hai bên thoả thuận — xuống tới ≤ 250 ms — thay vì phụ thuộc lúc đó hàng chung đang tải bao nhiêu. Đó là một trần ghi trong hợp đồng và định cỡ theo lưu lượng của bạn, không phải con số bạn đo được trên endpoint dùng chung rồi trông đợi nó giữ nguyên. Nếu bạn đang xây một hệ thời gian thực, hãy trao đổi với chúng tôi trước khi thiết kế bám theo con số 1–2 giây ở trên.