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);
}

运行与验证

  1. 构建固件:在ESP-IDF环境中执行 idf.py build,将生成的固件烧录到ESP32-S3开发板。
  2. 模型文件放置:将预训练模型转换为C数组后保存为kws_model_data.h,与主代码同目录。
  3. 串口监控:使用 idf.py monitor 打开串口,波特率115200。
  4. 测试步骤:
    • 上电后观察日志,确认出现"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控制部分,推理核心代码保持不变。
KWS系统数据流 PDM麦克风 16kHz采样 MFCC特征提取 49帧×10维 30ms帧长/20ms移 TFLM推理 DS-CNN模型 int8量化 结果平滑 连续3次确认 置信度>0.6 识别结果输出 输出类别 0: "yes" 1: "no" | 2: "unknown" GPIO2 LED指示