“语音控制”这个词听起来挺唬人,但真正落地到 ESP32 项目里,拆开看就是三步:麦克风听声音、把识别结果变成串口数据、主控根据数据干活。我在家里调试智能灯和桌面小舵机的时候,最开始用的是手机 APP 蓝牙方案,后来换成了离线语音模块,才发现动嘴比点屏幕自然太多了。这篇文章就是一次完整的语音控制实战记录,适合想自己复现“说句话控制设备”的 ESP32 玩家,也适合那些已经卡在“识别不准、串口没数据、一接外设就重启”这类问题上的同学。最终实现效果很简单:你对模块说“打开灯”,灯亮;说“舵机左转”,舵机转过去;整个流程离线运行,识别延迟大约在几百毫秒,不依赖任何网络服务。
1. 语音方案怎么选:云端API、手机APP还是离线模块
1.1 三种主流方案的优劣势对比
做智能硬件的人应该都有体会,语音控制方案并没有唯一解。项目一开始就必须想清楚:你要求在什么场景下唤醒、断网能不能用、需要几条例句、开发周期多长。这些问题没想清楚,后面大概率要返工。
| 方案 | 依赖 | 识别延迟 | 离线可用 | 抗干扰 | 上手成本 |
|---|---|---|---|---|---|
| 云端语音API | 需要网络 + 麦克风/录音模块 | 1~3秒 | 否 | 受网络波动影响 | 高,要申请密钥、处理音频流 |
| 手机APP + 蓝牙 | 需要手机,APP内识别 | 0.5~1秒 | 依赖APP本地词库 | 跟APP实现强相关 | 中,要写APP和BLE协议 |
| 离线语音识别模块(如SU-03T) | 无任何网络依赖 | 0.2~0.8秒 | 是 | 离线词条识别稳定 | 低,可视化工具配置 |
以我实际做过的几个项目来看,如果只是控制灯、风扇、窗帘、舵机、继电器这类固定动作,语音控制的核心诉求是“把口语变成动作指令”,不是“识别任意内容”。把词表限定在十几条固定命令,识别率会高很多,这也是离线方案最大的优势。
这里有个很现实的对比:云端方案在识别长句、连续自由说话上有碾压性优势,但代价是要解决音频采集、上传、密钥管理、网络容错等问题。一次家里的路由器重启,就会让你的智能灯“变哑”。做智能家居类的小项目,稳定性比“能听懂更多话”重要得多。
1.2 为什么最终选了SU-03T这类离线模块
我最后选型的思路很简单,就四点:
- 不依赖网络:网络波动时设备仍然可用,这一点对智能家居场景太重要了。
- 词条可定制:唤醒词、命令词都能自己改,不需要写音频处理代码。
- 串口输出简单:模块通过 UART 把识别结果发给 ESP32,主控端只需要解析一帧数据。
- 成本低:模块十几块钱,比带麦克风阵列加云端方案便宜一个数量级。
你可能担心离线识别率不够。我的实测结论是:只要避开同音、近音的命令词,家居场景的指令识别率稳定在 90% 以上。尤其是词条数量被限制在几十条以内时,关键词检测的效果非常稳定。
我最终把方案定成了“SU-03T + ESP32”组合:SU-03T 负责语音识别,ESP32 负责串口解析、GPIO 输出、联动外设。如果你只是控制一盏灯,甚至可以纯模块直连;但想加 Wi-Fi、传感器、屏幕、电机驱动这些能力,ESP32 就是绝对主角。这也就是为什么标题里点的是 ESP32,而不是语音模块本身。
2. ESP32开发环境搭建:从Arduino IDE到PlatformIO
模块选好了,下一步就是烧录环境。很多新手会卡在“板子插上电脑没反应”“点上传一直报错”,所以我先把环境搭建完整说一遍,分三块:Arduino IDE、VS Code + PlatformIO、驱动与烧录。
2.1 Arduino IDE快速上手与国内镜像加速
如果你之前没碰过 ESP32 开发环境,我建议先用 Arduino IDE,理由只有一个:它把绝大多数细节都封装好了,官方板卡库安装完,基本就是“选择板卡、选串口、点上传”三件事。
Arduino IDE 安装完成后,第一步是添加 ESP32 开发板管理器地址。打开“文件 -> 首选项 -> 附加开发板管理器网址”,填入官方索引:
https://espressif.github.io/arduino-esp32/package_esp32_index.json国内网络环境下这个地址有时候下载很慢,甚至中途失败。这时候有两个变通方案:
- 换成国内镜像源。镜像源的具体地址会因为版本更新而变动,建议直接去乐鑫中文社区或者 Arduino 中文社区找最新的同步地址。
- 直接下载离线完整包。比如搜索“arduino esp32 3.3.11 完整离线包 windows”,把 3.3.11 版本的板卡文件下载下来,手动解压到 Arduino 的 hardware/espressif 目录。
我个人更推荐 3.3.11 这个版本,它是当前阶段非常稳定的一个 ESP32 Arduino 内核版本,踩坑记录多、参考代码全,比追新版本省心得多。
添加完地址之后,“工具 -> 开发板 -> 开发板管理器”里搜“esp32”,安装由 Espressif Systems 维护的包。安装完成后,板卡列表里会出现 “ESP32 Dev Module” 或对应的具体型号选项。
2.2 VS Code + PlatformIO的正规打法
当项目开始变大,比如要加蓝牙、要跑 FreeRTOS 任务、要管理多个模块时,Arduino IDE 的单文件编辑体验就不太够用了。这时候我建议直接切到 VS Code + PlatformIO。
VS Code 里安装 PlatformIO IDE 插件后,需要建一个工程,核心是platformio.ini配置文件:
[env:esp32dev] platform = espressif32 board = esp32dev framework = arduino monitor_speed = 115200 upload_speed = 921600 upload_port = COM23这里有几个点要注意。platform = espressif32表示使用乐鑫官方平台;board = esp32dev对应最常见的 ESP32 DevKit 开发板,如果你的板子型号特殊,可以在 PlatformIO 的 Boards 搜索里确认;monitor_speed是串口监视器波特率,upload_speed可以拉高到 921600,上传速度比默认快不少。
第一次创建工程时 PlatformIO 会下载工具链,国内网络可能很慢。我的经验是:找一个网络空闲的时间段,或者预先准备 PlatformIO 离线包,不然容易在下载到一半时卡死。PlatformIO 最大的优势是依赖隔离和项目管理规范,缺点就是初次环境安装比 Arduino IDE 折腾一点点。
2.3 USB驱动识别与烧录操作细节
环境装好后,把 ESP32 插入电脑。如果设备管理器里看不到 COM 口,先确认开发板用的 USB 转串口芯片。常见的两种是 CH340 和 CP2102,需要分别安装对应驱动。
烧录时最常见的报错是 “A fatal error occurred: Failed to connect to ESP32: Timed out waiting for packet header”,遇到这个不要慌,处理方式就一条:按住开发板上的 BOOT 键,保持按住再点上传,看到 “Connecting...” 提示出现时松开 BOOT。部分自动下载电路比较稳的板子不需要手动按,但如果报超时,手动 BOOT 永远是第一方案。
还有一个小细节:选板子时别选错模组型号。如果芯片是 ESP32-S3、ESP32-C3,要选对应的型号,头铁选 ESP32 Dev Module 即使能编译也大概率无法正确烧录或运行。
3. 让语音模块先学会听:接线与固件词条配置全流程
ESP32 环境准备好了,接下来就要面对核心设备——离线语音模块。以 SU-03T 为例,完整流程分为接线、固件配置、验收三部分。
3.1 模块引脚功能与接线图
SU-03T 这类离线语音模块,引脚数量不多,核心就几个。以我常用的定义为例:
| 模块引脚 | 功能 | 接ESP32 |
|---|---|---|
| VCC | 供电正极,一般支持3.7~5V | 接5V输出 |
| GND | 电源地 | 接GND |
| TXD | 串口发送 | 接ESP32的RX(GPIO16) |
| RXD | 串口接收 | 接ESP32的TX(GPIO17) |
| IO引脚 | 可配置直接输出 | 按需接继电器/指示灯 |
接线逻辑上,关键就是 TXD 接对方的 RX、RXD 接对方的 TX,交叉连接,不要同向导通。很多新手第一次接错,就是因为把 TXD 接到了自己的 TXD 上。
供电上我给两个明确建议:
- 模块的 VCC 接 5V,不要接 3.3V。虽然部分模块标称支持 3.3V,但接 5V 时内部的麦克风偏置和功放会更稳定,识别距离也会好一些。
- ESP32 与语音模块之间必须共地,也就是两边 GND 要连在一起。共地是串口通信的前提,不共地时串口数据可能全是乱码或干脆没反应。
信号电平方面,SU-03T 的 UART 是 TTL 电平,ESP32 的 GPIO 也是 3.3V TTL,所以不需要额外接电平转换电路。我长期把模块 TXD 直连 ESP32 的 GPIO16,没有出现过电平不兼容问题。
3.2 语音固件配置:唤醒词与命令词的设计
模块出厂时通常会带一套演示固件,能识别几个演示词条,但你要真正让它执行“打开灯”“关闭灯”这样的自定义命令,就必须重新配置固件。这一步需要用到厂家的 Windows 上位机工具,网上直接搜“SU-03T 语音配置工具”就能找到下载渠道。
配置流程大概分五步:
- 用 USB-TTL 工具把语音模块接到电脑。
- 打开配置工具,选择对应串口和波特率。
- 添加唤醒词,比如“小智小智”。
- 添加命令词,并把命令词绑定到指定命令 ID。
- 生成固件,通过串口下载到模块。
命令词设计是有讲究的。我踩过不少坑之后总结出三条经验:
- 命令词长度控制在两到四个字,比如“打开灯”“风扇高速”“舵机左转”。太短容易出现误识别,太长用户说起来费劲。
- 同一条动作尽量多录几个同义词,比如“打开灯”和“开灯”都映射到同一个 ID。实测下来,“打开灯”和“开灯”虽然就差一个字,识别结果差距却很大。
- 避免同音词或近音词。比如“风扇高速”和“风扇高数”这种就尽量别同时出现,否则识别引擎容易打架。
还要注意,命令 ID 是你和 ESP32 代码之间唯一的沟通契约。在配置工具里给每条命令分配固定 ID,比如 01 对应灯开、02 对应灯关、03 对应舵机左转、04 对应舵机右转,这个映射表一定要记下来,后面写代码就是靠这个表来分发指令的。
3.3 下载固件后用串口助手验收
固件下载完后,不要急着接 ESP32。先用 USB-TTL 工具把模块单独接到电脑,打开任意串口调试助手,波特率设置为配置工具里指定的值,一般是 9600 或 115200。
这时做两个测试:
- 说唤醒词,观察模块有没有通过串口打印信息或点亮指示灯。
- 继续说命令词,看串口助手能不能收到一帧完整数据。
这一步虽然很简单,却能帮你把故障范围切开。如果模块在这里都发不出数据,问题在模块侧,比如固件没烧成功、供电不足、波特率不对;如果模块在这里能正常发数据,再接上 ESP32,问题范围就只剩 ESP32 的接线或代码了。我后面调试过太多“ESP32没反应”的案例,最后发现一半是因为压根没验证过模块端输出。
4. ESP32端代码怎么写:UART帧解析与命令分发
语音模块学会了听,现在轮到 ESP32 干活了。这一章是整篇最核心的部分,我会把串口帧解析的原理和代码一次性说清楚。
4.1 先理解语音模块发出来的数据帧
语音模块识别到词条之后,会从串口发出一固定格式的数据帧。不同厂家的帧格式略有差异,常见结构是“帧头 + 长度 + 命令ID + 校验值”。比如帧头是 0xFD,紧接着是数据长度,然后是识别到的命令字编号,最后是加和校验。
为什么需要帧头?因为串口是流式数据,ESP32 并不知道一帧从哪里开始、到哪里结束。帧头就是数据流的“起跑线”,状态机从帧头开始,按长度字段读完一个完整帧,再用校验值判断这一帧有没有传错。
4.2 完整的ESP32代码框架
我提供一个可以直接抄走的 Arduino 工程。这段代码已经考虑了几件容易被忽略的事:串口数据逐字节喂给状态机、校验失败不执行动作、舵机缓动、超时自动复位。
#include <Arduino.h> #include <Servo.h> #define VOICE_RX 16 #define VOICE_TX 17 #define LED_PIN 2 #define SERVO_PIN 4 #define FRAME_HEAD 0xFD // 命令ID映射表,必须和语音配置工具里设置的一致 #define CMD_LED_ON 1 #define CMD_LED_OFF 2 #define CMD_SERVO_LEFT 3 #define CMD_SERVO_RIGHT 4 #define CMD_LIGHT_ONE 5 #define CMD_LIGHT_TWO 6 #define CMD_REBOOT 7 enum ParseState { WAIT_HEAD, WAIT_LEN, WAIT_DATA, WAIT_CHECK }; ParseState state = WAIT_HEAD; uint8_t frameCmd = 0; uint8_t frameLen = 0; uint8_t checkSum = 0; Servo myServo; int servoTarget = 90; int servoCurrent = 90; void handleCommand(uint8_t cmd) { Serial.printf("[CMD] 0x%02X\r\n", cmd); switch (cmd) { case CMD_LED_ON: digitalWrite(LED_PIN, HIGH); break; case CMD_LED_OFF: digitalWrite(LED_PIN, LOW); break; case CMD_SERVO_LEFT: servoTarget = 0; break; case CMD_SERVO_RIGHT: servoTarget = 180; break; case CMD_REBOOT: esp_restart(); break; default: Serial.printf("unknown cmd: 0x%02X\r\n", cmd); break; } } void feedByte(uint8_t b) { switch (state) { case WAIT_HEAD: if (b == FRAME_HEAD) { checkSum = b; frameLen = 0; frameCmd = 0; state = WAIT_LEN; } break; case WAIT_LEN: frameLen = b; checkSum += b; state = WAIT_DATA; break; case WAIT_DATA: frameCmd = b; checkSum += b; state = WAIT_CHECK; break; case WAIT_CHECK: if (checkSum == b) { handleCommand(frameCmd); } else { Serial.printf("check fail: %02X != %02X\r\n", checkSum, b); } state = WAIT_HEAD; break; } } void setup() { Serial.begin(115200); Serial2.begin(9600, SERIAL_8N1, VOICE_RX, VOICE_TX); pinMode(LED_PIN, OUTPUT); digitalWrite(LED_PIN, LOW); myServo.attach(SERVO_PIN, 500, 2500); myServo.write(servoCurrent); } unsigned long lastResetCheck = 0; void loop() { while (Serial2.available()) { uint8_t b = Serial2.read(); feedByte(b); } // 舵机缓动:防止指令突变导致舵机猛甩 if (servoCurrent < servoTarget) { servoCurrent += 2; myServo.write(servoCurrent); } else if (servoCurrent > servoTarget) { servoCurrent -= 2; myServo.write(servoCurrent); } // 如果长时间没有有效帧,强制回到等待帧头状态 if (millis() - lastResetCheck > 3000) { state = WAIT_HEAD; lastResetCheck = millis(); } }代码里有一个细节值得专门说:我在feedByte里用了四状态的状态机,而不是写一整串的if (Serial2.read() == 0xFD && Serial2.read() == ...)。原因很简单:串口数据是连续流,一个字节一个字节地到达,如果你连续调用多个read(),中间很可能被其他字节插入,导致帧错位。状态机则是每到一个字节判断一次“我现在该等什么”,对错位有很强的自恢复能力。
另外,如果你买的模块帧结构不止一个数据字节,只需把WAIT_DATA状态改成循环读取frameLen - 2个字节,存到缓冲数组里再做解析。框架可以复用,不用推翻重写。
4.3 命令执行端的三个注意点
- LED 引脚:ESP32 开发板上的板载 LED 引脚并不统一,有的开发板是 GPIO2,有的在 GPIO1,有的根本没有。使用外接 LED 时尽量选一个避开板载功能的引脚,串口打印日志也因此更干净。
- 舵机供电:舵机启动瞬间电流可以到几百毫安甚至更大,不能和 ESP32 的 3.3V 共用电源,否则一执行指令板子就复位。正确做法是给舵机单独接 5V 外部电源,并且把外部电源的 GND 和 ESP32 的 GND 连在一起。
- 串口波特率:代码里用了 9600,这是模块配置工具里最常用的值。如果你在工具里把波特率改成了 115200,记得同步改代码里的
Serial2.begin参数,否则收到的全是乱码。
4.4 协议帧和命令映射表如何维护
随着项目变大,命令词越来越多,我建议把映射表单独写成一张注释放在代码文件最顶部,例如:
| 命令ID | 命令词 | 动作 |
|---|---|---|
| 0x01 | 打开灯 / 开灯 | 点亮LED |
| 0x02 | 关闭灯 / 关灯 | 熄灭LED |
| 0x03 | 舵机左转 | 舵机转到0度 |
| 0x04 | 舵机右转 | 舵机转到180度 |
这样做的目的是防止“写代码时一时爽,调试时两行泪”。尤其是你下次重新配置语音模块固件,把命令 ID 顺序打乱了,代码里却没有更新映射表,排查起来会非常痛苦。我自己就吃过这个亏,后来把所有词条设计放到 Excel 里管理,配置工具填一份,代码注释同步一份,两边都对应得清清楚楚。
5. 联调阶段的坑与排障:从无声到误触发的完整排查
写完了代码,终于到了激动人心的联调环节。但现实往往很骨感,我在这个阶段遇到过的问题基本可以汇总成一个排查清单,希望能帮你省下半天时间。
5.1 现象一:说话之后ESP32完全没反应
遇到这种情况,先别急着改代码,按这个顺序排查:
- 用 USB-TTL 把语音模块单独接到电脑,用串口调试助手看它能不能发出数据。这一步能判断是模块问题还是 ESP32 问题。
- 如果模块单独测试正常,接上 ESP32 后没反应,检查 TXD/RXD 是否交叉连接。很多人把模块 TXD 接到了 ESP32 的 TXD,结果自然收不到。
- 看 ESP32 的 Serial 串口监视器里有没有打印。如果打印了 “unknown cmd”,说明 ESP32 收到数据了,但命令 ID 对不上,去查映射表。
- 如果 Serial 里一片空白,大概率是波特率不一致,或者 Serial2 用错了引脚。
我在实际排查时发现,很多人卡在第一步的占比其实最高。模块固件没下载成功,或者模块供电用的是电脑 USB 口而电流不足,都会导致模块完全不工作。所以模块端验收是重中之重的关卡。
5.2 现象二:ESP32随机重启或串口打印乱飞
这个现象十有八九是供电问题。ESP32 同时带语音模块、舵机、LED 灯带时,电流需求很快就会超过 USB 口所能提供的 500mA。我见过一个最经典的故障:说话时舵机动一下,然后 ESP32 立刻重启,串口打印乱飞。
解决办法:
- 语音模块和舵机不要都挂在 ESP32 的板载 5V 引脚上。
- 舵机单独用 5V/1A 以上的外部电源供电。
- 外部电源的 GND 务必和 ESP32 的 GND 连在一起。
- 做临时验证可以用电脑 USB,长时间运行建议换 5V/2A 的手机充电器供电。
5.3 现象三:识别率低和误触发
语音识别效果不好,很少是模块坏了,更多是词条设计和声学环境的问题。我的实测经验如下:
- 唤醒词选三到四个音节的词,发音末尾要清楚。比如“小智小智”比“阿智”好用。
- 命令词别选太短的单字,也别让两条命令词的前后音节相似,否则识别引擎会做选择困难。
- 麦克风开孔对准人的方向,不要让模块贴着金属面板安装,否则声音反射会干扰识别。
- 说话音量不需要很大,离模块半米以内,正常说话的声音识别效果最好。
有一次我调试误触发,最终定位到是命令词“风扇开”和“饭上开”读音太接近,两条都进了词表,结果一句“风扇开”经常触发两条指令。换掉其中一个词之后,问题立刻消失。
5.4 后续扩展思路:蓝牙APP、ROS2小车和云端联动
这个项目跑通之后,上限完全取决于你怎么扩展。这里给出几个我做过的方向,供你参考:
- 蓝牙双通道:ESP32 同时开启 BLE 和语音模块,手机 APP 和语音命令都能控制同一套外设,形成“语音为主、APP为辅”的双通道控制。
- ROS2 小车:ESP32 作为下位机,通过串口和上位机通信。语音模块识别到“左转”“右转”后,由 ESP32 把命令帧转发给 ROS2 桥接节点,最终驱动小车电机。
- 场景联动:把语音命令和传感器数据结合,比如人不在家时语音命令只执行灯光场景,不执行窗帘场景,这些逻辑都可以放在 ESP32 代码里做。
我实际用下来,最舒服的开发模式是:先把语音模块独立调通,再把 ESP32 串口解析调通,最后才接外设。每加一层,只改一层,出问题能立刻定位。
最后再分享一条经验:调试串口帧之前,务必先用串口助手看看模块侧的真实输出。如果模块在这里都不发数据,你把 ESP32 代码改一百遍也没用。还有,所有命令词映射最好先用表格设计好,再同步到语音配置工具和代码注释里。这个习惯一旦养成,后续加词条、加外设都会顺利得多。