去年做菜时手机放在客厅,厨房里听不到来电,等看到未接来电已经是半小时后。那之后我就琢磨做一个带WiFi的语音通知设备:ESP32负责联网获取信息,TTS文字转语音的事交给WT3000TX芯片,接个小喇叭,家里哪个角落都能听到。整条链路跑通以后,我把它做成了一个通用方案,不光能播报来电提醒,还能报天气、报时间、报传感器状态、报服务器告警。这篇博文把完整思路、硬件连接、软件代码和排错记录整理出来,给想自己动手做一版的朋友当个参考。
这套方案的核心关键词就四个:WiFi、TTS、ESP32、WT3000TX。ESP32是主控,负责连网、拉取数据、处理逻辑;WT3000TX是离线语音合成芯片,负责把中文文字变成可以播放的语音。两者通过串口通信,硬件上非常简单,但实际做下来有几个地方特别容易翻车,比如电平匹配、中文编码、断线重连、播报卡死,文章里都会说到。
1. 为什么是ESP32配WT3000TX,而不是其他方案
1.1 主控芯片的选型逻辑
刚开始我也纠结过用ESP8266还是ESP32。如果只是简单报个时、报个温湿度,ESP8266其实也够用,它便宜,而且同样有WiFi。但真正对比下来,我最终还是选了ESP32,原因很实际:
- 串口资源。ESP8266只有一个硬件串口,调试口和功能口经常打架。ESP32有3个UART,一个接WT3000TX芯片,一个接调试,一个还能留作扩展,这一条就把体验拉开一大截。
- 双核。ESP32是双核处理器,可以把网络任务和播报任务放在不同核心上跑,互不干扰。ESP8266是单核,播报过程中一旦网络回调卡一下,声音就可能断。
- 外设丰富。ESP32自带I2S、ADC、DAC、触摸引脚、BLE,后期想加按键、加传感器、加蓝牙配置都很方便,扩展空间大。
我的结论是:如果只是做一个“能响的玩具”,ESP8266也没问题;但如果你打算把它做成长期在线的通知设备,直接上ESP32,省得后面换主控重新写代码。
1.2 语音方案为什么不选“ESP32直接发声”
很多人第一反应是“ESP32不是有DAC吗,直接接个小喇叭不就行了”。理论上是能出声,但涉及到的麻烦事比想象中多。
第一种做法是在ESP32上跑TTS算法,把文字合成成音频数据,然后通过I2S或DAC播放。问题是:ESP32的Flash普遍只有4MB到16MB,中文语音库体积大,需要抠内存;合成过程占CPU,长时间跑会影响WiFi稳定性;而且软件TTS的音色普遍偏机械,听久了难受。
第二种做法是提前把MP3音频文件放进SD卡或Flash,用I2S功放模块播放。这个方案的音质可以做得很好,但致命缺点是“不能动态播报”——你没法在运行时把一句新产生的文字变成语音,只能播放预先录好的那几句。做固定提示音可以,做智能通知完全不行。
所以我最终选了第三条路:外挂一颗专用的离线TTS芯片。WT3000TX做的就是这件事——你通过串口发一串文字给它,它在内部完成语音合成,然后把音频信号直接送出去,接一个小功放和喇叭就能出声。好处非常明显:不占ESP32的CPU、支持中文/英文混读、音色自然、离线运行完全不需要云端接口。
1.3 这套方案的定位和适用边界
为了让大家更直观地理解我为什么做这个选型,我把当时对比的几个方案整理成了表格:
| 方案 | 动态文字播报 | 音质 | CPU占用 | 开发难度 | 成本 |
|---|---|---|---|---|---|
| ESP32软件TTS | 支持 | 较差 | 高 | 高 | 低 |
| I2S功放+预录音频 | 不支持 | 较好 | 低 | 中 | 中 |
| ESP32+WT3000TX | 支持 | 较好 | 低 | 低 | 中 |
这套方案最适合的场景是:需要把“随时产生的文本信息”转换成语音。典型需求包括智能家居通知、环境监测播报、定时提醒、老人看护设备、工业设备的语音告警。不适合的场景是长篇幅语音朗读(播报几分钟的文章)和对音质有专业要求的应用(比如做音乐播放器),那种场景上专用音频编解码方案更合适。
2. 接线之前的两个关键问题:电平匹配和供电
2.1 3.3V和5V的TTL电平问题
WT3000TX模块在市面上有两种常见版本,一部分是3.3V供电,电平也是3.3V TTL;另一部分做成5V供电,串口电平跟随5V。你买回的模块到底属于哪种,一定先看原理图或者问卖家,不能想当然直接接。
这里有个容易忽略的点:ESP32的大部分GPIO并不耐5V。如果你拿一个5V TTL的WT3000TX模块直接接到ESP32的RX引脚,轻则读不到数据,重则烧掉GPIO。反过来,如果模块是5V电平而ESP32是3.3V输出,模块侧也可能识别不了低电平信号,造成通信不稳定。
我自己的做法是统一用3.3V版本的模块,一来和ESP32电平匹配,二来省去一个电平转换芯片。如果你手头只有5V版本,最简单可靠的办法是在中间加一个3.3V转5V的电平转换模块,或者用MOS管搭一个双向电平转换电路。用电阻分压凑合接也能用,但信号沿会变差,高速通信下容易丢字节,不建议。
2.2 供电设计:音频功放是隐藏的电老虎
另一个容易踩的坑是供电。TTS芯片本身功耗不大,但芯片后面接的功放模块在播报的时候瞬态电流能到几百毫安甚至更高。如果你用ESP32板载的3.3V LDO直接给TTS模块和功放供电,播报时电压会瞬间跌落,轻则芯片复位从头播报,重则整板死机。
安全做法是分路供电:
- ESP32用USB/5V输入供电,板载稳压给主控自己。
- TTS芯片如果需要3.3V,单独用一个AMS1117-3.3之类的LDO从5V取电。
- 功放模块直接吃5V,别让音频功率和主控抢电。
- 所有模块的地线要共地,这是串口通信稳定的基础。
这套供电方案看起来多花了几块钱,但实际跑起来比我最初“一根线全挂板载3.3V”的方案稳定太多了。原来播报声音一大就复位,改成独立供电之后问题彻底消失。
2.3 完整接线参考
以我用的ESP32开发板和3.3V TTL电平的WT3000TX模块为例,接线如下:
| ESP32引脚 | WT3000TX模块 | 说明 |
|---|---|---|
| GPIO17 | RX(模块串口接收) | ESP32发送数据给TTS芯片 |
| GPIO16 | TX(模块串口发送) | TTS芯片返回状态给ESP32 |
| GND | GND | 必须共地 |
| 3.3V(独立LDO) | VCC | 给TTS芯片供电 |
| 5V | 功放VCC | 功放单独从5V取电 |
GPIO16和GPIO17是ESP32的UART2默认引脚,我用的是这个组合。你也可以换成其他支持任意引脚映射的UART口,但接完之后记得在代码里把引脚号对应上。
3. WiFi接入的坑:连接、重连与超时
3.1 WiFi.begin之后不能一直等
ESP32的WiFi连接不是即时的,从调用WiFi.begin到连接成功,可能需要一两秒甚至更久,取决于路由器响应速度。很多人习惯写一个while循环死等,直到WiFi.status() == WL_CONNECTED才往下走。
这个写法最大的问题是:如果路由器信号弱、密码错误、或者路由器关掉了2.4G频段,while循环会一直卡住。更糟糕的是,有些情况下卡在WiFi连接中的死循环会让看门狗超时,ESP32直接重启。
我用的方式是带超时时间的轮询:每100毫秒检查一次状态,超过10秒还没连上就放弃,先播报一句“网络连接失败”,然后进入独立的重连状态。这样设备不会因为网没连上就变成一块砖。
3.2 断线重连不能靠侥幸
WiFi路由器重启、家里断电再恢复、你拿着设备走远又走回来,这些场景都会导致WiFi断开。如果只靠ESP32上电那一次连接,后面断了就再也没机会恢复。
有三种常见的断线重连策略:
- 周期检测:在主循环里每隔几秒检查一次WiFi.status(),不是WL_CONNECTED就调用WiFi.reconnect()。
- 事件回调:用WiFi.onEvent注册WL_DISCONNECTED事件,在事件回调里触发重连。
- 组合方案:事件回调负责快速响应,周期检测作为兜底。
我的实际经验是,事件回调虽然优雅,但回调函数里不能做太多耗时操作,否则会影响协议栈工作。所以我一般是事件回调里只设置一个标志位,真正的重连动作放在主循环里做,避免在中断上下文里搞复杂逻辑。
3.3 HTTP请求要设置超时,不然卡到你怀疑人生
WiFi显示已连接,并不代表能访问外网。常见情况是路由器本身能连上,但连不上外网域名;也可能是路由器用的透明代理拨号断了。这时候如果你直接发HTTP请求,可能一直等不到响应,整个设备卡死在等待里。
我写HTTP请求的时候都会显式设置超时:
HTTPClient http; http.setTimeout(5000); // 最多等5秒另外,如果请求的是HTTPS接口,ESP32需要初始化TLS库,这个过程比较吃内存,而且对老版本的Arduino库兼容性不太好。我的建议是:如果只是做简单天气/时间播报,优先用HTTP接口,避免TLS握手带来的问题;如果一定要HTTPS,做好内存规划,并且用ESP32官方库比较新的版本。
这块我从几轮折腾里得到的体会是:智能设备“不会说话”的失败,根源往往在第一次网络交互就卡死了。把超时和重连逻辑做扎实,后面整个系统才能长期稳定跑。
4. TTS芯片通信:帧格式、文本编码与一次完整播报
4.1 串口参数和帧格式
WT3000TX这类离线TTS芯片,通信接口本质就是UART透传。初始化时注意把串口参数和芯片固件匹配上,常见的是波特率9600或115200,数据位8位,无校验位,1位停止位。
发送命令的帧格式因芯片型号而异,但通用套路是:帧头 + 数据长度 + 命令字 + 数据内容 + 校验。我在项目里先仔细读了一遍模块手册再写的代码,不同的批次固件版本都可能不一样,一定不要凭感觉照抄网上代码。我封装了一层发送函数,后续如果换芯片型号,只需要改这一层即可。
4.2 中文编码:一个隐藏的大坑
TTS芯片的文本编码通常有两种支持:GBK和UTF-8。而Arduino环境下默认的字符串常量编译后是什么编码,取决于编译器的运行环境;如果你从HTTP接口动态获取数据,返回的内容几乎总是UTF-8。
如果芯片只支持GBK而你的文本是UTF-8,直接发过去会合成乱码。花了两天时间排查才发现,中文编码不匹配才是“语音播报全是乱音”的元凶,一度还以为是硬件问题。
最简单的处理办法是买支持UTF-8输入的新型TTS芯片,省去编码转换。如果芯片只支持GBK,那就需要做一次编码转换,我写了小的转换函数,原理就是维护一张Unicode到GBK的码表,逐字符转换。注意不要用系统级的iconv,那样体积太大,ESP32放不下。
4.3 一次播报的状态流转
一次完整播报看起来只是“发一句文字给芯片”,但实际运行时要考虑芯片的处理时间。芯片收到整帧文本后,需要几百毫秒合成语音,播报期间如果又收到新数据,不同固件的处理方式也不同,有的会排队,有的直接丢弃。
我的做法是做一个简单的播报状态机:
- 空闲态:没有播报任务,可以接受新的文字。
- 发送态:把文本按帧格式打包,通过串口发给TTS芯片。
- 等待完成态:发送完成后不再发新数据,等待芯片播报结束。芯片如果有状态输出引脚,可以接一个GPIO读取播报完成信号;没有的话就根据文本长度估算一个播报时长,用延时代替。
状态机的好处是主循环不会被播报过程阻塞,中途还能继续处理网络请求和按键响应。从整体架构上看,这比“直接串口.write完就继续跑”要可靠得多。
5. 核心代码骨架:从开机到报出第一句话
5.1 工程结构和初始化流程
我的工程基于Arduino框架,因为生态好、调试方便、例程多。整个工程就一个main.cpp,通过PlatformIO管理编译,比Arduino IDE在库管理和代码跳转上舒服很多。
setup函数里做四件事:
void setup() { Serial.begin(115200); // 调试串口 ttsSerial.begin(9600, SERIAL_8N1, 16, 17); // TTS芯片串口 initWiFiWithTimeout(); // 带超时的WiFi初始化 initNTP(); // 网络时间同步 if (WiFi.status() == WL_CONNECTED) { sendTTS("网络连接成功,设备已准备就绪"); } else { sendTTS("网络连接失败,请检查路由器"); } }调试串口和TTS串口我用的是不同的UART实例,两者互不干扰。initNTP做的是从NTP服务器获取当前时间,这样后续整点报时和定时播报才有时间基准。
5.2 sendTTS发送函数的封装
发送函数是所有语音功能的地基。我封装成下面这样,后续所有播报逻辑都调用它:
void sendTTS(const char* text) { // 构造数据帧并发送,帧格式以实际芯片手册为准 uint8_t frame[256]; int len = buildTTSPacket(frame, text); // 根据手册把text打包进frame ttsSerial.write(frame, len); ttsSerial.flush(); // 简单等待芯片完成,实际项目中建议用状态引脚 delay(estimatePlaybackTime(text) + 200); }buildTTSPacket里需要注意文本帧的超长问题。实测下来,一次性发送几百字节的长文本,有些芯片会因为缓冲区溢出而直接丢弃整帧。稳妥做法是把长文本按标点符号切分成多个短句,一句一句发送,中间加一点延时。
5.3 主循环:状态机驱动,不阻塞
主循环我用了一个简单的时间片轮询结构,不在任何一点死等:
void loop() { handleWiFiReconnectIfNeeded(); // 断线检查 handleButton(); // 按键触发播报 handleSchedule(); // 整点/定时播报 handleHttpNotification(); // 拉取远程通知消息 delay(50); }这样写的好处是任一环节都不能阻塞太久。比如说拉取天气接口设置了5秒超时,那这个5秒只是循环里的一个片段,不会影响其他任务的响应。实测中整个设备跑了好几周,没有出现过需要拔电重启的情况。
6. 实测中踩过的四个坑与完整排查链路
6.1 播报内容只有第一句,后面全部哑火
现象:设备上电后播报了第一句“网络连接成功”,之后就怎么触发都没有声音。
排查过程:我先加了调试串口打印,发现触发播报的函数确实被调用了,sendTTS也执行了。再检查串口波形,发现第一帧发出去之后,第二帧就没有正常到达芯片侧。用逻辑分析仪抓TXD引脚,发现第二帧的数据明显比第一帧短,像是被截断了。
根因:这是一个典型的帧发送太快导致的丢帧问题。第一帧发送完,底层没有等待足够的时间让芯片处理,第二帧紧接着到达,芯片还没从合成状态恢复过来,数据被丢弃。
解决:在sendTTS函数末尾增加300毫秒的间隔,同时尽量做到“一条播报发完再发下一条”,不做并发。改完以后哑火现象消失。
6.2 WiFi断开后设备变“砖”
现象:断电重启、路由器重启后,设备无法自动恢复联网,播报功能也失去响应。
排查过程:开始以为是WiFi模块坏了,后来打印日志发现设备一直卡在一个重连的死循环里。代码里写的逻辑是“断开就while循环重连”,一旦路由器没有重新启动,设备就在循环里空转,主循环完全被占死,播报函数根本没有机会执行。
根因:死循环重连把整个系统拖进了阻塞状态。WiFi是否连接和语音播报本来应该是两个独立的关注点,不该互相滞后。
解决:改用前面提到的“标志位+主循环检测”方案,重连动作每次最多持续一个时间片,如果没连上,先播报“网络未连接”,让用户知道设备还活着。改完之后,哪怕断网三天,设备也会安静地尝试重连,不会死机。
6.3 播报时有“嘶嘶”底噪
现象:语音播报时,背景有持续的沙沙声,听久了耳朵累。
排查过程:一开始怀疑是TTS芯片输出信号本身质量差,后来把喇叭拔掉,底噪还在;用示波器查电源轨,发现播报瞬间3.3V电源上有明显的纹波抖动,约等于200mV的噪声。
根因:音频电路的电源没做干净。TTS芯片和功放的电源线从ESP32板载LDO出来,和主控电路共用一条路径,数字信号和模拟音频信号在电源上互相干扰。
解决:重新按第二章的供电思路改板,TTS芯片独立供电,功放直接吃5V并用一个大容量的电解电容储能,彻底分隔模拟地和数字地,底噪降到几乎听不见。
6.4 发送长字符串时,芯片完全无响应
现象:播报超过一百个字的文本时,芯片没反应,调试串口也没有报错。
排查过程:逐段缩减文本长度,发现缩到80字以内就能播报,超过80字就完全没声音。进一步分析,问题出在我一次性把整帧下发,芯片串口接收缓冲区装不下,导致整帧被丢弃。
根因:串口缓冲区长度有限,长文本一次性发送超过芯片处理上限。
解决:在sendTTS里增加分片逻辑,按句号、逗号把长文本切成小段,每段不超过50字,逐段发送并加短暂延时。这样一来,再长的文本也能稳定播报。
| 现象 | 根因 | 解法 |
|---|---|---|
| 只播第一句,后续哑火 | 帧间隔太短,芯片未就绪 | 增加300ms间隔,串行播报 |
| WiFi断开后死机 | 死循环重连占死主循环 | 改标志位+主循环检测 |
| 播报有底噪 | 电源纹波干扰音频信号 | 独立供电,模拟数字地分离 |
| 长文本不播报 | 超过串口缓冲区 | 按标点分片发送 |
7. 从一个语音播报节点到整套通知系统
做完基础的WiFi+TTS之后,我马上开始琢磨这套东西还能延伸到哪里。几个实际的扩展方向对大家有参考价值:
7.1 接入MQTT,让服务器主动推送播报
HTTP轮询的缺点是实时性差,而且每几秒请求一次对服务器不算友好。改用MQTT协议后,服务器可以随时推送消息给ESP32,设备收到消息就调用sendTTS播报。这个非常适合做家庭告警系统:比如家里烟雾传感器报警,服务端推一条消息到设备,立刻语音播报“厨房烟雾浓度过高”。
MQTT接入在ESP32上的做法很成熟,使用PubSubClient库,只需要指定broker地址、主题、订阅回调,在回调函数里调用sendTTS即可。注意回调函数运行在协议栈上下文,不要在回调里直接做长延时,把播报内容放到一个队列里,由主循环去消费。
7.2 整点报时与定时提醒
用NTP同步完时间后,设备就具备了本地时钟。在主循环里判断当前分钟数,每到整点触发一次播报:“现在是上午九点整”。再配合一个简单的定时任务表,可以做成吃药提醒、起床提醒、浇花提醒。这里的关键点是时间逻辑要写在主循环里而不是用delay嵌套,避免设备挂起期间错过提醒。
7.3 接入温湿度、门磁传感器做本地联动
给ESP32接一个DHT22温湿度传感器,每五分钟播报一次室内温湿度;再接一个门窗磁传感器,门开的时候播报“大门已打开”。这些功能本质上是把“联网获取远程信息”和“本地采集信息”统一到同一个播报通道里,不需要额外硬件,加传感器和判断逻辑就行。
从我个人实际使用的角度说,这套方案最值得推荐的地方是它的稳定性和极低的维护成本。系统跑起来以后,基本是“拧上电就不管了”。唯一需要长期关注的就是供电质量和路由器的2.4G频段设置,这两点做好了,设备可以连续运行几个月不重启。
如果你计划复刻这个项目,我的建议是:第一,先把你手上的TTS芯片手册完整读一遍,确认串口波特率和帧格式,再开始写sendTTS函数;第二,接线时优先解决电平匹配和电源分离,硬件基础不牢,后面软件怎么调都白搭;第三,代码结构不要用一堆delay串,尽量用状态机驱动,这样你后续加功能会轻松很多。做好这三点,你也能在半天内跑出一个会开口说话的智能通知设备。