从播放即误打断,到候选语音确认、旧回复隔离和表情恢复,一次跨 ESP32、NestJS 与 FastAPI 的对话控制复盘。
本文基于项目截至 2026 年 9 月 5 日的代码,以及 8 月至 9 月的真实调试日志。历史耗时用于说明当时的问题,当前参数用于解释今天的行为,两者并非同一版本的 A/B 测试。
1. 从“一问一答”到“允许插话”,难度变在哪里
我们的 AI 玩偶使用 ESP32-S3,驱动两块眼睛屏和一套 ES8311 + ES7210 音频链路,实际使用单麦。NestJS 管理设备连接和业务数据,FastAPI 处理 AEC、VAD、ASR、LLM 与 TTS。
最初的交互相对简单:用户说完,设备回答,回答结束后继续收音。后来我们希望它更像真实对话:用户发现智能体理解错了,可以直接说“不是这个意思”,让旧回复停下来。
这个功能通常叫 Barge-in,即用户在系统回答过程中插话。
真正实现时,它同时提出了几个要求:麦克风必须持续工作;扬声器的声音不能被当成用户;新语音的开头不能丢失;已经排队的旧音频必须停止;旧任务后到的结束事件不能覆盖新一轮聆听状态。
其中任何一项失效,用户感受到的都可能是同一句话:“它不听我说话。”
2. 最早的失败:扬声器一响,设备就把自己打断
早期端侧 AEC 探索中,一轮日志出现了这样的过程:
16241 TTS PCM stream begin
16351 microphone WebSocket upload paused for playback
22551 enhanced_avg=5406 peak=23881 reference_avg=0
22651 local AEC barge-in: playback stopped
23131 PCM stream aborted
这些数字来自设备开机后的毫秒计时。它们说明,当时已经触发本地打断,但参考信号日志仍为零。结合用户“刚开始出声就被打断”的反馈,我们不能把较大的麦克风能量直接解释成用户插话。
另一轮自动识别参考槽位时,日志为:
20312 detected ES7210 hardware reference on slot 1: levels=2872,30,0,0
20582 local AEC barge-in
21232 PCM stream aborted
“某个槽位有非零数值”不足以证明它是稳定、正确的播放参考。软件需要知道这路信号的来源、时间关系和幅度关系,不能仅凭一次能量检测就相信它。
这段经历后来推动我们采用云端 AEC。当前设备上传麦克风 PCM,并在播放期间携带播放游标;云端利用缓存的 TTS PCM 构造参考。最终方案没有持续上传第二路完整参考 PCM。
但把 AEC 放到云端后,打断问题并没有自动结束。AEC 输出仍可能保留残余回声,VAD 仍可能认为那是一段语音。
3. VAD 检测到声音,不等于可以立即取消回复
我们曾经追求一个非常直接的规则:VAD 一检测到讲话,就发送 speech_start,设备立即停止播放。
这种规则响应快,却会把误检直接变成可听见的故障:智能体说到一半停下来,接着识别自己的话,再回答自己的话。
8 月 24 日的一组 Python 日志记录了当时从候选语音到确认的等待:
| 候选语音时刻 | 发送打断时刻 | 日志中的确认等待 |
|---|---|---|
| 15:18:47.603 | 15:18:47.603 | 0 ms |
| 15:19:12.182 | 15:19:12.510 | 328 ms |
| 15:19:16.159 | 15:19:17.058 | 899 ms |
这一组数字只能衡量服务端候选确认阶段。它既不包含用户发声到音频上传的时间,也不包含控制消息抵达设备和扬声器停声的时间。
而且,同一轮测试仍报告了自我打断。即使日志写着 AEC-safe speech confirmed,也不能因此认定说话人一定是用户。
这让我们重新划分了两个事件:
- 语音候选出现:检测链路认为可能有人插话,可以开始积累证据。
- 新一轮对话获得控制权:允许取消旧回复,并通知设备切换请求。
当前播放期间的普通路径会先等待连续满足条件的 AEC 帧,再启动候选 ASR。ASR 返回非空文本,且文本没有被判为当前回复的回声时,才调用抢占逻辑。这里不一定等最终结果,有效的中间识别结果也可以触发确认。
因此,当前协议里的 speech_start 更接近“新一轮语音已被系统接受”,不应被读作“原始 VAD 刚刚跳变”。
4. 响应速度与误打断,必须在同一张表里讨论
当前代码中的几个参数解释了为什么系统比早期保守:
| 机制 | 当前配置或行为 | 目的与代价 |
|---|---|---|
| 播放开始保护 | 1.2 s | 降低起播瞬态误检;用户此时插话可能延后确认 |
| 播放结束保护 | 0.8 s | 抑制尾音;结束边界附近的语音需要缓存衔接 |
| 连续安全帧 | 4 帧 | 避免单帧波动触发候选确认 |
| 播放中候选 ASR | 有效文本且未命中回声判断后抢占 | 增加识别等待,换取更少误打断 |
| 聆听表情超时 | 7000 ms | 空语音或事件缺失时恢复默认表情 |
设备当前以 100 ms 音频块上传。四帧证据覆盖约 400 ms 音频,但从第一帧候选到第四帧到达的等待通常约为三个块间隔;加上队列、网络和执行调度后,不能把它机械地当作固定 400 ms 延迟。
声纹还会改变分支:严格模式下,未通过的语音可以在进入 ASR 前被拒绝;个性化模式下,播放中未匹配的候选通常继续等待 ASR 文本确认。可信的主人匹配允许更早抢占,但声纹采样和验证本身也耗时。
这里存在明确的产品取舍。我们曾提出“一秒内打断”的目标,但当前保护窗口与候选确认逻辑并不能保证所有场景都在一秒内停声,尤其是刚起播就插话的情况。
这些参数是项目当前的选择,不能直接当成另一块板、另一个音量或另一种外壳的推荐值。后续调整需要同时观察真实插话延迟和无人插话时的误中断频率。
5. 为了保住前几个字,检测和音频传递不能使用同一把开关
另一类反馈是:播放期间能插话,但识别经常丢掉开头几个字。
原因之一在于,系统确认“这确实是用户”需要时间。如果确认后才开始保存音频,最早的音节已经过去了。
当前 Python 使用 pre-roll 保存最近一段音频。候选被接受后,把缓存接入新 ASR 会话,再继续发送后续音频。这样,检测发生得稍晚,也有机会恢复语句开头。
代码还区分了“允许启动新 ASR”和“给已启动的 ASR 送音频”:残余回声判断可以阻止新会话启动,却不应反复在已经开始的语句中挖掉 100 ms 音频。
如果每次判断不安全就直接丢一块,ASR 收到的会是一句话的断续版本。用户听起来没有停顿,服务端收到的音频却缺了音节。
播放参考缺失仍需要谨慎处理。当前候选阶段会抑制这类帧进入 VAD;保护窗口也会让 VAD 接收等长静音,同时保存可供后续使用的音频。缓存只能补回实际保留下来的内容,无法恢复采集端已经丢弃的数据,也无法让错误的参考自动变正确。
6. 一次打断,实际上要穿过三层缓冲
即使已经确认用户插话,单纯取消 Python 任务仍然不够。
旧音频可能已经离开 Python,停在 Nest、网络连接、设备播放队列,甚至 I2S DMA 中。取消生产者不会收回这些数据。
当前控制过程可以概括为:
新语音被接受,分配新的 requestId
↓
Python 更新当前请求,取消旧回复任务
↓
通过 Nest 下发 speech_start(newRequestId)
↓
ESP 更新允许接收的回复 UUID
↓
清理待播队列,投递 Abort,让播放任务处理停声
↓
后到的旧 PCM / 旧结果因 requestId 不匹配而被丢弃
Python 调用旧任务的 cancel() 后就发送控制事件,不等待旧任务完成归档。这样归档耗时不会成为发送打断的必要前置条件。
设备侧有两道隔离:WebSocket 接收处核对当前请求,播放处核对活跃请求。下行 EYAU 音频头中携带回复 UUID 和序号,使二进制音频能够与控制事件对应。
这里的关键是请求归属。一条旧连接上的音频可以合法到达,但属于已经被用户打断的回复,就没有继续播放的资格。
当前 Abort 仍由播放任务执行,结束播放函数还会写入静音刷新输出。因此,“调用 abort 函数”“打印 aborted 日志”和“扬声器实际安静”是不同观测点。
8 月 24 日的设备日志有这样的时间差:
45784 WS message: speech_start
45784 barge-in: stopped previous reply and armed new request
45794 WS message: asr_final
45994 PCM stream aborted
从控制事件到播放任务的 Abort 日志相差 210 ms。它解释了为什么用户可能已经看到 ASR 结果,仍觉得声音没有立刻停下;但这 210 ms 不是经过声学测量的停声延迟,也不是当前版本的保证值。
7. 统一状态机,要解决的是“谁有权改变现在”
随着功能增加,设备状态已经不止“正在说话”和“没有说话”。当前枚举包括:
BOOTING / IDLE / LISTENING / THINKING / SPEAKING
MUTED / UPGRADING / SWITCHING_ASSET / ERROR / FACTORY_RESET
这些名称让日志、设备诊断和界面可以使用共同语言。正常的一轮语音大致经历:
IDLE → LISTENING → THINKING → SPEAKING → IDLE
↑ │
└─ 新语音 ────┘
先回到 LISTENING
不过,状态名称统一,只完成了一部分工作。更难的是让不同任务遵守同一套状态归属规则。
我们曾在 8 月 28 日的日志里看到:
680683 state: THINKING -> LISTENING
680683 barge-in: stopped previous reply and armed new request
680683 state: LISTENING -> IDLE
680683 PCM stream aborted
用户刚获得聆听状态,旧回复的清理过程又让设备回到 IDLE。这个片段暴露了最典型的异步状态问题:旧任务结束是真的,新一轮正在聆听也是真的,但旧任务无权把新一轮的状态改掉。
当前表情控制器的播放完成处理会先检查状态。如果已经处于 LISTENING、THINKING、MUTED、UPGRADING、SWITCHING_ASSET 或 FACTORY_RESET,就不会把状态重置为 IDLE。
这个保护直接对应上述历史问题。它表达的规则是:旧播放结束时,要尊重新一轮以及设备高优先级操作的状态。
8. 当前实现距离严格的统一状态机,还有哪些边界
检查代码会发现,app_controller 有一个容量为 16 的事件队列,也有专门的控制任务,但同时保留了 app_controller_set_state()。WebSocket 等模块仍可直接写状态。
因此,当前实现可以称为统一状态表示和部分状态协调,尚不能说所有状态转换都由单一事件处理器串行裁决。
例如当前固件收到 asr_final 就进入 THINKING,这个分支没有先检查文本是否为空,也没有像 PCM 接收那样核对请求归属。SPEAKING 在流开始时就可能设置,此时设备还可能处于预缓冲,尚未真正发声。
这些细节会影响诊断含义:
| 观测事件 | 能确认的事实 | 不能据此确认的事实 |
|---|---|---|
tts_stream_start | 一轮播放流已经建立 | 第一份声音已经从扬声器发出 |
tts_stream_end | 服务端声明本轮发送结束 | 本地队列已经播放完 |
PCM stream aborted | 播放任务执行到了中止处理 | 用户发声到停声的全部耗时 |
LISTENING | 控制逻辑接受了聆听状态 | 每一帧都有效、ASR 一定会返回内容 |
更严格的演进方向,是让状态事件携带 requestId 或轮次编号,由统一入口检查事件是否仍属于当前轮次。旧轮次的完成事件只能释放旧资源,不能改变新轮次的状态。
这是由当前代码边界推导出的下一步设计,并非本文已经实现的新功能。
9. 眼睛表情也参与了对话协议
用户判断设备是否听懂,往往先看眼睛,再听声音。
因此,我们遇到的两个小问题其实都与对话生命周期有关:无内容的声音触发 VAD 后,眼睛一直保持 listen;回复播放结束后,happy 一直保留到下一次切换。
当前策略是播放结束后恢复默认表情,用户静音时保持 mute;listen 表情设置 7 秒定时器,避免事件缺失导致无限等待。
这个 7 秒是表情层的兜底时间,不是 ASR 超时,也不会自动取消云端任务。如果状态已经进入 THINKING,定时器可以恢复 neutral,但不会把 THINKING 改成 IDLE。
这正对应另一条历史日志:
state: LISTENING -> THINKING
listening timed out after 7000 ms; restoring default
它不一定意味着模型失败,可能只是模型回答较慢,表情先恢复默认。
当前情绪通过 MQTT 到达,语音事件通过 WebSocket 到达,两个通道之间没有共同的到达顺序保证。播放完成后的恢复逻辑已经改善了体验,但不能据此声称迟到的情绪消息已被全部隔离。将情绪也绑定到轮次,是继续收紧状态归属的一种方向。
10. 被打断的回复,要不要保存到聊天记录
测试中还出现过一个让人困惑的现象:设备已经说了很大一段,用户插话后,这段回答在聊天记录里消失了。
原因是旧任务被取消后,没有走到正常保存路径。
当前 Python 在捕获 CancelledError 时,会尝试保存已经累计的回复文本,并标记:
{
"interrupted": true,
"partial": true
}
同时归档已经收到的回复 PCM。这里保存的是截至取消时已经生成、已经累计的内容,并不是取消后继续等模型写完全文;它也不等于用户实际听到的全部内容。
这三个范围必须分清:模型生成的文本、服务端收到的音频、设备真正播放的音频。它们在正常结束时比较接近,在打断时可能明显不同。
保留部分回复能让聊天记录更连贯,也便于排查。不过归档与入库仍可能失败,所以代码记录成功与异常日志,而不是把任务取消等同于持久化成功。
11. 怎样证明打断真的变好了
我们过去的日志足以证明故障类型和具体时序,却不足以给出“当前误打断率下降了多少”这样的百分比。缺少统一环境、标注过的用户插话和可重复测试,就不应把几轮体感改善写成成功率。
下一阶段更有价值的测量是把一次插话拆开:
用户真实发声
→ 服务端检测候选
→ 候选确认并发送控制事件
→ 设备收到事件
→ 播放任务处理中止
→ 扬声器实际停声
同一进程内可以用单调时钟比较阶段耗时;跨设备与服务端不能直接拿未经校准的时间戳相减。实际停声最好结合录音或输出测量确认。
测试也需要包含不同场景:起播瞬间插话、长回复中途插话、尾音期间说话、没有人说话只让设备播放,以及开启声纹后的主人和陌生人语音。
除了插话延迟,还应记录每小时无人插话时的误中断次数、真实插话被拒绝的比例、开头音节是否保留、旧音频是否重新冒出来、设备最终是否回到正确状态。长回复尤其值得单独测试,因为回声条件、参考对齐和网络缓存可能在一轮内部发生变化。
12. 这次工程实践留下的规则
这项功能最终涉及了信号、请求和状态三个层次。
信号层提供“可能有人说话”的证据;对话层决定什么时候把控制权交给新请求;设备层停止旧播放,并保证旧任务不能再影响现在。
我们最初关注的是停止播放的那一行代码,后来逐渐把注意力放到它前后的过程:有没有把回声误判成用户,确认期间有没有保存开头,取消后有没有隔离旧数据,清理资源时有没有覆盖新状态。
一次可靠的打断,需要系统记住:现在轮到谁说话,以及哪些过去的任务已经失去控制权。
统一的状态名称让问题可以被看见,请求归属让问题可以被限制,逐段测量则让速度与可靠性之间的取舍有据可依。这个项目仍在继续收紧这些边界,而每一次明确边界,都比单独调低一个检测阈值更接近自然对话。
附:实现与数据来源
- 固件
main/app/cloud_client.c:接收speech_start、切换回复 UUID、流状态与播放状态通知。 - 固件
main/app/pcm_stream_player.cpp:清理队列、执行 Abort、按请求归属处理 Data 与 End。 - 固件
main/app/app_controller.c:统一状态名称、事件队列及直接设置状态入口。 - 固件
main/app/emotion_controller.c:7 秒聆听兜底、播放完成状态保护、默认表情恢复。 - 固件
main/board/board_audio.c:播放结束时写入静音刷新输出。 - Python
py_api/api/ws_audio.py:候选确认、pre-roll、声纹分支、任务抢占与取消后的部分回复保存。
历史数值来自 8 月 22 日端侧参考探索日志、8 月 24 日 15:18–15:19 的 Python 日志及配套固件日志、8 月 28 日状态转换片段。本文移除了账户标识和音频访问地址。历史事件用于说明演进动机,当前行为以本文日期核对的代码为准。