# Voice WebSocket 使用说明

基于 `src/voice` 产品语音管线与 `static/voice-demo` 浏览器示例整理。

## 概览

| 项目 | 说明 |
|------|------|
| WebSocket 路径 | `/ws-product` |
| 协议标识 | `va.ws.v1`（JSON + base64；音频上行也支持二进制 PCM） |
| 默认音频 | PCM16 小端（`pcm_s16le`）、16 kHz、单声道 |
| 会话 ID | 连接 URL 查询参数 `chatId` 或 `chat_id`；未传时服务端自动生成 |
| 健康检查 | `GET /voice/health` |
| 浏览器 Demo | 默认挂载于 `/voice-demo`（由 voice 配置 `server.serve_webpage` 控制） |

完整 URL 示例：

```
ws://127.0.0.1:8000/ws-product?chatId=voice_abc123
wss://your-host/ws-product?chatId=voice_abc123
```

## 连接流程

```mermaid
sequenceDiagram
    participant Client
    participant Server

    Client->>Server: WebSocket connect (?chatId=...)
    Server-->>Client: 101 Switching Protocols
    Client->>Server: session.start (JSON)
    Note over Client,Server: 可选：固定开场白 / FastGPT opener / LLM 生成问候
    loop 会话中
        Client->>Server: input.audio (binary 或 JSON)
        Client->>Server: input.text
        Server-->>Client: input.transcript.* / response.text.* / response.audio.*
        Server-->>Client: response.state（若启用状态标签）
    end
    Client->>Server: session.stop
    Server-->>Client: WebSocket close
```

推荐顺序（与 `voice-demo/app.js` 一致）：

1. 建立 WebSocket 连接（建议 `binaryType = "arraybuffer"`）。
2. 连接成功后立即发送 `session.start`。
3. 开始推送麦克风音频（二进制帧或 `input.audio` JSON）。
4. 处理服务端 JSON 事件（文本、转写、TTS 音频等）。
5. 断开前发送 `session.stop`，再关闭连接。

## 消息信封

除二进制音频外，所有消息均为 UTF-8 JSON 对象。服务端下发事件统一包含：

| 字段 | 类型 | 说明 |
|------|------|------|
| `type` | string | 事件类型 |
| `protocol` | string | 固定为 `va.ws.v1` |
| `seq` | number | 单调递增序号（仅服务端事件） |

## 客户端 → 服务端

### `session.start`

开始会话，必须在发送音频或文本输入之前调用。

```json
{
  "type": "session.start",
  "protocol": "va.ws.v1",
  "chatId": "voice_abc123",
  "audio": {
    "encoding": "pcm_s16le",
    "sample_rate": 16000,
    "channels": 1
  }
}
```

`chatId` 也可写作 `chat_id`。若省略，服务端使用 URL 查询参数或自动生成 ID。

### `session.stop`

正常结束会话。

```json
{
  "type": "session.stop",
  "reason": "client_disconnect"
}
```

### `input.audio`（JSON 形式）

```json
{
  "type": "input.audio",
  "audio": "<base64 PCM16>",
  "sample_rate": 16000,
  "channels": 1
}
```

`audio` 字段也可命名为 `data`。`sample_rate` / `channels` 可省略，默认与服务端配置一致。

### 二进制音频（推荐）

直接发送 **原始 PCM16 小端** 字节流，无需 JSON 包装。`voice-demo` 通过 AudioWorklet 每 20 ms 发送一帧（16 kHz 单声道下约 640 字节/帧）。

服务端同时接受 JSON 与二进制两种上行格式。

### `input.text`

发送文本回合；默认会打断当前 bot 回复（`interrupt: true`）。

```json
{
  "type": "input.text",
  "text": "你好，我想报案",
  "interrupt": true
}
```

注意：文本输入**不会**以 `input.transcript.final` 回显，客户端需自行在 UI 中展示用户消息（Demo 即如此处理）。Demo 的相机步骤通过发送 `input.text`（如 `【拍摄完成】`）完成，不上传图片帧。

## 服务端 → 客户端

### 用户语音转写

| 事件 | 说明 |
|------|------|
| `input.transcript.interim` | ASR 中间结果（流式识别过程中） |
| `input.transcript.final` | 用户一句话结束后的最终转写 |

```json
{
  "type": "input.transcript.final",
  "protocol": "va.ws.v1",
  "seq": 12,
  "text": "发生了交通事故",
  "user_id": "product-user",
  "timestamp": "2026-06-01T10:00:00.000Z"
}
```

### 助手文本流

文本通常**早于**对应 TTS 音频到达，便于客户端先渲染字幕。

| 事件 | 说明 |
|------|------|
| `response.text.started` | 新一轮助手回复开始 |
| `response.text.delta` | 流式文本片段 |
| `response.text.final` | 本轮文本结束；`interrupted: true` 表示被打断 |

```json
{
  "type": "response.text.delta",
  "protocol": "va.ws.v1",
  "seq": 20,
  "text": "您好，"
}
```

```json
{
  "type": "response.text.final",
  "protocol": "va.ws.v1",
  "seq": 45,
  "text": "您好，请问发生了什么情况？",
  "interrupted": false
}
```

### 助手语音（TTS）

| 事件 | 说明 |
|------|------|
| `response.audio.started` | Bot 开始说话 |
| `response.audio.delta` | PCM16 音频块（base64） |
| `response.audio.stopped` | Bot 说完 |

```json
{
  "type": "response.audio.delta",
  "protocol": "va.ws.v1",
  "seq": 30,
  "audio": "<base64 PCM16>",
  "bytes": 640,
  "sample_rate": 16000,
  "channels": 1
}
```

客户端应将各 `delta` 块按序解码并无缝拼接播放（Demo 使用 Web Audio `AudioContext` 调度）。

### 助手状态（可选）

当 voice 配置启用 `agent.response_state` 时，LLM 输出开头的 `<state>...</state>` 标签会被剥离，并单独下发：

```json
{
  "type": "response.state",
  "protocol": "va.ws.v1",
  "seq": 18,
  "state": "2000"
}
```

Demo 根据状态码展示拍照引导（如 `2000`–`2015` 等车险场景状态）。

## 音频参数

| 参数 | 默认值 | 说明 |
|------|--------|------|
| 采样率 | 16000 Hz | 配置项 `audio.sample_rate_hz` |
| 声道 | 1（mono） | 配置项 `audio.channels` |
| 帧长 | 20 ms | 配置项 `audio.frame_ms`；每帧 640 字节 |
| 编码 | PCM signed 16-bit LE | 小端有符号 16 位整数 |

## 会话与打断行为

- **chatId**：同一 ID 用于 LLM（如 FastGPT）多轮上下文；连接时可写在 URL 或 `session.start` 中。
- **语音回合**：VAD + 静音超时判定用户说完；说完后触发 STT 最终转写与 LLM。
- **打断**：用户说话或 `input.text`（`interrupt: true`）可打断 bot；被打断的助手文本在 `response.text.final` 中带 `interrupted: true`。
- **空闲超时**：长时间无活动会断开（`session.inactivity_timeout_sec`，默认 60 秒）；可配置空闲提示语。
- **开场白**：由 `agent.greeting_mode` 控制（`fixed` / `fastgpt_opener` / `generated` 等）。

## 浏览器 Demo 参考实现

Demo 位于 `static/voice-demo/`，无构建步骤，核心文件：

| 文件 | 职责 |
|------|------|
| `app.js` | WebSocket 连接、事件处理、聊天 UI、TTS 播放 |
| `pcm-recorder.worklet.js` | 麦克风采集、重采样至 16 kHz、20 ms 二进制帧 |
| `index.html` / `styles.css` | 页面与样式 |

### 启动 Demo

1. 启动 API 服务并加载 voice 配置（环境变量 `VOICE_CONFIG` 指向 JSON，默认 `config/voice.json`）。
2. 浏览器打开 `http://127.0.0.1:8000/voice-demo/`（挂载路径见配置 `server.webpage_mount`）。
3. 点击 **Connect** → **Enable mic** 开始对话。

### Demo 关键实现要点

**连接与握手**

```javascript
const ws = new WebSocket("ws://127.0.0.1:8000/ws-product?chatId=voice_xxx");
ws.binaryType = "arraybuffer";

ws.onopen = () => {
  ws.send(JSON.stringify({
    type: "session.start",
    protocol: "va.ws.v1",
    chatId: "voice_xxx",
    audio: { encoding: "pcm_s16le", sample_rate: 16000, channels: 1 },
  }));
};
```

**发送麦克风（二进制，与 Demo 一致）**

```javascript
// AudioWorklet 每 20ms postMessage { type: "frame", buffer: ArrayBuffer }
recorderNode.port.onmessage = (event) => {
  if (event.data?.type === "frame") {
    ws.send(event.data.buffer);
  }
};
```

**播放 TTS**

```javascript
function decodeBase64ToInt16(b64) {
  const binary = atob(b64);
  const bytes = new Uint8Array(binary.length);
  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
  return new Int16Array(bytes.buffer);
}

// 收到 response.audio.delta 后，将 Int16 转为 Float32 并调度到 AudioContext
```

**发送文本并打断**

```javascript
ws.send(JSON.stringify({
  type: "input.text",
  text: "【拍摄完成】",
  interrupt: true,
}));
// 客户端应停止本地 TTS 播放队列；服务端会发 response.text.final(interrupted=true)
```

### 跨域静态页

若 Demo 托管在其他端口，需在 voice 配置中设置 `server.cors_origins`，并将 WebSocket URL 指向 API 主机。

> 浏览器 `getUserMedia` 需要安全上下文：`https://` 或 `http://localhost` 可用；其他 HTTP 源需改用 HTTPS + `wss://`。

## 最小客户端示例（伪代码）

```javascript
const ws = new WebSocket(`${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/ws-product?chatId=voice_demo_1`);
ws.binaryType = "arraybuffer";

ws.onopen = () => {
  ws.send(JSON.stringify({
    type: "session.start",
    protocol: "va.ws.v1",
    audio: { encoding: "pcm_s16le", sample_rate: 16000, channels: 1 },
  }));
};

ws.onmessage = (event) => {
  if (typeof event.data !== "string") return;
  const msg = JSON.parse(event.data);
  switch (msg.type) {
    case "input.transcript.final":
      console.log("User:", msg.text);
      break;
    case "response.text.delta":
      process.stdout?.write?.(msg.text); // 流式打印助手文本
      break;
    case "response.audio.delta":
      playPcm16(decodeBase64(msg.audio));
      break;
    case "response.state":
      console.log("State:", msg.state);
      break;
  }
};

function disconnect() {
  ws.send(JSON.stringify({ type: "session.stop", reason: "done" }));
  ws.close(1000, "done");
}
```

## 健康检查响应示例

```bash
curl http://127.0.0.1:8000/voice/health
```

```json
{
  "status": "healthy",
  "config": "/path/to/config/voice.json",
  "protocols": {
    "/ws-product": "va.ws.v1.json_base64"
  },
  "features": {
    "product_text_input": true,
    "product_text_interrupt": true
  },
  "demo": "/voice-demo",
  "llm_provider": "fastgpt",
  "stt_provider": "xfyun",
  "tts_provider": "xfyun"
}
```

## 常见问题

| 现象 | 可能原因 |
|------|----------|
| 连接后立即断开 | 未发送 `session.start`；或超过 inactivity 超时 |
| 无 bot 语音 | 未处理 `response.audio.delta`；AudioContext 未在用户手势后 resume |
| 回声/啸叫 | 建议使用耳机；Demo 已开启浏览器 AEC，但扬声器外放仍可能串音 |
| 文本发送无用户气泡 | 设计如此，需客户端本地展示 `input.text` 内容 |
| 跨域 WebSocket 失败 | 检查 `cors_origins` 与 `wss` 证书 |
