如果你只是做“能说话”的语音系统, WebRTC + ASR + TTS 基本就够了。
但只要你开始做可插嘴(barge-in)的实时语音交互,你会很快发现:
真正难的不是“停声音”, 而是停得对、停得干净、还能接着来。
这一步,99% 的问题都不是音频问题,而是系统状态问题。
一、一个真实现象:为什么“中断”一加就开始乱?
很多项目里,中断逻辑一开始是这样的:
- 检测到用户说话
- 直接 stop 当前 TTS 播放
- 重新开始识别
刚开始看起来没问题,但稍微复杂一点就会出现:
- 声音停了,模型还在吐 token
- 新一轮输入进来,旧上下文还没清
- 有时能打断,有时完全失效
如果你遇到过这些情况,基本可以确定一件事:
系统根本不知道自己现在处在哪个执行阶段。
二、问题根源:把“行为决策”塞进了 WebRTC / async 里
WebRTC 本身的职责其实很清晰:
- 采集音频
- 播放音频
- 处理网络传输
它不负责:
- 现在是不是该说话
- 插嘴该不该生效
- 当前输出能不能被打断
但很多实现会下意识在这里写逻辑:
- AudioTrack callback 里加 if
- async 链路里靠 flag 判断
- Promise resolve 顺序“刚好对齐”
这在 streaming + 并发 场景下,几乎必炸。
三、一个关键转折:把“中断”当成状态迁移
真正可控的做法,是引入状态机(FSM)作为系统中枢。
不是“加一个 FSM 模块”, 而是把它当成 System Anchor:
它只负责三件事:
- 当前系统状态是什么
- 收到一个事件,是否允许迁移
- 是否触发中断 / 清理 / 切换执行权
所有其他模块,只是:
- 事件的生产者
- 或副作用的执行者
四、推荐的整体架构(掘金实战向)
┌───────────────┐
│ 前端 / 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
当中断发生时:
- FSM 触发 cancel token
- TTS 停止生成音频帧
- channel 自然关闭
- 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 解决的是“行为有没有边界”。
当你开始做:
- 插嘴
- 多轮对话
- 错误恢复
你最终都会发现:
没有状态机,系统迟早失控。