WebRTC 實時語音系統,為什麼必須引入狀態機(FSM 作為 System Anchor)

在很多實時語音項目裏,WebRTC 往往被當成“核心系統”。

但只要你真的做過可插嘴(barge-in)、可中斷、不中斷就會亂套的語音交互,就會意識到一件事:

WebRTC 解決的是“音頻怎麼進出”, 而不是“系統現在該不該説話”。

真正決定系統是否可靠的,不是 codec,也不是模型,而是—— 你有沒有一個明確的“行為中樞”。


一、先説結論:FSM 必須是語音系統的 System Anchor

在一個嚴肅的實時語音系統裏,狀態機(FSM)不是實現細節,而是系統錨點(System Anchor)

它唯一做三件事:

  1. 系統當前處於什麼狀態
  2. 收到一個事件後,是否允許遷移
  3. 是否觸發中斷 / 清理 / 切換執行權

所有其他模塊——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 的要求只有三點:

  1. 能持續送音頻幀
  2. 能持續播放音頻幀
  3. 能被“軟中斷”(停止生產或消費)

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

中斷髮生時:

  1. FSM 觸發 cancel_token.cancel()
  2. TTS 停止生成
  3. channel 自然關閉 / drain
  4. 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 決定:

誰在説、誰能停、是否允許繼續。