在 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 方案:基于能量的自适应检测 ⚡

最开始我选的是最经典的方案—— 短时能量 + 自适应阈值 。原理简单:语音段的能量通常显著高于背景噪声。

核心思路

  1. 每 20ms 采集一帧音频(320 个样本 @16kHz)
  2. 计算该帧的均方能量并取对数(压缩动态范围)
  3. 维护一个“噪声基底”估计值,动态调整触发门限
  4. 加入状态机防抖,避免咳嗽、敲桌子误触发

听起来挺完美吧?代码写起来也不难:

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 用。

怎么部署?

  1. 下载预训练模型 micro_speech.tflite
  2. 使用 xxd 转成 C 数组嵌入固件
  3. 初始化 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);
}
  1. 提取 MFCC(可用 esp-dsp dsps_mfcc_init dsps_mfcc_process

  2. 推理并判断:

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 调到白天不误触发、晚上能听见我打呼噜。” 😴

更多推荐