17浏览
查看: 17|回复: 0

[K10项目分享] K10 百炼 AI 语音助手 从网络配置到全链路语音交互的嵌...

[复制链接]
本帖最后由 topdog 于 2026-6-14 03:44 编辑

摘要

本文介绍一个运行在 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
PSRAM8MB OPITTS 音频缓冲,JSON 解析
Flash16MB固件 + NVS 配置 + LittleFS
录音 ADCES7243E (I2C 0x11)16kHz / 16bit 立体声 ADC
播放功放NS4168 (I2S 输入)3W D 类功放
IO 扩展XL9535 (I2C 0x20)按键 + 功放使能
显示屏ST7701 240×320LovyanGFX 驱动,状态/对话展示
存储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-realtimeFun-ASR 实时流式识别
调用协议WebSocket(全双工长连接)发送音频 BIN 帧 / 接收识别 TEXT 帧
连接地址wss://dashscope.aliyuncs.com/api-ws/v1/inference/华北2(北京)地域端点
鉴权方式Header Authorization: bearer sk-xxxWebSocket 连接时设置
音频要求16kHz / 16bit / 单声道 / PCM原始无头音频数据
支持方言普通话、粤语、四川话等自动语种检测

在控制台找到此 API:百炼控制台 → 顶部导航「模型广场」→ 在模型列表中找到「实时语音识别」(Fun-ASR)→ 点击进入可查看模型详情、完整 API 文档和 Python / Java 示例代码。

官方文档直达:实时语音识别 — 阿里云帮助中心

代码中通过 WebSocket 连接后,先发送一条 JSON 握手消息(指定 model、format、sample_rate),随后以 BIN 帧持续发送 PCM 音频块。服务端以 TEXT 帧(JSON)异步回调识别结果:text 字段为部分识别文本,sentence_end 标记句子结束。

3.3 LLM:大语言模型 API配置项值说明
模型名称qwen-turbo通义千问 Turbo,低成本高并发
调用协议HTTP POST(请求-响应短连接)发送 JSON / 接收 JSON
请求地址https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generationOpenAI 兼容格式
鉴权方式Header Authorization: Bearer sk-xxx与 ASR / TTS 共用同一个 Key
对话记忆最近 2000 字符,通过 history.txt 持久化启动时自动加载
可选模型qwen-plus(均衡)、qwen-max(最强)按需换用,API 地址不变

在控制台找到此 API:百炼控制台 → 顶部导航「模型广场」→ 在「文本生成」分类下找到「通义千问-Turbo」→ 点击进入查看模型详情、计费说明和 curl / Python / Java 示例代码。也可在左侧导航栏「API 参考」→「文本生成」中直接查看完整的请求/响应 JSON 格式规范。

官方文档直达:文本生成模型 API 参考 — 阿里云帮助中心

请求体为标准 JSON:指定 model(模型名)、input.messages(role + content 数组,system / user / assistant)。本项目将 history.txt 中的最近对话拼接到 messages 数组的前部,作为上下文传入,实现跨重启的连续对话。

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 实时流式调用的示例代码。也可在左侧导航栏「API 参考」→「语音合成」查看完整规范。

官方文档直达:语音合成文档 — 阿里云帮助中心

WebSocket 连接后发送 JSON 握手消息,指定 model、text(要合成的文本)、voice(音色名)、format(输出 wav)。服务端以 BIN 帧流式推送 WAV 音频数据。本项目将所有 BIN 帧直接拼接到 PSRAM 缓冲区,连帧完成后一次性通过 I2S TX 播放,避免 SD 卡反复读写的延迟抖动。

3.4.1 更换音色(改变声音角色)

两种方式,推荐第一种。

方式一:网页配网页面直接改(免编译)。连接 K10 的 WiFi 热点 K10-Config(或已配网后的局域网 IP),浏览器打开 192.168.4.1,在配网页面中直接修改「TTS 音色」字段,填入想要的音色名,点「保存并连接」即可。音色配置存储在 NVS 中,断电不丢失。下次开机自动加载,无需重新编译。

方式二:修改 config.h 中的默认值(影响首次烧录和新设备出厂默认音色):

#define TTS_VOICE "longdaiyu_v3"

把引号里的音色名换成你想要的,重新编译烧录后生效。此值仅在 NVS 为空(首次烧录或恢复出厂)时作为默认值,后续以网页配置为准。

代码中无论是网页配置还是默认值,都会映射到 voice_map 多音色选择表,支持 longxiaochun、Cherry、longanyang、longdaiyu_v3 等。switchVoice() 和 setVoiceID() 函数维护了当前音色和映射索引,方便程序化切换。

ASR 和 TTS 共用同一个 WebSocket 路径,仅通过握手 JSON 中的 model 字段区分服务。


4. 网络配置策略4.1 双模式 WiFi

设备启动时首先尝试以 Station 模式连接已保存的 WiFi。连接超时设为 15 秒——在用户体验和网络容错之间取得平衡。失败时并非简单地报错退出,而是自动降级为 AP 模式,广播 SSID K10-Config,启动嵌入式 Web 配网服务。用户手机连接该热点后,浏览器打开 192.168.4.1 即可完成配网。

WiFi 连接失败诊断:通过对 WiFi.status() 返回值的精确判断,向串口和屏幕输出具体失败原因——

状态码含义用户提示
WL_NO_SSID_AVAILSSID 不存在找不到该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;}String utf8Substring(const String& str, int maxChars) {  int count = 0, i = 0;  while (i < str.length() && count < maxChars) {    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 str.substring(0, i);}

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 超过实际缓冲区剩余长度时,自动使用实际大小:

uint32_t pos = 12;  // 跳过 RIFF/WAVE 头while (pos + 8 <= length) {  uint32_t chunkId   = *(uint32_t*)(data + pos);  uint32_t chunkSize = *(uint32_t*)(data + pos + 4);  if (chunkId == 0x20746D66) { /* "fmt " — 解析格式 */ }  else if (chunkId == 0x61746164) { /* "data" — 记录偏移和大小 */ break; }  pos += 8 + chunkSize;  if (chunkSize % 2 != 0) pos++;  // WAV chunk 对齐}if (dataSize > length - dataOffset) {  dataSize = length - dataOffset;  // 占位值回退}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 头供播放时解析格式参数:

if (ttsAudioSize > 0 && memcmp(data, "RIFF", 4) == 0 && memcmp(data + 8, "WAVE", 4) == 0) {  // 后续 WAV 片段:剥离头部,只保留 PCM  uint32_t pos = 12;  while (pos + 8 <= length) {    uint32_t chunkId   = *(uint32_t*)(data + pos);    uint32_t chunkSize = *(uint32_t*)(data + pos + 4);    if (chunkId == 0x61746164) {  // "data"      writeOffset = pos + 8;      writeLength = length - writeOffset;      break;    }    pos += 8 + chunkSize;  }}memcpy(ttsAudioBuffer + ttsAudioSize, data + writeOffset, writeLength);

修复效果:三处修复协同作用——文本截断修复确保 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
AI写代码
(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 键开始对话。



阿凡提.jpg
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

为本项目制作心愿单
购买心愿单
心愿单 编辑
[[wsData.name]]

硬件清单

  • [[d.name]]
btnicon
我也要做!
点击进入购买页面
上海智位机器人股份有限公司 沪ICP备09038501号-4 备案 沪公网安备31011502402448

© 2013-2026 Comsenz Inc. Powered by Discuz! X3.4 Licensed

mail