目錄(20 節)

即時語音轉文字 WebSocket 教學:delta 點拼、token 點簽、close code 點解

實時聽寫 API 到底點接?本文拆解 WebSocket 事件次序、ephemeral token 點簽、delta 增量文字點拼、11 個 close code 邊啲可以重試,同一套完整嘅 Node 程式碼。

— 港嘢內容團隊約 13 分鐘語音轉文字WebSocket實時聽寫api
分享:

一句答你:即時聽寫嘅 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。)

事件次序:八步,由連線到收線

即時聽寫 WebSocket 事件時序
即時語音轉文字 WebSocket 由 gateway.welcome 到 gateway.close 嘅八步事件時序。

逐個講:

#方向事件內容
1server → clientgateway.welcome連線建立,附費率等資料
2server → clientsession.createdsession 已經開好
3client → serversession.update宣告 model: "asr"
4client → serverinput_audio_buffer.commitfinal: false,開一段新嘅 buffer
5client → serverinput_audio_buffer.appendaudio: "<base64>",可以送 N 次
6server → clienttranscription.delta增量文字,大約每 2 秒一段
7client → serverinput_audio_buffer.commitfinal: true,要求 flush
8server → clienttranscription.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」;壞處係喺句子中途,你見到嘅文字可能仲會變 — 如果引擎真係改寫咗,會另開一段。

實務上你要做嘅嘢:

  1. 收到 transcription.delta 就 append 落一個 buffer,唔好嘗試改之前嘅字。
  2. 收到 transcription.done 就用 text 取代 buffer(唔係再 append)。
  3. 喺 UI 上分開處理「進行中」同「已完成」兩種狀態 — 例如灰色顯示 delta、黑色顯示 done。
  4. 如果你嘅下游(LLM、關鍵字觸發)需要穩定文字,等 done 先用。

因為逐字稿嘅語義係「忠於原話」,中間結果唔應該當成最終輸出攞去存檔或者發送。

實測:base64 流量同編碼開支,數字係幾多

好多人在估算即時聽寫嘅前端開支時,只知道「32,000 bytes = 1 秒」,但唔知實際要傳幾多、編碼要幾耐。我哋喺 Node 20 度實測咗一次。

測試方法:產生 60 秒嘅 16 kHz 單聲 PCM16LE 緩衝(填入 440 Hz 正弦波,避免全零被壓縮),量度實際位元組、base64 輸出長度,再用 200 次重複編碼量度平均耗時。跑喺一般 x86-64 Linux 開發機。

項目實測值
60 秒 raw PCM1,920,000 bytes(正好 32,000 bytes/s)
60 秒 base642,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 會出現兩種極端:不停重連撞牆,或者一遇到錯就死。

codereason意思應該點做
1000completed正常完(收到 done)唔使處理
1001idle_timeout120 秒冇收到你任何 frame檢查你係咪停咗推音訊;唔好即刻重試
1008protocol_violation壞 protocol/binary frame/壞 base64/超速唔好重試,先查你嘅 client
1008invalid_profileprofile id 唔存在唔好重試,改 profile
1009message_too_big單 frame 超過 640 KB拆細啲再送
1011engine_lost引擎中途收線可以退避重連
1013engine_unavailable引擎暫時連唔上可以退避重連
4001invalid_or_expired_tokenkey 無效/過期/撤銷唔好重試,重新簽 token
4002insufficient_balance連線前餘額唔夠唔好重試,儲值
4007session_too_long超過 3600 秒唔好重試同一條,分段
4008capacity_exceeded同時 session 撞上限退避重試(幾分鐘後)
4009insufficient_balancesession 中途餘額唔夠唔好重試,儲值
4010invalid_audio_format送咗容器檔唔好重試,改傳 raw PCM16LE

分界線好清楚:凡係「暫時性」嘅(引擎失聯、服務不可用、容量滿)可以退避重連;凡係「你做錯咗」或者「你冇錢」嘅(token 過期、餘額不足、格式錯、profile 錯、session 太長),重試都會即刻再死。

【獨家觀察】退避重連嘅正確做法:每次重新連線都要重新簽一枚 token(因為舊 token 可能已經用咗或者過期),並且用指數退避加隨機 jitter,避免一大堆 client 同時撞返上去。呢個位好易漏:好多 client 實作重連時只係重新 new WebSocket(url),token 沿用返舊嗰個,結果撞 4001 就以為係「偶發網絡問題」不停重試 —— 但 4001 係永久性錯誤,重試一百次都一樣。分得出「可重試」同「唔可重試」,係實時聽寫最基本嘅功課。

WebSocket 嘅連線同關閉語義,可以喺 RFC 6455 同 MDN 嘅 WebSocket 文件搵到完整規格。

限額同計費

項目限制
全平台同時 session10 條
每條 API key 同時 session3 條
單 session 長度3600 秒
閒置(冇 client frame)120 秒收線
每日計費音頻總量24 小時(批次同實時共用同一個上限)

計費係 HK$1.40/音頻小時,係批次轉寫 HK$0.80 嘅 1.75 倍,按秒計、唔進位。三個時機要記:

  1. 連線前驗餘額,唔夠一個 tick 就 4002。
  2. session 中每 60 秒做一次 in-arrears 預扣,唔夠就 4009,即係話你可能聽到一半先收線。
  3. 收線時按實際音頻秒結算 — 計嘅係你真正推咗幾多秒,唔係連線開咗幾耐。

換句話講,一個靜靜地開住但唔推音訊嘅 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 實作;如文件有更新,我哋會同步更新本文。想了解我哋嘅背景,可以睇關於港嘢;有技術問題或者想申請功能開通,聯絡我哋。

相關文章