# 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>`）：
- 根据 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 秒 |
