Files
keyboard/keyBoard/Class/AiTalk/websocket-api.md
2026-01-21 17:25:38 +08:00

772 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 实时语音对话 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 | 初始版本 |