# 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": "", "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": "", "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`)可打断 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` 证书 |