上一篇说屏和 web。这篇说音频——从板上 ES7210 双麦阵列读 PCM、丢进 VAD 判断”是不是有人在说话”、攒成段落写 WAV。VAD 这条单一判据,迭代了三代才稳。
硬件 pipeline
ES7210 是 4 通道 ADC(板上接了 2 路 MIC),通过 I2C0 配置寄存器(增益 / 通路 / 分频),数据走 I2S。ESP32-S3 I2S0 配置成 Philips stereo / 16kHz / 16-bit,左声道 = MIC1、右声道 = MIC2,interleaved 进 DMA buffer。
v1:手搓能量阈值 VAD
最朴素的判据:算每个 256-sample 块的 RMS(root mean square),过阈值就算有人说话。
fn rms(samples: &[i16]) -> u32 { let sum: u64 = samples.iter().map(|&s| (s as i32).pow(2) as u64).sum(); ((sum / samples.len() as u64) as f64).sqrt() as u32}
if rms(&chunk) > THRESHOLD { /* 开始录 */ }跑通了,但误触发率爆炸:
- 关门(瞬态 ~150ms 50Hz 冲击)→ 触发
- 键盘按一下 → 触发
- 空调启停 → 触发
- 风扇底噪上一个台阶 → 触发到不停
阈值往上调,正常说话也漏判;调下来,全是噪音段。问题根本不在阈值,是能量这个特征本身没有区分度——人声和瞬态噪声 RMS 量级一样。
v2:esp-sr WebRTC pipeline
esp-sr 是乐鑫官方音频前端库,里面打包了 WebRTC 那套经典 NS(Noise Suppression)+ VAD:
切到这套之后稳态噪声完全压住了——空调、风扇、远处车流,都干净。但瞬态噪声仍然误触发:
- 关门 / 撞击 / 咳嗽,~50-300ms 的高能瞬态
- WebRTC VAD 是 90 年代电话信道高斯混合模型,训练样本里没见过这种东西
加 min_speech_ms = 300 时间门有点用——必须连续 300ms 都判定为 speech 才算开段。但代价是首字常被切,“你好” 的 “你” 经常没了。AFE 内置 vad_cache 预滚 buffer 能补回来一部分,但效果不稳定。
v3:VADnet1_medium + NSnet2
esp-sr 2.x 起把模型层换成神经网络版:
- VADnet1_medium:CNN VAD,真实噪声训练,瞬态杀手
- NSnet2:深度噪声抑制,把稳态底噪压到 VAD 看不见
切换只需要 sdkconfig 里两行:
CONFIG_SR_VADN_VADNET1_MEDIUM=yCONFIG_SR_NSN_NSNET2=ybuild 时 movemodel.py 自动把模型打包成 srmodels.bin 烧到 model 分区(partitions.csv 里预留 2MB)。Rust 这边几乎没改 —— Afe::new() 通过 esp_srmodel_filter 按 vadnet* / nsnet* 前缀匹配,自动拿到模型句柄。
效果立刻上一个台阶:关门、键盘、咳嗽全都不开段。说话首字保留,尾静音 hangover 也不太需要往上调了。
段切分状态机
VAD 给的是逐帧 vad_speech: bool,我们要从这个时序里切出”一段完整发言”:
三道关卡:
- HANGOVER_FRAMES = 30(约 1s 尾静音)—— 短停顿不切段
- MIN_SEG_FRAMES = 32(约 1s 最短段)—— 太短认作误触发
- RMS_DISCARD_THRESHOLD = 800 —— 整段最大单帧 RMS 低于这个,落盘前再丢一次
第三关是兜底——AFE 偶尔判错时,“全段都是底噪”会被这道闸刀拦掉。日志里打整段 max_rms,方便回头看实际数值调阈值。
三线程拆分
最初版本两线程:feed(read I2S)+ fetch(VAD + 写 WAV)。问题:SPIFFS 单次 write 吃 30-100ms,长段累积下来 fetch 跟不上 AFE feed,ringbuf 持续 full 报错。
拆成三线程:
- feed:阻塞在 I2S read,绝不能被别的事卡住
- fetch:阻塞在 AFE fetch,只做状态机 + push 到内存 buffer
- finalizer:段结束时一次性写 SPIFFS / SD,慢就让它慢
中间用 mpsc::sync_channel(capacity=2) 解耦。256KB 一个 chunk(~8s 音频),channel 最多压 ~16s 未写数据(512KB),扔 PSRAM 里安全。
SD 卡接管
12MB SPIFFS 撑不了几段,加 SD 卡:SDMMC 1-bit(CLK=38 / CMD=21 / D0=39,板子物理走 R7 mod 跳过的版本——查 Waveshare 官方原理图发现根本不需要改),FAT32,GB 级容量秒变录音笔。
启动顺序 SD 优先,SD mount 失败回退板内 SPIFFS。两路都挂在 /storage,recorder 和 HTTP 路径一行没改。文件名 20260425-235156-546.wav:年月日 - 时分秒 - 毫秒,新文件字典序最大,列表头插即按时序。
/recordings 网页播放器
录完直接在浏览器听:

每条 <audio controls> + Download / Delete。HTTP 直接 Range: bytes= 流式推 WAV,浏览器原生播放器拖进度条无延迟。
服务端的小 trick:SPIFFS / FATFS 目录扫描 O(N),68 个文件就要 12 秒,HTTP 必超时。开机启动时只扫一次/storage 把 wav 灌进内存 index Vec<RecEntry>,之后所有 HTTP 列表 / 删除 / 计数全走 index,不再碰文件系统目录。
SRAM 紧到 2.8KB → 31KB
切到神经网络模型代价:内部 SRAM heap_min_ever 跌到 2.8KB(差点 OOM 重启)。一通调参压回 31KB,11× 回血:
| 调整 | 目的 | 省下 |
|---|---|---|
MBEDTLS_DYNAMIC_BUFFER | TLS 握手缓冲完后立即缩 | ~25KB |
MBEDTLS_DYNAMIC_FREE_PEER_CERT | 握手完释放对端证书链 | ~5KB |
ESP_WIFI_DYNAMIC_TX_BUFFER + 走 PSRAM | TX buffer 不再钉死 SRAM | ~25KB |
ESP_WIFI_STATIC_RX_BUFFER_NUM=10(原 16) | 屏幕固件不需要那么多 RX | ~10KB |
SPIRAM_MALLOC_ALWAYSINTERNAL=4096(原 16384) | 大块默认走 PSRAM | 间接 ~30KB |
最坑的一条:CONFIG_ESP_WIFI_STATIC_TX_BUFFER 关掉必须写成 # CONFIG_ESP_WIFI_STATIC_TX_BUFFER is not set,写 =n 被 Kconfig 静默丢弃。这条吃过亏 —— 排查半天为啥 dynamic 没生效,最后才发现 sdkconfig 语法陷阱(上一篇环境坑有提)。
一句话总结
手搓能量 VAD 是 80 年代水平,WebRTC VAD 是 90 年代水平,神经网络 VAD 是现代水平。这三代差了 30 年,但只差 sdkconfig 两行。 站在乐鑫和 Microsoft DNS 团队肩膀上的感觉真好。
下一步:把 ES8311 + 喇叭打通,做语音播报;接 ASR / TTS / Claude API,绕回最初”esp32 Claude 助手”的设想。
代码全部在 GitHub。