一块没有回采链路的 ESP32 玩偶,如何做出可用的云端 AEC
从播放卡顿、自我对话、第三轮必现误打断,到二进制全双工协议、云端参考时间轴和 WebRTC AEC3 的一次完整工程复盘。
前言
我们正在做一款带双眼屏幕的 AI 玩偶。它使用 ESP32-S3,音频硬件由 ES8311 DAC 和 ES7210 ADC 组成,核心体验是持续语音对话:用户说话,云端完成 VAD、ASR、LLM 和 TTS,设备播放回复并根据情绪切换眼睛动画。
最初的半双工方案很简单:播放时关闭麦克风,播放结束后恢复采集。它能工作,却不自然——用户不能打断角色,长回复只能被迫听完。
于是我们决定实现全双工和语音打断。这看起来只是“播放时继续上传麦克风”,实际上却把系统里几乎所有隐藏问题同时暴露了出来:
- 播放音频被麦克风再次采集,云端把角色自己的声音识别成用户说话;
- 播放期间不断触发 VAD,设备刚开口就打断自己;
- 打开采集和上传后,PCM 播放开始频繁欠载、卡顿;
- 端侧 AEC 找不到可靠的播放参考信号;
- 前两轮看似正常,第三轮以后误打断却越来越严重;
- 真正的用户插话又可能被回声抑制逻辑一并过滤。
最后,我们没有继续强行在设备上做 AEC,而是重新设计了整条音频链路:设备持续上传原始单麦 PCM,并报告真实播放游标;云端重建 far-end reference,运行 WebRTC AEC3,再把清理后的音频交给 VAD 和 ASR。
本文记录这条路线为何成立、途中哪些方案失败了,以及最终稳定版本中最重要的工程细节。
一、先认清硬件:有 Codec,不等于有 AEC 回采
这块板使用:
- ES8311:负责扬声器输出;
- ES7210:负责麦克风输入;
- 实际产品只有一颗麦克风;
- ES7210 通过四槽 TDM 总线输出,但只有 MIC1 是有效的近端采集通道。
最容易踩的坑,是把 TDM 中另一个有波形的 slot 当成扬声器 reference。
我们曾经尝试自动扫描其他 slot。当播放开始后,某个 slot 的能量确实会升高,看起来很像硬件回采;然而“存在信号”并不代表它是与 DAC 同步、可用于 AEC 的远端参考。错误的 reference 送入 AEC 后,结果比完全不做 AEC 更危险:用户声音会被错误削弱,扬声器回声仍可能保留,VAD 也会产生大量误判。
最终我们回到板卡真实配置:
ES7210: MIC1 only
TDM bus: preserve 4-slot timing
AEC/AFE: removed from device
uplink: continuous raw PCM S16LE / 16 kHz / mono
这里还有一个反直觉细节:即使只使用 MIC1,也不能随意把输入 Codec 配成单通道总线。该板的 ES7210 仍按四槽 TDM 时序工作,因此应保持 channel=4,再用 channel mask 选择 slot 0。把总线直接改成一到两槽,会让采集波形和时序劣化。
结论是:硬件没有可靠回采,不代表不能做 AEC;只代表 reference 必须从数字播放链路中构造。
二、第一次全双工为什么让播放卡顿
最初的麦克风上传路径沿用了业务消息的做法:PCM 先 Base64,再放进 JSON,通过 WebSocket 发送。
对于偶发控制消息,这种格式方便调试;对于每秒十几到几十个音频包,它却很昂贵:
- Base64 使体积膨胀约三分之一;
- JSON 构造和字符串复制增加 CPU 与内存压力;
- 高频堆内存申请加剧碎片;
- WebSocket 发送锁被上传任务频繁占用;
- GIF 解码、TLS、麦克风上传和 I2S 播放争抢同一颗 MCU 的时间片与内存。
表现出来就是播放队列不断见底:
PCM underrun: buffered=0
Could not lock ws-client within 100 timeout
send queued microphone frame failed
这不是 ESP32 “算不动音频”,而是实时数据路径做了太多本不该做的封装。
我们的处理方式是把音频上行改成纯二进制协议,控制消息继续保留 JSON。职责由此分开:
JSON: 登录、audio_start、ping、状态和控制事件
Binary: 连续 PCM 与时间轴元数据
二进制帧协议内部称为 EYUP/1。麦克风每 100 ms 形成一帧:
16 kHz × 100 ms × 16 bit × 1 channel = 3200 bytes
帧头携带:
- magic 与版本号;
- frame type;
- sequence;
- sample clock;
- stream/request UUID;
- PCM 长度与格式信息。
同时,我们把 WebSocket I/O buffer 调到 8192 字节。早期 4096 字节缓冲会把一帧拆成多次 TLS write,不仅增加锁竞争,实测还会限制有效上传速率。
这次协议改造之后,播放卡顿显著缓解,ASR 精度也恢复了。它说明一个很实用的原则:
实时音频链路首先要保证“持续、顺序和低抖动”,可读性应该由旁路日志提供,而不是让每一个 PCM 包都承担 JSON 的便利性成本。
三、reference 不应该取“收到的 TTS”,而要取“实际播放的 TTS”
云端已经生成了 TTS PCM,为什么不能直接拿它作为 AEC reference?
因为“云端生成到哪里”和“设备扬声器播放到哪里”并不是同一条时间轴。
中间存在:
- WebSocket 网络抖动;
- 设备预缓冲;
- 播放队列排队;
- I2S DMA;
- DAC、功放、扬声器与空气传播;
- ADC 和麦克风采集缓冲。
AEC 面对的是:
Mic(t) = User(t) + Echo(Reference(t - D)) + Noise(t)
如果 reference 用的是“刚发送出去的 PCM”,而麦克风收到的是“设备几百毫秒前真正播出的声音”,即使两段数据内容一致,也无法正确抵消。
因此 reference 的采集点必须尽可能靠近实际播放:
TTS PCM queue
↓
device playback task
├──→ ES8311 → speaker
└──→ playback sample clock
设备在真正写入音频输出时推进 reference sequence 和 sample clock。这样上报的不是“计划播放位置”,而是“已经送入播放链路的位置”。
播放为 24 kHz,而麦克风与 AEC 工作在 16 kHz,因此 reference 时间轴同时按 16 kHz 表示。当前实现按 3 个 24 kHz 样本生成 2 个 16 kHz 样本,从而保持长期时钟比例稳定。
四、不要上传两遍音频:云端已经拥有 TTS 原文波形
第一版云端 reference 方案上传两种二进制帧:
- MIC:32 KB/s;
- playback reference:约 32 KB/s。
上行瞬间翻倍后,设备再次出现 WebSocket 写锁超时、断线和播放抖动。AEC 还没有发挥作用,reference 本身先破坏了实时性。
进一步观察后我们意识到:云端本来就是 TTS PCM 的生产者,完全没有必要让设备把同一段 PCM 再传回来。
最终协议使用 duplex cursor frame:
┌────────────────────────────────────────────┐
│ microphone PCM, 100 ms │
├────────────────────────────────────────────┤
│ mic sequence + mic sample clock │
│ playback sequence + playback sample clock │
│ TTS reply UUID │
└────────────────────────────────────────────┘
云端按照 TTS reply UUID 找到自己缓存的完整 PCM,再根据设备报告的 playback sample clock 截取对应 reference。
这样,一帧仍只上传一次麦克风数据,附加成本只有几十字节元数据,却保留了完整的播放定位能力。
这是整个方案中收益最大的一次优化:
- 上行带宽没有因为 AEC 翻倍;
- 播放任务不需要等待 reference 上传;
- reference 不会因为单独队列丢包而与麦克风错位;
- request UUID 天然隔离不同轮次的 TTS;
- 云端可以保存原始 MIC、reference 和 clean PCM,便于离线复盘。
五、云端 AEC3:算法只是一半,时间对齐才是另一半
服务端没有单独部署新的微服务,而是在现有 Python WebSocket 服务中通过 Python binding 调用 WebRTC Audio Processing 的 AEC3。
每个设备 WebSocket 会话维护独立的 CloudAecSession:
device binary frame
↓
parse MIC + playback cursor + reply UUID
↓
locate cached 24 kHz TTS PCM
↓
resample reference to 16 kHz
↓
estimate acoustic delay
↓
WebRTC AEC3
↓
clean PCM → VAD → streaming ASR
1. 延迟估计
即使拥有准确播放游标,扬声器到麦克风仍有物理和系统延迟。服务端会在最多约 800 ms 的窗口内,用近端音频与 reference 做归一化相关性搜索。
为了控制计算量,搜索步长为 10 ms。得到稳定候选后,后续只在上次延迟附近的小窗口搜索,并使用平滑更新:
delay = old_delay × 0.8 + candidate × 0.2
这样可以避免用户真正说话时,双讲信号把延迟估计拖到另一段无关语音上。
2. 每轮回复重置 AEC 状态
我们遇到过一个很有代表性的现象:前两轮正常,从第三轮开始设备越来越容易自我打断。
根因不是“第三轮”这个数字,而是 AEC 自适应滤波器和延迟估计跨多轮复用了旧状态。每条 TTS 都是独立时间轴,旧滤波状态可能仍对齐上一轮的语音,积累后便会把扬声器残留误判成近端讲话。
最终每次新的 TTS reply 开始时都会:
- reset AEC3 processor;
- 清空旧 reference track;
- 重置 delay estimate;
- 从新 reply UUID 建立时间轴。
这个改动解决了“运行越久越容易误打断”的状态污染问题。
3. AEC 必须有预热期
播放刚开始时,滤波器还没学到声学路径。如果此时允许 clean PCM 直接触发打断,最容易出现“角色刚开口就打断自己”。
当前策略是在大约 1 秒的 far-end 音频处理后,才允许播放期间的音频进入打断判定。预热阶段麦克风仍持续采集和处理,只是不把结果当作可靠的 barge-in 证据。
在一次实测中,每 100 ms 音频块的 AEC 处理耗时约为毫秒级,远低于实时预算。真正影响体验的主要仍是网络抖动、参考对齐和判定策略,而非 AEC3 本身的 CPU 时间。
六、为什么不能让 VAD 一响就立刻停播
理想体验是:用户一说话,设备在 1 秒内停止播放。
但如果直接采用下面的逻辑:
VAD speech_start → abort playback
扬声器回声只需穿透一帧,就会造成自我打断。
最终我们把播放期间的打断改成“候选—确认”状态机:
- VAD 检测到 speech start,先建立 provisional barge-in;
- 要求至少出现 AEC-safe frame;
- reference 缺失或 AEC 尚未预热时,不允许触发;
- 连续满足条件后再通知设备停止播放;
- 后续 ASR 若没有有效文本,则撤销该候选,不生成新回复;
- 已经被打断的旧 TTS 后续 PCM 全部丢弃,但已生成文本仍可以保存到数据库。
服务端还记录 near RMS、reference RMS、clean RMS、相关性和 residual ratio。当输入与 reference 高度相关、AEC 后能量仍过高时,会标记为 residual echo,不让该帧单独触发打断。
这套逻辑的核心不是把阈值调得越严格越好,而是在两个目标之间找平衡:
- 太宽松:设备自言自语、自问自答;
- 太严格:用户真的插话时,开头几个字被吃掉,甚至完全无法打断。
因此 VAD、AEC 和 ASR 不能各自独立决定状态,必须组合成统一的对话状态机。
七、那些看似合理但最终失败的路线
路线一:播放时关闭麦克风
稳定、简单,但只能做半双工。它适合最小可用版本,不适合自然对话。
路线二:从 ES7210 其他 TDM slot 猜硬件 reference
波形有能量不等于它是可靠回采。错误 reference 会让 AEC 输出更不可控。
路线三:端侧 AFE/AEC + 本地 VAD 直接打断
在没有正确 reference 的前提下,本地算法无法凭空消除扬声器回声。它还占用 ESP32 内存和任务栈,一度导致 WebSocket 任务无法创建。
路线四:MIC 与 reference 分两路完整上传
逻辑直观,却使上行带宽和 WebSocket 写压力接近翻倍,重新引入播放卡顿和断线。
路线五:把云端生成进度当作设备播放进度
忽略网络、队列和 DMA 延迟,reference 内容正确但时刻错误,AEC 很难收敛。
路线六:一旦 VAD 触发就立即打断
响应最快,同时也是最容易自我打断的方案。工程上必须给 AEC 预热、reference 完整性和连续证据留出空间。
八、如何判断系统是真的稳定,而不是“刚好这轮没出错”
语音系统不能只凭耳朵测试一两轮。我们最终补充了几组关键指标:
设备端
- 麦克风队列深度、丢帧数和 frame age;
- WebSocket 写锁等待与重连原因;
- PCM 队列水位、预缓冲和 underrun 次数;
- 当前播放 request UUID;
- reference sequence 与 sample clock;
- 内部内存、最大连续块和任务栈水位。
云端
- reference 是否命中对应 TTS UUID;
- sequence/sample clock 是否回退或跳变;
- delay estimate 与 correlation;
- near/reference/clean RMS;
- AEC 每帧处理耗时;
- residual echo 被拦截次数;
- VAD candidate 到确认打断的耗时;
- ASR 最终文本是否与被打断回复高度相似。
最重要的测试不是安静桌面上的三轮对话,而是:
- 连续运行几十轮;
- 长回复中途插话;
- 高低音量分别测试;
- Wi-Fi 抖动和短暂断线;
- 狭小、有强反射的外壳与桌面环境;
- 用户距离、方向和说话音量变化;
- 播放开始、结束和下一轮衔接的边界时刻。
我们正是通过长时间日志发现了“第三轮后状态污染”和“独立 reference 上传挤占带宽”,而它们在短测试中都很难暴露。
九、最终架构
┌──────────────────────────┐
│ Python Cloud │
│ │
MIC1 → raw PCM 16 kHz ──→│ EYUP parser │
│ ↓ │
playback cursor + UUID ─→│ cached TTS reference │
│ ↓ │
│ delay alignment │
│ ↓ │
│ WebRTC AEC3 │
│ ↓ │
│ VAD → ASR → LLM → TTS │
└─────────────┬────────────┘
│ PCM 24 kHz
↓
┌──────────────────────────────────────────────────┐
│ ESP32-S3 │
│ TTS queue → playback task → ES8311 → speaker │
│ │ │
│ └→ playback sample clock │
│ ES7210 MIC1 → continuous capture → EYUP uplink │
└──────────────────────────────────────────────────┘
在这套架构里,设备只负责它最擅长且必须靠近硬件完成的事情:
- 稳定采集;
- 稳定播放;
- 报告准确的硬件侧播放进度;
- 执行云端确认后的中断。
云端负责需要更多状态、算力和可观测性的部分:
- reference 重建;
- 延迟估计;
- AEC3;
- VAD/ASR;
- 打断确认和对话状态管理。
十、我们最终学到的事
1. AEC 首先是同步问题,其次才是算法问题
拿到两路 PCM 不等于拿到了可用的 near-end 和 far-end。reference 采集点、播放游标、采样率和每轮时间轴隔离,比“用了哪个 AEC 库”更先决定结果。
2. 嵌入式实时链路中,少一次复制都可能有意义
Base64 和 JSON 并非错误,只是不适合持续 PCM。二进制传输、固定帧长、预分配缓冲和非阻塞 reference 记录,直接决定了播放是否连续。
3. 云端方案不意味着设备可以完全不参与
没有设备提供的真实播放游标,云端只能猜测 reference 时间。最好的分工不是把所有工作搬到某一端,而是让每一端提供只有自己最清楚的信息。
4. “快速打断”与“禁止误打断”天然冲突
不存在一个在所有音量、外壳和房间里都完美的固定阈值。可靠产品需要预热、连续证据、残余回声保护、ASR 二次验证和可调参数共同工作。
5. 外壳也是音频算法的一部分
狭小空间、平行硬表面、扬声器与麦克风距离、密封腔体和共振都会改变声学路径。AEC 能适应一定变化,却不能替代合理的结构设计。量产前仍需要围绕外壳做声学测试和参数标定。
结语
这次 AEC 改造最初只是为了实现一句话:“角色说话时,用户也能随时打断。”
真正落地后,它却涉及硬件总线、I2S、实时队列、WebSocket、二进制协议、重采样、时钟同步、WebRTC AEC3、VAD、ASR 和对话状态机。任何一层只要稍微失配,用户听到的都只是“卡顿”“没反应”或“它怎么又在和自己说话”。
最终可用的方案并不是某个神奇参数,而是一连串工程判断:承认硬件没有可靠回采;移除无效的端侧 AEC;让麦克风保持原始和连续;用设备播放游标重建云端 reference;避免重复上传音频;按回复重置 AEC 状态;最后用状态机谨慎确认打断。
如果你的嵌入式语音设备也没有硬件回采链路,这条路线至少证明了一件事:云端 AEC 是可行的。但在调用 AEC3 之前,请先把协议、时间轴和可观测性做好——算法往往不是最难的那一部分。

浙公网安备 33010602011771号