这篇内容,想完整复盘一下我用CY8CKIT-062S2-AI这块板子做鼾声识别(snore detection)的全过程。项目目标很直接:用板上的PDM麦克风实时采集环境声音,在本地识别出当前有没有打鼾,然后通过串口和板载LED把结果输出。表面上看这是个典型的TinyML玩具项目,真正做下去才发现,它几乎把所有嵌入式AI该踩的坑都踩了一遍——数据采集、特征提取、模型训练、INT8量化、TFLite Micro部署、双核调度、实时性调优,一个不少。
这篇文章我打算按实际推进顺序写,从“为什么选这个场景”到“最终实测数据”,再到“问题排查记录”,把关键参数和踩坑细节都摊开说。如果你正准备做嵌入式语音分类、声音事件检测,或者想在真实的ARM Cortex-M硬件上把一套TinyML流程完整跑通,这篇总结应该能帮你省下不少周末。
1. 项目定位与整体方案选型
1.1 为什么是鼾声识别,为什么要在本地做
睡眠健康监测这几年很火,鼾声识别是其中比较有代表性的一类音频事件检测任务。它和“关键词唤醒”还不完全一样:关键词唤醒只需要识别几个固定词,鼾声则没有固定的词表,个体差异极大,有人鼾声像低频轰鸣,有人像喉咙里的气泡音,还有人像急促的吹气声。这导致它比“识别一个唤醒词”更有挑战性。
选这个场景的原因还有一个:它天然适合端侧部署。卧室场景如果依赖云端识别,一来隐私问题很敏感,二来网络一抖动整夜数据就断了,三来持续上传音频的功耗和流量成本也不可小觑。放在MCU本地做分类,既可以做到事件触发级响应,又能把原始音频留在设备内部,从产品设计角度来说更干净。
任务定义也比较明确:二分类,输入是1秒左右的单声道音频,输出是“鼾声”或者“非鼾声”。这里说的“非鼾声”包括翻身摩擦声、空调风声、窗外汽车声、人说话声等。分类粒度不需要到帧级,最终产品输出一个“这段时间在打鼾/没打鼾”的结论即可,所以后处理可以用滑窗投票把结果做平滑。
1.2 板子硬件资源盘点
CY8CKIT-062S2-AI的核心是一颗PSoC 6双核MCU:Cortex-M4负责应用和推理,Cortex-M0+负责低功耗外设管理。双核架构在这个项目里很好用,因为音频采集是持续性的,如果让单个CPU在采数据、搬数据、跑推理之间来回切换,调度会很难看。后面我会详细讲我怎么把数据采集和推理分别放到两个核上。
板上音频链路很完整:有PDM数字麦克风,可以直接把麦克风输出接到PDM转PCM的硬件模块上,省掉外部Codec和模拟前端的一堆麻烦。除此之外还有USB、QSPI Flash、板载调试器,以及若干LED和按键可以用来做结果指示。开发环境用官方工具链ModusToolbox,支持图形化配置外设、生成代码,也能直接集成第三方模型部署的库。
对比同级别的通用MCU开发板,这块板最大的优势是“音频链路 + 双核 + 低功耗”三者兼备。你不需要再去买一块音频Codec子板,也不需要费劲移植音频驱动,硬件上几乎是开箱即用。对于想验证“音频事件检测”这个方向的团队来说,用它能少走很多弯路。
1.3 技术路线对比与最终选择
做声音事件检测,通常有三条路线:传统DSP特征+规则阈值、云端模型推理、端侧模型推理。
传统DSP路线,常见做法是算短时能量的过零率、频谱质心、低频带能量这类指标,再手工定阈值。优点是实现快,对固定场景、固定设备有效;缺点也很致命,换一个人、换一张床垫、把窗户打开,阈值基本就要重新调。我一开始用这个方案做了一个快速原型,晚上放录音测试的时候,空调风声直接导致误报,当场放弃。
云端推理路线的识别效果确实更好,模型可以做得很大,但延迟、隐私、功耗的问题前面说了,不适合作为这个项目的主路线。
最终我选的是“端侧轻量模型”:先算log-mel谱特征,再用一个小型CNN分类。这个组合在语音事件检测里被验证了很多次,特征冗余小,模型参数可以压缩到几十KB级别,INT8量化后在Cortex-M4上跑一次推理也就几十毫秒。
| 方案 | 精度潜力 | 实时性 | 隐私性 | 功耗 | 开发复杂度 |
|---|---|---|---|---|---|
| 规则阈值 | 低 | 高 | 高 | 低 | 极低 |
| 云端模型 | 高 | 差 | 差 | 高 | 中 |
| 端侧模型 | 中高 | 高 | 高 | 低 | 高 |
我最终选择端侧模型,核心原因是:整个系统的瓶颈不在“模型精度能做多高”,而在“用户愿不愿意在床边放一个需要联网的麦克风”。设备端独立运行的方案,从产品逻辑上才成立。
2. 开发环境搭建与硬件数据通路验证
2.1 工具链搭建与第一次烧录
开发环境使用的是ModusToolbox,安装过程没什么特别的,唯一要注意的是版本选择。我建议直接装Release版本,不要装Beta,因为第三方AI部署库对Beta版本的BSP支持往往滞后。安装完之后,它会自动带一套eclipse IDE,也可以用命令行工具来创建和构建工程。命令行方式适合脚本化,我后面做批量编译时经常用。
第一次烧录建议先跑一个最简单的LED闪烁例程,验证驱动、调试器、工具链都是通的。这里有一个容易被忽略的点:板载调试器(KitProg3)第一次插到电脑上时,可能会被识别成未升级固件的状态,导致IDE连接不上。解决办法是用官方固件升级工具把KitProg3刷到最新版本,然后再回IDE里烧录。我在这上面浪费了大半个小时,因为Windows设备管理器里明明能看到一个USB设备,但IDE就是枚举不到调试接口。
烧录成功后,通过串口终端看板子的调试输出。串口波特率一般例程里写的是115200,但要注意,如果BSP里uart时钟配置错误,会出现能输出但内容是乱码的现象。这个在刚上手板子时很常见,后面排查时优先检查时钟配置。
2.2 PDM麦克风到内存的数据通路
这个环节是整个项目最核心的硬件链路,没有之一。PDM麦克风输出的是一路1bit的高频脉冲密度调制流,不能直接当PCM音频用,必须经过一个“PDM转PCM”的硬件模块做抽取滤波。在ModusToolbox的Device Configurator里,这个模块叫PDM_PCM,你只需要在里面配置采样率和声道数,生成代码后它会给你一个数据接收回调。
我的配置参数是这样的:
- 采样率:16000 Hz
- 声道:单声道
- 每个DMA buffer的样本数:256
- 输出的PCM位宽:16bit
选择16kHz采样率,一方面是人耳语音的主要能量在4kHz以下,打鼾声的基频和低次谐波基本都在几百Hz到1kHz区间,16kHz完全够用;另一方面是采样率越低,每秒需要处理的数据量越小,对内存带宽和功耗都更友好。我不想给MCU增加不必要的负担。
PDM_PCM的典型工作方式是:硬件把PDM流转换成PCM数据,通过DMA持续写入内存中的ring buffer,当buffer装满时触发中断或回调。在双核分配上,我把M0+核负责配置外设和DMA搬运,M4核只负责从共享内存里取数据、算特征、跑模型。这样音频采集不会因为M4在做推理而出现数据断流。
2.3 数据是否采对的快速判断方法
麦克风通路配好之后,第一件事不是急着训练模型,而是确认“采进来的数据确实是正常语音”。我当时是用串口把采集到的原始PCM数据以十六进制格式打印出来,连续打印256字节,然后把数据导出到电脑上,用Python重新拼成wav文件听了一下。
这个步骤虽然土,但非常有效。它能同时验证三件事:PDM时钟是否起振、DMA搬运是否正常、数据格式是否是16bit对齐。如果打印出来全是0,基本是PDM时钟配置或者麦克风供电的问题;如果打印出来数据变化剧烈但没有规律,很可能是时钟不稳定或格式解析错了。
还有一个细节:PDM麦克风通常对电源纹波敏感,如果你用USB供电,USB的5V经过板载LDO后一般没问题,但如果你自己外接电源,一定要确认纹波足够低。我后来在测试过程中遇到过一次采集中频繁卡顿,排查到最后是供电不稳导致的DMA丢数据,换成优质USB口之后问题消失。
3. 数据集构建与音频特征工程
3.1 鼾声数据从哪来,怎么标注
模型要能干活,首先得有数据。鼾声数据的来源主要有三个:自己录制、公开数据集、夜间设备实录。我自己录制的方式是晚上放好开发板,睡前一键启动录音,第二天起来把整夜音频导出,再用标注软件切出鼾声段。这种方式最贴近实际场景,缺点是标注很费时间,一整夜音频里有效鼾声可能只有几十段。
公开数据集的优势是样本量大、覆盖面广,但要注意两点:一是数据集授权问题,商业使用时要格外小心;二是公开数据集的麦克风型号、摆放位置和你自己的板子不同,可能导致特征分布偏移,训练出来的模型在你板子上效果打折。所以我的做法是公开数据集用来预训练和增强多样性,自己录的数据用来做最终验证和微调。
标注工具用Audacity就行,把鼾声段标注出来,导出标签文件。这里有一个经验:标注时不要抠得太精确,让标签的时间边界稍微外扩一些,因为鼾声不是一个瞬时事件,它有起振和衰减过程,边界切得太锐利会让模型学到奇怪的特征。
切样本的方式我推荐这样:以1秒为窗口,滑动步长0.5秒,从标注段中心开始切且只在正样本中心附近切,负样本从没有鼾声的时间段里随机切。切完之后检查正负样本数量,如果差异太大,可以对多数类做欠采样,或者对少数类做增强,不要让比例超过2比1。
3.2 特征参数选择:帧长、帧移、Mel滤波器
原始PCM波形如果直接丢给CNN,模型很难学出鲁棒的语义特征,一般要先做频谱特征。我这里用的是log-mel谱,参数如下:
- FFT点数:512
- 帧长:30ms(480点)
- 帧移:20ms(320点)
- Mel滤波器组数量:40
- 特征维度:对每帧得到40维log-mel向量
1秒音频经过分帧之后大约能算出49帧,所以一个样本的特征形状是40×49×1,类似一张“灰度图”。这个形状直接作为CNN输入。
为什么不直接用MFCC而用log-mel?原因很简单:log-mel保留了完整的频带能量结构,更适合CNN来学习局部频谱模式,MFCC是经过离散余弦变换压缩过的,信息有一定损失。在MCU上计算MFCC反而省不了多少算力,因为DCT那一步也要耗时。所以对于小模型,log-mel是性价比更好的选择。
这里要特意提醒一件事:训练阶段用Python的librosa库算特征非常方便,但部署到MCU上时你不可能跑librosa,必须用C语言重写特征提取。这意味着你需要提前把Mel滤波器的频率响应权重做成查找表,部署时直接查表计算,否则在MCU上实时计算三角滤波器权重会拖慢速度。
为了保证训练和部署的特征完全一致,我在Python端写了一个特征提取类,然后把滤波器权重和归一化参数导出成C头文件。MCU端的C代码和Python端逐一对照过,确保对同一段音频算出的特征向量几乎一致,误差只来自浮点精度。
3.3 数据增强与训练集划分的细节
鼾声数据的个体差异问题,必须靠数据增强来缓解。我用的增强手段包括:添加不同类型的环境噪声(白噪声、粉红噪声、空调声)、音量随机增益、时间拉伸和音频片段随机裁剪。其中最有用的其实是“加噪 + 音量抖动”,因为麦克风摆放距离不一致带来的音量差异,在真实使用中非常常见。
增强后的样本不是越多越好。如果增强过度,模型会把噪声特征当成鼾声特征,导致实际场景中误报率飙升。我的做法是控制增强样本不超过原始样本的2倍,并且增强样本只用于训练集,验证集和测试集保持干净。
训练集划分还有一个容易犯的严重错误:随机打乱样本。鼾声样本是成段出现的,如果你把同一段录音切出来的样本一部分分到训练集,一部分分到验证集,模型实际上见过验证集的内容了,评估结果会虚高。正确的做法是按录音文件分组划分,确保同一个录音源的所有样本只出现在一个集合里。我听到过不少团队在语音分类项目里吃过这个亏,模型在测试集上显示99%准确率,一上真机就崩。
4. 模型训练、量化和转换
4.1 网络结构设计与参数量估算
任务简单时,模型没必要做得大。我用的是一个极小的CNN,结构如下:
model = Sequential([ Input(shape=(40, 49, 1)), Conv2D(8, 3, activation="relu", padding="same"), MaxPooling2D((2, 2)), Conv2D(16, 3, activation="relu", padding="same"), MaxPooling2D((2, 2)), Conv2D(32, 3, activation="relu", padding="same"), GlobalAveragePooling2D(), Dense(2), ])为什么选这个结构:鼾声识别的本质是“频谱图中的特定时空模式检测”,不需要非常深的网络。三层卷积已经足够提取出低频持续能量、周期性脉冲这些关键特征,再加两层全连接反而容易过拟合,而且参数量增大后,Flash和RAM都吃不消。
参数量可以粗略估算:第一层8个3x3卷积核,输入1通道,参数量约80;第二层16个核,输入8通道,参数量约1168;第三层32个核,输入16通道,参数量约4640;最后的全局池化和Dense层参数量约1056。合计不到7000个参数。这个量级在INT8量化后,模型文件大小只有几十KB,放在PSoC 6的Flash里绰绰有余。
4.2 训练过程与评估要点
训练用TensorFlow / Keras,优化器选Adam,初始学习率1e-3,批大小32,损失函数用交叉熵。训练轮数我不固定,用了EarlyStopping,patience设5轮,验证集loss不再下降就停止。
训练完成后不能只看准确率。鼾声识别场景常见的问题是类别不平衡,如果负样本略多,模型可能学会“全都判负”,准确率依然很高但毫无用处。我习惯同时看精确率(Precision)、召回率(Recall)和混淆矩阵。对于夜间睡眠监测场景,我更看重“少误报”,也就是精确率优先,因为一晚上误报几次会严重影响用户体验;但也不能把召回率压得太低,否则漏报成了摆设。
在阈值选择上,模型输出的是两个类别的logits,默认用0.5做softmax阈值。实际上不需要在MCU上做softmax,直接比较两个logits的大小也可以,省掉一次指数运算。后面调优时我调整的是logits的差值阈值,而不是softmax后的概率,这样MCU上实现更简单。
4.3 TFLite INT8量化与校准
训练好的FP32模型不能直接部署到MCU上,浮点运算在Cortex-M4上没有硬件加速,跑起来会很慢,而且模型体积也大。所以必须转成TFLite格式,再做INT8量化。
转换的核心代码如下:
converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_gen converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.int8 converter.inference_output_type = tf.int8 tflite_model = converter.convert()这里最容易踩坑的是校准数据集(representative dataset)的构建。INT8量化需要统计激活值的动态范围,校准数据集就是用来做这个统计的。校准数据要和真实推理时输入的特征分布一致,并且要足够多样。
我的做法是从验证集里抽200个样本作为校准数据,这200个样本覆盖了不同音量、不同噪声环境。如果只用几十个安静环境下的样本做校准,量化后的模型精度会明显下降,甚至直接输出全0。这个坑我踩过,当时量化后模型在测试集上准确率从94%掉到70%,排查了半天才发现是校准集太单薄。
转换完成后,用TFLite的Python解释器在PC上验证一下量化模型的精度,如果精度和FP32模型差距太大,先别急着部署,回到校准集优化。一般来说,一个训练充分的模型在INT8量化后损失1%到3%是正常的,超过5%就要找原因。
5. 板载部署、实时推理与性能调优
5.1 模型嵌入与TFLite Micro接入
模型转换完成后,把.tflite文件转成C字节数组,可以用xxd工具生成一个数组文件,直接放进工程里。然后就是接入TFLite Micro的推理运行时,这一步要处理几个关键配置。
首先是tensor arena大小。TFLite Micro需要一个预先分配的内存缓冲区来存放中间激活值,这个大小取决于模型结构和输入尺寸。我一开始随便给了8KB,结果模型初始化直接失败,提示arena不够。后来用官方文档推荐的估算方法,先给一个大值(比如80KB)验证能跑,再逐步调小,找到最小值后留出20%余量。
PSoC 6的双核分配在这里起了作用:M4核负责跑TFLite Micro的推理,M0+核负责音频数据采集和DMA搬运,两个核之间通过共享内存池交换数据。这样避免了音频采集被打断,推理过程中的延迟也更稳定。如果你用单核MCU做这个项目,建议在中断里只做数据搬运,不要在主循环里长时间关中断跑推理。
5.2 实时推理流程与后处理策略
推理流程并不复杂,真正需要设计的是后处理策略。整夜运行的系统如果每一秒都输出一个分类结果,输出会非常抖,毕竟环境里总会有一些短促的声音被误判成鼾声。我采用的是滑窗投票机制:模型只负责对最新1秒特征做分类,系统维护最近5秒的判决历史,如果5秒内有至少3个窗口判定为鼾声,才最终触发“鼾声事件”。
触发后还有一个抑制机制:触发一次后强制进入10秒的静默期,期间不输出新的鼾声事件。这个抑制时间是为了避免一个持续数十秒的鼾声被重复计数。睡眠监测产品里通常关注的是“鼾声事件次数”和“鼾声时段占比”,所以事件级输出比帧级输出更有价值。
在MCU侧,我通过串口输出两种信息:一种是调试级的信息,每一窗口的分类logits;另一种是事件级信息,只有触发鼾声事件时输出带时间戳的提示。同时控制板载LED,若检测到鼾声就亮灯,方便现场演示和功能验证。
事件输出的简易流程如下:
// 伪代码 while (1) { wait_feature_ready(); float *out = tflm_interpreter_input(); preprocess_audio_to_feature(out); tflm_invoke(); get_logits(out); if (snore_vote() == true) { print_event(); led_on(); suppress_until = now_ms() + 10000; } }5.3 实测性能指标与调优方法
整个系统跑起来后,我记录了这样一组实测数据:
- 单次特征提取耗时:约35ms
- 单次模型推理耗时:约25ms(INT8,M4核)
- 单个1秒窗口的总耗时:约60ms
- 峰值RAM占用:约72KB(含arena和音频缓冲)
- 模型Flash占用:约22KB
- 整板平均电流:约85mA(收音+推理全开)
这个性能对夜间监测完全够用。因为窗口滑动步长是500ms,1秒窗口的重叠率为50%,系统每500ms就要计算一次新窗口的特征和推理。理论计算负载是60ms ÷ 500ms,CPU占用约12%左右,剩余时间M4核可以处于休眠状态,进一步降低功耗。
调优过程中最有价值的修改是调整了logits阈值。默认情况下,模型在两个类别差异不大时会输出接近相同的logits,导致大量边界样本抖动。我把阈值从“logits差值>0”改为“logits差值>2.0”后,一夜录音测试中的误报次数明显减少,召回率只下降了约4个百分点,但观感上好了很多。
如果你发现误报还是太多,优先检查数据增强和负样本构成,而不是盲目加大模型。很多误报来自环境中的低频噪声,比如冰箱压缩机、空调风声,这些声音和鼾声在频谱上很接近。把这些噪声单独做成负样本,通常比修改网络结构更有效。
6. 问题排查记录与避坑清单
6.1 五个典型问题与解决思路
把这次项目中遇到的主要问题整理成一个表,方便排查时直接对照。
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 麦克风数据全为0 | PDM时钟未配置或麦克风供电异常 | 检查Device Configurator的PDM_PCM时钟源,确认MEMS供电引脚使能 |
| 数据波形像随机噪声 | PDM转PCM的抽取滤波参数错误 | 降低采样率配置重新生成代码,检查DMA buffer大小是否匹配 |
| 串口输出乱码 | UART时钟配置错误 | 核对BSP时钟树,确认UART外设时钟频率与波特率匹配 |
| 模型部署后Flash空间不足 | 工程里存在大量调试组件 | 裁剪BSP中未使用的组件,关闭Debug打印,检查map文件定位占用 |
| 浮点模型替换为INT8后精度骤降 | 校准数据集代表性不足 | 扩充校准集,确保覆盖不同音量、不同噪声环境,使用代表性样本生成器 |
第一条里麦克风全为0的情况,我当时排查了很久。板子是新的,麦克风默认情况下并不会自动开始工作,必须在代码里把麦克风供电引脚使能,并在PDM_PCM组件里开启硬件启动。如果你复现时发现数据是0,请先检查这两个点,大概率不是硬件坏了。
Flash空间不足的问题,我用了一个比较实用的手段:打开链接脚本里生成的map文件,按占用从大到小排序,找到最大的20个符号逐一判断能否裁剪。最后发现主要占用来自设备库中的WiFi协议栈和图形驱动,我把不用的外设驱动从BSP中移除,Flash占用立刻降了一大截。
6.2 好用的调试手段
调试这种音频+模型项目时,最忌讳“只看最终输出”。我强烈建议在开发阶段把模型输出的logits直接打出来,而不是只打“鼾声/非鼾声”的分类结果。因为在边界样本上,网络输出的绝对值会告诉你它对当前输入的判断有多坚定,这比二值结果有用得多。看到logits数值在临界点不断抖动,你就知道该去调阈值而不是改模型了。
第二种手段是在PC端搭建一个“特征级联调环境”:把MCU端的特征提取C代码抽出来,编译成一个命令行工具,输入wav文件输出特征文件;再和Python训练时的特征做对比。只要两边特征向量一致,模型在MCU上的输出就和PC上一致,问题基本就锁定在数据通路或系统调度上。
第三种手段是串口分级日志。用宏控制日志级别,INFO级打印事件输出,DEBUG级打印每个窗口的logits,VERBOSE级打印原始音频数据摘要。调试阶段开VERBOSE,平时跑测试开INFO。这个成本很低,但能把“推理结果错误”和“音频采集异常”两类问题快速区分开。
最后再分享一点实际体会:这类端侧语音识别项目,真正的难点往往不在模型本身,而在于“数据采集→特征提取→模型部署→后处理→系统集成”这条链路是否能稳定闭环。模型再小、精度再高,只要麦克风数据通路有一个环节不稳,整夜测试就会出各种莫名奇妙的误报和漏报。所以我建议你拿到板子之后,先花足够多的时间把数据通路验证扎实,再做模型,否则后面每一步调试都会反复怀疑到底是模型的问题还是数据的问题。
这板子后续扩展空间也很大。把二分类换成四分类可以识别鼾声类型;加一个BLE通信模块可以把鼾声事件上报到手机App;如果再融合加速度传感器和血氧传感器数据,就能往睡眠呼吸暂停初筛方向走了。我个人觉得,这块板子做一个“夜间鼾声事件监测”的完整原型,已经足够撑起一个小型健康监测Demo了。