19 KiB
实时语音对话 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)。
消息格式
通用规则
- 文本消息: JSON 格式,用于控制指令和状态通知
- 二进制消息: 原始字节,用于音频数据传输
- 编码: UTF-8
客户端 → 服务端消息
1. 开始会话 (session_start)
发送时机: 建立 WebSocket 连接后,准备开始录音前
{
"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 代码示例:
// 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)
发送时机: 用户停止录音(松开录音按钮)
{
"type": "audio_end"
}
| 字段 | 类型 | 必填 | 描述 |
|---|---|---|---|
| type | String | ✅ | 固定值 audio_end |
说明: 发送此消息后,服务端将完成语音识别并开始生成 AI 响应
4. 取消会话 (cancel)
发送时机: 用户主动取消对话(如点击取消按钮)
{
"type": "cancel"
}
| 字段 | 类型 | 必填 | 描述 |
|---|---|---|---|
| type | String | ✅ | 固定值 cancel |
说明: 服务端将停止所有处理,不再返回任何消息
服务端 → 客户端消息
1. 会话已启动 (session_started)
接收时机: 发送 session_start 后
{
"type": "session_started",
"session_id": "abc123-def456-ghi789"
}
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 session_started |
| session_id | String | 服务端分配的会话 ID |
客户端处理: 收到此消息后,可以开始发送音频数据
2. 轮次开始 (turn_start) 🆕
接收时机: 用户开始说话时(Flux 检测到语音活动)
{
"type": "turn_start",
"turn_index": 0
}
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 turn_start |
| turn_index | Integer | 当前轮次索引(从 0 开始) |
客户端处理:
- 可显示"正在听..."状态
- 准备接收转写结果
3. 中间转写结果 (transcript_interim)
接收时机: 用户说话过程中,实时返回
{
"type": "transcript_interim",
"text": "Hello how are",
"is_final": false
}
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 transcript_interim |
| text | String | 当前识别到的文本(可能会变化) |
| is_final | Boolean | 固定为 false |
客户端处理:
- 实时更新 UI 显示转写文本
- 此文本可能会被后续消息覆盖
- 可用于显示"正在识别..."效果
3. 最终转写结果 (transcript_final)
接收时机: 一句话识别完成时
{
"type": "transcript_final",
"text": "Hello, how are you?"
}
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 transcript_final |
| text | String | 最终确定的转写文本 |
客户端处理:
- 用此文本替换之前的中间结果
- 此文本不会再变化
6. 提前端点检测 (eager_eot) 🆕
接收时机: Flux 检测到用户可能说完时(置信度达到阈值)
{
"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 后,用户继续说话
{
"type": "turn_resumed"
}
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 turn_resumed |
客户端处理:
- 用户继续说话,之前的
eager_eot是误判 - 服务端已取消正在准备的草稿响应
- 恢复"正在听..."状态
- 继续接收
transcript_interim更新
8. LLM 开始生成 (llm_start)
接收时机: 语音识别完成,AI 开始生成响应
{
"type": "llm_start"
}
| 字段 | 类型 | 描述 |
|---|---|---|
| type | String | 固定值 llm_start |
客户端处理:
- 可显示"AI 正在思考..."状态
- 准备接收 AI 响应文本和音频
5. LLM Token (llm_token)
接收时机: AI 生成过程中,逐 token 返回
{
"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 |
| 声道 | 单声道 |
客户端处理:
// 使用 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 响应生成完成,所有音频已发送
{
"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)
接收时机: 处理过程中发生错误
{
"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 实现
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)")
}
}
}
使用示例
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 | 初始版本 |