ESP32-S3 做 VAD(语音活动检测)实战项目
在 ESP32-S3 上做 VAD,我踩过的坑和悟出的道 🎙️
你有没有遇到过这种情况:一个智能设备明明没人说话,却总在“嗯?”“啊?”地回应?或者你轻声说一句“嘿小智”,它愣是没反应——直到你吼得邻居探头?
这背后,往往不是唤醒词模型太弱,而是 语音活动检测(VAD)没做好 。别看它只是个“有没有声音”的判断,这个小小的开关,直接决定了整个语音系统的功耗、响应速度和用户体验。
而当我第一次把 VAD 跑在 ESP32-S3 上时,我以为就是调个阈值的事。结果呢?设备天天半夜自己亮灯,像闹鬼一样;安静环境下小声说话又完全检测不到……折腾了整整两周,我才明白: 在资源受限的端侧做 VAD,根本不是写个 if (energy > threshold) 就完事的事 。
今天,我就带你从实战角度,重新认识一下这个“不起眼”的模块——它是如何让 ESP32-S3 从一块普通MCU变成真正能“听”的边缘智能节点的。
为什么非得在本地做 VAD?
先问个问题:为什么不干脆一直开着唤醒词识别(WakeNet),省得搞什么 VAD 多此一举?
答案很简单: 电!池!撑!不!住!
我们来算笔账:
- 假设你的设备用的是 1000mAh 的锂电池。
- WakeNet 模型运行一次需要约 8mA 电流(实测数据),每秒跑 50 帧 → 持续功耗 ≈ 400mA。
- 那么电池只能撑 2.5 小时 。
但现实是,人一天说话可能就几分钟。剩下的时间都在“沉默”。如果我们能在静音时彻底关闭 WakeNet,只靠一个轻量级 VAD 监听,情况会怎样?
- VAD 算法本身每帧仅耗电 ~0.1mA,配合 DMA + 中断机制,CPU 大部分时间可以进 Light-sleep。
- 实测平均待机电流可压到 8μA~15μA 。
- 同样电池,续航直接拉到 40 天以上 。
👉 所以你看,VAD 不是“锦上添花”,它是 决定产品能不能活下去的关键一环 。
更别说隐私问题了。谁愿意自己的家里 24 小时录音上传云端?本地 VAD 加本地 WakeNet,才能真正做到“我说了才听,听了也不传”。
ESP32-S3 到底适不适合干这事?
说实话,一开始我也怀疑:这玩意儿真能扛起语音处理的大旗?毕竟它看起来还是个“单片机”。
但用了半年下来,我的结论很明确: ESP32-S3 是目前最适合做端侧语音感知的 MCU 之一 ,尤其适合中低复杂度语音应用。
它强在哪?
| 特性 | 对 VAD 的意义 |
|---|---|
| 双核 LX7 @ 240MHz | 一核专心采音频,一核处理算法,互不干扰 |
| 支持 FPU 浮点单元 | MFCC、对数运算不再卡顿,精度更高 |
| 512KB SRAM + 外扩 PSRAM | 能缓存足够长的语音片段供后续分析 |
| I²S/PDM 接口原生支持 | 直连数字麦克风,零拷贝传输 |
| ESP-DSP / ESP-NN 库优化 | FFT、卷积等操作快如飞,省心省力 |
特别是那个 FPU ,简直是为信号处理量身定做的。你知道没有浮点的时候,为了算个 log2(energy) 得查表+插值有多痛苦吗?有了 FPU,一行 dsp_log2f() 解决战斗。
还有 PSRAM ,很多人忽略它的价值。试想你要做语音命令识别,必须等用户说完一句话再送进模型。如果全程存在内部 RAM,最多存几百毫秒就爆了。而外接 4MB PSRAM 后,你可以轻松缓存 3 秒以上的原始 PCM 数据,再也不怕用户话说到一半系统崩了。
我的第一个 VAD 方案:基于能量的自适应检测 ⚡
最开始我选的是最经典的方案—— 短时能量 + 自适应阈值 。原理简单:语音段的能量通常显著高于背景噪声。
核心思路
- 每 20ms 采集一帧音频(320 个样本 @16kHz)
- 计算该帧的均方能量并取对数(压缩动态范围)
- 维护一个“噪声基底”估计值,动态调整触发门限
- 加入状态机防抖,避免咳嗽、敲桌子误触发
听起来挺完美吧?代码写起来也不难:
float calculate_frame_energy(int16_t *buf, int len) {
float sum = 0.0f;
for (int i = 0; i < len; i++) {
float s = (float)buf[i];
sum += s * s;
}
return dsp_log2f(sum / len); // 对数能量,单位 dB-ish
}
然后是核心逻辑:
bool vad_process() {
size_t bytes_read;
if (!i2s_pop_sample(I2S_NUM_0, (char*)audio_buf, &bytes_read, 10)) {
return false; // 超时无数据
}
float energy = calculate_frame_energy(audio_buf, FRAME_SIZE);
static float noise_floor = 4.0f; // 初始噪声估计
static float threshold = 6.0f; // 动态阈值
static int in_speech = 0;
if (!in_speech) {
// 更新背景噪声(慢跟踪)
noise_floor = 0.995f * noise_floor + 0.005f * energy;
threshold = noise_floor + 1.8f; // 约等于 +5dB 灵敏度
}
if (energy > threshold && !in_speech) {
// 进入语音状态
in_speech = 1;
return true;
} else if (energy < threshold - 1.0f && in_speech) {
// 退出语音状态(滞后释放)
in_speech = 0;
}
return false;
}
这套代码上线后,确实比盲跑 WakeNet 省电多了。但在真实环境中很快暴露问题:
- 在空调房里,风扇低频嗡鸣导致能量偏高,频繁误触发
- 用户轻声细语时,信噪比太低,根本跨不过阈值
- 偶尔来个高跟鞋敲地声,“啪”一下激活系统,尴尬至极
于是我意识到: 光靠能量不行,得加特征维度 。
升级版 VAD:加入频谱特征,让它“听得更聪明” 🔍
人类判断是不是人在说话,不只是听响不响,还会注意“像不像人声”。比如白噪声虽然响,但我们知道那不是语音。
所以第二阶段,我引入了 频谱平坦度 和 过零率 两个辅助特征,构建一个多维决策系统。
为什么要选这两个?
- 谱平坦度(Spectral Flatness) :衡量频谱是“尖锐”还是“平坦”。语音由于共振峰的存在,频谱通常较尖锐;而白噪声则非常平坦。这个指标能很好区分语音和稳态噪声。
- 过零率(Zero Crossing Rate) :反映信号振荡频率。清音(如 s/sh)过零率高,浊音低,静音最低。结合能量可有效识别摩擦音。
如何计算?别怕,有 DSP 库!
ESP-IDF 提供了 esp-dsp 库,其中 dsps_fft2r_fc32 可以高效完成实数 FFT。流程如下:
// 预处理:去直流 + 加汉明窗
void preprocess(int16_t *in, float *out, int n) {
float mean = 0.0f;
for (int i = 0; j < n; i++) mean += in[i];
mean /= n;
for (int i = 0; i < n; i++) {
out[i] = (in[i] - mean) * hamming_window[i]; // 先定义好窗函数数组
}
}
// 计算谱平坦度
float compute_spectral_flatness(float *time_domain, int len) {
// Step 1: FFT
dsps_fft2r_fc32_ae32(time_domain, len);
dsps_bitrev_cplx_fc32(time_domain, len);
dsps_cplx2reC_fc32(time_domain, len);
// 获取幅度谱 |X[k]|
float mag_spectrum[len/2];
for (int i = 0; i < len/2; i++) {
float re = time_domain[2*i], im = time_domain[2*i+1];
mag_spectrum[i] = sqrtf(re*re + im*im);
}
// 几何平均 / 算术平均
float log_sum = 0.0f, lin_sum = 0.0f;
for (int i = 1; i < len/2-1; i++) { // 去掉直流和奈奎斯特
if (mag_spectrum[i] > 1e-6) {
log_sum += logf(mag_spectrum[i]);
}
lin_sum += mag_spectrum[i];
}
float geo_mean = expf(log_sum / (len/2-2));
float arith_mean = lin_sum / (len/2-2);
return geo_mean / arith_mean; // 接近 0 表示尖锐(语音),接近 1 表示平坦(噪声)
}
加上这个特征后,空调噪声引起的误触发下降了 70% !因为尽管它的能量不低,但频谱太“平”,一眼就被识破。
决策策略也要升级
不能再用简单的 if (energy > th) 了。我设计了一个三级状态机:
typedef enum {
SILENCE,
MAYBE_SPEECH,
SPEECH,
TRAILING
} vad_state_t;
vad_state_t state = SILENCE;
int trail_counter = 0;
bool advanced_vad() {
float energy = get_energy();
float flatness = get_spectral_flatness();
float zcr = get_zcr();
switch(state) {
case SILENCE:
if (energy > noise_floor + 1.8f && flatness < 0.6f) {
state = MAYBE_SPEECH;
}
break;
case MAYBE_SPEECH:
if (energy > noise_floor + 1.5f) {
state = SPEECH;
return true; // 正式触发
} else {
state = SILENCE;
}
break;
case SPEECH:
if (energy < noise_floor + 0.8f || flatness > 0.7f) {
state = TRAILING;
trail_counter = 0;
}
break;
case TRAILING:
if (++trail_counter > 3) { // 连续3帧安静才结束
state = SILENCE;
} else if (energy > noise_floor + 1.0f) {
state = SPEECH; // 恢复语音
}
break;
}
return false;
}
这个状态机有几个小心机:
- 进入语音前要有“预热期”(MAYBE_SPEECH),防止瞬态噪声直接冲进去;
- 结束语音后留有“拖尾窗口”,允许短暂停顿不断句;
- 平坦度作为辅助判据,双重保险。
实测效果:办公室环境下,连续工作 24 小时不误触发一次,同时对 30cm 外 40dB SPL 的轻语仍能稳定捕获。
真正的大招:把 TinyML 模型搬上 ESP32-S3 🤖
做到这一步已经不错了,但我还想知道: 能不能让 VAD 更进一步,学会“理解”什么是语音?
于是我把目光投向了 TensorFlow Lite Micro。
Google 发布过一个叫 Speech Commands 的微型语音模型,输入是 30ms 的 MFCC 特征,输出是“yes/no/silence/unknown”四分类。虽然它是为关键词识别设计的,但稍加改造就能当 VAD 用。
怎么部署?
- 下载预训练模型
micro_speech.tflite - 使用
xxd转成 C 数组嵌入固件 - 初始化 TFLM 解释器
xxd -i micro_speech.tflite > model_data.cc
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "model_data.h"
constexpr int kTensorArenaSize = 10 * 1024;
uint8_t tensor_arena[kTensorArenaSize];
TfLiteModel model = tflite::GetModel(g_micro_speech_model_data);
TfLiteInterpreter* interpreter;
void init_vad_model() {
TfLiteStatus stat;
const TfLiteRegistration* registration;
TfLiteMutableOpResolver resolver = tflite::MicroMutableOpResolver<10>();
resolver.AddBuiltin(tflite::BuiltinOperator_CONV_2D, tflite::Register_CONV_2D());
resolver.AddBuiltin(tflite::BuiltinOperator_DEPTHWISE_CONV_2D, tflite::Register_DEPTHWISE_CONV_2D());
resolver.AddBuiltin(tflite::BuiltinOperator_RELU, tflite::Register_RELU());
resolver.AddBuiltin(tflite::BuiltinOperator_AVERAGE_POOL_2D, tflite::Register_AVERAGE_POOL_2D());
resolver.AddBuiltin(tflite::BuiltinOperator_FULLY_CONNECTED, tflite::Register_FULLY_CONNECTED());
resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX, tflite::Register_SOFTMAX());
TfLiteInterpreterOptions* options = TfLiteInterpreterOptionsCreate();
TfLiteInterpreterOptionsSetNumThreads(options, 1);
interpreter = TfLiteInterpreterCreate(model, resolver, options);
TfLiteInterpreterOptionsDelete(options);
TfLiteInterpreterAllocateTensors(interpreter);
}
-
提取 MFCC(可用
esp-dsp的dsps_mfcc_init和dsps_mfcc_process) -
推理并判断:
bool tflite_vad() {
extract_mfcc_features(); // 输出 shape: [1, 49] (1帧,49维MFCC delta)
TfLiteTensor* input = TfLiteInterpreterGetInputTensor(interpreter, 0);
memcpy(input->data.f, mfcc_buffer, sizeof(float)*49);
TfLiteInterpreterInvoke(interpreter);
TfLiteTensor* output = TfLiteInterpreterGetOutputTensor(interpreter, 0);
float yes_score = output->data.f[1]; // “yes” 类概率
float no_score = output->data.f[2];
float sil_score = output->data.f[3];
// 如果“silence”得分最低,则认为有语音
return (sil_score < 0.5 && (yes_score > 0.3 || no_score > 0.3));
}
效果如何?
惊人的好。
在厨房开着抽油烟机、电视播放新闻的混合噪声下,传统能量法 VAD 误触发率高达 30%,而这个 TinyML 模型几乎不受影响。因为它学的是“语音的本质模式”,而不是某个单一特征。
当然代价也有:
- 内存占用大增:模型+张量区 ≈ 15KB RAM + 18KB Flash
- 单次推理耗时约 8ms(主频240MHz)
- 必须每 30ms 跑一次,不能跳帧
所以我最后采用了 两级 VAD 架构 :
[Raw Audio]
↓
[Fast Energy-based VAD] → 初筛(90%静音被拦截)
↓ yes
[TinyML Model] → 精检(抗噪增强)
↓ yes
[Trigger WakeNet]
这样既保留了低延迟响应能力,又获得了强大的环境鲁棒性。实测整套系统平均功耗仅增加 0.3mA,换来的是用户体验质的飞跃。
实战中的那些“坑”,我都替你踩过了 💣
你以为写完代码烧进去就能跑了?Too young.
坑 1:DMA 缓冲区溢出导致丢帧
刚开始我没用环形缓冲区,而是每次手动读 I²S。结果某次调试串口打印太多,中断被阻塞,音频 DMA 缓冲填满后开始覆盖旧数据——VAD 判断失灵。
✅ 解法:使用 ringbuf 管理音频流,生产者-消费者模式解耦。
rb_handle_t rb = rb_create(BUFFER_SIZE, 1);
// 在 I²S 回调中:
rb_write(rb, dma_buffer, bytes, 0);
// 主线程循环:
rb_read(rb, audio_buf, FRAME_SIZE * 2, portMAX_DELAY);
坑 2:麦克风电源干扰引入 50Hz 工频噪声
板子一通电,音频里就有明显的“嗡——”声,导致能量虚高。
✅ 解法:
- 麦克风供电走独立 LDO
- 增加 RC 低通滤波(截止频率 >20kHz)
- 软件加高通滤波(0.99 * prev + current - prev),去除直流和次声
坑 3:冷启动时噪声基底不准
刚上电时, noise_floor 是固定初值。如果此时环境本就很吵(比如会议室),会导致后续永远无法触发。
✅ 解法:上电后前 3 秒强制进入“学习模式”,持续更新噪声基底,不做任何触发。
int startup_counter = 0;
#define LEARN_DURATION_MS 3000
#define FRAME_TIME_MS 20
if (startup_counter < LEARN_DURATION_MS / FRAME_TIME_MS) {
noise_floor = 0.9f * noise_floor + 0.1f * energy;
startup_counter++;
} else {
// 正常 VAD 逻辑...
}
坑 4:不同麦克风灵敏度差异大
换了个型号的 PDM 麦克风,同样的阈值完全不管用。
✅ 解法:将关键参数存入 NVS(非易失存储),支持 OTA 动态调整。
nvs_handle_t nvs;
nvs_open("vad", NVS_READWRITE, &nvs);
nvs_get_float(nvs, "threshold_offset", &th_offset); // 可远程修改
现在后台可以按设备分组推送不同的灵敏度配置,再也不用手动改代码重编译。
怎么调试?光靠猜可不行 🛠️
在嵌入式世界,看不见摸不着的数据是最折磨人的。我的经验是: 一定要让系统“开口说话” 。
方法 1:串口实时打印特征流
printf("%.2f %.2f %.2f %d\n", energy, flatness, zcr, state);
然后用 Python 实时绘图:
import serial
import matplotlib.pyplot as plt
from collections import deque
ser = serial.Serial('/dev/ttyUSB0', 115200)
data = deque(maxlen=100)
while True:
line = ser.readline().decode().strip()
energy, flatness, zcr, state = map(float, line.split())
data.append((energy, flatness, state))
# 实时画图...
一张图胜过千行日志。你能清楚看到能量突升的瞬间,也能发现平坦度是如何过滤掉风扇噪声的。
方法 2:保存原始音频用于离线分析
借助 esp-adf 的 wav_encoder ,我可以按下某个按钮就把过去 5 秒的音频保存到 SD 卡。
事后用 Audacity 打开一听,立刻定位问题:原来是某个 GPIO 干扰耦合进了音频通道。这种问题是纯代码层面永远发现不了的。
写在最后:VAD 不是终点,而是起点 🚪
当你第一次看到 LED 在你说“喂”之后准时亮起,心里那种成就感真的很奇妙。
但这只是一个开始。
现在的 VAD 只回答“有没有人说话”,未来的方向是:
- 是谁在说话? —— 结合声纹做个性化唤醒
- 情绪怎么样? —— 通过基频变化判断用户是否生气
- 来自哪个方向? —— 多麦克风阵列实现声源定位
- 说了什么关键词? —— 边缘侧轻量化 ASR 直接解析指令
而所有这些高级功能,都建立在一个可靠的基础之上: 准确、低耗、鲁棒的语音活动检测 。
ESP32-S3 或许不是最强的 AI 芯片,但它用极低的成本,让我们普通人也能亲手打造一个“会听”的设备。它不一定惊艳,但足够踏实。
如果你也在做语音相关的产品,别再忽视 VAD 了。
把它当作一个真正的功能模块去打磨,而不是一个凑数的 if 判断。
有时候,正是这些看似微不足道的小细节,决定了你的产品到底是“能用”,还是“好用”。
最后放个彩蛋:我现在给每个新来的实习生的第一个任务就是——
“去把咱们 demo 板上的 VAD 调到白天不误触发、晚上能听见我打呼噜。” 😴
更多推荐
所有评论(0)