11 KiB
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
连接流程
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 一致):
- 建立 WebSocket 连接(建议
binaryType = "arraybuffer")。 - 连接成功后立即发送
session.start。 - 开始推送麦克风音频(二进制帧或
input.audioJSON)。 - 处理服务端 JSON 事件(文本、转写、TTS 音频等)。
- 断开前发送
session.stop,再关闭连接。
消息信封
除二进制音频外,所有消息均为 UTF-8 JSON 对象。服务端下发事件统一包含:
| 字段 | 类型 | 说明 |
|---|---|---|
type |
string | 事件类型 |
protocol |
string | 固定为 va.ws.v1 |
seq |
number | 单调递增序号(仅服务端事件) |
客户端 → 服务端
session.start
开始会话,必须在发送音频或文本输入之前调用。
{
"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
正常结束会话。
{
"type": "session.stop",
"reason": "client_disconnect"
}
input.audio(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)。
{
"type": "input.text",
"text": "你好,我想报案",
"interrupt": true
}
注意:文本输入不会以 input.transcript.final 回显,客户端需自行在 UI 中展示用户消息(Demo 即如此处理)。
input.image
上传相机/图片并附带可选说明文本,供多模态 LLM 使用。
{
"type": "input.image",
"image": "<base64>",
"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 流)。
{
"type": "response.cancel"
}
服务端 → 客户端
用户语音转写
| 事件 | 说明 |
|---|---|
input.transcript.interim |
ASR 中间结果(流式识别过程中) |
input.transcript.final |
用户一句话结束后的最终转写 |
{
"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 表示被打断 |
{
"type": "response.text.delta",
"protocol": "va.ws.v1",
"seq": 20,
"text": "您好,"
}
{
"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 说完 |
{
"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> 标签会被剥离,并单独下发:
{
"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
- 启动 API 服务并加载 voice 配置(环境变量
VOICE_CONFIG指向 JSON,默认config/voice.json)。 - 浏览器打开
http://127.0.0.1:8000/voice-demo/(挂载路径见配置server.webpage_mount)。 - 点击 Connect → Enable mic 开始对话。
Demo 关键实现要点
连接与握手
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 一致)
// AudioWorklet 每 20ms postMessage { type: "frame", buffer: ArrayBuffer }
recorderNode.port.onmessage = (event) => {
if (event.data?.type === "frame") {
ws.send(event.data.buffer);
}
};
播放 TTS
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
发送文本并打断
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://。
最小客户端示例(伪代码)
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");
}
健康检查响应示例
curl http://127.0.0.1:8000/voice/health
{
"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 证书 |