王智刚

ES7210 双麦 + esp-sr:从手搓 VAD 到神经网络

Misc 4 分钟
目录

上一篇说屏和 web。这篇说音频——从板上 ES7210 双麦阵列读 PCM、丢进 VAD 判断”是不是有人在说话”、攒成段落写 WAV。VAD 这条单一判据,迭代了三代才稳。

硬件 pipeline

graph LR A[MIC1 模拟] --> B[ES7210 ADC] C[MIC2 模拟] --> B B -->|I2C 配置| D[I2S 16kHz/16-bit/stereo] D --> E[ESP32-S3 I2S0] E --> F[Mic::read 阻塞] F --> G[esp-sr AFE] G --> H[VAD 状态机] H --> I[WAV 文件] I --> J[SD 卡 / SPIFFS]

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:

graph LR A[双麦 stereo i16] --> B[BSS 盲源分离] B --> C[WebRTC NS] C --> D[WebRTC VAD] D --> E["min_speech / min_noise 时间门"] E --> F[vad_speech 状态] C --> G[去噪后单声道 PCM]

切到这套之后稳态噪声完全压住了——空调、风扇、远处车流,都干净。但瞬态噪声仍然误触发

  • 关门 / 撞击 / 咳嗽,~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=y
CONFIG_SR_NSN_NSNET2=y

build 时 movemodel.py 自动把模型打包成 srmodels.bin 烧到 model 分区(partitions.csv 里预留 2MB)。Rust 这边几乎没改 —— Afe::new() 通过 esp_srmodel_filtervadnet* / nsnet* 前缀匹配,自动拿到模型句柄。

效果立刻上一个台阶:关门、键盘、咳嗽全都不开段。说话首字保留,尾静音 hangover 也不太需要往上调了。

段切分状态机

VAD 给的是逐帧 vad_speech: bool,我们要从这个时序里切出”一段完整发言”:

graph LR A[Idle] -->|VAD=true| B[Recording] B -->|连续 hangover 帧 VAD=false| C{段长 OK?} C -->|< 1s| D[Discard] C -->|max_rms 太低| D C -->|OK| E[写 WAV] D --> A E --> A

三道关卡:

  1. HANGOVER_FRAMES = 30(约 1s 尾静音)—— 短停顿不切段
  2. MIN_SEG_FRAMES = 32(约 1s 最短段)—— 太短认作误触发
  3. RMS_DISCARD_THRESHOLD = 800 —— 整段最大单帧 RMS 低于这个,落盘前再丢一次

第三关是兜底——AFE 偶尔判错时,“全段都是底噪”会被这道闸刀拦掉。日志里打整段 max_rms,方便回头看实际数值调阈值。

三线程拆分

最初版本两线程:feed(read I2S)+ fetch(VAD + 写 WAV)。问题:SPIFFS 单次 write 吃 30-100ms,长段累积下来 fetch 跟不上 AFE feed,ringbuf 持续 full 报错。

拆成三线程:

graph LR A[I2S DMA] --> B[feed thread] B -->|"afe.feed()"| C[AFE 内部线程] C -->|"afe.fetch()"| D[fetch thread] D -->|状态机 + 内存 push| E[finalizer thread] E -->|落盘| F[SD / SPIFFS WAV]
  • 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 网页播放器

录完直接在浏览器听:

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_BUFFERTLS 握手缓冲完后立即缩~25KB
MBEDTLS_DYNAMIC_FREE_PEER_CERT握手完释放对端证书链~5KB
ESP_WIFI_DYNAMIC_TX_BUFFER + 走 PSRAMTX 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