開發筆記

把即時語音辨識 API 放進 App——用 Soniox 做串流設計

把即時語音辨識 API 放進 App——用 Soniox 做串流設計

你想在 App 裡加上「對方一開口,字幕就跟著出來」的體驗。於是開始找即時語音辨識 API,會發現選項很多,但真正寫到「實作要點」的文章卻意外地少——一個人做了半年的即時翻譯 App「Hiroi(拾い)」之後,最有感的就是這個落差。

這篇用我實際在用的 Soniox 當例子,從架構的角度整理:把即時語音辨識放進 App 時,該怎麼設計。與其說是 Soniox 教學,不如說是「哪一層藏在後面的結構,決定了它在現場能不能動」的思路。我在 iOS/SwiftUI 上開發,但這套想法跟平台無關。

為什麼「講完才出結果」太慢

語音辨識 API 大致分兩種。批次(檔案)辨識是把錄好的音檔整包丟過去、處理完再拿回結果;串流語音辨識則是趁對方還在講的時候,一點一點把音送過去,邊講邊回傳逐漸成形的文字。

要做字幕或即時翻譯,串流是唯一選擇。理由很單純:批次只能在「一句話講完之後」才出結果。以會議的節奏,等一句話講完,話題早就換了。(這個體感差,我在即時翻譯會議 App 真的會改變一場會議嗎裡也寫過。)

用串流的話,對方還在講到一半,尚未定案的「暫定文字」就會先回來。比起完美的字幕,人更在意「此刻螢幕上有東西在動」。節奏不斷掉,比精準度更有效——即時字幕的實作,就是從這一點往外設計的。

WebSocket 與 PCM 音框:地基

串流的實作,幾乎都是架在 WebSocket 上。以 Hiroi 來說,我對 Soniox 開一條 WebSocket,模型指定 `stt-rt-v5`。

音訊格式樸素得很直接:`pcm_s16le`(16-bit 線性 PCM)、16 kHz、單聲道。把它切成一個個約 120 毫秒的小片段(frame),依序往 socket 送。從麥克風收音、轉檔,每 120 毫秒送一片。想關掉串流時,就送一個空的 frame——那就是「講完了」的訊號。

連線當下,用一段 JSON 一次性把辨識語言、翻譯設定、模型名稱交出去,之後就是穩定地送音訊。地基就這麼多。反過來說,這個很笨的小迴圈(切音 → 送 → 收結果)是後面一切的前提;它一卡,上面全部跟著垮。

中間結果(interim)與確定結果(final)

串流的關鍵,是結果分兩階段回來。

  • 中間結果(interim):還沒定案、寫到一半的文字。話一往下走就會被覆寫,可能改掉、也可能消失。
  • 確定結果(final):引擎判定不會再變的文字。

UI 也該老實照這個分法做。interim 就「淡淡地、暫時地」顯示,一旦轉成 final,再把它定下來當正文。使用者看到的,是文字先冒出來、再慢慢定型。把兩者混為一談,畫面就會閃爍,或是你以為已經定案的字幕還在抖,變得難讀。中間結果是「還可以動的地方」,確定結果是「不再去碰的地方」——這條線先畫清楚,後面會輕鬆很多。

直接從語音翻譯,再依語言分流

Soniox 有意思的地方,是它能直接從語音、而非從文字回傳翻譯。一般機器翻譯是兩段式——語音 → 逐字稿 → 翻譯那段文字;這裡辨識與翻譯是在同一條串流上一起回來。

更棒的是,回來的每個 token(一個詞或一小塊字)都帶著語言標籤。Hiroi 就用它,把 token 依「正規化後的來源語言」分流到螢幕的左右兩側:原文 token 放在自己語言那側,翻譯 token 則以它的「來源語言」決定該去哪一邊。能把日文、英文、中文三種語言,不隨講者亂成一團地並排呈現,全靠這份 per-token 的語言資訊。

怎麼用會因產品而異,但只要知道這兩個特性——翻譯直接來自語音、token 帶語言標籤——排字幕版面時的自由度就會大很多。

真正有效的,是不起眼的可靠度工程

以上這些,每篇教學都寫得到。可是即時語音辨識「在 demo 會動、在現場卻掛掉」的分水嶺,是更不起眼的部分。一個人把 App 推出去之後,最花我時間的也正是這裡。

1. 重連要用指數退避。 行動網路本來就會斷。斷了別急著馬上重連,而是每次把等待時間拉長一點(指數退避)。失敗一次就間隔久一點,才不會在收訊死角裡無限狂敲端點。

2. 給每條 socket 一個「世代編號」。 這招很樸素卻真的有效。socket 斷掉、重開之後,舊 socket 遲來的非同步 callback,有可能伸手去動到那條新連線。解法是每次連線都給一個遞增的世代計數;每個 callback 一開始就先確認「我的世代還是最新的嗎?」,不是就默默丟掉——概念上就是 `if generation != current { return }`。少了它,死掉 socket 的殭屍 callback 會把活著的連線弄壞。

3. 每次啟動都重新綁定麥克風。 這點我在 macOS 上吃過虧。每次(重新)啟動辨識時,都要明確地重新指定要用哪個輸入裝置。因為途中若有藍牙耳機接上,OS 會悄悄把預設麥克風切成那副耳機的麥克風。若只在一開始綁一次,路由一改變,收音就會被劫持到你沒選過的麥克風。所以不是「只綁一次」,而是「每次都綁」。

4. API key 綁地區。 最後一個運維陷阱。Soniox 的 key 是綁定地區的——美國的 key 只能打美國的端點。key 的地區和端點對不上時,會跳出看起來像「Incorrect API key(金鑰錯誤)」的錯誤,但其實 key 沒問題,是地區不一致。

金鑰怎麼交出去也是這層不起眼工程的一部分。Hiroi 用同一個介面支援兩條路:代管(managed)是由我的後端在伺服器端簽發短期 token,App 拿它連線,長期金鑰不落在裝置上;自帶(BYO)則是用使用者自己的 Soniox key。無論哪種,原則只有一條——音訊本身一律從裝置直連 Soniox,我絕不用自己的伺服器去中繼(proxy)。伺服器只負責發 token,沉重的音訊串流維持裝置↔Soniox 的直連。隱私、成本、延遲三者一起變輕。

這些都不華麗。但這東西能不能天天在別人手上正常運作,幾乎就是在這一層決定的。

即時語音辨識 API 的導入,在「接上第一條 WebSocket」之前,簡單得出奇。差距出現在那之後——中間結果怎麼處理、世代防護、麥克風重綁、地區陷阱。不起眼,但正是這層安靜的工,把「demo」和「天天能用的工具」分了開來。

(順帶一提:我最後親手砍掉了自己花最多時間做的語音口譯功能。那個決定我寫在為什麼砍掉最花時間的功能。)

想看看它最後長成什麼樣子,歡迎逛逛 hiroi.app。願你接上的第一條 WebSocket 很順,而底下那層不起眼的工,成為你默默自豪的地方。

用其他語言閱讀日本語English