WebRTC 實時語音系統,為什麼必須引入狀態機(FSM 作為 System Anchor)
在很多實時語音項目裏,WebRTC 往往被當成“核心系統”。
但只要你真的做過可插嘴(barge-in)、可中斷、不中斷就會亂套的語音交互,就會意識到一件事:
WebRTC 解決的是“音頻怎麼進出”, 而不是“系統現在該不該説話”。
真正決定系統是否可靠的,不是 codec,也不是模型,而是—— 你有沒有一個明確的“行為中樞”。
一、先説結論:FSM 必須是語音系統的 System Anchor
在一個嚴肅的實時語音系統裏,狀態機(FSM)不是實現細節,而是系統錨點(System Anchor)。
它唯一做三件事:
- 系統當前處於什麼狀態
- 收到一個事件後,是否允許遷移
- 是否觸發中斷 / 清理 / 切換執行權
所有其他模塊——WebRTC、ASR、LLM、TTS—— 都只是 I/O 或 Side Effect。
二、正確的職責劃分(非常關鍵)
先給一張結論級結構圖:
┌───────────────┐
│ PWA / UI │ ← 設備、按鈕、展示
│ (JS / React) │
└───────▲───────┘
│ control events
│
┌───────┴────────┐
│ WebRTC Layer │ ← 音頻 I/O、網絡
│ (AudioTrack) │
└───────▲────────┘
│ audio frames / vad
│
┌───────┴──────────────────────┐
│ Rust Voice Runtime (FSM) │ ← 系統錨點
│ - State Machine │
│ - Event Queue │
│ - Cancel / Cleanup │
│ - ASR / LLM / TTS orchestration
└───────────────────────────────┘
核心原則只有一句話:
❗ FSM 不在前端 ❗ FSM 不藏在 WebRTC callback ❗ FSM 是唯一有“行為裁決權”的模塊
三、WebRTC 在這套系統裏的真實角色
在這套設計裏,WebRTC 不是“語音系統”,它只是:
- 麥克風的音頻來源
- 揚聲器的音頻出口
- 網絡傳輸層
我們對 WebRTC 的要求只有三點:
- 能持續送音頻幀
- 能持續播放音頻幀
- 能被“軟中斷”(停止生產或消費)
� WebRTC 永遠不判斷“該不該説話”。
四、事件是唯一進入 FSM 的通道
1️⃣ Audio → VAD → Event
在 WebRTC AudioTrack 裏,不做任何決策,只做事實採集:
AudioFrame
↓
VAD / 能量檢測
↓
Event::VadSpeechStart / VadSpeechEnd
Rust FSM 側只接收事件:
event_tx.send(Event::VadSpeechStart);
是否中斷、是否忽略、是否切換狀態,完全由 FSM 決定。
2️⃣ ASR / LLM / TTS 統一事件化
統一原則:
- ASR partial →
Event::AsrPartial - ASR final →
Event::AsrFinal - LLM token →
Event::LlmToken - LLM 完成 →
Event::LlmCompleted
FSM 不關心你用的是哪家模型,只關心:
當前狀態,是否允許處理這個事件?
五、FSM 的核心形態:事件驅動主循環
整個 Runtime 的“中樞神經”只有這一段邏輯:
loop {
let event = event_rx.recv().await;
state = state.on_event(event);
}
所有模塊都只能是:
- Event Producer
- 或 Side Effect Consumer
❌ 業務邏輯不寫在 callback ❌ 不在 async 鏈路裏偷偷改狀態
這一步,直接決定了系統能不能被中斷。
六、音頻輸出:FSM 控制“生產權”,WebRTC 只消費
這是最容易寫錯的地方。
正確模型是:
TTS Generator
│
├─(bounded channel)─▶ WebRTC AudioTrack
FSM 決定:
- 是否允許 TTS 繼續生產音頻幀
- 是否觸發 cancel token
中斷髮生時:
- FSM 觸發
cancel_token.cancel() - TTS 停止生成
- channel 自然關閉 / drain
- WebRTC 播放自然結束(無爆音)
� WebRTC 從頭到尾不知道“中斷”這個概念。
七、一條完整的 Barge-in(插嘴)路徑
這是最關鍵的一條真實路徑:
[Speaking]
↓
WebRTC AudioTrack 檢測到麥克風能量
↓
VAD → Event::VadSpeechStart
↓
FSM.on_event()
↓
cancel_token.cancel()
↓
停止 TTS 生產幀
↓
FSM → Interrupted
↓
ASR Final
↓
FSM → Listening / Repair
你會發現:
- 沒有一個 if 寫在 WebRTC 裏
- 沒有“搶跑”的 async
- 所有決策都發生在 FSM
八、為什麼 FSM 必須在 Rust,而不是 JS
結論先行:
FSM 一旦是隱式的,系統就不可中斷。
如果 FSM 在 JS:
- async / Promise 打散執行順序
- 狀態藏在 closure
- 中斷變成“誰先 resolve”
如果 FSM 在 Rust:
- 狀態是 enum(可枚舉)
- 遷移是 match(可審計)
- cancel 是顯式協議
Rust 不是更快,而是不允許你寫爛系統。
九、三個最容易踩的工程坑(Warning)
⚠️ 坑 1:在 WebRTC callback 裏直接 stop TTS
→ 聲音停了,狀態沒停
⚠️ 坑 2:一個 cancel token 跨狀態複用
→ 上一輪中斷污染下一輪
⚠️ 坑 3:FSM 裏 await 外部 I/O
→ 狀態機被阻塞,系統失去響應性
FSM 永遠只做三件事: 狀態 + 事件 + 決策
十、總結一句話
在 WebRTC 實時語音系統中, FSM 不是“集成進去的組件”, 而是整個系統的“行為憲法”。
WebRTC 負責傳輸, 前端負責展示, Rust FSM 決定:
誰在説、誰能停、是否允許繼續。