# hw_service_go 本地硬件通讯测试计划 > 目标:用浏览器模拟 ESP32 硬件,验证 `hw_service_go` WebSocket 服务能否正常接收指令、获取故事、推送 Opus 音频。 --- ## 一、协议对比分析 ### 1.1 小智(xiaozhi-server)vs 我们的服务 | 维度 | xiaozhi-server | hw_service_go(本服务) | |------|---------------|------------------------| | **WebSocket URL** | `ws://host:port/xiaozhi/v1/?device-id=&client-id=` | 完全相同 | | **连接参数** | `device-id`(MAC)、`client-id`(UUID)| 完全相同 | | **握手消息** | 需要发送 `hello` JSON | **不需要**,连上即用 | | **触发指令** | `listen`(语音输入) | **只需发 `{"type":"story"}`** | | **音频方向** | 双向(硬件上传语音 + 服务下发 TTS)| **单向下行**(服务→硬件,推 Opus) | | **Opus 编解码** | 需要编码(麦克风)+ 解码(播放)| **只需解码**(浏览器只播放) | | **认证** | token 参数 | **无需认证**(仅 device-id 校验) | | **消息复杂度** | hello/listen/stt/llm/tts/mcp | **只有 tts 系列** | ### 1.2 我们服务的完整消息流 ``` 浏览器(模拟硬件) hw_service_go Django │ │ │ │── WS 连接 ──────────────────────────→│ │ │ ?device-id=AA:BB:CC:DD:EE:FF │ │ │ &client-id=test-001 │ │ │ │ │ │── {"type":"story"} ────────────────→ │ │ │ │── GET /api/v1/devices/ │ │ │ stories/?mac_address │ │ │ =AA:BB:CC:DD:EE:FF → │ │ │ │ │← {"type":"tts","state":"start"} ───── │ │ │ │← {title, audio_url} ── │ │ │ │ │ │── 下载 MP3 ──────────→ CDN │ │← MP3 二进制流 ─────── │ │ │ │ │ │ ffmpeg 转码 PCM→Opus │ │ │ │ │← {"type":"tts","state":"sentence_start","text":"故事标题"} ─── │ │ │ │ │← [Opus帧1 二进制] ─────────────────── │ 60ms/帧,前3帧预缓冲 │ │← [Opus帧2 二进制] ─────────────────── │ │ │← [Opus帧3 二进制] ─────────────────── │ │ │← [Opus帧N 二进制] ─────────────────── │ 按时序流控发送 │ │ │ │ │← {"type":"tts","state":"stop"} ─────── │ │ │ │ │ ``` ### 1.3 Opus 音频参数(与小智完全一致) | 参数 | 值 | |------|----| | 采样率 | 16000 Hz | | 声道 | 1(单声道)| | 帧时长 | 60ms | | 每帧采样数 | 960 | | 编码器 | libopus(WASM) | --- ## 二、前置条件检查 在开始测试之前,需要满足以下条件: ### 2.1 服务运行状态 - [ ] Django 后端运行在 `http://localhost:8000` - [ ] `hw_service_go` 运行在 `ws://localhost:8888` - [ ] 健康检查通过:`curl http://localhost:8888/healthz` 返回 200 ### 2.2 Django 数据准备(关键!) 测试必须使用一个在 Django 数据库中**真实存在**的设备 MAC 地址。 Django API 查询逻辑(`GET /api/v1/devices/stories/?mac_address=`): - 根据 MAC 查找设备 → 找到设备绑定的用户 → 查找该用户的故事 - 任何一步缺失,服务返回 `null`,硬件不会播放任何内容 **需要在 Django Admin 或 API 中准备:** 1. 注册一个设备,记下其 MAC 地址(格式:`AA:BB:CC:DD:EE:FF`) 2. 该设备需已绑定用户(owner) 3. 该用户名下有至少一个故事(有 `audio_url` 字段) > **快速验证**:`curl "http://localhost:8000/api/v1/devices/stories/?mac_address=你的MAC"` 应返回 `{"code":0,"data":{"title":"...","audio_url":"..."}}` --- ## 三、测试程序设计 ### 3.1 技术选型 | 方案 | 优点 | 缺点 | |------|------|------| | **纯 HTML+JS(推荐)** | 零依赖,直接浏览器打开,与小智方案一致 | - | | Python 脚本 | 简单但无法播放音频 | 无法验证音频播放端到端 | | Go 命令行 | 需额外音频库 | 环境搭建复杂 | **选择方案:纯 HTML+JS 单文件**,复用小智项目的 `libopus.js`(WASM)做解码。 ### 3.2 文件结构 ``` hw_service_go/test/ ├── PLAN.md ← 本文件 ├── test.html ← 测试主页面(待实现) └── libopus.js ← 复制自小智项目(Opus WASM 解码库) ``` `libopus.js` 来源: ``` /Users/maidong/Desktop/zyc/jikashe/xiaozhi-server/main/xiaozhi-server/test/libopus.js ``` ### 3.3 测试页面功能 ``` ┌─────────────────────────────────────────────────────┐ │ hw_service_go 硬件通讯测试 │ ├─────────────────────────────────────────────────────┤ │ 服务地址: [ws://localhost:8888/xiaozhi/v1/ ] │ │ device-id: [AA:BB:CC:DD:EE:FF ] │ │ client-id: [test-browser-001 ] [随机生成] │ ├─────────────────────────────────────────────────────┤ │ [连接] [断开] 状态: ● 未连接 │ ├─────────────────────────────────────────────────────┤ │ [▶ 触发故事播放] [⏹ 停止] │ ├─────────────────────────────────────────────────────┤ │ 消息日志 [清空] │ │ ┌───────────────────────────────────────────────┐ │ │ │ [10:23:01] → 已连接 │ │ │ │ [10:23:02] → 发送: {"type":"story"} │ │ │ │ [10:23:02] ← 收到: {"type":"tts","state":"start"} │ │ │ [10:23:03] ← 收到: {"type":"tts","state":"sentence_start","text":"..."} │ │ │ [10:23:03] ← 收到: [Binary] Opus帧 #1 (38 bytes) │ │ │ [10:23:03] 🔊 开始播放... │ │ │ │ [10:23:15] ← 收到: {"type":"tts","state":"stop"} │ │ │ [10:23:15] 🔊 播放完毕 │ │ │ └───────────────────────────────────────────────┘ │ │ │ │ 统计: 已收到 85 个Opus帧 | 约 5.1s 音频 │ └─────────────────────────────────────────────────────┘ ``` ### 3.4 核心实现逻辑 #### 连接流程 ```javascript const ws = new WebSocket( `ws://localhost:8888/xiaozhi/v1/?device-id=${deviceId}&client-id=${clientId}` ); ws.binaryType = 'arraybuffer'; ``` #### 触发故事 ```javascript ws.send(JSON.stringify({ type: 'story' })); ``` #### 接收消息处理 ```javascript ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { // 二进制:Opus 音频帧 const opusFrame = new Uint8Array(event.data); const pcm = opusDecoder.decode(opusFrame); // Int16Array schedulePlay(pcm); // 排队播放 } else { // 文本:控制消息 const msg = JSON.parse(event.data); handleTtsControl(msg); // 处理 start/sentence_start/stop } }; ``` #### Opus 解码 + 播放(与小智方案完全一致) - 使用 `libopus.js`(WASM)初始化解码器:16000Hz,单声道 - 解码:`Int16Array` → `Float32Array` - 使用 `AudioContext` + `AudioBufferSourceNode` 按时序排队播放 - 使用 `BlockingQueue` 缓冲帧,避免播放卡顿 --- ## 四、测试用例 ### Case 1:基础连接测试 - 输入正确的 `device-id` 和 `client-id` - 期望:WebSocket 连接建立成功,状态变为"已连接" ### Case 2:故事触发测试 - 发送 `{"type":"story"}` - 期望: 1. 收到 `{"type":"tts","state":"start"}` 2. 收到 `{"type":"tts","state":"sentence_start","text":"<故事标题>"}` 3. 陆续收到多个二进制 Opus 帧 4. 最终收到 `{"type":"tts","state":"stop"}` ### Case 3:音频播放验证 - 期望:浏览器实际播放出故事音频,声音正常无杂音、无卡顿 ### Case 4:设备不存在测试 - 使用未注册的 MAC 地址 - 期望:发送故事指令后立即收到 `{"type":"tts","state":"stop"}`(服务侧找不到故事,直接结束) ### Case 5:重复触发测试 - 播放过程中再次点击"触发故事" - 期望:旧播放被打断,新故事从头开始(hw_service_go 的 `StartPlayback` 会 close 旧 abortCh) ### Case 6:断线重连测试 - 连接后断开,再重新连接 - 期望:可以正常重新发起故事请求 --- ## 五、实现步骤 1. **复制 libopus.js** ```bash cp /Users/maidong/Desktop/zyc/jikashe/xiaozhi-server/main/xiaozhi-server/test/libopus.js \ /Users/maidong/Desktop/zyc/qy_gitlab/rtc_backend/hw_service_go/test/ ``` 2. **编写 test.html**(单文件,嵌入所有 JS) - 参考小智 `StreamingContext.js` 和 `BlockingQueue.js` 的逻辑 - 去掉录音/编码部分(我们只需解码) - 保留 Opus 解码 + AudioContext 播放部分 - 添加连接配置 UI 和消息日志面板 3. **浏览器打开测试** ``` 直接用浏览器打开 test.html(file:// 协议即可) ``` > 注意:macOS Safari 对 WebSocket + file:// 可能有限制,建议用 Chrome 4. **按测试用例逐项验证** --- ## 六、已知限制与注意事项 | 问题 | 说明 | |------|------| | **device-id 必须真实存在** | MAC 地址若未在 Django 数据库注册,服务会静默返回无故事 | | **ffmpeg 必须安装** | `hw_service_go` 的转码依赖系统 `ffmpeg`,需提前安装 | | **audio_url 必须可访问** | 故事的 MP3 链接需要能从本机下载(阿里云 OSS 等) | | **浏览器 AudioContext 限制** | 需要用户交互(点击)后才能创建 AudioContext,不能自动播放 | | **WASM 加载** | libopus.js 较大(844KB),首次加载需要等待约 1-2 秒 |