本文档定义前端浏览器与后端WSS服务之间的通信协议和接口规范。
┌─────────────┐ WSS/HTTPS ┌─────────────┐ WebSocket ┌─────────────┐
│ │ ◄────────────────────── │ │ ◄─────────────────── │ │
│ 浏览器 │ │ WSS服务器 │ │ 豆包API │
│ (前端) │ ─────────────────────► │ (后端) │ ──────────────────► │ │
└─────────────┘ 实时音频流/控制消息 └─────────────┘ 实时音频流/控制消息 └─────────────┘
| 要求项 | 说明 |
|---|---|
| 协议 | WSS (WebSocket over TLS) |
| 端口 | 443 (生产环境) / 8443 (开发环境) |
| 认证 | Token/API Key (可选) |
| 心跳 | 每30秒一次ping/pong |
| 超时 | 60秒无数据自动断开 |
生产环境:
wss://api.yourdomain.com/ws/realtime
开发环境:
wss://localhost:8443/ws/realtime
GET /ws/realtime HTTP/1.1
Host: api.yourdomain.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Authorization: Bearer <your-token> # 可选
成功 (101 Switching Protocols):
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
失败响应:
| 状态码 | 说明 | 处理建议 |
|---|---|---|
| 400 | 请求格式错误 | 检查请求头 |
| 401 | 认证失败 | 检查Token |
| 403 | 权限不足 | 联系管理员 |
| 429 | 请求过于频繁 | 稍后重试 |
| 503 | 服务不可用 | 等待服务恢复 |
// 前端连接示例
class RealtimeClient {
private ws: WebSocket | null = null;
connect(token?: string) {
const url = 'wss://api.yourdomain.com/ws/realtime';
const fullUrl = token ? `${url}?token=${token}` : url;
this.ws = new WebSocket(fullUrl);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
console.log('WebSocket连接成功');
// 发送初始化消息
this.sendMessage({
type: 'init',
payload: {
version: '1.0',
capabilities: ['pcm', 'opus']
}
});
};
this.ws.onmessage = (event) => {
this.handleMessage(event);
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
};
this.ws.onclose = (event) => {
console.log('WebSocket关闭:', event.code, event.reason);
this.handleReconnect();
};
}
}
WebSocket支持两种消息类型:
| 类型 | 用途 | 格式 |
|---|---|---|
| 文本消息 | 控制指令、状态同步 | JSON |
| 二进制消息 | 音频数据传输 | ArrayBuffer |
基础结构:
interface ControlMessage {
type: string; // 消息类型
id?: string; // 消息ID(用于追踪)
timestamp?: number; // 时间戳(毫秒)
payload?: any; // 消息负载
}
音频数据帧:
[Header: 4 bytes] [Audio Data: N bytes]
Header结构:
- Byte 0: 帧类型 (0x01=PCM, 0x02=Opus)
- Byte 1-3: 数据长度(小端序)
或简化版(推荐):
[Audio Data: N bytes] // 直接发送原始音频数据
消息类型: init
{
"type": "init",
"id": "msg_001",
"timestamp": 1696752000000,
"payload": {
"version": "1.0",
"client": "web",
"capabilities": ["pcm", "opus"],
"settings": {
"sampleRate": 16000,
"channels": 1,
"bitDepth": 16
}
}
}
响应:
{
"type": "init_ack",
"id": "msg_001",
"timestamp": 1696752000100,
"payload": {
"status": "success",
"sessionId": "session_abc123",
"config": {
"maxAudioChunkSize": 8192,
"heartbeatInterval": 30000
}
}
}
消息类型: start_recording
{
"type": "start_recording",
"id": "msg_002",
"timestamp": 1696752010000,
"payload": {
"sessionId": "session_abc123"
}
}
响应:
{
"type": "recording_started",
"id": "msg_002",
"timestamp": 1696752010050,
"payload": {
"status": "ready",
"message": "准备接收音频数据"
}
}
消息类型: stop_recording
{
"type": "stop_recording",
"id": "msg_003",
"timestamp": 1696752015000,
"payload": {
"sessionId": "session_abc123",
"reason": "user_released" // user_released | timeout | error
}
}
响应:
{
"type": "recording_stopped",
"id": "msg_003",
"timestamp": 1696752015050,
"payload": {
"status": "success",
"duration": 5000, // 录音时长(毫秒)
"message": "录音已停止,等待AI回复"
}
}
消息类型: ping
{
"type": "ping",
"timestamp": 1696752020000
}
响应:
{
"type": "pong",
"timestamp": 1696752020010
}
消息类型: close
{
"type": "close",
"payload": {
"reason": "user_exit"
}
}
消息类型: ai_response_start
{
"type": "ai_response_start",
"timestamp": 1696752015200,
"payload": {
"responseId": "resp_xyz789",
"estimatedDuration": 3000, // 预估时长(可选)
"message": "AI开始回复"
}
}
消息类型: ai_response_end
{
"type": "ai_response_end",
"timestamp": 1696752018500,
"payload": {
"responseId": "resp_xyz789",
"duration": 3300,
"audioSize": 52800, // 音频数据大小(字节)
"message": "AI回复完成"
}
}
消息类型: state_change
{
"type": "state_change",
"timestamp": 1696752010000,
"payload": {
"state": "listening", // idle | listening | speaking
"previousState": "idle",
"reason": "user_started_recording"
}
}
消息类型: error
{
"type": "error",
"timestamp": 1696752010000,
"payload": {
"code": "CONNECTION_POOL_EXHAUSTED",
"message": "连接池已耗尽,请稍后重试",
"severity": "error", // warning | error | critical
"recoverable": true
}
}
消息类型: metrics
{
"type": "metrics",
"timestamp": 1696752018500,
"payload": {
"latency": {
"total": 285, // 总延迟(毫秒)
"network": 45, // 网络延迟
"processing": 150, // 处理延迟
"ai": 90 // AI延迟
},
"audio": {
"received": 80000, // 接收字节数
"sent": 52800 // 发送字节数
}
}
}
格式要求:
发送示例:
// 发送PCM音频数据
sendAudioChunk(pcmData: Int16Array) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(pcmData.buffer);
}
}
// 来自Recorder的实时数据
recorder = Recorder({
type: 'pcm',
sampleRate: 16000,
bitRate: 16,
onProcess: (buffers, powerLevel, duration, sampleRate) => {
const pcmData = Recorder.SampleData(buffers, sampleRate, 16000);
const int16Data = new Int16Array(pcmData.data);
this.sendAudioChunk(int16Data);
}
});
格式:
接收示例:
// 接收音频数据
handleMessage(event: MessageEvent) {
if (event.data instanceof ArrayBuffer) {
// 二进制数据 = 音频
this.appendAudioChunk(event.data);
} else {
// 文本数据 = 控制消息
const message = JSON.parse(event.data);
this.handleControlMessage(message);
}
}
// 使用MediaSource API流式播放
appendAudioChunk(chunk: ArrayBuffer) {
if (!this.sourceBuffer.updating) {
this.sourceBuffer.appendBuffer(chunk);
} else {
this.audioQueue.push(chunk);
}
// 自动开始播放
if (this.audio.paused && this.sourceBuffer.buffered.length > 0) {
this.audio.play();
}
}
sequenceDiagram
participant C as 客户端
participant S as WSS服务器
participant D as 豆包API连接池
C->>S: WebSocket连接请求
S->>S: 验证请求
S->>C: 101 连接成功
C->>S: init (初始化消息)
S->>D: 从连接池获取连接
D-->>S: 返回可用连接
S->>C: init_ack (会话ID)
Note over C,S: 连接建立完成
sequenceDiagram
participant C as 客户端
participant S as WSS服务器
participant D as 豆包API
Note over C: 用户按下按钮
C->>S: start_recording
S->>C: recording_started
S->>C: state_change (listening)
loop 实时流式传输
C->>S: PCM音频块
S->>D: 转发PCM音频块
end
Note over C: 用户松开按钮
C->>S: stop_recording
S->>C: recording_stopped
S->>D: 停止音频输入
S->>C: ai_response_start
S->>C: state_change (speaking)
loop 实时流式接收
D->>S: OGG/Opus音频块
S->>C: 转发音频块
C->>C: 边接收边播放
end
S->>C: ai_response_end
S->>C: state_change (idle)
S->>C: metrics (性能指标)
sequenceDiagram
participant C as 客户端
participant S as WSS服务器
C->>S: 音频数据
S->>S: 检测到错误
S->>C: error (错误通知)
alt 可恢复错误
C->>C: 显示提示
C->>S: 重试操作
else 不可恢复错误
C->>C: 显示错误
C->>S: close
S->>C: WebSocket关闭
end
| 错误码 | 名称 | 说明 | 可恢复 |
|---|---|---|---|
| 4000 | INVALID_MESSAGE | 消息格式错误 | 是 |
| 4001 | INVALID_AUDIO_FORMAT | 音频格式不支持 | 否 |
| 4002 | AUDIO_TOO_LARGE | 音频数据过大 | 是 |
| 4003 | RATE_LIMIT_EXCEEDED | 请求频率超限 | 是 |
| 4004 | SESSION_EXPIRED | 会话已过期 | 否 |
| 4005 | UNAUTHORIZED | 未授权 | 否 |
| 错误码 | 名称 | 说明 | 可恢复 |
|---|---|---|---|
| 5000 | INTERNAL_ERROR | 内部服务器错误 | 是 |
| 5001 | CONNECTION_POOL_EXHAUSTED | 连接池已耗尽 | 是 |
| 5002 | DOUBAO_API_ERROR | 豆包API错误 | 是 |
| 5003 | DOUBAO_API_TIMEOUT | 豆包API超时 | 是 |
| 5004 | SERVICE_UNAVAILABLE | 服务不可用 | 是 |
{
"type": "error",
"timestamp": 1696752010000,
"payload": {
"code": "CONNECTION_POOL_EXHAUSTED",
"message": "服务繁忙,连接池已满,请稍后重试",
"severity": "error",
"recoverable": true,
"retryAfter": 3000, // 建议3秒后重试
"details": {
"poolSize": 10,
"activeConnections": 10
}
}
}
| 状态 | 值 | 说明 |
|---|---|---|
| CONNECTING | 0 | 连接中 |
| OPEN | 1 | 已连接 |
| CLOSING | 2 | 关闭中 |
| CLOSED | 3 | 已关闭 |
| 状态 | 说明 | 可触发操作 |
|---|---|---|
| IDLE | 空闲状态 | 开始录音 |
| LISTENING | 倾听状态(用户说话) | 停止录音 |
| PROCESSING | 处理中(等待AI) | 无 |
| SPEAKING | 说话状态(AI回复) | 无 |
状态转换:
IDLE → (用户按下) → LISTENING
LISTENING → (用户松开) → PROCESSING
PROCESSING → (AI开始回复) → SPEAKING
SPEAKING → (播放完成) → IDLE
| 指标 | 目标值 | 说明 |
|---|---|---|
| 连接建立延迟 | < 100ms | WebSocket握手时间 |
| 首包延迟 | < 50ms | 收到第一个音频包的时间 |
| 音频传输延迟 | < 20ms | 单个音频包传输时间 |
| 端到端延迟 | < 300ms | 用户停止说话到听到回复 |
上行带宽:
PCM音频: 16000 Hz × 16 bit × 1 channel = 256 kbps = 32 KB/s
下行带宽:
Opus音频: 约 24-32 kbps (压缩后) = 3-4 KB/s
| 限制类型 | 限制值 | 说明 |
|---|---|---|
| 连接频率 | 10次/分钟 | 每IP每分钟最多建立10个连接 |
| 消息频率 | 100条/秒 | 每连接每秒最多100条控制消息 |
| 音频数据 | 1MB/秒 | 每连接每秒最多1MB音频数据 |
interface RealtimeClientOptions {
url: string;
token?: string;
autoReconnect?: boolean;
maxReconnectAttempts?: number;
}
class RealtimeClient {
private ws: WebSocket | null = null;
private sessionId: string = '';
private reconnectAttempts: number = 0;
private options: RealtimeClientOptions;
// 事件回调
public onOpen?: () => void;
public onClose?: (code: number, reason: string) => void;
public onError?: (error: Error) => void;
public onAudioData?: (data: ArrayBuffer) => void;
public onStateChange?: (state: string) => void;
public onMetrics?: (metrics: any) => void;
constructor(options: RealtimeClientOptions) {
this.options = {
autoReconnect: true,
maxReconnectAttempts: 5,
...options
};
}
// 连接
connect(): Promise<void> {
return new Promise((resolve, reject) => {
const url = this.options.token
? `${this.options.url}?token=${this.options.token}`
: this.options.url;
this.ws = new WebSocket(url);
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
console.log('WebSocket已连接');
this.reconnectAttempts = 0;
// 发送初始化消息
this.sendControlMessage({
type: 'init',
payload: {
version: '1.0',
client: 'web',
capabilities: ['pcm', 'opus']
}
});
this.onOpen?.();
resolve();
};
this.ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// 音频数据
this.onAudioData?.(event.data);
} else {
// 控制消息
const message = JSON.parse(event.data);
this.handleControlMessage(message);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket错误:', error);
this.onError?.(new Error('WebSocket错误'));
reject(error);
};
this.ws.onclose = (event) => {
console.log('WebSocket关闭:', event.code, event.reason);
this.onClose?.(event.code, event.reason);
if (this.options.autoReconnect && !event.wasClean) {
this.handleReconnect();
}
};
});
}
// 发送控制消息
private sendControlMessage(message: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({
...message,
timestamp: Date.now()
}));
}
}
// 处理控制消息
private handleControlMessage(message: any) {
switch (message.type) {
case 'init_ack':
this.sessionId = message.payload.sessionId;
console.log('会话已建立:', this.sessionId);
break;
case 'state_change':
this.onStateChange?.(message.payload.state);
break;
case 'metrics':
this.onMetrics?.(message.payload);
break;
case 'error':
console.error('服务器错误:', message.payload);
this.onError?.(new Error(message.payload.message));
break;
}
}
// 开始录音
startRecording() {
this.sendControlMessage({
type: 'start_recording',
payload: { sessionId: this.sessionId }
});
}
// 停止录音
stopRecording() {
this.sendControlMessage({
type: 'stop_recording',
payload: {
sessionId: this.sessionId,
reason: 'user_released'
}
});
}
// 发送音频数据
sendAudio(audioData: ArrayBuffer) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(audioData);
}
}
// 关闭连接
close() {
this.sendControlMessage({
type: 'close',
payload: { reason: 'user_exit' }
});
setTimeout(() => {
this.ws?.close();
}, 100);
}
// 重连处理
private handleReconnect() {
if (this.reconnectAttempts >= (this.options.maxReconnectAttempts || 5)) {
console.error('重连次数已达上限');
return;
}
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`${delay}ms后尝试重连...`);
setTimeout(() => {
this.reconnectAttempts++;
this.connect().catch(console.error);
}, delay);
}
}
// 使用示例
const client = new RealtimeClient({
url: 'wss://api.yourdomain.com/ws/realtime',
token: 'your-auth-token'
});
client.onAudioData = (data) => {
// 播放音频
audioPlayer.appendChunk(data);
};
client.onStateChange = (state) => {
// 更新UI状态
updateAnimationState(state);
};
await client.connect();
client.startRecording();
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from typing import Dict
import asyncio
import json
import time
app = FastAPI()
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, WebSocket] = {}
self.sessions: Dict[str, dict] = {}
async def connect(self, websocket: WebSocket, session_id: str):
await websocket.accept()
self.active_connections[session_id] = websocket
self.sessions[session_id] = {
'state': 'idle',
'connected_at': time.time()
}
def disconnect(self, session_id: str):
if session_id in self.active_connections:
del self.active_connections[session_id]
if session_id in self.sessions:
del self.sessions[session_id]
async def send_message(self, session_id: str, message: dict):
if session_id in self.active_connections:
await self.active_connections[session_id].send_text(
json.dumps(message)
)
async def send_audio(self, session_id: str, audio_data: bytes):
if session_id in self.active_connections:
await self.active_connections[session_id].send_bytes(audio_data)
manager = ConnectionManager()
@app.websocket("/ws/realtime")
async def websocket_endpoint(websocket: WebSocket):
session_id = f"session_{int(time.time() * 1000)}"
try:
await manager.connect(websocket, session_id)
# 等待初始化消息
init_message = await websocket.receive_text()
init_data = json.loads(init_message)
if init_data.get('type') == 'init':
# 发送确认
await manager.send_message(session_id, {
'type': 'init_ack',
'timestamp': int(time.time() * 1000),
'payload': {
'status': 'success',
'sessionId': session_id,
'config': {
'maxAudioChunkSize': 8192,
'heartbeatInterval': 30000
}
}
})
# 获取豆包连接
doubao_client = await connection_pool.acquire()
# 处理消息循环
while True:
message = await websocket.receive()
if 'text' in message:
# 控制消息
control_data = json.loads(message['text'])
await handle_control_message(
session_id, control_data, doubao_client
)
elif 'bytes' in message:
# 音频数据
audio_data = message['bytes']
await handle_audio_data(
session_id, audio_data, doubao_client
)
except WebSocketDisconnect:
print(f"客户端断开: {session_id}")
except Exception as e:
print(f"错误: {e}")
await manager.send_message(session_id, {
'type': 'error',
'payload': {
'code': 'INTERNAL_ERROR',
'message': str(e)
}
})
finally:
manager.disconnect(session_id)
if doubao_client:
await connection_pool.release(doubao_client)
async def handle_control_message(session_id: str, message: dict, doubao_client):
msg_type = message.get('type')
if msg_type == 'start_recording':
await manager.send_message(session_id, {
'type': 'recording_started',
'payload': {'status': 'ready'}
})
await manager.send_message(session_id, {
'type': 'state_change',
'payload': {'state': 'listening'}
})
elif msg_type == 'stop_recording':
await manager.send_message(session_id, {
'type': 'recording_stopped',
'payload': {'status': 'success'}
})
async def handle_audio_data(session_id: str, audio_data: bytes, doubao_client):
# 转发给豆包API
await doubao_client.send_audio(audio_data)
# 接收豆包响应并转发给客户端
response_audio = await doubao_client.receive_audio()
await manager.send_audio(session_id, response_audio)
推荐工具:
wscat示例:
# 安装
npm install -g wscat
# 连接测试
wscat -c wss://api.yourdomain.com/ws/realtime
# 发送消息
> {"type":"init","payload":{"version":"1.0"}}
# 发送测试音频文件
websocat wss://api.yourdomain.com/ws/realtime < test_audio.pcm
A: WebSocket提供全双工通信,延迟更低,适合实时音频传输。HTTP轮询会引入额外的延迟和服务器负载。
A: 推荐直接发送原始PCM/Opus数据,不需要额外封装。服务端通过消息类型(文本/二进制)区分控制消息和音频数据。
A:
A:
| 版本 | 日期 | 变更说明 |
|---|---|---|
| v1.0 | 2025-10-08 | 初始版本 |
文档维护者: 开发团队
最后更新: 2025-10-08
反馈邮箱: dev@yourdomain.com