摘要
本文介绍一个运行在 DFRobot 行空板 K10 上的全链路 AI 语音助手系统。用户按住 A 键说话,设备通过 I2S 接口采集 16kHz PCM 音频,经 WebSocket 实时上传至阿里云百炼 ASR 引擎进行语音识别;识别结果送入通义千问大语言模型(LLM)生成回复文本;再经由百炼 TTS 引擎合成为 WAV 音频流,最终通过 I2S TX 通道驱动 NS4168 功放播报。全程无需手机或云端中转------ESP32-S3 一颗芯片完成从拾音到播报的全部链路。
本文将从硬件架构、API 获取、网络配置策略、音频驱动设计、Web 配网机制、LCD 交互界面五个维度展开分析,重点阐释在资源受限的嵌入式平台上实现稳定网络通信与实时音频处理的关键技术决策。
1. 硬件平台
| 组件 | 型号/规格 | 用途 |
|---|---|---|
| 主控 | ESP32-S3 (双核 240MHz) | 系统核心,WiFi/BT,音频 I2S |
| PSRAM | 8MB OPI | TTS 音频缓冲,JSON 解析 |
| Flash | 16MB | 固件 + NVS 配置 + LittleFS |
| 录音 ADC | ES7243E (I2C 0x11) | 16kHz / 16bit 立体声 ADC |
| 播放功放 | NS4168 (I2S 输入) | 3W D 类功放 |
| IO 扩展 | XL9535 (I2C 0x20) | 按键 + 功放使能 |
| 显示屏 | ST7701 240×320 | LovyanGFX 驱动,状态/对话展示 |
| 存储 | microSD (SPI) | 录音 PCM + TTS WAV + 对话历史 |
K10 的硬件组合为语音交互提供了完整的信号链:ES7243E 的 100dB SNR 保证了前端拾音质量;ESP32-S3 的双核架构允许音频 I2S 操作与 WiFi 协议栈并行运行;8MB PSRAM 使得 TTS 返回的 WAV 音频可以在内存中完整缓存后一次性播放,避免 SD 卡反复读写的延迟抖动。
关键决策 :本项目完全剥离了对
AudioTools等第三方音频框架的依赖,转而直接操控 ESP-IDF 原生driver/i2s_std.h接口。原因有三:(1) 第三方库的内部缓冲策略与硬件 DMA 不匹配,导致爆音;(2) 其软件音量控制路径引入额外延迟;(3) I2S RX 与 TX 通道在同一引脚组上分时复用时,框架层的生命周期管理不够精细,容易产生底层冲突。
2. 系统工作流
┌──────────┐ 按A ┌───────────┐ 松A ┌──────────────┐
│ STATE_IDLE │ ────────▶ │STATE_RECORDING│ ────────▶ │STATE_ASR_WAITING│
│ 待机 │ │ 录音+PCM写SD │ │ WebSocket ASR │
└──────────┘ └───────────┘ └──────┬─────┘
▲ │ 识别结果
│ ┌────────▼────────┐
│ 播放完成 │STATE_LLM_THINKING│
┌────┴─────────┐ │ HTTP→通义千问 │
│STATE_TTS_PLAYING│ └────────┬────────┘
│ I2S TX 播WAV │ │ AI回复
└────────▲───────┘ ┌───────────▼──────────┐
│ TTS音频数据 │STATE_LLM_RESPONDING │
└────────────────────────────────│ WebSocket TTS合成 │
└─────────────────────┘
状态机设计的核心考量是异步非阻塞 。ASR 和 TTS 均为 WebSocket 长连接,音频数据以 BIN 帧流式传输,识别结果和合成进度以 TEXT 帧(JSON)异步回调。loop() 中以 10ms 粒度轮询两个 socket 的事件队列,主线程永不阻塞在某一个网络操作上。
录音阶段采用环形缓冲 + 分批写 SD 的策略:I2S RX 以 2048 字节为单位 DMA 搬运,每攒够 1024 字节即写入 SD 卡,避免 PSRAM 被原始音频占满。单次录音上限 30 秒,约 960KB PCM 数据。
3. API 获取指南
3.1 第一步:获取百炼 API Key
三个 API(ASR、LLM、TTS)共享同一个 API Key 鉴权。获取步骤如下:
- 浏览器打开 阿里云百炼控制台 (
bailian.console.aliyun.com),使用阿里云账号登录。无账号需先注册,支持支付宝实名认证。 - 在页面右上角 确认当前地域为「华北2(北京)」------本项目默认使用北京地域的 API 端点。
- 点击左侧导航栏中的 「API Key」,进入 API Key 管理页面。
- 点击 「创建 API Key」。弹窗中:归属业务空间选「默认业务空间」,权限选「全部」。(如需 IP 白名单限制,可选「自定义」并配置允许的 IP 地址/网段。)
- 点击「确定」后,立即复制 弹出窗口中显示的
sk-开头的密钥------关闭弹窗后将无法再次查看完整的 API Key。 - 将复制的 API Key 填入项目
config.h中的#define DEFAULT_API_KEY "sk-xxx"字段,替换默认值。
地域差异 :华北2(北京)和新加坡、美国(弗吉尼亚)、德国(法兰克福)等海外地域的 API Key 不通用。如果切换地域,需要在对应地域的控制台重新创建 API Key,代码中的 API 端点地址也不同(详见官方文档各 Tab 切换)。
3.2 ASR:实时语音识别 API
| 配置项 | 值 | 说明 |
|---|---|---|
| 模型名称 | fun-asr-realtime |
Fun-ASR 实时流式识别 |
| 调用协议 | WebSocket(全双工长连接) | 发送音频 BIN 帧 / 接收识别 TEXT 帧 |
| 连接地址 | wss://dashscope.aliyuncs.com/api-ws/v1/inference/ |
华北2(北京)地域端点 |
| 鉴权方式 | Header Authorization: bearer sk-xxx |
WebSocket 连接时设置 |
| 音频要求 | 16kHz / 16bit / 单声道 / PCM | 原始无头音频数据 |
| 支持方言 | 普通话、粤语、四川话等 | 自动语种检测 |
在控制台找到此 API :百炼控制台 → 顶部导航「模型广场 」→ 在模型列表中找到「实时语音识别」(Fun-ASR)→ 点击进入可查看模型详情、完整 API 文档和 Python / Java 示例代码。
3.3 LLM:大语言模型 API
| 配置项 | 值 | 说明 |
|---|---|---|
| 模型名称 | qwen-turbo |
通义千问 Turbo,低成本高并发 |
| 调用协议 | HTTP POST(请求-响应短连接) | 发送 JSON / 接收 JSON |
| 请求地址 | https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation |
OpenAI 兼容格式 |
| 鉴权方式 | Header Authorization: Bearer sk-xxx |
与 ASR / TTS 共用同一个 Key |
| 对话记忆 | 最近 2000 字符,通过 history.txt 持久化 |
启动时自动加载 |
| 可选模型 | qwen-plus(均衡)、qwen-max(最强) |
按需换用,API 地址不变 |
在控制台找到此 API :百炼控制台 → 顶部导航「模型广场 」→ 在「文本生成」分类下找到「通义千问-Turbo」→ 点击进入查看模型详情、计费说明和 curl / Python / Java 示例代码。
3.4 TTS:语音合成 API
| 配置项 | 值 | 说明 |
|---|---|---|
| 模型名称 | cosyvoice-v1(默认) |
CosyVoice 系列,支持多种音色 |
| 调用协议 | WebSocket(流式) | 发送合成请求 / 接收 WAV 音频 BIN 帧 |
| 连接地址 | wss://dashscope.aliyuncs.com/api-ws/v1/inference/ |
与 ASR 共用同一路径 |
| 鉴权方式 | Header Authorization: bearer sk-xxx |
与 ASR / LLM 共用同一个 Key |
| 默认音色 | longxiaochun |
可换:Cherry、longanyang 等 |
| 输出格式 | WAV(16kHz / 16bit / 单声道) | 通过 WebSocket BIN 帧流式推送 |
在控制台找到此 API :百炼控制台 → 顶部导航「模型广场 」→ 找到「语音合成」(CosyVoice / Qwen-TTS 系列)→ 点击进入可查看全部可用音色列表、试听示例和 WebSocket 实时流式调用的示例代码。
3.4.1 更换音色(改变声音角色)
两种方式,推荐第一种。
方式一:网页配网页面直接改(免编译) 。连接 K10 的 WiFi 热点 K10-Config(或已配网后的局域网 IP),浏览器打开 192.168.4.1,在配网页面中直接修改「TTS 音色」字段,填入想要的音色名,点「保存并连接」即可。音色配置存储在 NVS 中,断电不丢失。下次开机自动加载,无需重新编译。
方式二:修改 config.h 中的默认值(影响首次烧录和新设备出厂默认音色):
#define TTS_VOICE "longdaiyu_v3"
把引号里的音色名换成你想要的,重新编译烧录后生效。此值仅在 NVS 为空(首次烧录或恢复出厂)时作为默认值,后续以网页配置为准。
4. 网络配置策略
4.1 双模式 WiFi
设备启动时首先尝试以 Station 模式连接已保存的 WiFi。连接超时设为 15 秒------在用户体验和网络容错之间取得平衡。失败时并非简单地报错退出,而是自动降级为 AP 模式 ,广播 SSID K10-Config,启动嵌入式 Web 配网服务。用户手机连接该热点后,浏览器打开 192.168.4.1 即可完成配网。
WiFi 连接失败诊断 :通过对 WiFi.status() 返回值的精确判断,向串口和屏幕输出具体失败原因------
| 状态码 | 含义 | 用户提示 |
|---|---|---|
| WL_NO_SSID_AVAIL | SSID 不存在 | 找不到该WiFi |
| WL_CONNECT_FAILED | 密码错误 | 密码错误 |
| WL_DISCONNECTED | 信号弱断开 | 信号丢失 |
4.2 Web 配网与 NVS 持久化
配网页面的设计遵循 零 JavaScript 依赖的 form POST 模式 ------这是从同类 K10 网络收音机项目借鉴的成熟策略。纯 HTML <form action="/save" method="POST"> 提交,不依赖异步 fetch 或 JSON 解析,最大程度降低浏览器兼容风险。三个配置字段:WiFi 名称、WiFi 密码、百炼 API Key。
配置数据通过 ESP32 的 NVS(Non-Volatile Storage)持久化。NVS 基于 Flash 的键值存储,写入后断电不丢失。启动时 loadConfig() 优先从 NVS 读取;若 NVS 为空(首次烧录或恢复出厂),回退到 config.h 中的编译期默认值。
设计权衡:为什么不用 WiFiManager 库?WiFiManager 需要在 AP 模式下运行完整的 Captive Portal + DNS 劫持,代码体积大(约 150KB),且与 WebSocket 长连接库存在端口冲突。本项目的自研 Web 配网仅约 3KB 嵌入式 HTML + 200 行 C++ 处理逻辑,更适合资源受限场景。
4.3 WiFi 事件驱动重连
借鉴了 Internet_Radio_Share 项目的 WiFi 事件回调模式。通过 WiFi.onEvent() 注册全局事件处理器:
- STA_GOT_IP:STA 连接成功时,自动关闭 AP 模式、重启 Web 服务到新 IP、初始化百炼 ASR socket。
- STA_DISCONNECTED :非配网模式下断连,自动调用
WiFi.reconnect()。
关键的 g_wifiRestarting 互斥标志解决了 loop() 中 10 秒定时重连与 WiFi.begin() 异步操作的竞争条件------这是 ESP-IDF 底层明确禁止的操作(sta is connecting, return error)。
4.4 mDNS 与 NTP
Station 模式连接成功后,启动 mDNS 广播 k10-assistant.local------局域网内任何支持 Bonjour/Avahi 的设备均可通过该域名访问配网页面。NTP 时间同步设置了 5 秒超时保护,避免无互联网环境下的启动阻塞。
5. 音频驱动架构
5.1 I2S 通道的"用完即弃"策略
I2S RX(录音)和 I2S TX(播放)共享同一组物理引脚(BCLK=0, WS=38, DIN=39, DOUT=45, MCLK=3)。ESP-IDF 不允许两个通道同时以不同方向打开同一引脚组。本项目的解决方案是动态生命周期管理:
- 录音前:
i2s_new_channel()创建 RX 通道 → 录音 →i2s_del_channel()销毁 - 播放前:
i2s_new_channel()创建 TX 通道 → 播放 →i2s_del_channel()销毁
每次创建时重新配置为标准 Philips 格式、16bit 位宽、采样率自适应(录音 16kHz,播放按 TTS 返回的 WAV 头解析)。"用完即弃"消除了通道复用的底层冲突,代价是每次 new/del 约 2ms 的开销------对用户体验无感知。
5.2 软件增益与音质优化
NS4168 功放本身不提供音量寄存器。本项目的做法是在 PCM 样本写入 I2S TX 之前,直接操控 int16_t 样本数组:
for (size_t i = 0; i < sampleCount; i++) {
int32_t amplified = samples[i] * 2; // 6dB 软件增益
samples[i] = (int16_t)constrain(amplified, -32768, 32767); // 硬限幅防破音
}
这一简单的技巧将原本在安静环境下几乎听不清的 TTS 语音提升到了洪亮清晰的商用级别,是音质体验的"点睛之笔"。
6. LCD 交互界面设计
K10 的 2.8 英寸 240×320 屏幕被设计为三区布局:
┌──────────────────────────┐
│ ● 按住A说话 │ 状态指示行(图标 + 文字)
├──────────────────────────┤
│ 问: [用户语音识别文字] │ 绿色圆角卡片
├──────────────────────────┤
│ │
│ 答: [AI 回复内容] │ 蓝色圆角卡片(自适应高度)
│ │
│ │
├──────────────────────────┤
│ 按住A说话 按住B重置 │ 底部按键提示栏
└──────────────────────────┘
设计原则:
- 标题和 IP 信息仅在启动阶段显示------一旦进入交互状态,全屏让位给对话内容。
- 状态图标编码:●(红色/录音)、◉(橙色/识别中)、◎(橙色/思考中)、▶(绿色/播放中)、○(灰色/待机)------无需文字即可快速判断当前阶段。
- 底部固定提示:与顶部动态状态互补,提供永久性的操作指引。
drawChineseWrap():利用 LovyanGFX 的中文自动换行,AI 回复最长可填充约 280 像素高度。
7. 会话管理
SD 卡上按日期组织会话文件夹:
/session_20260613/
├── history.txt # 对话历史(含时间戳)
├── rec_001.pcm # 第1次录音(16kHz 16bit mono PCM)
├── tts_001.wav # 第1次TTS合成音频
├── rec_002.pcm
├── tts_002.wav
└── ...
对话历史以纯文本格式追加写入 history.txt,格式为:
[用户 143015] 今天天气怎么样
[助手 143022] 抱歉,我无法获取实时天气信息...
启动时从文件加载最近 2000 字符的对话历史到内存,LLM 请求时将历史作为上下文拼接到 prompt 前缀,实现跨重启的对话记忆。此设计避免了 NVS 的字符串长度限制(单条最大 1984 字节),适合任意长度的对话记录。
8. 技术亮点与心得
8.1 全链路延迟分析
| 阶段 | 耗时(典型值) | 瓶颈 |
|---|---|---|
| 录音 | 用户说话时长 | 按键操作 |
| ASR 识别 | 0.5 ~ 2.0s | 网络延迟 + 音频大小 |
| LLM 推理 | 1.0 ~ 5.0s | 模型响应时间 |
| TTS 合成 | 0.5 ~ 2.0s | 文本长度 |
| WAV 播放 | 回复语音时长 | I2S 速率 |
| 端到端总计 | 3 ~ 12s | 取决于 LLM 回复长度 |
8.2 TTS 流式音频拼接与文本截断修复
在实际测试中,TTS 语音播报出现了内容不完整的问题------例如"遵医嘱用药"只播报到"遵"就戛然而止。排查后发现三个相互叠加的根因,逐一修复后问题彻底解决。
8.2.1 文本截断:字节数 vs 字符数
原始代码中 sendTTSText() 使用 textToSend.length() > 200 做截断保护。但 Arduino String::length() 返回的是字节数而非字符数。UTF-8 编码下每个中文字符占 3 字节,因此 200 字节仅能容纳约 66 个中文字符------远低于预期。当 LLM 返回较长的回复时,文本被过早截断,TTS 自然只合成了不完整的内容。
修复方案:新增 utf8CharCount() 和 utf8Substring() 两个工具函数,按 UTF-8 字符边界正确计数和截断,上限提升至 500 字符:
int utf8CharCount(const String& str) {
int count = 0, i = 0;
while (i < str.length()) {
uint8_t c = str[i];
if (c < 0x80) i += 1;
else if ((c & 0xE0) == 0xC0) i += 2;
else if ((c & 0xF0) == 0xE0) i += 3; // 中文
else if ((c & 0xF8) == 0xF0) i += 4;
else { i++; continue; }
count++;
}
return count;
}
UTF-8 截断陷阱 :绝不能使用
String.substring(0, 200)截断含中文的字符串------如果截断位置恰好落在某个 3 字节 UTF-8 字符的中间,会产生非法字节序列,轻则乱码,重则导致 LovyanGFX 的lcd.print()内存指针崩溃并重启 ESP32。上述utf8Substring()严格按字符边界推进,保证截断结果始终是合法的 UTF-8。
8.2.2 WAV 头 dataSize 占位值
百炼 TTS 以 WebSocket BIN 帧流式推送音频数据。第一个 BIN 帧包含完整的 WAV 头(RIFF/fmt/data),但流式传输时服务端无法预知总数据量,data chunk 的 size 字段可能填入占位值(如 0x7FFFFFEB,约 2GB)。原始代码直接读取该值作为播放数据大小,导致日志中出现异常的 数据大小: 2147483547。
修复方案:WAV 解析改为动态 chunk 扫描 ,逐个遍历 RIFF 容器内的子 chunk,找到 fmt 和 data。同时增加异常值回退保护------当 dataSize 超过实际缓冲区剩余长度时,自动使用实际大小。
8.2.3 流式多 WAV 片段拼接
百炼 TTS 的每个 sentence 可能返回独立的 WAV 片段(各自带完整 RIFF/WAVE/fmt/data 头)。原始 appendTTSAudio() 将所有 BIN 帧简单 memcpy 拼接到同一个 PSRAM 缓冲区,导致拼接后的数据结构为:
[WAV头1 + PCM数据1] + [WAV头2 + PCM数据2] + ...
播放时只解析第一个 WAV 头,后续的 WAV 头(44+ 字节)被当作 PCM 音频数据播放,产生短暂噪音脉冲,并使后续音频的采样点偏移,导致内容听不清或"不完整"。
修复方案:在 appendTTSAudio() 中检测后续 BIN 帧是否以 RIFF 开头------如果是,则扫描找到 data chunk,剥离 WAV 头,只保留纯 PCM 数据拼接到缓冲区。第一个片段保留 WAV 头供播放时解析格式参数。
修复效果:三处修复协同作用------文本截断修复确保 LLM 回复完整传入 TTS;WAV 解析修复消除了 dataSize 占位值导致的异常;流式拼接修复确保多 sentence 音频无缝衔接。修复后 TTS 语音可完整播报全部内容,不再出现中途截断或杂音。
8.3 内存预算
在 8MB PSRAM + 328KB 片内 SRAM 的约束下,内存分配策略如下:
- TTS 音频缓冲:
ps_malloc(51200)初始 50KB,按需realloc扩容。 - JSON 文档:ArduinoJson
DynamicJsonDocument按需分配,栈上分配 8KB 临时缓冲。 - 录音缓冲:2048 字节栈数组,直接 DMA 搬运后立即写 SD,不堆积在内存中。
- 对话历史:2000 字字符串,约 6KB。
实际运行中,空闲内存始终保持在 200KB 以上,为 WiFi 协议栈和 WebSocket 长连接留有充裕余量。
9. 总结
K10 百炼 AI 语音助手是一个完整的嵌入式 AI 交互系统,验证了以下技术命题:
- ESP32-S3 单芯片足以承载全链路语音交互------录音、网络传输、AI 推理(云端)、语音合成播放,均在一颗 240MHz 双核 MCU 上完成。
- 原生 I2S 驱动替代第三方音频框架------在资源受限场景下,直接操控硬件抽象层比引入臃肿的通用库更可靠、更高效。
- WiFi 双模式 + 事件驱动重连 + Web 配网------兼顾了初次配置的便捷性、运行时的鲁棒性和断电后的持久性。
- 简单的软件增益即可显著提升 TTS 音质------无需额外硬件或复杂 DSP 算法。
本项目可作为 ESP32 生态中语音交互产品的参考实现,尤其适用于智能家居、教育机器人、无障碍辅助设备等场景。
10.项目分享烧录即用
1. 固件分享链接: http://cloud.189.cn/t/3I7JJfIBNVVj(访问码:n9db)
2. 下载 k10_ws_ai_assistant_merged.bin(16MB),用 esptool 写入地址 0x0:
esptool.py --chip esp32s3 --port COM7 --baud 921600 write_flash 0x0 k10_ws_ai_assistant_merged.bin
(COM7 替换为你的实际端口,设备管理器 → 端口 查看)
3. 也可用 ESP Flash Download Tool(Windows GUI):
选 ESP32-S3,地址填 0x0,加载 merged.bin,点击 START 烧录。
4. 开机即用:
手机连热点 K10-Config → 浏览器打开 192.168.4.1 → 输入 WiFi 和百炼 API Key → 保存 → 按住 A 键开始对话。
