# 实时语音对话 WebSocket API 文档 > Version: 2.0.0 (Flux) > Last Updated: 2026-01-21 > Author: Backend Team --- ## 概述 本文档描述实时语音对话 WebSocket API,用于 iOS 客户端与后端进行实时语音交互。 **v2.0 更新**: 升级为 Deepgram Flux 模型,支持智能轮次检测和 EagerEndOfTurn 提前响应。 ### 核心特性 - **智能轮次检测**: Flux 模型语义理解,自动判断用户说完(非简单静默检测) - **EagerEndOfTurn**: 提前启动 LLM 响应,进一步降低延迟 - **实时语音识别**: 边说边识别,实时显示转写文本 - **流式响应**: AI 响应边生成边返回,无需等待完整响应 - **流式音频**: TTS 音频边合成边播放,极低延迟 - **Barge-in 支持**: 用户可以打断 AI 说话 ### 性能指标 | 指标 | 目标值 | 说明 | |------|--------|------| | 端点检测延迟 | ~260ms | Flux 智能检测 | | TTFA (首音频延迟) | < 300ms | EagerEndOfTurn 优化 | | 端到端延迟 | < 1.5秒 | 完整对话周期 | | 实时转写延迟 | < 100ms | 中间结果 | --- ## 连接信息 ### WebSocket 端点 ``` 生产环境: wss://api.yourdomain.com/api/ws/chat?token={sa_token} 开发环境: ws://localhost:7529/api/ws/chat?token={sa_token} ``` ### 认证方式 通过 URL Query 参数传递 Sa-Token: ``` ws://host:port/api/ws/chat?token=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... ``` | 参数 | 类型 | 必填 | 描述 | |------|------|------|------| | token | String | ✅ | Sa-Token 登录令牌,通过 Apple Sign-In 获取 | ### 认证失败 如果 token 无效或过期,WebSocket 连接将被拒绝(HTTP 403)。 --- ## 消息格式 ### 通用规则 1. **文本消息**: JSON 格式,用于控制指令和状态通知 2. **二进制消息**: 原始字节,用于音频数据传输 3. **编码**: UTF-8 --- ## 客户端 → 服务端消息 ### 1. 开始会话 (session_start) **发送时机**: 建立 WebSocket 连接后,准备开始录音前 ```json { "type": "session_start", "config": { "language": "en", "voice_id": "a5zfmqTslZJBP0jutmVY" } } ``` | 字段 | 类型 | 必填 | 描述 | |------|------|------|------| | type | String | ✅ | 固定值 `session_start` | | config | Object | ❌ | 会话配置(可选) | | config.language | String | ❌ | 语音识别语言,默认 `en` | | config.voice_id | String | ❌ | TTS 声音 ID,默认使用服务端配置 | **响应**: 服务端返回 `session_started` 消息 --- ### 2. 音频数据 (Binary) **发送时机**: 用户正在录音时,持续发送音频数据 **格式**: Binary WebSocket Frame,直接发送原始音频字节 **音频规格要求**: | 参数 | 值 | 说明 | |------|------|------| | 编码格式 | PCM (Linear16) | 未压缩的脉冲编码调制 | | 采样率 | 16000 Hz | 16kHz | | 位深度 | 16-bit | 有符号整数 | | 声道数 | 1 (Mono) | 单声道 | | 字节序 | Little-Endian | 小端序 | **iOS 代码示例**: ```swift // AVAudioEngine 配置 let format = AVAudioFormat( commonFormat: .pcmFormatInt16, sampleRate: 16000, channels: 1, interleaved: true )! // 发送音频数据 audioEngine.inputNode.installTap( onBus: 0, bufferSize: 1024, format: format ) { buffer, time in let audioData = buffer.int16ChannelData![0] let byteCount = Int(buffer.frameLength) * 2 // 16-bit = 2 bytes let data = Data(bytes: audioData, count: byteCount) webSocket.write(data: data) } ``` **发送频率**: 建议每 20-100ms 发送一次,每次 320-1600 字节 --- ### 3. 结束录音 (audio_end) **发送时机**: 用户停止录音(松开录音按钮) ```json { "type": "audio_end" } ``` | 字段 | 类型 | 必填 | 描述 | |------|------|------|------| | type | String | ✅ | 固定值 `audio_end` | **说明**: 发送此消息后,服务端将完成语音识别并开始生成 AI 响应 --- ### 4. 取消会话 (cancel) **发送时机**: 用户主动取消对话(如点击取消按钮) ```json { "type": "cancel" } ``` | 字段 | 类型 | 必填 | 描述 | |------|------|------|------| | type | String | ✅ | 固定值 `cancel` | **说明**: 服务端将停止所有处理,不再返回任何消息 --- ## 服务端 → 客户端消息 ### 1. 会话已启动 (session_started) **接收时机**: 发送 `session_start` 后 ```json { "type": "session_started", "session_id": "abc123-def456-ghi789" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `session_started` | | session_id | String | 服务端分配的会话 ID | **客户端处理**: 收到此消息后,可以开始发送音频数据 --- ### 2. 轮次开始 (turn_start) 🆕 **接收时机**: 用户开始说话时(Flux 检测到语音活动) ```json { "type": "turn_start", "turn_index": 0 } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `turn_start` | | turn_index | Integer | 当前轮次索引(从 0 开始) | **客户端处理**: - 可显示"正在听..."状态 - 准备接收转写结果 --- ### 3. 中间转写结果 (transcript_interim) **接收时机**: 用户说话过程中,实时返回 ```json { "type": "transcript_interim", "text": "Hello how are", "is_final": false } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `transcript_interim` | | text | String | 当前识别到的文本(可能会变化) | | is_final | Boolean | 固定为 `false` | **客户端处理**: - 实时更新 UI 显示转写文本 - 此文本可能会被后续消息覆盖 - 可用于显示"正在识别..."效果 --- ### 3. 最终转写结果 (transcript_final) **接收时机**: 一句话识别完成时 ```json { "type": "transcript_final", "text": "Hello, how are you?" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `transcript_final` | | text | String | 最终确定的转写文本 | **客户端处理**: - 用此文本替换之前的中间结果 - 此文本不会再变化 --- ### 6. 提前端点检测 (eager_eot) 🆕 **接收时机**: Flux 检测到用户可能说完时(置信度达到阈值) ```json { "type": "eager_eot", "transcript": "Hello, how are you", "confidence": 0.65 } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `eager_eot` | | transcript | String | 当前转写文本 | | confidence | Double | 端点置信度 (0.0-1.0) | **客户端处理**: - 这是一个**预测性事件**,表示用户可能说完了 - 服务端已开始提前准备 LLM 响应 - 可显示"准备响应..."状态 - **注意**: 用户可能继续说话,此时会收到 `turn_resumed` --- ### 7. 轮次恢复 (turn_resumed) 🆕 **接收时机**: 收到 `eager_eot` 后,用户继续说话 ```json { "type": "turn_resumed" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `turn_resumed` | **客户端处理**: - 用户继续说话,之前的 `eager_eot` 是误判 - 服务端已取消正在准备的草稿响应 - 恢复"正在听..."状态 - 继续接收 `transcript_interim` 更新 --- ### 8. LLM 开始生成 (llm_start) **接收时机**: 语音识别完成,AI 开始生成响应 ```json { "type": "llm_start" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `llm_start` | **客户端处理**: - 可显示"AI 正在思考..."状态 - 准备接收 AI 响应文本和音频 --- ### 5. LLM Token (llm_token) **接收时机**: AI 生成过程中,逐 token 返回 ```json { "type": "llm_token", "token": "Hi" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `llm_token` | | token | String | AI 输出的单个 token(词或字符片段) | **客户端处理**: - 可选择实现打字机效果 - 逐个 token 追加显示 AI 响应文本 - 如不需要打字效果,可忽略此消息 --- ### 6. 音频数据 (Binary) **接收时机**: TTS 合成过程中,流式返回音频 **格式**: Binary WebSocket Frame,MP3 音频块 **音频规格**: | 参数 | 值 | |------|------| | 格式 | MP3 | | 采样率 | 44100 Hz | | 比特率 | 64 kbps | | 声道 | 单声道 | **客户端处理**: ```swift // 使用 AVAudioEngine 或 AudioQueue 播放流式音频 webSocket.onEvent = { event in switch event { case .binary(let data): // 方案1: 追加到缓冲区,使用 AVAudioPlayerNode audioBuffer.append(data) playBufferedAudio() // 方案2: 使用 AVAudioEngine + AVAudioCompressedBuffer // 方案3: 累积后使用 AVAudioPlayer } } ``` **重要提示**: - 音频是分块返回的,需要正确拼接或流式播放 - 每个二进制消息是 MP3 数据的一部分 - 收到 `complete` 消息后,音频传输完成 --- ### 7. 处理完成 (complete) **接收时机**: AI 响应生成完成,所有音频已发送 ```json { "type": "complete", "transcript": "Hello, how are you?", "ai_response": "Hi! I'm doing great, thanks for asking!" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `complete` | | transcript | String | 完整的用户语音转写文本 | | ai_response | String | 完整的 AI 响应文本 | **客户端处理**: - 更新 UI 显示完整对话 - 可开始下一轮对话 - 建议保存对话历史 --- ### 8. 错误 (error) **接收时机**: 处理过程中发生错误 ```json { "type": "error", "code": "DEEPGRAM_ERROR", "message": "Speech recognition failed" } ``` | 字段 | 类型 | 描述 | |------|------|------| | type | String | 固定值 `error` | | code | String | 错误代码 | | message | String | 错误描述 | **错误代码列表**: | 错误代码 | 描述 | 建议处理 | |----------|------|----------| | PARSE_ERROR | 消息解析失败 | 检查消息格式 | | DEEPGRAM_ERROR | 语音识别服务错误 | 重试或提示用户 | | DEEPGRAM_INIT_ERROR | 语音识别初始化失败 | 重新开始会话 | | LLM_ERROR | AI 生成错误 | 重试或提示用户 | | PIPELINE_ERROR | 处理流程错误 | 重新开始会话 | | EMPTY_TRANSCRIPT | 未检测到语音 | 提示用户重新说话 | **客户端处理**: - 显示友好的错误提示 - 根据错误类型决定是否重试 --- ## 完整交互流程 ### 时序图 ``` iOS Client Server | | |------ WebSocket Connect --------->| | ?token=xxx | | | |<-------- Connected ---------------| | | |------ session_start ------------->| | | |<----- session_started ------------| | {session_id: "abc"} | | | |======= 用户开始说话 ===============| | | |------ Binary (audio) ------------>| |------ Binary (audio) ------------>| |<----- transcript_interim ---------| | {text: "Hello"} | |------ Binary (audio) ------------>| |<----- transcript_interim ---------| | {text: "Hello how"} | |------ Binary (audio) ------------>| |<----- transcript_final -----------| | {text: "Hello, how are you?"}| | | |======= 用户停止说话 ===============| | | |------ audio_end ----------------->| | | |<----- llm_start ------------------| | | |<----- llm_token ------------------| | {token: "Hi"} | |<----- llm_token ------------------| | {token: "!"} | |<----- Binary (mp3) ---------------| |<----- Binary (mp3) ---------------| |<----- llm_token ------------------| | {token: " I'm"} | |<----- Binary (mp3) ---------------| | ... | |<----- complete -------------------| | {transcript, ai_response} | | | |======= 可以开始下一轮 =============| | | ``` --- ## iOS 代码示例 ### 完整 Swift 实现 ```swift import Foundation import Starscream // WebSocket 库 class VoiceChatManager: WebSocketDelegate { private var socket: WebSocket? private var audioBuffer = Data() // MARK: - 回调 var onSessionStarted: ((String) -> Void)? var onTranscriptInterim: ((String) -> Void)? var onTranscriptFinal: ((String) -> Void)? var onLLMStart: (() -> Void)? var onLLMToken: ((String) -> Void)? var onAudioChunk: ((Data) -> Void)? var onComplete: ((String, String) -> Void)? var onError: ((String, String) -> Void)? // MARK: - 连接 func connect(token: String) { let urlString = "wss://api.yourdomain.com/api/ws/chat?token=\(token)" guard let url = URL(string: urlString) else { return } var request = URLRequest(url: url) request.timeoutInterval = 30 socket = WebSocket(request: request) socket?.delegate = self socket?.connect() } func disconnect() { socket?.disconnect() socket = nil } // MARK: - 发送消息 func startSession(language: String = "en", voiceId: String? = nil) { var config: [String: Any] = ["language": language] if let voiceId = voiceId { config["voice_id"] = voiceId } let message: [String: Any] = [ "type": "session_start", "config": config ] sendJSON(message) } func sendAudio(_ data: Data) { socket?.write(data: data) } func endAudio() { sendJSON(["type": "audio_end"]) } func cancel() { sendJSON(["type": "cancel"]) } private func sendJSON(_ dict: [String: Any]) { guard let data = try? JSONSerialization.data(withJSONObject: dict), let string = String(data: data, encoding: .utf8) else { return } socket?.write(string: string) } // MARK: - WebSocketDelegate func didReceive(event: WebSocketEvent, client: WebSocketClient) { switch event { case .connected(_): print("WebSocket connected") case .disconnected(let reason, let code): print("WebSocket disconnected: \(reason) (\(code))") case .text(let text): handleTextMessage(text) case .binary(let data): // 收到 MP3 音频数据 onAudioChunk?(data) case .error(let error): print("WebSocket error: \(error?.localizedDescription ?? "unknown")") default: break } } private func handleTextMessage(_ text: String) { guard let data = text.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let type = json["type"] as? String else { return } switch type { case "session_started": if let sessionId = json["session_id"] as? String { onSessionStarted?(sessionId) } case "transcript_interim": if let text = json["text"] as? String { onTranscriptInterim?(text) } case "transcript_final": if let text = json["text"] as? String { onTranscriptFinal?(text) } case "llm_start": onLLMStart?() case "llm_token": if let token = json["token"] as? String { onLLMToken?(token) } case "complete": if let transcript = json["transcript"] as? String, let aiResponse = json["ai_response"] as? String { onComplete?(transcript, aiResponse) } case "error": if let code = json["code"] as? String, let message = json["message"] as? String { onError?(code, message) } default: print("Unknown message type: \(type)") } } } ``` ### 使用示例 ```swift class VoiceChatViewController: UIViewController { let chatManager = VoiceChatManager() let audioRecorder = AudioRecorder() // 自定义录音类 let audioPlayer = StreamingAudioPlayer() // 自定义流式播放类 override func viewDidLoad() { super.viewDidLoad() setupCallbacks() } func setupCallbacks() { chatManager.onSessionStarted = { [weak self] sessionId in print("Session started: \(sessionId)") // 开始录音 self?.audioRecorder.start { audioData in self?.chatManager.sendAudio(audioData) } } chatManager.onTranscriptInterim = { [weak self] text in self?.transcriptLabel.text = text + "..." } chatManager.onTranscriptFinal = { [weak self] text in self?.transcriptLabel.text = text } chatManager.onLLMStart = { [weak self] in self?.statusLabel.text = "AI is thinking..." } chatManager.onLLMToken = { [weak self] token in self?.aiResponseLabel.text = (self?.aiResponseLabel.text ?? "") + token } chatManager.onAudioChunk = { [weak self] data in self?.audioPlayer.appendData(data) } chatManager.onComplete = { [weak self] transcript, aiResponse in self?.statusLabel.text = "Complete" self?.addToHistory(user: transcript, ai: aiResponse) } chatManager.onError = { [weak self] code, message in self?.showError(message) } } @IBAction func startTapped(_ sender: UIButton) { // 连接并开始会话 chatManager.connect(token: AuthManager.shared.saToken) chatManager.onSessionStarted = { [weak self] _ in self?.chatManager.startSession() } } @IBAction func stopTapped(_ sender: UIButton) { audioRecorder.stop() chatManager.endAudio() } @IBAction func cancelTapped(_ sender: UIButton) { audioRecorder.stop() audioPlayer.stop() chatManager.cancel() } } ``` --- ## 注意事项 ### 1. 音频录制 - 必须使用 PCM 16-bit, 16kHz, Mono 格式 - 建议每 20-100ms 发送一次音频数据 - 录音权限需要在 Info.plist 中声明 ### 2. 音频播放 - 返回的是 MP3 格式音频块 - 需要实现流式播放或缓冲播放 - 建议使用 AVAudioEngine 实现低延迟播放 ### 3. 网络处理 - 实现自动重连机制 - 处理网络切换场景 - 设置合理的超时时间 ### 4. 用户体验 - 显示实时转写文本 - 显示 AI 响应状态 - 提供取消按钮 - 处理录音权限被拒绝的情况 ### 5. 调试建议 - 使用 `wss://` 确保生产环境安全 - 本地开发可使用 `ws://` - 检查 Sa-Token 是否过期 --- ## 版本历史 | 版本 | 日期 | 变更 | |------|------|------| | 1.0.0 | 2026-01-21 | 初始版本 |