目錄(20 節)
- 重點摘要
- 實時聽寫同檔案轉寫,係兩個唔同嘅物種
- 連線之前:簽一枚短命 token
- 事件次序:八步,由連線到收線
- 音訊格式:PCM16LE 單聲 16 kHz
- delta 語義:點解唔可以當佢係定稿
- 實測:base64 流量同編碼開支,數字係幾多
- 五個語言 profile,揀錯會即刻畀你知
- close code 對照:邊啲可以重試、邊啲唔好
- 限額同計費
- 完整 Node 例子
- 常見問題
- 實時聽寫同檔案轉寫,應該邊個?
- delta 唔係定稿,我應該幾時用?
- 一次 session 可以聽幾耐?
- 我 send 咗 WAV 檔,點解即刻被踢?
- 重連之後啱啱嗰段文字會唔會重複出現?
- 計費係按開連線時間定,定係按實際音訊定?
- 想即刻試下,唔使寫 code?
- 想將即時聽寫接落自己嘅系統?
即時語音轉文字 WebSocket 教學:delta 點拼、token 點簽、close code 點解
實時聽寫 API 到底點接?本文拆解 WebSocket 事件次序、ephemeral token 點簽、delta 增量文字點拼、11 個 close code 邊啲可以重試,同一套完整嘅 Node 程式碼。
一句答你:即時聽寫嘅 WebSocket 協議,最容易踩坑嘅唔係點樣送音頻,而係點樣處理
transcription.delta— 佢係增量文字,永遠唔會改寫已經發佈過嘅內容;真正嘅定稿係transcription.done。你如果當 delta 係定稿,就會出現字幕越出越錯、或者要重寫成段歷史嘅 bug。
做過語音 agent 嘅人大概都試過呢個位:server 慢慢吐字,你唔知幾時可以當佢完成;重連之後又唔知啱啱嗰段係咪要重做。呢篇將港嘢公開嘅實時聽寫 WebSocket 契約逐項拆開:連線、token、音訊格式、事件次序、delta 規則、close code、限額、計費,全部照公開文件寫,唔加料。
想先了解引擎能力同點測準確度,睇廣東話語音轉文字實測方法;想知呢個功能嘅整體規格同使用場景,睇廣東話語音轉文字功能頁。
重點摘要
- 連線之前先簽 token:
POST /v1/asr/realtime/token攞一枚cst_…短命憑證(60–600 秒,預設 300 秒),再以?token=開連線。永久 API key 唔可以放入 URL。 - 事件次序有八步:server 出
gateway.welcome同session.created,你送session.update同input_audio_buffer.commit,之後反覆送input_audio_buffer.append,server 每約 2 秒回一次transcription.delta,最後你送final: true嘅 commit,server 回transcription.done之後收線。 - 增量文字 係指只包含新增加嘅部分、而且係單調遞增嘅輸出;
transcription.done先係最終全文。兩者唔可以互換。 - 音訊必須係 PCM16LE 單聲 16 kHz 嘅 base64,32,000 bytes = 1 秒;單 frame ≤640 KB、平均 ≤640 KB/s。送 WAV/MP3 容器檔會被即刻拒絕(
4010),而且零計費。 - 11 個 close code 分兩類:
1011/1013/1006可以退避重連;4001/4002/4007/4008/4009/1008/1001唔好重試,因為重試都會即刻再死。 - 語言 profile 有五個(
auto-v1、yue-zh-en-mixed-v1、yue-trad-v1、zh-cmn-v1、en-v1),用?profile=帶;傳錯 id 會以1008 invalid_profile拒絕,唔會靜靜轉錯語言。 - 限額:全平台同時 10 條 session、每條 API key 同時 3 條、單 session 上限 3600 秒、閒置 120 秒收線。
- 計費 HK$1.40/音頻小時(批次轉寫嘅 1.75 倍),按秒計、只收成功音訊。
實時聽寫同檔案轉寫,係兩個唔同嘅物種
開始寫 code 之前,先分清楚兩條路,唔好將佢哋嘅心智模型搞混。
| 檔案轉寫 | 實時聽寫 | |
|---|---|---|
| 傳輸 | 一次過交一個完整音檔 | 一條長連線,逐段推音訊入去 |
| 輸入格式 | wav/mp3/m4a/webm/ogg/flac 容器 | raw PCM16LE 單聲 16 kHz,base64 |
| 交付 | 同一個 request 回完整文字 | transcription.delta 增量 + transcription.done 定稿 |
| 單段長度 | 同步 8 分鐘;非同步 4 小時 | 單 session 3600 秒,閒置 120 秒 |
| 計費 | HK$0.80/音頻小時 | HK$1.40/音頻小時 |
| 典型用途 | 會後整理、批量轉寫 | 直播字幕、語音 agent 內部轉寫 |
最實際嘅分別係:檔案轉寫你唔使理增量,收到就係定稿;實時聽寫你一定要自己處理「增量拼接」同「定稿取代」兩件事。好多 bug 都係因為用咗檔案轉寫嘅心智模型去寫實時聽寫。
如果你想要嘅係「錄完先交」,行檔案轉寫就夠,格式同壓縮嘅嘢睇音檔壓縮上載指南。
連線之前:簽一枚短命 token
第一個要記嘅規矩:永久 API key 唔可以放入 URL。 URL 會出現喺日誌、代理記錄、Referer header,風險太大。
正確做法係分兩步:
第一步(伺服器端)簽 token。 叫 POST /v1/asr/realtime/token,攞返一枚 cst_… 開頭嘅短命憑證。
curl -X POST https://api.canto-gen.com/v1/asr/realtime/token \
-H "Authorization: Bearer $CANTAGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"expires_in_seconds":300}'
expires_in_seconds 可以係 60 至 600 之間,預設 300。短命 token 嘅意思係:呢個憑證只係一次短暫開門用,過期就作廢,就算漏咗喺 URL 都冇長期損失。
第二步(瀏覽器)用 token 開連線。
const ws = new WebSocket(
`wss://api.canto-gen.com/v1/asr/realtime?token=${token}&sample_rate=16000&channels=1`
);
sample_rate 同 channels 係格式宣告 — 你用咩 rate 推,就按咩 rate 折算計費秒數。唔宣告嘅話,就當 16 kHz 單聲嘅預設契約。宣告錯咗,會出現兩種情況:送 48 kHz 但宣告 16 kHz,server 會按 16 kHz 嘅速率解讀,你收到嘅係垃圾轉寫,而且按你收到嘅 bytes 計費。呢個係協議寫明嘅轉檔責任喺 client。至於宣告同實際採樣率唔一致時錯譯嘅具體程度(以及實際扣費秒數),我哋未有一份可公開重複嘅實測,所以呢度唔會俾一個數;協議只寫明會按收到嘅 bytes 計費。如果你要喺 production 用,請自己用一段已知內容嘅音檔做一次對照。
(伺服器對伺服器嘅場景,即係 Node 後端,仲可以直接用 Authorization: Bearer sk_… 開連線,唔使 token。)
事件次序:八步,由連線到收線
逐個講:
| # | 方向 | 事件 | 內容 |
|---|---|---|---|
| 1 | server → client | gateway.welcome | 連線建立,附費率等資料 |
| 2 | server → client | session.created | session 已經開好 |
| 3 | client → server | session.update | 宣告 model: "asr" |
| 4 | client → server | input_audio_buffer.commit | final: false,開一段新嘅 buffer |
| 5 | client → server | input_audio_buffer.append | audio: "<base64>",可以送 N 次 |
| 6 | server → client | transcription.delta | 增量文字,大約每 2 秒一段 |
| 7 | client → server | input_audio_buffer.commit | final: true,要求 flush |
| 8 | server → client | transcription.done | 最終全文;之後 server 會收線 |
最後 server 會送一個 gateway.close(帶 code 同 reason),然後 socket 關閉。transcription.done 一到就會收線(≤6 秒兜底),所以你唔會收到「done 之後仲有 delta」嘅情況 — 但亦即係話,done 之後呢條連線就唔再用得,要繼續聽就要開新 session。
第 4 步同第 7 步嘅分別好重要:final: false 係「我開一段,你開始聽」;final: true 係「我講完,flush 埋」。中間你可以送幾百次 append,每次都係新嘅 PCM 音訊。
音訊格式:PCM16LE 單聲 16 kHz
呢段係最多人搞錯嘅位。
- PCM16LE 係 16-bit little-endian 線性 PCM,冇任何壓縮。
- 單聲(mono)。
- 16 kHz 採樣率。
- 每秒 = 16,000 個 sample × 2 bytes = 32,000 bytes,base64 編碼之後大約多三成。
瀏覽器最直接嘅做法係用 Web Audio API 指定採樣率:
const audioCtx = new AudioContext({ sampleRate: 16000 });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const source = audioCtx.createMediaStreamSource(stream);
const processor = audioCtx.createScriptProcessor(4096, 1, 1);
new AudioContext({ sampleRate: 16000 }) 呢句係關鍵 — 瀏覽器會幫你重採樣。規格可以喺Web Audio API 規格查。
如果你係由檔案推(例如伺服器上面批量轉),用 ffmmpeg 轉成 raw PCM 就得(ffmpeg 官方文件有完整參數):
ffmpeg -i input.wav -f s16le -ar 16000 -ac 1 output.pcm
送容器檔會被拒。 如果你將一個 wav 檔 base64 咁推上去,server 會回 4010 invalid_audio_format,而且係零計費 — 因為 gateway 喺計費之前就拒絕咗。呢個設計對用戶好好:錯唔會收錢。
另外有兩條 frame 規則:單一 frame 唔可以超過 640 KB,平均流量唔可以超過 640 KB/s。640 KB ≈ 20 秒嘅 raw PCM,實務上唔會撞到,但如果你一次過 buffer 咗好長先送,就有可能。
delta 語義:點解唔可以當佢係定稿
增量文字 係指每次 transcription.delta 只帶新增加嘅嗰截,而且已經發佈咗嘅部分永遠唔會被改寫。transcription.done 先係嗰段嘅最終全文。
呢個語義唔係我哋發明嘅,係即時語音辨識(streaming ASR)整個領域嘅通用設計。學術上有大量文獻研究呢個問題:串流式 ASR 嘅中間結果(partial hypothesis)通常係「可以被之後更多上下文改寫嘅」,因為喺語境未齊之前,模型只會猜最可能嘅版本。
一篇 2020 年嘅研究(串流式 ASR 質素與穩定性分析)就指出,partial 結果被改寫會造成「不穩定」問題,仲提議咗衡量詞級同段落級穩定性嘅方法。另一篇(以局部一致決定輸出前綴嘅低延遲解碼)提出咗「只提交最可能穩定嘅前綴、其餘先唔出」嘅做法 — 即係寧願遲啲出字,都唔出會被改寫嘅字。
所以港嘢揀咗最保守嗰條路:delta 係 append-only。 好處係你個 client 簡單到可以「收到就 append」;壞處係喺句子中途,你見到嘅文字可能仲會變 — 如果引擎真係改寫咗,會另開一段。
實務上你要做嘅嘢:
- 收到
transcription.delta就 append 落一個 buffer,唔好嘗試改之前嘅字。 - 收到
transcription.done就用text取代 buffer(唔係再 append)。 - 喺 UI 上分開處理「進行中」同「已完成」兩種狀態 — 例如灰色顯示 delta、黑色顯示 done。
- 如果你嘅下游(LLM、關鍵字觸發)需要穩定文字,等
done先用。
因為逐字稿嘅語義係「忠於原話」,中間結果唔應該當成最終輸出攞去存檔或者發送。
實測:base64 流量同編碼開支,數字係幾多
好多人在估算即時聽寫嘅前端開支時,只知道「32,000 bytes = 1 秒」,但唔知實際要傳幾多、編碼要幾耐。我哋喺 Node 20 度實測咗一次。
測試方法:產生 60 秒嘅 16 kHz 單聲 PCM16LE 緩衝(填入 440 Hz 正弦波,避免全零被壓縮),量度實際位元組、base64 輸出長度,再用 200 次重複編碼量度平均耗時。跑喺一般 x86-64 Linux 開發機。
| 項目 | 實測值 |
|---|---|
| 60 秒 raw PCM | 1,920,000 bytes(正好 32,000 bytes/s) |
| 60 秒 base64 | 2,560,000 bytes(剛好係原來嘅 1.3333 倍) |
| base64 實際流量 | 42,667 bytes/s(約 41.7 KiB/s) |
| 編碼 60 秒音訊耗時 | 0.39 ms(即每音頻秒約 6.5 微秒) |
| 100 ms frame 數量(60 秒) | 600 個,每個 4,268 個 base64 字元 |
| 640 KB 單 frame 上限 | 相當於 20.5 秒音訊 |
| 640 KB/s 平均上限 | 對比實際流量有 15.4 倍餘量 |
【實測數據】以下 3 個結論,全部建基於上面嗰 60 秒樣本、200 次重複編碼嘅實測:
一、base64 開支唔係瓶頸。 每音頻秒 6.5 微秒嘅編碼成本,遠低過即時播放(16 kHz 即時本身就係每 20 毫秒一幀)嘅時間要求。千萬唔好為咗「慳 base64」而改用 binary frame — 協議只收 text frame。
二、流量上限基本上唔會撞到。 640 KB/s 對比實際 42.7 KB/s 有 15.4 倍餘量;即使你用 100 ms 一個 frame(每個 4,268 字元)都綽綽有餘。真正要留意嘅反而係單 frame 上限:如果你貪方便,一次過把成段 30 秒音訊塞一個 frame,就會撞 1009。
三、base64 膨脹係固定嘅 4/3。 唔好喺容量估算時用「差唔多」— 1,920,000 bytes 一定變 2,560,000,多咗 640,000 bytes。做流量預算就照乘 1.3333。
五個語言 profile,揀錯會即刻畀你知
即時連線可以用 ?profile=<id> 指定語言:
| Profile id | 用途 |
|---|---|
yue-zh-en-mixed-v1 | 粵英中三語混合(預設) |
yue-trad-v1 | 純粵語 |
zh-cmn-v1 | 普通話 |
en-v1 | 英語 |
auto-v1 | 自動偵測 |
傳一個唔存在嘅 id,server 會以 close code 1008 加 invalid_profile 拒絕,唔會 fail-open 去猜。呢個設計值得欣賞:錯得明顯,好過靜靜地轉錯語言然後你花兩日去查。
選 profile 嘅原則好直接:內容以廣東話為主就揀 yue-zh-en-mixed-v1;如果係法庭、訪談、醫療呢種術語固定嘅場景,揀 yue-trad-v1 反而可能更穩定,因為語意範圍收窄咗。
close code 對照:邊啲可以重試、邊啲唔好
呢個係實時聽寫最實用嘅一張表。分錯類嘅話,你嘅 client 會出現兩種極端:不停重連撞牆,或者一遇到錯就死。
| code | reason | 意思 | 應該點做 |
|---|---|---|---|
| 1000 | completed | 正常完(收到 done) | 唔使處理 |
| 1001 | idle_timeout | 120 秒冇收到你任何 frame | 檢查你係咪停咗推音訊;唔好即刻重試 |
| 1008 | protocol_violation | 壞 protocol/binary frame/壞 base64/超速 | 唔好重試,先查你嘅 client |
| 1008 | invalid_profile | profile id 唔存在 | 唔好重試,改 profile |
| 1009 | message_too_big | 單 frame 超過 640 KB | 拆細啲再送 |
| 1011 | engine_lost | 引擎中途收線 | 可以退避重連 |
| 1013 | engine_unavailable | 引擎暫時連唔上 | 可以退避重連 |
| 4001 | invalid_or_expired_token | key 無效/過期/撤銷 | 唔好重試,重新簽 token |
| 4002 | insufficient_balance | 連線前餘額唔夠 | 唔好重試,儲值 |
| 4007 | session_too_long | 超過 3600 秒 | 唔好重試同一條,分段 |
| 4008 | capacity_exceeded | 同時 session 撞上限 | 退避重試(幾分鐘後) |
| 4009 | insufficient_balance | session 中途餘額唔夠 | 唔好重試,儲值 |
| 4010 | invalid_audio_format | 送咗容器檔 | 唔好重試,改傳 raw PCM16LE |
分界線好清楚:凡係「暫時性」嘅(引擎失聯、服務不可用、容量滿)可以退避重連;凡係「你做錯咗」或者「你冇錢」嘅(token 過期、餘額不足、格式錯、profile 錯、session 太長),重試都會即刻再死。
【獨家觀察】退避重連嘅正確做法:每次重新連線都要重新簽一枚 token(因為舊 token 可能已經用咗或者過期),並且用指數退避加隨機 jitter,避免一大堆 client 同時撞返上去。呢個位好易漏:好多 client 實作重連時只係重新 new WebSocket(url),token 沿用返舊嗰個,結果撞 4001 就以為係「偶發網絡問題」不停重試 —— 但 4001 係永久性錯誤,重試一百次都一樣。分得出「可重試」同「唔可重試」,係實時聽寫最基本嘅功課。
WebSocket 嘅連線同關閉語義,可以喺 RFC 6455 同 MDN 嘅 WebSocket 文件搵到完整規格。
限額同計費
| 項目 | 限制 |
|---|---|
| 全平台同時 session | 10 條 |
| 每條 API key 同時 session | 3 條 |
| 單 session 長度 | 3600 秒 |
| 閒置(冇 client frame) | 120 秒收線 |
| 每日計費音頻總量 | 24 小時(批次同實時共用同一個上限) |
計費係 HK$1.40/音頻小時,係批次轉寫 HK$0.80 嘅 1.75 倍,按秒計、唔進位。三個時機要記:
- 連線前驗餘額,唔夠一個 tick 就
4002。 - session 中每 60 秒做一次 in-arrears 預扣,唔夠就
4009,即係話你可能聽到一半先收線。 - 收線時按實際音頻秒結算 — 計嘅係你真正推咗幾多秒,唔係連線開咗幾耐。
換句話講,一個靜靜地開住但唔推音訊嘅 session,唔會燒你錢(雖然 120 秒後會被收線)。真正燒錢嘅係實際音訊。【獨家觀察】呢個語義有個好實用嘅推論:唔好為咗「保持個 session 唔死」而定期推靜音 frame。 靜音都係音訊,一樣計費;正確做法係如果真係冇嘢講,就唔好推,等佢 120 秒後自然收線(1001),需要時再重開。
完整 Node 例子
// npm i ws
import WebSocket from "ws";
const WS_URL = "wss://api.canto-gen.com/v1/asr/realtime";
const KEY = process.env.CANTAGEN_API_KEY;
// 1. 簽 token
const tokenRes = await fetch("https://api.canto-gen.com/v1/asr/realtime/token", {
method: "POST",
headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({ expires_in_seconds: 300 }),
});
const { token } = await tokenRes.json();
// 2. 開連線(伺服器對伺服器可以直接用 Bearer header)
const ws = new WebSocket(`${WS_URL}?token=${token}&sample_rate=16000&channels=1`, {
headers: { Authorization: `Bearer ${KEY}` },
});
let buffer = ""; // 增量拼接
const RETRYABLE = new Set([1011, 1013]);
ws.on("open", () => {
ws.send(JSON.stringify({ type: "session.update", model: "asr" }));
ws.send(JSON.stringify({ type: "input_audio_buffer.commit", final: false }));
});
ws.on("message", (raw) => {
const ev = JSON.parse(raw.toString());
if (ev.type === "transcription.delta") {
buffer += ev.delta; // append-only:只加,唔改
process.stdout.write(ev.delta);
}
if (ev.type === "transcription.done") {
buffer = ev.text; // 定稿:取代,唔係再 append
console.log("\n[final]", buffer);
ws.send(JSON.stringify({ type: "input_audio_buffer.commit", final: true }));
}
});
// 3. 餵音訊(16 kHz mono PCM16LE)
// setInterval(() => {
// const chunk = readPcmChunk(); // Buffer, 單聲 16 kHz PCM16LE
// ws.send(JSON.stringify({ type: "input_audio_buffer.append", audio: chunk.toString("base64") }));
// }, 100);
ws.on("close", (code) => {
if (RETRYABLE.has(code)) {
// 退避重連:重新簽 token,然後再連
}
});
瀏覽器版嘅分別只有兩處:用 new AudioContext({ sampleRate: 16000 }) 攞 mic 音訊,同埋 token 要由你自己嘅後端簽好再交畀前端(因為簽 token 需要 API key,唔可以放喺 browser)。
常見問題
實時聽寫同檔案轉寫,應該邊個?
睇你需唔需要「一邊講一邊出字」。要即時字幕、要語音 agent 內部有文字餵下一步,就用實時;要會後整理、要做搜尋同摘要,就用檔案。同一個引擎兩個模式嘅錯誤型態唔一樣,測試要分開跑。
delta 唔係定稿,我應該幾時用?
落任何持久化位置(下載、儲存、觸發下游)之前,都等 transcription.done。delta 淨係俾 UI 顯示用。
一次 session 可以聽幾耐?
3600 秒(60 分鐘)。超過會以 4007 收線,要繼續就開新 session。閒置 120 秒冇推音訊亦會被收線(1001)。
我 send 咗 WAV 檔,點解即刻被踢?
4010 invalid_audio_format。即時通道要 raw PCM16LE 單聲 16 kHz,唔係 wav 容器。零計費,改傳 PCM 就得。
重連之後啱啱嗰段文字會唔會重複出現?
唔會自動重複 — 因為 transcription.done 之後個 session 就完咗。但如果你喺收到 done 之前就斷線,重連係一個全新 session,之前未 flush 嘅內容就冇咗。所以實務上應該定期(例如每 30–60 秒)做一次 final: false 嘅 commit,等內容有機會被確認。
計費係按開連線時間定,定係按實際音訊定?
按實際音頻秒。你推咗幾多秒音訊就計幾多秒;開住連線但唔推音訊唔會燒錢。
想即刻試下,唔使寫 code?
可以喺語音轉錄頁直接開麥克風聽寫。API 層嘅完整規格、錯誤碼同限額喺語音轉寫 API 文件。
想將即時聽寫接落自己嘅系統?
最實際嘅起點係:先喺語音轉錄頁用真實語音試一次,睇吓你要嘅場景啱唔啱用 delta 拼接;之後如果要做落 voice agent,睇即時粵語語音 Agent 架構指南。至於逐字稿之後點處理、逐分鐘成本幾多,睇語音轉文字 vs 請人打字成本。
本文由港嘢內容團隊編輯審閱(editorial review,2026-10-25)。所有協議細節、限額同錯誤碼都對返公開 API 文件同 gateway 實作;如文件有更新,我哋會同步更新本文。想了解我哋嘅背景,可以睇關於港嘢;有技術問題或者想申請功能開通,聯絡我哋。
相關文章
Whisper 2027 年 2 月停用:廣東話 ASR 嘅遷移清單
OpenAI 2026-08-26 通知:whisper-1、gpt-4o-transcribe、gpt-4o-mini-transcribe、gpt-4o-transcribe-diarize 於 2027-02-26 關閉。粵語團隊要點搬?本文用公開文件同公開評測數據整理遷移清單。
2026年10月31日
廣東話轉錄點分辨講者:會議、訪談、客服錄音嘅實務做法
講者分辨(speaker diarization)係會議逐字稿最易被低估嘅一環。本文講清楚三種做法嘅差別、各家公開規格、粵語重疊發言同短回應嘅實務處理,同一個 10 段抽樣驗收法。
2026年10月31日
ASR 部署選擇:雲端、自托管定混合?錄音唔准出公司點算
需求一入「錄音唔可以出公司」,選型問題就由「邊個準」變成「資料去到邊」。
2026年10月31日