本文记录一次真实的 ESP32-A1S Audio Kit 语音采集调优过程:设备需要持续监听,将有效声音上传到云端并推送到飞书,但早期版本会上传静音、环境杂音和电流声;提高过滤强度后,又会漏掉 30 cm 范围内的低声说话和哼唱。最终通过 PCM 取证、分层判定和“上传期抗干扰门控”,在灵敏度与误触发之间取得了更合理的平衡。
一、项目背景
硬件使用 ESP32-A1S Audio Kit,音频输入经过 ES8388 编解码器,由 I2S 持续采集。固件将双声道输入转换为 16 kHz、16 bit、单声道 PCM,并使用 VAD(Voice Activity Detection,语音活动检测)切分录音段。
完整链路如下:
麦克风 / ES8388
↓
I2S 连续采集
↓
高通滤波 + 音量/频谱特征
↓
VAD 与能量门控
↓
预录音、静音结束、后滚缓冲
↓
PCM 上传云端
↓
降噪、存储、飞书通知
业务要求看似简单,实际存在两个互相冲突的目标:
- 设备不能把无意义的底噪、电流声持续上传。
- 设备必须能捕捉近距离轻声说话、叹气和哼唱,不能为了“安静”而变得迟钝。
二、最初遇到的问题
第一版仅依赖 ESP VAD。实际运行后发现,VAD 并不等同于“人声识别器”:周期性噪声、低频震动、摩擦声和部分电磁干扰也可能具有类似语音的包络,从而被判断为 VAD_SPEECH。
随后提高启动阈值,虽然无意义录音减少了,却出现了新的问题:距离设备约 30 cm、音量偏低的说话无法启动录音,人声哼唱也经常漏检。原因是哼唱的过零率和频谱形态与普通讲话不同,单纯提高能量阈值会把它和噪声一起过滤掉。
调优不能只靠“听起来像”,必须让设备产生的原始 PCM 成为证据。
三、先分析原始 PCM,而不是盲目调阈值
原始文件格式为:
pcm_s16le / 16000 Hz / 单声道 / 16 bit
分析时重点关注以下指标:
- 时长:是否总在某个固定长度附近结束。
- RMS:整体有效电平,可转换为 dBFS 比较。
- 峰值:判断是否存在削波或瞬态尖峰。
- 过零率:区分低频缓慢摆动与宽带声音。
- 分频段能量占比:观察能量是否长期集中在低频窄带。
- 文件生成时间:判断多个录音段之间是否存在稳定的因果时序。
可使用 FFmpeg 将 PCM 转为 WAV,便于播放和绘制波形:
ffmpeg -f s16le -ar 16000 -ac 1 -i input.pcm output.wav
也可以用 NumPy 做快速统计:
import numpy as np
samples = np.fromfile("input.pcm", dtype="<i2").astype(np.float64)
rms = np.sqrt(np.mean(samples ** 2))
peak = np.max(np.abs(samples))
zero_crossings = np.count_nonzero(np.diff(np.signbit(samples)))
print("duration:", len(samples) / 16000)
print("rms:", rms)
print("peak:", peak)
print("zero crossings:", zero_crossings)
四、文件时间揭示了真正的规律
在设备已经改为单独供电后,仍然出现“有效声音结束后,又发送一段三秒多刺耳兹拉声”的问题。对同一批 PCM 按文件生成时间排序后,出现了非常稳定的配对关系:
| 文件前缀 | 时长 | 生成时间 | 判断 |
|---|---|---|---|
bad7985d | 3.18 秒 | 14:51:08 | 有效录音 |
7f3968e9 | 3.09 秒 | 14:51:11 | 紧随其后的噪声 |
da445adf | 3.30 秒 | 14:51:29 | 有效录音 |
9ebd67d4 | 3.15 秒 | 14:51:33 | 紧随其后的噪声 |
c94a650f | 8.07 秒 | 14:53:26 | 有效录音 |
8a0c9112 | 3.72 秒 | 14:53:30 | 紧随其后的噪声 |
三个噪声文件都在上一条录音结束后的 3~4 秒内生成,而且长度接近。频谱统计也表现出明显共性:能量主要集中在 80~800 Hz,高频占比较低,属于低频窄带干扰,并不像正常讲话那样具有更丰富的宽带成分。
这组时序证据非常关键:问题并不是随机环境噪声,也不只是外部电源纹波,而是与“上一条录音上传”高度相关。
五、根因:无线上传干扰了仍在工作的模拟音频链路
设备完成一个录音段后会立即执行 Wi-Fi/TLS 上传。此时:
- Wi-Fi 射频开始高强度工作。
- TLS 加密和网络发送带来突发电流变化。
- 麦克风、ES8388、模拟地线或板内走线拾取到耦合干扰。
- 录音任务仍在持续采集,干扰被写入下一批音频帧。
- VAD 将具有周期性包络的“兹拉声”误判为语音。
- 固件开启新的录音段,并在满足最小时长后再次上传。
因此,单独供电并不能彻底解决问题。它可能减轻通过电源线传入的噪声,却无法消除同一块 PCB 上 Wi-Fi 射频、数字电流和模拟音频电路之间的耦合。
六、调优过程:不能只提高一个阈值
1. 使用温和高通滤波
固件加入一阶高通滤波,截止频率约为 80 Hz:
#define HP_ALPHA_Q15 31755 /* approximately 80 Hz at 16 kHz */
这个频率能够削弱直流漂移、触碰震动和极低频隆隆声,同时尽量保留低沉人声。截止频率不能推得过高,否则轻声、叹气和哼唱会进一步损失。
2. 启动录音需要连续多帧成立
偶发尖峰不应立刻开启录音,因此设置至少三个连续有效帧:
#define MIN_START_VAD_FRAMES 3
每帧 30 ms,三帧约为 90 ms。这个长度足以过滤多数瞬态毛刺,又不会明显截断正常讲话。配合预录音缓冲,即使确认过程需要几个帧,句首也能被补回来。
3. VAD 与能量特征联合判定
启动阶段同时保留两条通道:
- VAD 通道:适合普通说话。
- 宽带能量通道:补充捕捉轻声、叹气、哼唱等可能被 VAD 漏掉的声音。
宽带能量不仅检查音量,还检查过零率:
bool broadband_energy_voice =
level > start_energy_threshold &&
zero_crossings >= 10 &&
zero_crossings <= 180;
bool qualified_voice = vad_voice || broadband_energy_voice;
这比“只看音量”稳健,因为固定频率的低频嗡声可能有一定电平,却缺乏正常声音的宽带变化。
4. 启动与持续录音采用迟滞
启动录音应当严格,录音开始后则要适当宽松,否则一句话后半段变轻就会被错误切断。固件为“未录音”和“正在录音”使用不同阈值:
uint32_t energy_threshold = current ? noise_level * 2 : noise_level * 3;
uint32_t minimum_energy = current ? 120 : 180;
这种迟滞设计可以理解为:进门要严格,进门后不要因为声音稍微变小就立刻赶出去。
5. 对完整录音段做二次质量检查
即使某些噪声骗过启动判定,也不代表必须上传。录音结束时继续检查:
- 最小时长。
- 有效语音帧数。
- 有效帧占总帧的比例。
#define MIN_SEGMENT_VAD_FRAMES 6
#define MIN_VAD_PERCENT 5
这形成“帧级启动判定 + 录音段级质量判定”的两道防线。
七、最终修复:上传期抗干扰门控
仅靠通用 VAD 参数无法稳定区分“真实人声”和“设备自己上传时产生的干扰”,但固件知道上传何时发生。既然干扰具有明确的系统状态,就应把系统状态加入判定,而不是继续盲调全局阈值。
首先记录上传状态,并为上传结束后的模拟电路恢复预留 800 ms:
#define UPLOAD_GUARD_AFTER_MS 800
static volatile bool g_upload_active;
static volatile TickType_t g_upload_guard_until;
上传前后更新状态:
g_upload_active = true;
int status = http_execute(
client,
segment->pcm,
segment->length,
response,
sizeof(response)
);
g_upload_active = false;
g_upload_guard_until =
xTaskGetTickCount() + pdMS_TO_TICKS(UPLOAD_GUARD_AFTER_MS);
VAD 任务在上传干扰窗口内切换到更严格的宽带门控:
TickType_t now_tick = xTaskGetTickCount();
bool upload_guard = g_upload_active ||
(int32_t)(g_upload_guard_until - now_tick) > 0;
uint32_t guarded_threshold = noise_level * 4;
if (guarded_threshold < 240) {
guarded_threshold = 240;
}
bool guarded_broadband_voice =
level > guarded_threshold &&
zero_crossings >= 10 &&
zero_crossings <= 180;
bool qualified_voice = upload_guard
? guarded_broadband_voice
: (vad_voice || broadband_energy_voice);
这里有三个重要设计点:
- 不停止 I2S 采集。 停止再启动编解码器可能产生新的爆音,还可能丢失上传期间的真实声音。
- 不全局降低灵敏度。 正常状态仍沿用已经校准好的轻声捕捉参数。
- 上传期不是完全静音。 足够明显且具备宽带特征的真人声音仍可以启动录音,只过滤符合已知干扰形态的低频窄带信号。
使用历史 PCM 离线模拟该门控后,三条上传后噪声的强门控命中帧均为 0;多条有效录音仍能连续命中足够帧。这说明门控方向与样本证据相符,而不是凭感觉设置参数。
八、云端降噪应作为第二层,而不是替代采集判定
设备端解决的是“该不该产生并上传一个录音段”。云端解决的是“已经确认有价值的音频应当如何改善听感”。两者职责不同。
项目中提供了三种降噪策略:
| 模式 | 适用场景 | 特点 |
|---|---|---|
| 关闭 | 原始音频分析、问题排查 | 不改变频谱,便于定位硬件问题 |
| 温和降噪 | 日常语音、轻声和哼唱 | 默认模式,尽量保留人声细节 |
| 增强降噪 | 固定底噪较重的环境 | 抑制更强,但可能损失微弱声音 |
不建议依赖云端增强降噪来“修复”整段纯噪声,因为这类文件本来就不应上传。正确顺序是:设备端先避免误触发,云端再改善有效录音的听感。
九、编译、刷写与验证
本项目使用脚本构建和刷写:
# 仅编译
& "D:projectsesp32build-aicat-voice.ps1" -Action build
# 刷写 COM7
& "D:projectsesp32build-aicat-voice.ps1" -Action flash -Port COM7
# 查看串口日志
& "D:projectsesp32build-aicat-voice.ps1" -Action monitor -Port COM7
刷写后至少进行以下场景的交叉测试:
- 距离 30 cm 正常说话。
- 距离 30 cm 偏低声说话。
- 哼唱、叹气等弱语音类声音。
- 敲桌、摩擦、风扇和纯电流噪声。
- 有效声音结束后立即观察 5~10 秒,确认是否出现第二条噪声录音。
- 正在上传时再次讲话,确认门控没有把真实人声完全屏蔽。
- 连续多次录音,检查缓冲区、上传队列和网络重试是否正常。
固件启动日志应确认以下状态:
Continuous capture started
SNTP ready
Cloud config applied
heartbeat_ok
若上传期间检测到了足够强的真实宽带声音,启动日志会显示:
vad_start source=upload_guard_voice ...
十、后续可以继续完善的诊断能力
为了让云端问题可以远程分析,建议继续上报以下指标:
- VAD 启动来源:
vad、broadband_energy或upload_guard_voice。 - 启动时的
level、动态噪声基线和实际阈值。 - 被丢弃的短录音数量。
- 被判定为噪声的录音数量。
- 上传期间被门控拦截的帧数。
- 队列溢出、缓冲区不足、I2S 错误和网络重试次数。
- 设备固件版本、降噪模式及生效配置版本。
还可以提供受权限保护的诊断接口,下载指定设备最近若干条原始 PCM、对应时间戳和判定元数据。这样不需要依赖用户手工拷贝文件,就能重建“采集—切段—上传”的完整时间线。
十一、经验总结
这次问题最有价值的结论并不是某一个具体阈值,而是调优方法:
- 保留原始 PCM。 没有原始数据,很容易把射频干扰误认为电源问题或环境噪声。
- 同时分析声音特征和事件时间。 文件生成时间最终揭示了噪声与上传动作的因果关系。
- 避免用一个全局阈值解决所有问题。 阈值过高会漏掉轻声,过低又会误触发。
- 利用系统已知状态。 上传是否正在发生是固件完全掌握的信息,应当参与音频判定。
- 使用分层防线。 高通滤波、帧级判定、连续帧确认、迟滞、录音段质量检查和上传期门控各自解决不同问题。
- 设备端与云端各司其职。 设备端控制误触发,云端降噪改善有效内容。
嵌入式语音采集的难点通常不在“能不能录到”,而在复杂电气和网络环境中,能否稳定判断什么值得留下。面对这类问题,比反复盲调参数更有效的方法,是让每一次判断都能被数据解释,让每一次修改都可以用历史样本回放验证。