# Streaming

Source: https://docs.vieneu.io/vi/docs/cloud-api/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.

:::info 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](./overview.md#engines).

Đ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](#cloned-voices).
:::

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 {#the-native-stream}

```bash
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-Rate` | Tần số lấy mẫu của audio, tính bằng Hz |
| `X-Output-Format` | Kiểu mã hoá: `wav`, `mp3`, `opus`, `pcm` hoặc `ulaw` |
| `X-Stream-Format` | `len32-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 {#the-zero-length-frame-is-the-point}

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 {#heartbeat-frames}

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:

```python
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 {#formats}

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

```json
{ "text": "…", "voiceId": "Ngọc Lan", "outputFormat": "ulaw" }
```

| Định dạng | Ghi chú |
|---|---|
| `wav` | Mặc định. Mỗi khung là một tệp trọn vẹn, 48 kHz. |
| `pcm` | Signed 16-bit little-endian thô, **không có header** — đọc tần số lấy mẫu từ `X-Sample-Rate`. |
| `ulaw` | G.711 mu-law thô, luôn 8 kHz. Đúng thứ một đường điện thoại cần. |
| `mp3`, `opus` | Có 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.

:::warning 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 {#how-a-stream-ends}

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ẫu | Thành công | **có** |
| Các khung, rồi một khung độ dài 0, nhưng mọi khung đều là nhịp tim | Tổ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 0 | Bị cắt giữa chừng khi đang sinh | khô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ết | không — không thu gì cả |
| `502` | Mọi node của engine này đều hỏng | khô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 {#if-your-client-disconnects}

**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 {#limits}

| Giới hạn | Giá trị | Khi vượt |
|---|---|---|
| Số stream đồng thời | 4 | `429`, `code: STREAM_CONCURRENCY` |
| Số yêu cầu tổng hợp mỗi phút | 300 | `429`, tôn trọng `Retry-After` |
| Độ dài văn bản | 50 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ì.

:::caution 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](./overview.md#rate-limits-and-request-ids).
:::

## Giọng nhân bản {#cloned-voices}

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:

```json
{ "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](https://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.

:::caution 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](https://vieneu.io/#/clone); 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áo | Chuyệ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 {#reference-decoder--python}

```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 {#reference-decoder--javascript}

```js
/** 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 {#openai-style-streaming}

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

```python
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ễ {#latency-honestly}

**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.
