把自定义唤醒词部署到 ESP32-S3:ONNX 转 INT8 TFLite 的完整链路

很多端侧语音项目都会经历同一个过程:模型在电脑上用 ONNX Runtime 跑通了,准备复制到 ESP32-S3 时,却发现固件里根本没有 ONNX Runtime。

这不是少装了一个库,而是运行环境发生了变化。

ONNX 很适合作为训练端和通用平台的交付格式;到了内存和算力都受限的微控制器,通常要把模型转换成 INT8 TFLite,再交给 TensorFlow Lite Micro 执行。Espressif 当前文档列出的微控制器推理路径也是 .tflite 配合 TFLite Micro,量化 INT8 模型通常才是板端的实际目标。

本文用自定义唤醒词模型走一遍完整链路:

text 复制代码
训练模型
  ↓
FP32 ONNX:PC 端基准
  ↓
恢复/映射到可转换的 TensorFlow 计算图
  ↓
代表性 Mel 特征校准
  ↓
INT8 TFLite
  ↓
TFLite Micro + ESP32-S3
  ↓
对齐输出、接入实时音频和检测逻辑

先澄清:ESP32-S3 不是直接推理 ONNX

Android、Linux 和浏览器拥有完整操作系统及相对充足的内存,可以使用 ONNX Runtime 或 ONNX Runtime Web。ESP32-S3 属于 MCU,通常使用为微控制器裁剪的 TFLite Micro,只注册模型真正需要的算子,并提前规划张量内存。

所以"同一个模型跨平台"不一定意味着所有平台读取完全相同的文件:

平台 推荐交付格式 典型运行时
Windows / Linux / macOS ONNX ONNX Runtime
Android ONNX ONNX Runtime Mobile
Web ONNX ONNX Runtime Web
ESP32-S3 INT8 .tflite TensorFlow Lite Micro

真正需要保持一致的是模型语义:输入特征、时间窗口、输出类别、归一化参数和判决逻辑。

第一步:保留 ONNX 作为基准模型

不要一拿到 TFLite 就只测开发板。先在 PC 上固定一套基准:

  • 一组目标词正样本;
  • 一组普通对话和相似词;
  • 安静、音乐、键盘和设备实际噪声;
  • ONNX 对每个样本输出的原始分数。

这些输出是后面判断"转换正确"和"量化损失"的参照。如果只在板子上测试最终是否亮灯,出现漏检时很难分清是模型转换、音频前处理还是实时缓冲出了问题。

建议保存逐样本结果:

text 复制代码
sample_id, label, onnx_score
positive_001, wake, 0.9312
negative_tv_001, other, 0.0384
similar_001, other, 0.2147

第二步:不要把 ONNX 转 TFLite 当成格式改后缀

ONNX 和 TFLite 是两套计算图与算子体系。可靠的生产链路通常是在训练工程中同时保留可导出 ONNX 和 TFLite 的源模型,或者将 ONNX 中的权重映射回等价 TensorFlow 图后再转换。

直接依赖"一键 ONNX 转 TFLite"工具也可能成功,但必须检查:

  1. 是否插入了额外的 Transpose
  2. 卷积 padding 和时间轴方向是否一致;
  3. 激活函数、归一化和输出 sigmoid 是否被完整保留;
  4. 动态 shape 是否已经固定;
  5. 是否出现 TFLite Micro 不支持的算子。

对因果时序卷积模型来说,时间轴弄反、padding 差一帧都可能让文件"可以运行",但分数完全失真。

因此推荐顺序是:

text 复制代码
源训练图 → FP32 ONNX
         → FP32 TFLite

先让 FP32 ONNX 与 FP32 TFLite 对齐,再做 INT8。不要把"图转换"和"量化"两个变量混在一次排错里。

第三步:使用真实 Mel 特征做 INT8 校准

全整数量化不仅要量化权重,还要估算输入和中间激活的动态范围。TensorFlow 转换器通过 representative dataset 完成这一步。

对唤醒词模型,代表性数据不应该是随机矩阵,而应该是和部署端完全同分布的 Mel 特征:

  • 正常音量和较小音量的目标词;
  • 多个说话人和距离;
  • 静音及房间底噪;
  • 相似发音和普通对话;
  • 设备麦克风会遇到的音乐、风扇或机械噪声。

示意代码如下,模型加载方式要替换成训练工程自己的 TensorFlow/Keras 图:

python 复制代码
import tensorflow as tf

def representative_dataset():
    for mel in calibration_mels:
        # mel 的 shape、归一化方式必须与模型输入一致
        yield [mel.astype("float32")]

converter = tf.lite.TFLiteConverter.from_keras_model(tf_model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_dataset
converter.target_spec.supported_ops = [
    tf.lite.OpsSet.TFLITE_BUILTINS_INT8
]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8

tflite_int8 = converter.convert()

TFLITE_BUILTINS_INT8 有一个重要作用:遇到不能整数量化的算子时直接报错,而不是悄悄留下浮点回退。对 MCU 来说,这比"先生成一个能打开的文件"更重要。

第四步:正确处理 scale 和 zero point

INT8 模型的输入不是简单地把浮点数强转成 int8_t。每个量化张量都带有 scalezero_point

text 复制代码
q = round(real_value / scale) + zero_point
real_value ≈ (q - zero_point) × scale

PC 端验证时先读取 TFLite 输入、输出张量的量化参数:

python 复制代码
interpreter = tf.lite.Interpreter(model_path="wakeword_int8.tflite")
interpreter.allocate_tensors()

input_info = interpreter.get_input_details()[0]
output_info = interpreter.get_output_details()[0]

print(input_info["shape"], input_info["dtype"])
print(input_info["quantization_parameters"])
print(output_info["quantization_parameters"])

ESP32 端必须使用同一组量化参数。漏掉 zero point 或重复做一次归一化,都会造成输出分布整体偏移。

第五步:先做三端输出对齐

在接麦克风之前,先让同一份 Mel 输入依次跑过:

  1. PC ONNX FP32;
  2. PC TFLite INT8;
  3. ESP32-S3 TFLite Micro。

对每个样本记录原始输出:

样本 ONNX FP32 TFLite INT8 ESP32-S3
目标词 1 待实测 待实测 待实测
目标词 2 待实测 待实测 待实测
相似词 待实测 待实测 待实测
电视负样本 待实测 待实测 待实测

这里不要先套最终阈值,只比较原始分数和样本排序。如果 PC TFLite 已经和 ONNX 差异很大,问题在转换或量化;如果两者一致而 ESP32 不一致,再检查板端输入、算子和内存。

第六步:在 TFLite Micro 中只注册需要的算子

TFLite Micro 的常见初始化结构如下:

cpp 复制代码
const tflite::Model* model = tflite::GetModel(model_data);

tflite::MicroMutableOpResolver<OP_COUNT> resolver;
// 根据实际模型逐个 Add,不要照抄无关算子
// resolver.AddConv2D();
// resolver.AddDepthwiseConv2D();
// resolver.AddFullyConnected();
// resolver.AddLogistic();

static uint8_t tensor_arena[TENSOR_ARENA_SIZE];

tflite::MicroInterpreter interpreter(
    model, resolver, tensor_arena, sizeof(tensor_arena));

if (interpreter.AllocateTensors() != kTfLiteOk) {
    // arena 不够或存在未注册算子
}

OP_COUNT、算子列表和 TENSOR_ARENA_SIZE 必须来自实际导出的模型和运行日志。文章里给一个看起来漂亮的固定数字没有意义:模型结构、TFLite Micro 版本和内核实现变化后,内存需求也会变。

实用的调试方法是先给 arena 留出较宽裕空间,确保 AllocateTensors() 成功并记录实际占用,再逐步收紧;不要一开始就为了省几 KB 反复猜测。

第七步:音频前处理必须逐项一致

模型对不上时,很多人先怀疑 INT8,其实更常见的是 Mel 特征不一致。至少要逐项核对:

  • 采样率是否都是 16 kHz;
  • 单帧长度与帧移;
  • FFT 大小;
  • Mel bin 数量;
  • 频率上下限;
  • 窗函数;
  • 对数处理中的常数;
  • 均值方差或其他归一化;
  • 时间窗口的拼接顺序。

最稳妥的方法是准备一段固定 PCM,分别导出 PC 和 ESP32 端前几帧 Mel 数值做比较。Mel 还没对齐之前,不要继续调模型阈值。

第八步:再接入实时 I2S 音频

离线样本对齐之后,才进入实时链路:

text 复制代码
麦克风/Codec
  → I2S DMA
  → 环形缓冲
  → 16 kHz 单声道 PCM
  → 增量 Mel 特征
  → INT8 TFLite Micro
  → 连续帧、冷却等检测逻辑
  → GPIO / 串口 / 网络事件

这里有两个容易混淆的问题。

第一,模型推理通过不等于音频驱动通用。当前参考工程针对 ESP32-S3 和指定 ES7210 音频硬件验证;更换 INMP441 或其他 Codec 时,需要适配 I2S 通道、位宽、左右声道、增益和采样率。

第二,KWS 模型输出的是连续分数,不应该单帧超过阈值就直接控制继电器。至少要加入连续帧确认和触发冷却,再根据真实误唤醒决定是否增加更严格的过滤。

一张排错表

现象 优先检查
TFLite 转换失败 不支持的算子、动态 shape、图中浮点回退
PC TFLite 与 ONNX 差很多 图转换、代表性数据、量化参数
PC 对齐但 ESP32 分数异常 scale/zero point、输入布局、算子版本、arena
WAV 正常但麦克风叫不醒 I2S 数据、采样率、增益、Mel 实现
一句话触发多次 连续帧和 cooldown
电视或音乐误触发 负样本、能量/背景过滤、最终设备实测

最后:模型文件只是链路的一部分

把自定义唤醒词部署到 ESP32-S3,不是简单地把 ONNX 文件复制进 Flash。真正完整的工作包括:

text 复制代码
模型语义一致
+ 可落地的 INT8 图
+ 有代表性的量化校准
+ PC/板端输出对齐
+ 完全一致的 Mel 前处理
+ 稳定的实时音频缓冲
+ 防重复和防误触发逻辑

如果想直接看可运行的参考工程,可以查看开源项目 onnx-wakeword。听词控制台也提供 Demo 模型在线测试和下载入口:www.voicute.com/user/overvi...。下载模型时,通用平台选择 ONNX,ESP32-S3 选择 INT8 TFLite。

参考资料:

相关推荐
前端繁华如梦15 分钟前
用 Vite + Electron + React + Python 重造 3D 服装打版软件
前端
豆沙沙包?24 分钟前
函数重载/类和对象(P14-P60)
前端·算法
三十而立洋39 分钟前
彻底搞懂跨域:原理、CORS、解决方案与实战避坑
前端
计算机魔术师41 分钟前
ASR转录总出错?Google新模型WER降到2.6%,还能自动帮你改口误
前端
xcs1940543 分钟前
新版 IDEA(尤其是 2024/2025/2026)越来越臃肿
前端·人工智能·intellij-idea
Patrick_Wilson43 分钟前
iOS 第三方浏览器图片下载失败问题
前端·ios·浏览器
菜鸟小前端在线卖艺1 小时前
因为找不到好用的前端占位图,于是我自己写了个谁都能用的占位图功能
前端·程序员·产品
Z小明1 小时前
第 1 章 Vite 项目初始化
前端·vue.js
IT_陈寒1 小时前
Redis大KEY删除慢到手抖,这几个方法让我少熬一夜
前端·人工智能·后端