WebRTC 实时语音交互怎么做“可中断”?一个绕不开的 FSM 设计

32 阅读4分钟

如果你只是做“能说话”的语音系统, WebRTC + ASR + TTS 基本就够了。

但只要你开始做可插嘴(barge-in)的实时语音交互,你会很快发现:

真正难的不是“停声音”, 而是停得对、停得干净、还能接着来

这一步,99% 的问题都不是音频问题,而是系统状态问题


一、一个真实现象:为什么“中断”一加就开始乱?

很多项目里,中断逻辑一开始是这样的:

  • 检测到用户说话
  • 直接 stop 当前 TTS 播放
  • 重新开始识别

刚开始看起来没问题,但稍微复杂一点就会出现:

  • 声音停了,模型还在吐 token
  • 新一轮输入进来,旧上下文还没清
  • 有时能打断,有时完全失效

如果你遇到过这些情况,基本可以确定一件事:

系统根本不知道自己现在处在哪个执行阶段。


二、问题根源:把“行为决策”塞进了 WebRTC / async 里

WebRTC 本身的职责其实很清晰:

  • 采集音频
  • 播放音频
  • 处理网络传输

不负责

  • 现在是不是该说话
  • 插嘴该不该生效
  • 当前输出能不能被打断

但很多实现会下意识在这里写逻辑:

  • AudioTrack callback 里加 if
  • async 链路里靠 flag 判断
  • Promise resolve 顺序“刚好对齐”

这在 streaming + 并发 场景下,几乎必炸。


三、一个关键转折:把“中断”当成状态迁移

真正可控的做法,是引入状态机(FSM)作为系统中枢

不是“加一个 FSM 模块”, 而是把它当成 System Anchor

它只负责三件事:

  1. 当前系统状态是什么
  2. 收到一个事件,是否允许迁移
  3. 是否触发中断 / 清理 / 切换执行权

所有其他模块,只是:

  • 事件的生产者
  • 或副作用的执行者

四、推荐的整体架构(掘金实战向)

┌───────────────┐
│   前端 / UI   │  ← 按钮、状态展示
│ (JS / React)  │
└───────▲───────┘
        │ 控制事件
        │
┌───────┴────────┐
│  WebRTC 层     │  ← 音频输入 / 输出
│ (AudioTrack)  │
└───────▲────────┘
        │ 音频帧 / VAD
        │
┌───────┴──────────────────────┐
│   Rust 语音 Runtime(FSM)    │
│  - 状态机                     │
│  - 事件队列                   │
│  - Cancel / 清理逻辑          │
│  - ASR / LLM / TTS 协调
└───────────────────────────────┘

核心原则:

  • FSM 不在前端
  • FSM 不写在 WebRTC callback
  • FSM 是唯一有“行为裁决权”的地方

五、事件化:让系统不再靠“时序运气”

1️⃣ 音频输入只产出“事实”

在 AudioTrack 中,不做判断,只做采集:

AudioFrame
   ↓
VAD / 能量检测
   ↓
Event::VadSpeechStart / VadSpeechEnd

是否中断、是否忽略, 全部交给 FSM。


2️⃣ ASR / LLM / TTS 全部事件化

统一成事件流:

  • ASR partial / final
  • LLM token / completed
  • TTS frame(只在 Speaking 状态消费)

FSM 的判断非常简单:

当前状态,是否允许处理这个事件?


六、FSM 的核心运行模型(避免回调地狱)

整个 Runtime 的“心跳”只有这一段:

loop {
    let event = event_rx.recv().await;
    state = state.on_event(event);
}

含义是:

  • callback 里不写业务逻辑
  • async 只负责生产事件
  • FSM 永远是唯一决策点

这一步,直接决定系统能不能“被安全中断”。


七、音频输出:不要在 WebRTC 里“硬停”

最容易踩的坑之一:

在 WebRTC callback 里直接 stop 播放。

正确模式是:

TTS Generator
   ├─(有界 channel)─▶ WebRTC AudioTrack

当中断发生时:

  1. FSM 触发 cancel token
  2. TTS 停止生成音频帧
  3. channel 自然关闭
  4. WebRTC 播放自然结束(不会爆音)

WebRTC 完全不知道“中断”这个概念, 它只是在消费数据。


八、一条完整的插嘴(barge-in)流程

[Speaking]
   ↓
WebRTC 检测到麦克风语音能量
   ↓
VAD → Event::VadSpeechStart
   ↓
FSM 决策中断
   ↓
Cancel TTS
   ↓
FSM → Interrupted
   ↓
ASR Final
   ↓
FSM → Listening / Repair

如果你现在的系统里, 找不到这样一条清晰的路径, 那它迟早会在并发场景下出问题。


九、为什么 FSM 更适合放在 Rust

不是为了性能,而是为了不出事故

  • 状态是 enum,可枚举
  • 迁移是 match,可审计
  • 中断是协议,不是副作用

在 JS 里,这些往往会被 async / Promise 稀释成隐式状态。


十、写在最后:这是在给系统“立规矩”

WebRTC 解决的是“音频怎么流动”, FSM 解决的是“行为有没有边界”。

当你开始做:

  • 插嘴
  • 多轮对话
  • 错误恢复

你最终都会发现:

没有状态机,系统迟早失控。