diff --git a/docs/voice-websocket.md b/docs/voice-websocket.md new file mode 100644 index 0000000..109c595 --- /dev/null +++ b/docs/voice-websocket.md @@ -0,0 +1,409 @@ +# 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 / input.image / response.cancel + 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": "", + "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 即如此处理)。 + +### `input.image` + +上传相机/图片并附带可选说明文本,供多模态 LLM 使用。 + +```json +{ + "type": "input.image", + "image": "", + "mime_type": "image/jpeg", + "width": 1920, + "height": 1080, + "text": "Answer using this camera image.", + "append_to_context": true +} +``` + +约束: + +- 支持 `image/jpeg`、`image/png`、`image/webp` +- 单张最大 8 MB +- `width`、`height` 必须为正整数 +- 支持 `data:image/jpeg;base64,...` 格式(会自动剥离前缀) + +### `response.cancel` + +取消当前 bot 回复(等价于打断 TTS / LLM 流)。 + +```json +{ + "type": "response.cancel" +} +``` + +## 服务端 → 客户端 + +### 用户语音转写 + +| 事件 | 说明 | +|------|------| +| `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": "", + "bytes": 640, + "sample_rate": 16000, + "channels": 1 +} +``` + +客户端应将各 `delta` 块按序解码并无缝拼接播放(Demo 使用 Web Audio `AudioContext` 调度)。 + +### 助手状态(可选) + +当 voice 配置启用 `agent.response_state` 时,LLM 输出开头的 `...` 标签会被剥离,并单独下发: + +```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`)或 `response.cancel` 可打断 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` 证书 |