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

19 KiB
Raw Blame History

实时语音对话 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 连接后,准备开始录音前

{
  "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 FrameMP3 音频块

音频规格:

参数
格式 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 初始版本