MCU 端音频关键词识别系统设计:MFCC 特征提取与 CNN 推理全链路实现
一、从麦克风电压到识别结果:离网语音唤醒的信号处理链
在电池供电的 IoT 设备中实现本地关键词识别(Keyword Spotting, KWS),面临的主要矛盾是:DNN 推理需要较大的计算和内存资源,而 MCU 平台只有百 KB 级别的 SRAM 和数十 MHz 的时钟频率。以"Hey Sensor"两个词的唤醒为例,要求 24 小时不间断监听,在 CR2032 纽扣电池下续航 6 个月以上。
从信号处理角度看,完整的 KWS 链路包含五个阶段:
- PDM/I2S 音频采样:通过数字 MEMS 麦克风以 16kHz 采样率采集音频
- 预加重与分帧:补偿高频衰减,将连续音频流切分为 30ms 帧(480 样本),帧移 10ms(160 样本)
- MFCC 特征提取:将每帧时域信号转换为 13 维或 40 维梅尔频率倒谱系数
- CNN/DS-CNN 推理:对多帧 MFCC 特征做卷积分类
- 后处理与去抖:对连续推理结果做平滑和阈值判断
其中 MFCC 特征提取是全链路中运算量最大的非推理环节,在 Cortex-M4 @80MHz 上,40 维 MFCC 提取(含 FFT、Mel 滤波、DCT)约消耗 5ms。对于 100ms 的推理窗口(10 帧),MFCC 提取的总耗时约占推理周期的 50%,是需要重点优化的环节。
二、底层机制与原理深度剖析
2.1 KWS 全链路信号流
flowchart LR subgraph Audio["音频采集"] A1["MEMS 麦克风<br/>PDM 输出"] --> A2["PDM → PCM<br/>抽取滤波"] end subgraph Preprocess["预处理"] B1["预加重<br/>H(z)=1-0.97z⁻¹"] --> B2["分帧<br/>帧长30ms, 帧移10ms"] B2 --> B3["汉明窗<br/>w(n)=0.54-0.46cos(2πn/N)"] end subgraph MFCC["MFCC 特征提取"] C1["FFT<br/>N=512点"] --> C2["Mel滤波器组<br/>40个三角滤波器"] C2 --> C3["Log能量<br/>ln(mel_energy)"] C3 --> C4["DCT<br/>13维MFCC"] end subgraph CNN["CNN 推理"] D1["DS-CNN<br/>深度可分离卷积"] --> D2["全连接层<br/>Softmax"] end subgraph Post["后处理"] E1["滑动窗口平滑<br/>5帧中位数"] --> E2["双阈值去抖<br/>ON>0.8, OFF<0.3"] end Audio --> Preprocess Preprocess --> MFCC MFCC --> CNN CNN --> Post Post --> Output["唤醒 / 非唤醒"]2.2 MFCC 中 FFT 的定点和 CMSIS-DSP 加速
MFCC 中最耗时的模块是 512 点 FFT。如果使用自己编写的浮点实现,在 Cortex-M4 上约消耗 15ms。使用 CMSIS-DSP 库的定点 FFT(arm_rfft_q15)可将耗时降至约 2ms:
浮点 FFT 512 点 = 512/2 * log2(512) * (复数乘加) ≈ 2304 * 2 * 4 = 18432 次乘法 在 80MHz Cortex-M4(单周期 MAC + SIMD)上约 18432/80M ≈ 0.23ms 的纯计算时间 实际耗时约 2ms,额外的开销来自:位反转索引、查旋转因子表、数据搬移 优化:arm_rfft_fast_q15 使用 Cooley-Tukey 的基-4 分解,减少约 25% 的乘法次数2.3 DS-CNN 在 MCU 上的算子映射
KWS 模型通常使用深度可分离卷积(Depthwise Separable CNN, DS-CNN)来压缩模型尺寸。MobileNet 类模型在 224x224 图像上表现优异,但在 1D 音频频谱(40 频率通道 × N 时间帧)上,需要调整卷积核的 shape:
| 层类型 | 图像(2D) | 音频(1D) |
|---|---|---|
| 输入 | 224×224×3 | 40×32×1(40 频率通道,32 帧) |
| Conv2D | 3×3 kernel, stride=2 | 3×1 kernel, stride=(2,1) |
| DepthwiseConv | 3×3 | 3×1 |
| FullyConnected | 1024→N_class | 256→N_class |
关键区别:音频的频谱维度(频率)和时间维度(帧)不应使用相同的 stride。在频率维度使用 stride=2 做下采样(类似图像的 2D downsampling),在时间维度保持 stride=1 以避免丢失时序信息。
三、生产级代码实现与最佳实践
/** * kws_mfcc.c — MCU 端 MFCC 特征提取实现(使用 CMSIS-DSP) * * 功能:从 16kHz PCM 音频数据中提取 13 维 MFCC 特征 * 依赖:CMSIS-DSP 库(arm_math.h) */ #include "arm_math.h" #include <math.h> /* ============ MFCC 参数配置 ============ */ #define SAMPLE_RATE 16000 /* 采样率 16kHz */ #define FRAME_LEN 480 /* 帧长 30ms @16kHz */ #define FRAME_SHIFT 160 /* 帧移 10ms @16kHz */ #define FFT_LEN 512 /* FFT 点数(≥ FRAME_LEN) */ #define NUM_MEL_FILTERS 40 /* Mel 滤波器数量 */ #define NUM_MFCC_COEFFS 13 /* 输出的 MFCC 系数维数(不含 C0) */ /* ============ Mel 滤波器组查找表 ============ */ /* 40 个三角滤波器在 257 个 FFT bin 上的权重矩阵 * 由离线 Python 脚本生成,编译时链接到 .rodata */ extern const float g_mel_filterbank[NUM_MEL_FILTERS][FFT_LEN / 2 + 1]; /* ============ DCT 矩阵 ============ */ /* 40×13 的 DCT 矩阵,将 40 个 Mel 能量压缩到 13 维 MFCC */ extern const float g_dct_matrix[NUM_MFCC_COEFFS][NUM_MEL_FILTERS]; /* ============ 预加重滤波状态 ============ */ static float g_preemph_prev = 0.0f; /* 上一帧最后一个样本 */ /** * @brief 单帧 MFCC 特征提取 * * @param pcm_frame 输入:480 个 int16 PCM 样本 * @param mfcc_out 输出:13 维 MFCC 系数 (float32) * @return 0: 成功, -1: 参数无效 */ int kws_mfcc_extract(const int16_t *pcm_frame, float *mfcc_out) { if (pcm_frame == NULL || mfcc_out == NULL) { return -1; } /* 工作缓冲区 */ static float32_t frame_float[FRAME_LEN]; /* 浮点化后的帧 */ static float32_t windowed[FRAME_LEN]; /* 加窗后的帧 */ static float32_t fft_input[FFT_LEN * 2]; /* FFT 输入(实部+虚部交错) */ static float32_t mel_energy[NUM_MEL_FILTERS]; /* Mel 能量 */ static arm_rfft_fast_instance_f32 fft_instance; /* FFT 实例 */ static bool fft_initialized = false; /* 初始化 FFT 实例(仅执行一次) */ if (!fft_initialized) { arm_rfft_fast_init_f32(&fft_instance, FFT_LEN); fft_initialized = true; } /* === 步骤1: 预加重 (Pre-emphasis) === * y[n] = x[n] - 0.97 * x[n-1] * 补偿高频衰减,提升高频成分的信噪比 */ frame_float[0] = (float32_t)pcm_frame[0] - 0.97f * g_preemph_prev; for (int i = 1; i < FRAME_LEN; i++) { frame_float[i] = (float32_t)pcm_frame[i] - 0.97f * (float32_t)pcm_frame[i - 1]; } g_preemph_prev = (float32_t)pcm_frame[FRAME_LEN - 1]; /* === 步骤2: 汉明窗加窗 (Hamming Window) === * w[n] = 0.54 - 0.46 * cos(2πn / (N-1)) * 减少频谱泄漏 */ for (int i = 0; i < FRAME_LEN; i++) { float window_val = 0.54f - 0.46f * arm_cos_f32(2.0f * PI * i / (FRAME_LEN - 1)); windowed[i] = frame_float[i] * window_val; } /* === 步骤3: FFT (512 点实数 FFT) === * CMSIS-DSP 的 RFFT 将结果以 {Re[0], Re[1], ..., Re[N/2], Im[N/2-1], ..., Im[1]} 排列 * 这是 CMSIS 特有的紧凑格式,需要后续计算功率谱时注意索引 */ for (int i = 0; i < FRAME_LEN; i++) { fft_input[i] = windowed[i]; } /* 零填充:帧长之后用 0 填充到 FFT_LEN */ for (int i = FRAME_LEN; i < FFT_LEN; i++) { fft_input[i] = 0.0f; } arm_rfft_fast_f32(&fft_instance, fft_input, fft_input, 0); /* === 步骤4: 功率谱计算 === * power[bin] = |X(bin)|² = Re² + Im² * 从 CMSIS RFFT 紧凑格式中提取实部和虚部 */ float32_t power_spectrum[FFT_LEN / 2 + 1]; power_spectrum[0] = fft_input[0] * fft_input[0]; /* DC 分量,虚部为 0 */ for (int k = 1; k < FFT_LEN / 2; k++) { float re = fft_input[k]; float im = fft_input[FFT_LEN - k]; power_spectrum[k] = re * re + im * im; } power_spectrum[FFT_LEN / 2] = fft_input[FFT_LEN / 2] * fft_input[FFT_LEN / 2]; /* === 步骤5: Mel 滤波器组 === * mel_energy[i] = Σ power_spectrum[k] * filterbank[i][k] * 将线性频谱映射到 Mel 尺度(人耳感知尺度) */ for (int m = 0; m < NUM_MEL_FILTERS; m++) { float sum = 0.0f; for (int k = 0; k <= FFT_LEN / 2; k++) { sum += power_spectrum[k] * g_mel_filterbank[m][k]; } /* 防止 log(0):将极小值置为阈值 */ mel_energy[m] = (sum < 1e-10f) ? 1e-10f : sum; } /* === 步骤6: Log 能量 === * log_mel[m] = ln(mel_energy[m]) */ for (int m = 0; m < NUM_MEL_FILTERS; m++) { mel_energy[m] = logf(mel_energy[m]); } /* === 步骤7: DCT(离散余弦变换)=== * mfcc[c] = Σ log_mel[m] * cos(π * c * (m + 0.5) / M) * c = 1..13(跳过 c=0,即能量系数) */ for (int c = 0; c < NUM_MFCC_COEFFS; c++) { float sum = 0.0f; for (int m = 0; m < NUM_MEL_FILTERS; m++) { sum += mel_energy[m] * g_dct_matrix[c][m]; } mfcc_out[c] = sum; } return 0; } /** * @brief KWS 推理:对多帧 MFCC 特征进行分类 * * @param mfcc_frames 输入:NUM_FRAMES 帧 × 13 维 MFCC(特征图 40×NUM_FRAMES×1) * @param scores 输出:N_CLASSES 维的分类得分 * @return 得分最高的类别索引, -1 表示错误 */ int kws_infer(const float *mfcc_frames, float *scores) { /* 将 MFCC 特征拷贝到 TFLM 输入张量 */ TfLiteTensor *input = g_interpreter->input(0); /* 输入形状: [1, NUM_FRAMES, NUM_MFCC_COEFFS, 1] * 实际按 (time, freq, channel) 排列,即 NHWC 格式 */ if (input->type == kTfLiteFloat32) { memcpy(input->data.f, mfcc_frames, NUM_FRAMES * NUM_MFCC_COEFFS * sizeof(float)); } else if (input->type == kTfLiteInt8) { /* 量化模型需要做输入量化 */ int8_t *q_input = input->data.int8; float input_scale = input->params.scale; int32_t input_zero = input->params.zero_point; for (int i = 0; i < NUM_FRAMES * NUM_MFCC_COEFFS; i++) { int32_t q_val = (int32_t)roundf(mfcc_frames[i] / input_scale) + input_zero; q_input[i] = (int8_t)(q_val < -128 ? -128 : (q_val > 127 ? 127 : q_val)); } } else { return -1; } /* 执行推理 */ TfLiteStatus status = g_interpreter->Invoke(); if (status != kTfLiteOk) { return -1; } /* 读取输出并找最大值 */ TfLiteTensor *output = g_interpreter->output(0); int max_idx = 0; float max_val = -1e9f; if (output->type == kTfLiteFloat32) { for (int i = 0; i < N_CLASSES; i++) { scores[i] = output->data.f[i]; if (scores[i] > max_val) { max_val = scores[i]; max_idx = i; } } } else { /* int8 输出,需要反量化 */ float output_scale = output->params.scale; int32_t output_zero = output->params.zero_point; for (int i = 0; i < N_CLASSES; i++) { scores[i] = (output->data.int8[i] - output_zero) * output_scale; if (scores[i] > max_val) { max_val = scores[i]; max_idx = i; } } } return max_idx; }后处理与去抖
/** * @brief 唤醒去抖逻辑:双重阈值 + 防抖计数 * * 策略: * - ON 阈值 0.8:唤醒得分超过此值开始计数 * - OFF 阈值 0.3:得分低于此值重置计数 * - 防抖窗口 5:连续 5 帧超过 ON 阈值才确认唤醒 */ int kws_debounce(float score, int target_class) { static int consecutive_count = 0; static const int DEBOUNCE_WINDOW = 5; static const float ON_THRESHOLD = 0.8f; static const float OFF_THRESHOLD = 0.3f; if (score >= ON_THRESHOLD) { consecutive_count++; if (consecutive_count >= DEBOUNCE_WINDOW) { consecutive_count = 0; /* 重置计数器 */ return 1; /* 确认唤醒 */ } } else if (score <= OFF_THRESHOLD) { consecutive_count = 0; /* 重置计数器 */ } /* 在阈值之间:保持当前计数,不做改变 */ return 0; /* 未达到唤醒条件 */ }四、边界分析与架构权衡
MFCC 维数选择。13 维 MFCC 是语音识别的经典选择,40 维 MFCC(不含 DCT 降维)提供更丰富的频谱信息但数据量增加 3 倍。对于 KWS 任务,输入到 CNN 的是 40 维频谱 × N 帧的二维特征图,使用 DCT 降维到 13 维后再拼帧会导致空间信息损失。工程实践中的权衡:如果 CNN 的输入层有足够的感受野(kernel size ≥ 5×3),使用 40 维原始 Mel 能量而非 13 维 MFCC,让网络自己学习最优的频率特征。
CMSIS-DSP 的 FFT 实现选择。arm_rfft_fast_f32使用基-4 分解,速度最快但需要较大的旋转因子表(512 点约 2KB)。arm_rfft_f32使用基-2 分解,旋转因子表较小(~1KB)但速度慢约 30%。arm_rfft_q15使用定点计算,无浮点运算单元(FPU)的平台上速度接近arm_rfft_fast_f32。
流式处理的内存规划。10 帧的特征图占用10 × 40 × 4 = 1600 字节(float32),加上 Arena 缓冲区约 20KB,总 SRAM 占用约 25KB。这个数字在 64KB SRAM 的芯片上可接受,但在 32KB 的芯片上需要进一步压缩——使用 int8 MFCC 特征(每个系数 1 字节)将特征图从 1600 字节降到 400 字节。
五、总结
MCU 端音频关键词识别的全链路设计要点:
- MFCC 提取:使用 CMSIS-DSP 的定点 FFT 可显著降低计算耗时,从浮点自实现的 15ms 降至 2ms。生产代码需要考虑预加重状态跨帧连续性、Mel 能量的 log(0) 防护。
- DS-CNN 模型设计:音频频谱是 2D 特征图但时间维度不应下采样,频率维度可使用 stride=2 下采样。模型应针对目标芯片(指令集、SRAM 大小)做算子兼容性验证。
- 后处理:双重阈值 + 防抖窗口可大幅降低误唤醒率,防抖窗口的大小需要在响应延迟和误唤醒率之间做折中(窗口越大、延迟越高、误唤醒越低)。
- 功耗优化:MFCC 每 10ms 执行一次(帧移 160 样本),CNN 推理每 100ms 执行一次(10 帧为一个推理窗口)。在帧间使用 MCU 的 Sleep 模式,可将平均功耗从 15mA 降至 3mA。
- 量产一致性:不同芯片的 MEMS 麦克风频率响应差异可达 ±3dB,需要在产线标定中采集参考音频并做频响补偿。这涉及将校准系数写入芯片内部 EEPROM 或 FLASH 的固定区域。