上个月半夜出差回来,刚把钥匙插进锁孔,门没开,楼道里的声控灯倒是先亮了。我一回头,发现门上那个报警蜂鸣器正在“哔——”地叫,是出门时门没关严,触发了防撬报警。问题是,我人在外面,压根没听见。那一刻我特别想要一个能“听懂”蜂鸣声、并把事件推送到手机的小装置。
于是就有了这个叫buzz的项目。它不是一个门锁,也不是一个摄像头,而是一个专门侦测“特定蜂鸣声/报警音”的本地声音事件检测器。整个项目从硬件选型、信号处理到阈值调校,前后折腾了将近三周。这篇文章就把整个过程拆开写清楚,包括我踩过的坑和最后稳定运行的状态机逻辑。适合正在做嵌入式声音识别、智能家居事件传感,或者单纯想用ESP32做点带信号处理项目的朋友参考。
1. 为什么我要自己做 buzz,而不是买现成产品
1.1 一个被忽略的感知维度
做智能家居的人通常会接一堆传感器:门磁、红外、摄像头、烟雾报警器。这些设备都有一个共同点——它们是“物理量”检测,靠的是通断、电平变化,或者画面差分。但声音这个维度经常被忽略,尤其是“特定声音事件”的识别,门槛比读一个GPIO高太多。
可现实生活里,大量关键信息恰恰是通过声音传递的:门锁报警、烟雾报警器、燃气报警、婴儿哭声、烧水壶鸣笛。这些声音的共同特征是:频段固定、节奏重复、持续时间短则一两秒长则几十秒。它们和语音识别完全是两码事——不需要理解语义,只需要判断“是不是那种声”。而这件事,用一块几十块钱的开发板加一个麦克风,完全能搞定。
那为什么不直接买成品?市面上的声音报警器基本都是分贝触发,看到的是“声强”,判断不了“声种”。楼下装修电钻声也能把它激活,然后你的手机一夜之间被垃圾通知塞满。而云端的音频识别服务,每个月有免费额度限制,更麻烦的是它会把我家里的原始声音上传到别人服务器。我这个人对隐私比较敏感,宁可自己用本地算法做,也不想为了听一个蜂鸣声把整屋子声音交给云端。
1.2 项目边界先说清楚
buzz 并不是要做一个通用声音识别引擎,它只解决一件事:在本地持续监听,一旦识别到预定类型的蜂鸣声或报警音,就触发通知。它需要满足几个硬指标:
- 实时性:从声音出现到触发通知,延迟控制在 500ms 以内;
- 误报率:每天误触不得超过 1 次,否则就不值得用了;
- 功耗:能长期插电运行,不发热,不死机;
- 隐私:所有音频数据只在本地处理,不上云,不存储原始录音。
抱着这几个目标,我开始选型。
2. 拾音链路与硬件选型笔记
2.1 麦克风:模拟驻极体还是数字 I2S
这是最初踩的第一个坑。我手头有几个现成的 MAX9814 模拟麦克风放大模块,接 ADC 就能用,看起来很简单。实际跑起来发现,ESP32 的 ADC 在采集音频时有两个毛病:一是噪声偏大,底噪里总有周期性的电源纹波;二是 ADC 的采样率虽然能配置,但精度和稳定性比不上专用音频 ADC。
后来我换成INMP441,一颗数字 I2S 接口的 MEMS 麦克风。它的好处是数字输出直接怼到 ESP32 的 I2S 外设,不经过模拟 ADC,信噪比高,而且模块本身很便宜,十块钱左右。以下是三个常见选择的对比:
| 麦克风方案 | 接口类型 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| MAX9814 模拟模块 | 模拟/ADC | 便宜、上手快、增益可调 | 底噪大、易受电源干扰 | 实验教学、语音响度检测 |
| INMP441 数字模块 | I2S | 抗干扰强、信噪比好、采样率高 | 模块方向有讲究,正反面容易焊错 | 声音特征识别、需要可靠采样的项目 |
| MSM261S4030H0 | I2S + PDM | 更小更便宜、低功耗 | 需要 PDM 转 PCM 代码,稍复杂 | 低功耗电池设备 |
我最后选了 INMP441,理由就一条:省心。I2S 采样在 ESP32 上是标准外设,配置好就能稳定拿数据,不用跟 ADC 里那些奇奇怪怪的衰减系数搏斗。注意,INMP441 模块有正反面,焊的时候认准金手指那一面;装反了采到的就是一坨噪声。
2.2 ESP32 的 I2S 采样参数配置
核心采样参数是这样的:16kHz 采样率、16bit 位深、单声道。为什么选 16kHz 而不是 44.1kHz?因为我要识别的门锁报警蜂鸣声,主频一般在 2kHz 到 4kHz 之间,16kHz 采样率能覆盖到 8kHz 的理论上限,完全够用;更高的采样率意味着每秒要处理的样本数翻倍,FFT 计算量、内存占用都跟着上去,对识别率没有实质帮助。
在 ESP-IDF 里,I2S 的驱动配置可以从老的i2s_driver_install迁移到新的i2s_std驱动。以下是一段可用的初始化代码(使用 ESP-IDF 5.x):
#include "driver/i2s_std.h" void i2s_init(void) { i2s_chan_config_t chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(I2S_NUM_0, I2S_ROLE_MASTER); i2s_std_config_t std_cfg = { .clk_cfg = I2S_STD_CLK_DEFAULT_CONFIG(16000), .slot_cfg = I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), .gpio_cfg = { .mclk = I2S_GPIO_UNUSED, .bclk = GPIO_NUM_4, .ws = GPIO_NUM_5, .dout = I2S_GPIO_UNUSED, .din = GPIO_NUM_18, }, }; i2s_new_channel(&chan_cfg, &rx_chan, NULL); i2s_channel_init_std_mode(rx_chan, &std_cfg); i2s_channel_enable(rx_chan); }注意几个细节:INMP441 需要dout接 ESP32 的din(就是麦克风数据输出到主控输入);ws是左右声道选择脚,单声道模式仍然要接;mclk可以不接,INMP441 内部有时钟恢复。如果跑起来一点声音都采不到,先用示波器或逻辑分析仪查bclk和ws有没有波形,大概率是引脚映射写错了。
2.3 组装时的两个易错点
第一个是供电。INMP441 的供电范围是 1.6V 到 3.6V,直接接 ESP32 的 3.3V 输出没问题。但注意不要让麦克风模块靠近 ESP32 的天线区,WiFi 发射瞬间的射频干扰会在 I2S 数据线上耦合出杂音,我实测下来把模块挪到离天线 3cm 以上,底噪立刻降了一个量级。
第二个是排线。I2S 的bclk、ws、data三根线最好等长、粗一点,不要用那种细长的杜邦线飞线。我刚开始为了省事用了 20cm 的杜邦线,采样数据里出现了大量间歇性毛刺,一度以为是麦克风坏了。换成一排短杜邦线之后问题消失。如果你要固定安装,建议直接把麦克风模块焊到转接板上,或者用排针直连。
3. 信号特征提取:从一路波形到几个可用数字
有了稳定的音频流,下一步是处理数据。一开始我以为最简单的方法是“声音大了就报警”,也就是用 RMS 音量做阈值。实测发现根本不靠谱:楼上的一声关门、楼下汽车喇叭,音量都能超过门锁蜂鸣声。音量只能说明“响不响”,说明不了“是什么在响”。
3.1 为什么要引入 FFT
蜂鸣器和普通噪声最大的区别在于能量集中。门锁报警那种“哔——”声,听起来是单一音调,能量几乎都集中在某个频点上;而关门声、说话声、电视声,能量是散布在很宽的频带里的。所以只要把时域波形变成频域,看“能量尖峰在哪个频率”,就能把蜂鸣声和其他声音分开。
这里用到的工具就是 FFT(快速傅里叶变换)。它能把一段 512 个采样点的时间窗口,拆成 256 个频率分量。16kHz 采样率、512 点 FFT 的频率分辨率是 16000 / 512 = 31.25Hz,也就是说能区分相隔 31Hz 左右的频点,对蜂鸣声识别来说足够精细。
3.2 三个核心特征量
我在 buzz 里提取了三组特征,分别对应“响不响”“在哪个频段响”“响得连不连续”:
短时能量(STE):一个窗口内所有采样点平方和的均值,直观反映这段时间里的声音强度。它的作用相当于前置闸门,只有能量高于阈值才进入下一步分析,省掉大量无谓的 FFT 计算。
主频峰值(Dominant Frequency):FFT 结果里幅度最大的频率分量。蜂鸣声的主频是稳定的(比如某款门锁在 3.2kHz),而噪声的主频是跳来跳去的。判断“主频是否落在目标频段”是过滤误报最关键的一步。
过零率(ZCR):一个窗口内信号穿越零点的次数。纯音在固定频率下过零率非常稳定,语音和噪声的过零率则很高且变化剧烈。它用来区分连续音和碎声,对防误报帮助很大。
这三个量组合起来,就能对一帧声音做出判断。三个特征的关系可以用一个粗浅的类比:短时能量是“有没有人说话”,主频峰值是“说话的是男是女”,过零率是“在唱歌还是吵架”。单独看哪个都不够,合在一起才有判别力。
3.3 特征计算的代码实现
我用的是 Arduino 环境配合arduinoFFT库,代码结构很直观。每次 I2S 读到 512 个样本,填充到vReal数组,然后执行 FFT,最后从结果中提取峰值。
#include <arduinoFFT.h> #define SAMPLES 512 #define SAMPLING_FREQ 16000 double vReal[SAMPLES]; double vImag[SAMPLES]; arduinoFFT FFT = arduinoFFT(vReal, vImag, SAMPLES, SAMPLING_FREQ); void compute_features() { // vReal 已经填充好 PCM 数据,vImag 清零 FFT.Windowing(FFT_WIN_TYP_HAMMING, FFT_FORWARD); FFT.Compute(FFT_FORWARD); FFT.ComplexToMagnitude(); // 找 200Hz 到 8000Hz 之间的峰值 double max_mag = 0; uint16_t max_index = 0; for (uint16_t i = 4; i < SAMPLES / 2; i++) { if (vReal[i] > max_mag) { max_mag = vReal[i]; max_index = i; } } double dominant_freq = (double)max_index * SAMPLING_FREQ / SAMPLES; // 顺便计算过零率(这里用原始时域数据,代码略) }这段代码跑一遍大约耗时 8ms 到 15ms,完全跟得上实时流。我设置的是每 20ms 取一次窗口、每 5 个窗口做一次特征计算,这样既不漏掉最短约 200ms 的短蜂鸣,又把 CPU 占用控制在合理范围。
4. 事件识别状态机与阈值标定
特征算出来之后,最核心的问题变成了:怎么判断这些特征意味着“一次报警”?如果单帧能量超了就发通知,一定被误报淹死。一个很短的关门声可能能量峰值达到了,但不能算事件;一次真正的蜂鸣报警则通常持续 300ms 以上、甚至连续好几秒。
4.1 用状态机滤掉瞬时噪声
我设计了一个四状态的状态机:IDLE→CONFIRMING→ALERT→COOLDOWN。这个状态机和网络用的有限状态机没什么区别,但它解决的问题非常具体——把“瞬时响声”和“持续报警”分离。
每次特征分析得到三个布尔量:is_loud(短时能量超阈值)、in_band(主频落在目标频带)、is_tone_like(过零率在合理范围)。只有三个条件同时满足,状态才从IDLE切到CONFIRMING。在CONFIRMING状态里,如果连续 5 帧(约 100ms)都满足条件,状态更新为ALERT,此时才真正触发推送;如果中途任何一帧不满足,状态立刻回落IDLE。
ALERT状态下,系统会持续跟踪声音是否还在响。直到连续 30 帧不满足触发条件,说明报警声已经停了,进入COOLDOWN,冷却 60 秒内不重复通知。这个设计的用意是防止同一次报警重复轰炸手机——很多商业产品忽略了这一点,一次报警能推 20 条消息。
下表是我最终确定的状态跳转规则:
| 当前状态 | 触发条件 | 下一状态 |
|---|---|---|
| IDLE | 单帧三条件全满足 | CONFIRMING |
| CONFIRMING | 连续 5 帧满足(约100ms) | ALERT |
| CONFIRMING | 任一帧不满足 | IDLE |
| ALERT | 连续 30 帧不满足 | COOLDOWN |
| ALERT | 仍满足 | ALERT(保持) |
| COOLDOWN | 计时满 60 秒 | IDLE |
这套状态机有个好处:确认窗口和松弛窗口的长度都能独立调节。想要更灵敏就缩短确认窗口,想要更稳定就加长确认窗口,不需要动任何特征阈值。
4.2 阈值标定的详细过程
阈值不能一拍脑袋定。我的做法很笨但很有效:把门锁报警器搬到桌面上,录了 10 次正常报警音作为正样本;再把日常环境声(电视、谈话、窗外车声)录了 30 分钟作为负样本。用一段脚本统计这两类样本的特征分布,然后取中间值作为初始阈值。
以我的环境为例,本底噪声的短时能量大约是 30,平时说话在 200 到 800,而门锁蜂鸣声稳定在 5000 到 9000,所以我设的energy_threshold = 1500,留出的余量足够规避日常说话。主频范围设定在2800Hz - 3600Hz,因为该门锁报警器实测主频是 3.2kHz,加上温漂和供电波动的影响,给频段留了一点余量。
这里特别提醒一点:主频范围宁可窄一点也不要宽。一开始我设的是 2kHz 到 5kHz,结果手机铃声、微波炉“叮”都中招了,误报率惨不忍睹。后来收到 1kHz 带宽,误报率下降了一个数量级,而目标蜂鸣声一次都没漏。窄频段可能会有漏报风险,解决办法是先用频谱仪或 FFT 准确测出目标声音的主频,再根据该值上下浮动 10% 到 15% 设定范围。
4.3 一次完整的事件识别过程
实跑效果很直观。我先模拟了三个场景:播放录音里的门锁报警声、用手机播放一首流行歌、正常聊天。系统串口日志输出如下:
[frame 234] STE=7200, dom=3215Hz, ZCR=42 -> IDLE -> CONFIRMING [frame 235] STE=6900, dom=3190Hz, ZCR=40 -> CONFIRMING [frame 236] STE=7100, dom=3220Hz, ZCR=41 -> CONFIRMING [frame 237] STE=6800, dom=3180Hz, ZCR=39 -> CONFIRMING [frame 238] STE=7000, dom=3205Hz, ZCR=40 -> ALERT: push notification看到这里可能有人问:为什么STE、dom、ZCR每个窗口都在波动,而不是恒定值?因为蜂鸣声本身会有轻微的幅度抖动,而且采样窗口起始位置不一样,算出来的瞬时值自然有起伏。只要这些值都在阈值范围内、主频不跳出目标频段,状态机就能稳定判断。相反,放歌的时候主频不断跳变,一会儿 500Hz 一会儿 3kHz,基本无法连续 5 帧都落在指定频段里。
5. 联动、提醒与日常可靠性的几个细节
5.1 联动通知的具体实现
识别到报警事件之后要怎么办?我在标题里最看重的就是这一层“感知后动作”。buzz 用 ESP32 的 WiFi 连接家里路由器,报警时通过 MQTT 推送到本地的 Home Assistant,再由 Home Assistant 统一发通知到手机、启动家里的卧室灯闪烁。这样做的好处是通知策略完全由自己掌控,不上任何第三方云。
关键代码如下:
void send_alert(void) { char topic[64] = "home/buzz/alert"; char payload[128]; snprintf(payload, sizeof(payload), "{\"event\":\"buzzer\",\"duration_ms\":%lu}", alert_duration_ms); mqtt_publish(topic, payload); }如果家里没有 Home Assistant,也可以用 ESP32 直接发 HTTP POST 到 Server酱、PushDeer 等服务商的接口,手机就能收到推送。但我个人推荐走 MQTT 到本地家庭网关,原因只有一个:通知链路和声音识别链路一样,不依赖外部网络,断网时也不至于完全失聪。
5.2 供电与长时间运行注意
buzz 适合找一个不碍事的角落长期插电运行。ESP32 配 INMP441,整板平均功耗在 70mA 到 100mA 左右,5V 供电随便一个旧手机充电头就能带动。不要用没有隔离的劣质充电头,我遇到过一次电源开关瞬间电压尖峰,直接把 ESP32 的 I2S 外设搞到死机,重启才恢复。如果打算长期跑,建议在 5V 输入和 3.3V LDO 之间并一颗 470uF 电解电容,能吸收掉相当一部分开机冲击。
6. 误报整治:我把这三类假警报逐个消灭了
这是我整个项目里花费时间最多、也最能体现“真实感”的部分。阈值设定完毕,第一版能跑通之后,挂着跑了三天,收集到的误报有 20 多次,每天七八次,根本没法用。我把它当作排障问题来处理,一类一类地消灭。
6.1 第一类:电视和音乐里的高频啸叫
电影特效里的高频音效、演唱会直播的掌声和尖叫声,都会短时间压过阈值,甚至主频落在目标频段内。我的对策是把确认窗口加长:从连续 3 帧改成连续 8 帧,确认时间从约 60ms 拉长到 160ms。电视啸叫往往一闪而过,很难撑满 160ms,于是被状态机滤掉了。这个改动几乎没有影响真正的门锁报警——它的蜂鸣声本来就会持续响 3 秒以上。
6.2 第二类:相同频段的家电噪声
空气净化器的电机声、老式空调压缩机的嗡嗡声,虽然频率不常落在 3.2kHz,但它们的谐波分量偶尔会撞进来。我的办法是增加主频稳定性判断:连续 10 帧的主频最大值和最小值之差不超过 150Hz,才认为是稳定音源。家电的噪声主频会漂移,蜂鸣器则稳如泰山。这个判断加进去之后,此类误报彻底清零。
6.3 第三类:同一声源重复触发
一次门磁报警持续 15 秒,我用 60 秒冷却期挡掉了大部分重复通知。但有一次报警停了之后 5 分钟又响了一次,实际上是门锁在第二次防撬尝试。这类情况属于真实新事件,不应该被冷却期挡住。目前的做法是记录最近 10 分钟内的历史报警次数,超过 3 次就只发一条“多次触发”的汇总状态。反正对用户来说,知道门有事就够了,不需要知道它具体闹了几次。
经过这三轮调整,现在 buzz 的误报率稳定在一周 0 到 1 次,而门锁报警的识别率在 50 次真实验证里做到了 100%。这个结果让我觉得,不靠机器学习、不做云端上传,用经典信号处理也能做到实用级别的声音事件检测。
7. 我给同样想做声音识别的人留几句体己话
这套方案不止能识别门锁蜂鸣声。换一个目标频段和阈值,它就能变成烧水壶哨音检测器、烟雾报警器远程转发器、甚至婴儿哭声提醒器。核心代码框架完全不用动,只需要重新录制一遍目标声音的正负样本来标定阈值。这也是我把它命名为 buzz 的初衷——学会识别“那一声响”,就等于给智能家居装上了一只耳朵。
如果你也准备做类似的本地声音事件检测,我最想提醒的不是 FFT 也不是阈值——而是别贪多。一次只锁定一种声音,把它的主频、时长、节奏特征摸清楚,再考虑扩展。通用声音识别是一个深不见底的坑,而特定声音检测是几天就能看到成果的捷径。先把一个应用跑稳,你会在调参的过程中积累出远超资料的直觉。