2. 基于TinyML的语音关键词识别
任务目标
在资源受限的MCU上实现一个语音关键词识别(KWS)系统,能够实时检测麦克风输入中的特定关键词(如"yes"),并输出识别结果。本案例聚焦于完整的端到端流程:音频特征提取、神经网络推理和后处理,全部在单片机上完成,不依赖云服务。
环境与依赖
- 硬件:ESP32-S3开发板(内置I2S外设,支持PDM麦克风)
- 软件:ESP-IDF v5.x,TensorFlow Lite for Microcontrollers库
- 模型:预训练关键词识别模型(基于DS-CNN架构,输入为49×10的MFCC特征图,输出3类:yes/no/unknown)
- 音频配置:16kHz采样率,30ms帧长,20ms帧移,10维MFCC特征
核心实现
系统分为三个模块:音频采集与特征提取、TFLM推理引擎、结果后处理。以下代码实现了完整的推理闭环,其中MFCC特征提取使用TFLM自带的音频前端库,模型数据通过kws_model_data.h头文件嵌入固件。
// kws_main.c - 关键词识别完整实现
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "driver/i2s.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "tensorflow/lite/micro/kernels/micro_ops.h"
#include "kws_model_data.h"
#include "feature_provider.h"
static const char* TAG = "KWS";
// 模型参数
#define NUM_FRAMES 49
#define NUM_MFCC 10
#define NUM_CLASSES 3
#define INPUT_SIZE NUM_FRAMES * NUM_MFCC
// 音频前端配置
#define SAMPLE_RATE 16000
#define FRAME_LEN_MS 30
#define FRAME_STEP_MS 20
#define NUM_CHANNELS 1
// 全局推理对象
static tflite::MicroInterpreter* interpreter = nullptr;
static uint8_t* input_buffer = nullptr;
static uint8_t tensor_arena[60 * 1024];
// 关键词平滑计数器
static int yes_count = 0;
static int no_count = 0;
static const int DETECT_THRESHOLD = 3;
// 初始化TFLM解释器
void kws_init(void) {
// 加载模型
const tflite::Model* model = tflite::GetModel(kws_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
ESP_LOGE(TAG, "Model schema version mismatch");
return;
}
// 注册算子
static tflite::MicroMutableOpResolver<10> resolver;
resolver.AddConv2D();
resolver.AddDepthwiseConv2D();
resolver.AddFullyConnected();
resolver.AddSoftmax();
resolver.AddReshape();
resolver.AddAveragePool2D();
// 创建解释器
static tflite::MicroInterpreter static_interpreter(
model, resolver, tensor_arena, sizeof(tensor_arena));
interpreter = &static_interpreter;
// 分配张量
TfLiteStatus allocate_status = interpreter->AllocateTensors();
if (allocate_status != kTfLiteOk) {
ESP_LOGE(TAG, "Tensor allocation failed");
return;
}
// 获取输入张量指针
input_buffer = interpreter->input(0)->data.uint8;
ESP_LOGI(TAG, "KWS initialized, input size: %d", INPUT_SIZE);
}
// 执行一次推理
int kws_run_inference(const float* mfcc_features) {
// 将MFCC特征量化到输入张量
TfLiteTensor* input = interpreter->input(0);
float input_scale = input->params.scale;
int input_zero_point = input->params.zero_point;
for (int i = 0; i < INPUT_SIZE; i++) {
input_buffer[i] = (uint8_t)(mfcc_features[i] / input_scale + input_zero_point);
}
// 执行推理
TfLiteStatus invoke_status = interpreter->Invoke();
if (invoke_status != kTfLiteOk) {
ESP_LOGE(TAG, "Invoke failed");
return -1;
}
// 获取输出
TfLiteTensor* output = interpreter->output(0);
uint8_t* output_data = output->data.uint8;
float output_scale = output->params.scale;
int output_zero_point = output->params.zero_point;
// 反量化并找最大类别
float scores[NUM_CLASSES];
int max_idx = 0;
float max_score = -1.0f;
for (int i = 0; i < NUM_CLASSES; i++) {
scores[i] = (output_data[i] - output_zero_point) * output_scale;
if (scores[i] > max_score) {
max_score = scores[i];
max_idx = i;
}
}
// 置信度阈值过滤
if (max_score < 0.6f) {
return -1; // 置信度不足
}
return max_idx; // 0=yes, 1=no, 2=unknown
}
// 主任务循环
void kws_task(void* arg) {
// 初始化音频前端
FeatureProvider feature_provider(SAMPLE_RATE, FRAME_LEN_MS, FRAME_STEP_MS, NUM_MFCC);
while (1) {
// 采集并提取MFCC特征
float mfcc_features[NUM_FRAMES * NUM_MFCC];
bool new_features = feature_provider.GetFeatures(mfcc_features);
if (new_features) {
// 执行推理
int result = kws_run_inference(mfcc_features);
// 结果平滑处理
if (result == 0) { // yes
yes_count++;
no_count = 0;
if (yes_count >= DETECT_THRESHOLD) {
ESP_LOGI(TAG, "KEYWORD DETECTED: YES");
yes_count = 0;
// 触发外部动作(如LED闪烁)
gpio_set_level(GPIO_NUM_2, 1);
vTaskDelay(pdMS_TO_TICKS(100));
gpio_set_level(GPIO_NUM_2, 0);
}
} else if (result == 1) { // no
no_count++;
yes_count = 0;
if (no_count >= DETECT_THRESHOLD) {
ESP_LOGI(TAG, "KEYWORD DETECTED: NO");
no_count = 0;
}
} else {
// unknown或低置信度,重置计数
yes_count = 0;
no_count = 0;
}
}
vTaskDelay(pdMS_TO_TICKS(50)); // 50ms检测周期
}
}
void app_main(void) {
ESP_LOGI(TAG, "KWS system starting...");
// 初始化I2S和GPIO
i2s_config_t i2s_config = {
.mode = I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM,
.sample_rate = SAMPLE_RATE,
.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT,
.channel_format = I2S_CHANNEL_FMT_ONLY_RIGHT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
.dma_buf_count = 8,
.dma_buf_len = 256,
};
i2s_driver_install(I2S_NUM_0, &i2s_config, 0, NULL);
i2s_set_pin(I2S_NUM_0, NULL); // 使用默认PDM引脚
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << GPIO_NUM_2),
.mode = GPIO_MODE_OUTPUT,
};
gpio_config(&io_conf);
// 初始化KWS
kws_init();
// 启动识别任务
xTaskCreate(kws_task, "kws_task", 8192, NULL, 5, NULL);
}
运行与验证
- 构建固件:在ESP-IDF环境中执行
idf.py build,将生成的固件烧录到ESP32-S3开发板。 - 模型文件放置:将预训练模型转换为C数组后保存为
kws_model_data.h,与主代码同目录。 - 串口监控:使用
idf.py monitor打开串口,波特率115200。 - 测试步骤:
- 上电后观察日志,确认出现"KWS initialized"表示模型加载成功;
- 靠近麦克风清晰说出"yes",观察串口是否打印"KEYWORD DETECTED: YES";
- 说出"no"验证第二类关键词;
- 播放音乐或环境噪声,确认不会触发误检。
通过标准:在安静环境下,连续10次说出"yes",至少8次正确触发;连续10次说出其他词语(如"hello"),误触发次数不超过1次。GPIO2连接的LED在检测到"yes"时闪烁,作为硬件指示。
关键说明
- 特征提取使用TFLM的
feature_provider组件,内部实现MFCC计算,需在CMakeLists.txt中链接audio_frontend库。 - 模型量化使用int8格式,输入输出均需按张量的scale/zero_point进行转换。
- 结果平滑机制(连续3次确认)用于减少单帧误判,实际部署时可根据环境调整阈值。
- 若使用其他MCU平台,需替换I2S驱动和GPIO控制部分,推理核心代码保持不变。