377 lines
10 KiB
Markdown
377 lines
10 KiB
Markdown
# 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` 证书 |
|