|
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 卡反复读写的延迟抖动。
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 鉴权。获取步骤如下:
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 中的编译期默认值。 4.3 WiFi 事件驱动重连 借鉴了 Internet_Radio_Share 项目的 WiFi 事件回调模式。通过 WiFi.onEvent() 注册全局事件处理器:
关键的 g_wifiRestarting 互斥标志解决了 loop() 中 10 秒定时重连与 WiFi.begin() 异步操作的竞争条件——这是 ESP-IDF 底层明确禁止的操作(sta is connecting, return error)。 4.4 mDNS 与 NTPStation 模式连接成功后,启动 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 不允许两个通道同时以不同方向打开同一引脚组。本项目的解决方案是动态生命周期管理:
每次创建时重新配置为标准 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重置 │ 底部按键提示栏└──────────────────────────┘设计原则:
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);}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);8.3 内存预算 在 8MB PSRAM + 328KB 片内 SRAM 的约束下,内存分配策略如下:
实际运行中,空闲内存始终保持在 200KB 以上,为 WiFi 协议栈和 WebSocket 长连接留有充裕余量。 9. 总结 K10 百炼 AI 语音助手是一个完整的嵌入式 AI 交互系统,验证了以下技术命题:
本项目可作为 ESP32 生态中语音交互产品的参考实现,尤其适用于智能家居、教育机器人、无障碍辅助设备等场景。 10.项目分享烧录即用1. 固件分享链接: http://cloud.189.cn/t/3I7JJfIBNVVj(访问码:n9db)
|
沪公网安备31011502402448© 2013-2026 Comsenz Inc. Powered by Discuz! X3.4 Licensed