This commit is contained in:
2026-01-21 17:25:38 +08:00
parent d1d47336c2
commit 36c0b0b210
10 changed files with 1877 additions and 10 deletions

View File

@@ -0,0 +1,771 @@
# 实时语音对话 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 FrameMP3 音频块
**音频规格**:
| 参数 | |
|------|------|
| 格式 | 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 | 初始版本 |