第 6 章:音频流传输协议

4 阅读11分钟

第 6 章:音频流传输协议

数据如何在浏览器和服务端之间流动?


前两章,我们在浏览器端搭好了音频采集和 VAD 模块。现在有一段干净的用户语音 PCM 数据摆在那里,需要发给服务端做 ASR 识别。

问题来了:怎么发?

最朴素的想法是 HTTP 请求——用户说完一句话,POST 一段 base64 编码的音频给服务端,等返回识别结果,再发 LLM 请求,再等 TTS 结果……

但这条路走不通,至少对流式 TTS 不行。TTS 生成的音频是一段一段流式返回的(就像 ChatGPT 打字一样,是一个字一个字出来的),用 HTTP 请求你要等全部生成完才能收到响应,延迟会非常高。

WebSocket 是正确答案。它是一条持久的全双工通道:客户端可以随时往里发数据,服务端也可以随时往外推数据,两个方向互不干扰。

本章的任务是:

  1. 设计客户端和服务端的通信协议(消息格式)
  2. 实现可靠的 WebSocket 客户端封装(含断线重连)
  3. 把上一章的 VAD 输出接入 WebSocket 发送

6.1 WebSocket 基础回顾

WebSocket 协议建立在 HTTP 之上(通过 HTTP Upgrade 握手升级),一旦连接建立,就变成一条 TCP 长连接,支持:

  • 文本消息:任意字符串,我们用 JSON 格式发控制消息
  • 二进制消息ArrayBufferBlob,我们用来发音频数据
浏览器                              服务端
  │                                   │
  │── HTTP GET /ws (Upgrade: websocket) ──→│
  │←── 101 Switching Protocols ─────────│
  │                                   │
  │  ←─────── WebSocket 全双工通道 ───────→│
  │                                   │
  │──→ JSON: {"type":"session_start"} ─→│
  │←── JSON: {"type":"session_info"}  ──│
  │                                   │
  │──→ Binary: PCM 音频帧 ─────────────→│
  │──→ Binary: PCM 音频帧 ─────────────→│
  │──→ JSON: {"type":"vad_end"} ───────→│
  │                                   │
  │←── JSON: {"type":"asr_result"} ───→│
  │←── Binary: TTS 音频 chunk ──────────│
  │←── Binary: TTS 音频 chunk ──────────│
  │←── JSON: {"type":"tts_end"} ───────│

6.2 协议设计

一个好的协议要解决两个问题:

  1. 区分消息类型:这条消息是控制信令还是音频数据?
  2. 消息边界:这条消息从哪里开始、到哪里结束?

WebSocket 天然解决了消息边界问题(WebSocket 帧有长度字段),所以我们只需要处理消息类型的区分。

两种消息通道

我们用消息类型区分控制消息和数据消息:

文本通道(JSON):传控制信令

{"type": "消息类型", "data": {...}, "seq": 1}

二进制通道:传音频数据

[1字节 消息类型标识][payload...]

为什么音频数据用二进制而不是 JSON?因为把 Int16Array 编码成 base64 会增加约 33% 的体积,而且还要 JSON 序列化/反序列化,对高频(每 20ms 一帧)的音频数据太浪费了。

文本消息类型

方向类型含义主要字段
客→服session_start建立会话sample_rate, language
服→客session_info会话确认session_id, server_time
客→服vad_start用户开始说话timestamp
客→服vad_end用户说话结束timestamp, duration_ms
服→客asr_resultASR 识别结果text, is_final
服→客llm_textLLM 生成的文字text, is_final
服→客tts_startTTS 开始输出utterance_id
服→客tts_endTTS 结束输出utterance_id, duration_ms
客→服tts_played客户端播放完毕utterance_id
双向ping / pong心跳检测timestamp
服→客error错误通知code, message

二进制消息格式

二进制消息的第一个字节作为类型标识:

[类型字节 1B][payload N bytes]

类型字节定义:
  0x01 = audio_frame  (客→服,PCM 音频帧)
  0x02 = tts_chunk    (服→客,TTS 音频块)

这样服务端收到二进制消息时,只需要读第一个字节就知道这是什么数据。

完整消息定义

// protocol.js
// 协议常量定义

// 文本消息类型
export const MessageType = Object.freeze({
  // 会话控制
  SESSION_START: 'session_start',
  SESSION_INFO:  'session_info',

  // VAD 事件
  VAD_START: 'vad_start',
  VAD_END:   'vad_end',

  // ASR 结果
  ASR_RESULT: 'asr_result',

  // LLM 输出
  LLM_TEXT: 'llm_text',

  // TTS 控制
  TTS_START:  'tts_start',
  TTS_END:    'tts_end',
  TTS_PLAYED: 'tts_played',

  // 心跳
  PING: 'ping',
  PONG: 'pong',

  // 错误
  ERROR: 'error',
});

// 二进制帧类型(第一个字节)
export const BinaryType = Object.freeze({
  AUDIO_FRAME: 0x01,   // 客→服:麦克风 PCM 数据
  TTS_CHUNK:   0x02,   // 服→客:TTS PCM 数据
});

/**
 * 构造一个 JSON 控制消息
 * @param {string} type     MessageType 中的一个
 * @param {object} data     消息体
 * @param {number} [seq]    序列号(可选)
 */
export function makeTextMessage(type, data = {}, seq = undefined) {
  const msg = { type, ...data };
  if (seq !== undefined) msg.seq = seq;
  return JSON.stringify(msg);
}

/**
 * 把 Int16Array 音频帧打包成二进制消息
 * 格式:[0x01][Int16 采样数据...]
 *
 * @param {Int16Array} frame
 * @returns {ArrayBuffer}
 */
export function packAudioFrame(frame) {
  // 总长度 = 1字节类型 + N字节音频数据
  const buffer = new ArrayBuffer(1 + frame.byteLength);
  const view = new DataView(buffer);

  // 第一个字节:类型标识
  view.setUint8(0, BinaryType.AUDIO_FRAME);

  // 后续字节:音频数据(直接内存拷贝)
  new Int16Array(buffer, 1).set(frame);

  return buffer;
}

/**
 * 解析服务端发来的二进制消息
 * @param {ArrayBuffer} buffer
 * @returns {{ type: number, data: Int16Array }}
 */
export function unpackBinaryMessage(buffer) {
  const view = new DataView(buffer);
  const type = view.getUint8(0);

  // 音频数据从第 1 个字节开始,以 Int16 方式解释
  // 注意:offset 必须是 2 的倍数(Int16Array 的对齐要求)
  // 如果原始 buffer 的偏移不对齐,需要先 slice
  const audioBuffer = buffer.slice(1);
  const data = new Int16Array(audioBuffer);

  return { type, data };
}

6.3 分帧策略:流式传输 vs 整句传输

上一章我们讨论过 VAD 的两种工作模式,对应两种不同的分帧策略:

策略 A:整句传输(推荐新手起步)

说话开始 ──────────────────── 说话结束
    │                              │
    ▼                              ▼
  [VAD_START]  [保存音频到内存]  [VAD_END + 发送完整音频]

实现简单,但有一个问题:如果用户说了 10 秒,这 10 秒的音频要到说话结束才发出去,ASR 才开始工作,延迟很高。

策略 B:流式传输(推荐生产环境)

说话开始后,每 20ms 发一帧
    │
    ▼
[VAD_START][AUDIO_FRAME][AUDIO_FRAME] → ... → [VAD_END]

ASR 可以在用户还在说话时就开始识别,大幅降低首字延迟。这是我们 VoiceBot 采用的方案。

时间线(用户说"今天天气怎么样",耗时约 1.5 秒):

t=0.0s  用户开口 → 发送 VAD_START
t=0.0s  发送 AUDIO_FRAME #1(前 20ms)
t=0.02s 发送 AUDIO_FRAME #2
t=0.04s 发送 AUDIO_FRAME #3
...
t=0.5s  ASR 已经识别出"今天天"(部分结果)
...
t=1.5s  用户说完 → 发送 VAD_END
t=1.6s  ASR 返回最终结果"今天天气怎么样"

相比整句传输(t=1.5s 才发数据,t=2.0s 才有结果)
流式传输可以节省约 0.5 秒延迟

6.4 断线重连机制

网络不稳定是现实,WebSocket 连接可能随时断开。一个好的客户端必须能自动重连。

重连策略:指数退避(Exponential Backoff)

第 1 次断线 → 等 1 秒重连
第 2 次断线 → 等 2 秒重连
第 3 次断线 → 等 4 秒重连
第 4 次断线 → 等 8 秒重连
...最多等 30 秒
┌─────────────┐     连接成功      ┌────────────┐
│  CONNECTING │ ─────────────────→ │  CONNECTED │
│  (连接中)  │ ←───────────────── │  (已连接) │
└─────────────┘     连接断开       └────────────┘
       ↑                                 │
       │                           网络断开/主动关闭
       │                                 │
       │           ┌──────────────┐      │
       └─────────── │ RECONNECTING │ ←───┘
         等待后重连  │  (重连中)   │
                   └──────────────┘
                         │
                   超过最大重试次数
                         │
                         ▼
                   ┌──────────┐
                   │  FAILED  │
                   │  (失败) │
                   └──────────┘

6.5 完整的 WebSocket 客户端封装

// voicebot-client.js

import {
  MessageType,
  BinaryType,
  makeTextMessage,
  packAudioFrame,
  unpackBinaryMessage,
} from './protocol.js';

// WebSocket 连接状态
export const ConnectionState = Object.freeze({
  DISCONNECTED:  'disconnected',
  CONNECTING:    'connecting',
  CONNECTED:     'connected',
  RECONNECTING:  'reconnecting',
  FAILED:        'failed',
});

export class VoiceBotClient {
  /**
   * @param {object} options
   * @param {string}   options.url               WebSocket 服务端地址
   * @param {number}   [options.sampleRate=16000] 音频采样率
   * @param {string}   [options.language='zh']   语言代码
   * @param {number}   [options.maxRetries=5]    最大重试次数
   * @param {number}   [options.baseDelay=1000]  初始重试延迟(ms)
   * @param {number}   [options.maxDelay=30000]  最大重试延迟(ms)
   * @param {number}   [options.pingInterval=15000] 心跳间隔(ms)
   *
   * 事件回调:
   * @param {function} [options.onConnect]       连接成功
   * @param {function} [options.onDisconnect]    连接断开,参数: {code, reason}
   * @param {function} [options.onReconnecting]  正在重连,参数: {attempt, delay}
   * @param {function} [options.onFailed]        重连失败放弃
   * @param {function} [options.onASRResult]     ASR 结果,参数: {text, isFinal}
   * @param {function} [options.onLLMText]       LLM 文字,参数: {text, isFinal}
   * @param {function} [options.onTTSStart]      TTS 开始,参数: {utteranceId}
   * @param {function} [options.onTTSChunk]      TTS 音频块,参数: Int16Array
   * @param {function} [options.onTTSEnd]        TTS 结束,参数: {utteranceId, durationMs}
   * @param {function} [options.onError]         服务端错误,参数: {code, message}
   */
  constructor(options) {
    const {
      url,
      sampleRate = 16000,
      language = 'zh',
      maxRetries = 5,
      baseDelay = 1000,
      maxDelay = 30000,
      pingInterval = 15000,

      onConnect     = () => {},
      onDisconnect  = () => {},
      onReconnecting = () => {},
      onFailed      = () => {},
      onASRResult   = () => {},
      onLLMText     = () => {},
      onTTSStart    = () => {},
      onTTSChunk    = () => {},
      onTTSEnd      = () => {},
      onError       = console.error,
    } = options;

    this.url = url;
    this.sampleRate = sampleRate;
    this.language = language;
    this.maxRetries = maxRetries;
    this.baseDelay = baseDelay;
    this.maxDelay = maxDelay;
    this.pingInterval = pingInterval;

    // 事件回调
    this.handlers = {
      onConnect, onDisconnect, onReconnecting, onFailed,
      onASRResult, onLLMText, onTTSStart, onTTSChunk, onTTSEnd, onError,
    };

    // 内部状态
    this._ws = null;
    this._state = ConnectionState.DISCONNECTED;
    this._retryCount = 0;
    this._retryTimer = null;
    this._pingTimer = null;
    this._sessionId = null;
    this._messageSeq = 0;

    // 是否主动断开(区分主动断开和异常断开)
    this._intentionalClose = false;
  }

  // ─── 公开 API ────────────────────────────────────────────────────────────

  /**
   * 连接到服务端
   */
  connect() {
    if (this._state === ConnectionState.CONNECTED ||
        this._state === ConnectionState.CONNECTING) {
      return;
    }
    this._intentionalClose = false;
    this._retryCount = 0;
    this._connect();
  }

  /**
   * 主动断开连接
   */
  disconnect() {
    this._intentionalClose = true;
    this._clearTimers();

    if (this._ws) {
      this._ws.close(1000, 'client disconnect');
      this._ws = null;
    }
    this._setState(ConnectionState.DISCONNECTED);
  }

  /**
   * 通知服务端:用户开始说话
   */
  sendVADStart() {
    this._sendText(makeTextMessage(MessageType.VAD_START, {
      timestamp: Date.now(),
    }));
  }

  /**
   * 通知服务端:用户说话结束
   * @param {number} durationMs  说话时长(ms)
   */
  sendVADEnd(durationMs) {
    this._sendText(makeTextMessage(MessageType.VAD_END, {
      timestamp: Date.now(),
      duration_ms: durationMs,
    }));
  }

  /**
   * 发送音频帧(每 20ms 一帧)
   * @param {Int16Array} frame
   */
  sendAudioFrame(frame) {
    if (this._state !== ConnectionState.CONNECTED) return;

    const buffer = packAudioFrame(frame);
    try {
      this._ws.send(buffer);
    } catch (err) {
      console.error('发送音频帧失败:', err);
    }
  }

  /**
   * 通知服务端:TTS 音频已播放完毕
   * @param {string} utteranceId
   */
  sendTTSPlayed(utteranceId) {
    this._sendText(makeTextMessage(MessageType.TTS_PLAYED, { utteranceId }));
  }

  /**
   * 获取当前连接状态
   */
  get state() {
    return this._state;
  }

  /**
   * 是否已连接
   */
  get isConnected() {
    return this._state === ConnectionState.CONNECTED;
  }

  // ─── 内部方法 ────────────────────────────────────────────────────────────

  _connect() {
    this._setState(ConnectionState.CONNECTING);

    try {
      this._ws = new WebSocket(this.url);
      this._ws.binaryType = 'arraybuffer'; // 接收二进制数据时使用 ArrayBuffer

      this._ws.onopen    = () => this._onOpen();
      this._ws.onmessage = (evt) => this._onMessage(evt);
      this._ws.onclose   = (evt) => this._onClose(evt);
      this._ws.onerror   = (evt) => this._onWSError(evt);
    } catch (err) {
      console.error('创建 WebSocket 失败:', err);
      this._scheduleReconnect();
    }
  }

  _onOpen() {
    console.log('[WS] 连接成功');
    this._setState(ConnectionState.CONNECTED);
    this._retryCount = 0;

    // 发送会话初始化消息
    this._sendText(makeTextMessage(MessageType.SESSION_START, {
      sample_rate: this.sampleRate,
      language: this.language,
    }));

    // 启动心跳
    this._startPing();

    this.handlers.onConnect();
  }

  _onMessage(evt) {
    if (typeof evt.data === 'string') {
      this._handleTextMessage(evt.data);
    } else if (evt.data instanceof ArrayBuffer) {
      this._handleBinaryMessage(evt.data);
    }
  }

  _handleTextMessage(raw) {
    let msg;
    try {
      msg = JSON.parse(raw);
    } catch (err) {
      console.error('[WS] 无效的 JSON 消息:', raw);
      return;
    }

    switch (msg.type) {
      case MessageType.SESSION_INFO:
        this._sessionId = msg.session_id;
        console.log(`[WS] 会话建立,ID: ${this._sessionId}`);
        break;

      case MessageType.ASR_RESULT:
        this.handlers.onASRResult({
          text: msg.text,
          isFinal: msg.is_final,
        });
        break;

      case MessageType.LLM_TEXT:
        this.handlers.onLLMText({
          text: msg.text,
          isFinal: msg.is_final,
        });
        break;

      case MessageType.TTS_START:
        this.handlers.onTTSStart({ utteranceId: msg.utterance_id });
        break;

      case MessageType.TTS_END:
        this.handlers.onTTSEnd({
          utteranceId: msg.utterance_id,
          durationMs: msg.duration_ms,
        });
        break;

      case MessageType.PONG:
        // 心跳响应,什么都不需要做
        break;

      case MessageType.ERROR:
        console.error(`[WS] 服务端错误 [${msg.code}]:${msg.message}`);
        this.handlers.onError({ code: msg.code, message: msg.message });
        break;

      default:
        console.warn('[WS] 未知消息类型:', msg.type);
    }
  }

  _handleBinaryMessage(buffer) {
    try {
      const { type, data } = unpackBinaryMessage(buffer);

      if (type === BinaryType.TTS_CHUNK) {
        this.handlers.onTTSChunk(data);
      } else {
        console.warn('[WS] 未知二进制消息类型:', type);
      }
    } catch (err) {
      console.error('[WS] 解析二进制消息失败:', err);
    }
  }

  _onClose(evt) {
    console.log(`[WS] 连接关闭,code=${evt.code}, reason=${evt.reason}`);
    this._clearTimers();
    this._ws = null;

    const wasConnected = this._state === ConnectionState.CONNECTED;

    if (this._intentionalClose) {
      this._setState(ConnectionState.DISCONNECTED);
      this.handlers.onDisconnect({ code: evt.code, reason: evt.reason });
      return;
    }

    // 非主动关闭:尝试重连
    this.handlers.onDisconnect({ code: evt.code, reason: evt.reason });
    this._scheduleReconnect();
  }

  _onWSError(evt) {
    // WebSocket 的 error 事件通常紧跟着 close 事件
    // 实际错误处理逻辑在 _onClose 里
    console.error('[WS] WebSocket error 事件');
  }

  _scheduleReconnect() {
    if (this._retryCount >= this.maxRetries) {
      console.error(`[WS] 已重试 ${this.maxRetries} 次,放弃连接`);
      this._setState(ConnectionState.FAILED);
      this.handlers.onFailed();
      return;
    }

    // 指数退避:delay = baseDelay * 2^retryCount,上限 maxDelay
    const delay = Math.min(
      this.baseDelay * Math.pow(2, this._retryCount),
      this.maxDelay
    );
    this._retryCount++;

    console.log(`[WS] ${delay}ms 后进行第 ${this._retryCount} 次重连...`);
    this._setState(ConnectionState.RECONNECTING);
    this.handlers.onReconnecting({ attempt: this._retryCount, delay });

    this._retryTimer = setTimeout(() => {
      this._connect();
    }, delay);
  }

  _sendText(message) {
    if (this._state !== ConnectionState.CONNECTED || !this._ws) {
      console.warn('[WS] 未连接,无法发送文本消息');
      return false;
    }
    try {
      this._ws.send(message);
      return true;
    } catch (err) {
      console.error('[WS] 发送文本消息失败:', err);
      return false;
    }
  }

  _setState(newState) {
    if (this._state !== newState) {
      console.log(`[WS] 状态变更:${this._state}${newState}`);
      this._state = newState;
    }
  }

  _startPing() {
    this._pingTimer = setInterval(() => {
      this._sendText(makeTextMessage(MessageType.PING, {
        timestamp: Date.now(),
      }));
    }, this.pingInterval);
  }

  _clearTimers() {
    if (this._retryTimer) {
      clearTimeout(this._retryTimer);
      this._retryTimer = null;
    }
    if (this._pingTimer) {
      clearInterval(this._pingTimer);
      this._pingTimer = null;
    }
  }
}

6.6 发送端:接入 VAD 输出

把上一章的 MicCaptureWithVAD 和这章的 VoiceBotClient 连接起来:

// audio-sender.js
// 负责将 VAD 检测到的语音帧通过 WebSocket 发送给服务端

import { MicCaptureWithVAD } from './mic-capture-with-vad.js';
import { VoiceBotClient, ConnectionState } from './voicebot-client.js';

export class AudioSender {
  constructor({ wsUrl, onStateChange = () => {}, onASRResult = () => {} }) {
    // 初始化 WebSocket 客户端
    this.client = new VoiceBotClient({
      url: wsUrl,

      onConnect: () => {
        onStateChange('connected');
        console.log('WebSocket 已连接,可以开始说话');
      },

      onDisconnect: ({ code, reason }) => {
        onStateChange('disconnected');
      },

      onReconnecting: ({ attempt, delay }) => {
        onStateChange(`reconnecting (${attempt})`);
      },

      onASRResult: ({ text, isFinal }) => {
        onASRResult({ text, isFinal });
      },

      // TTS 相关回调由第 7 章的播放器处理
      onTTSChunk:  () => {},
      onTTSStart:  () => {},
      onTTSEnd:    () => {},
    });

    // 初始化麦克风采集(含 VAD)
    this.mic = new MicCaptureWithVAD({
      energyThreshold: -35,
      speechStartFrames: 3,
      speechEndFrames: 20,

      onSpeechStart: () => {
        // 通知服务端:用户开始说话
        this.client.sendVADStart();
      },

      onAudioFrame: (frame) => {
        // 流式发送音频帧
        this.client.sendAudioFrame(frame);
      },

      onSpeechEnd: (durationMs) => {
        // 通知服务端:用户说话结束
        this.client.sendVADEnd(durationMs);
      },
    });
  }

  async start() {
    // 先连接 WebSocket
    this.client.connect();

    // 再开启麦克风(会弹权限框,在用户点击后调用)
    await this.mic.start();
  }

  async stop() {
    await this.mic.stop();
    this.client.disconnect();
  }
}

6.7 接收端:处理服务端消息

服务端会推送两类关键数据:

  1. JSON 消息:ASR 识别结果、TTS 控制事件
  2. 二进制消息:TTS PCM 音频块

这些已经在 VoiceBotClient 里通过回调分发了。主页面代码这样使用:

// main.js(完整版)

import { AudioSender } from './audio-sender.js';

const WS_URL = `ws://${location.host}/ws/voice`;

// UI 元素
const startBtn  = document.getElementById('start-btn');
const stopBtn   = document.getElementById('stop-btn');
const statusEl  = document.getElementById('status');
const transcript = document.getElementById('transcript');

let sender = null;

async function start() {
  startBtn.disabled = true;
  statusEl.textContent = '正在连接...';

  sender = new AudioSender({
    wsUrl: WS_URL,

    onStateChange(state) {
      statusEl.textContent = {
        'connected':    '已连接,可以说话',
        'disconnected': '连接断开',
        'reconnecting (1)': '重连中 (1/5)...',
        'reconnecting (2)': '重连中 (2/5)...',
      }[state] || state;
    },

    onASRResult({ text, isFinal }) {
      if (isFinal) {
        // 最终识别结果,追加到对话记录
        const p = document.createElement('p');
        p.textContent = `用户:${text}`;
        transcript.appendChild(p);
      } else {
        // 中间结果,实时显示
        statusEl.textContent = `识别中:${text}`;
      }
    },
  });

  try {
    await sender.start();
    stopBtn.disabled = false;
  } catch (err) {
    alert(err.message);
    startBtn.disabled = false;
    sender = null;
  }
}

async function stop() {
  if (!sender) return;
  await sender.stop();
  sender = null;
  startBtn.disabled = false;
  stopBtn.disabled = true;
  statusEl.textContent = '已停止';
}

startBtn.addEventListener('click', start);
stopBtn.addEventListener('click', stop);

6.8 消息时序图

用户说一句话,完整的消息交互如下:

浏览器                                      服务端
  │                                            │
  │────── WS 连接建立 ─────────────────────────→│
  │────── session_start ───────────────────────→│
  │←───── session_info ─────────────────────────│
  │                                            │
  │  [用户开始说话]                             │
  │────── vad_start ───────────────────────────→│  开始 ASR 流
  │────── Binary:audio_frame(0~20ms) ──────────→│
  │────── Binary:audio_frame(20~40ms) ─────────→│
  │────── Binary:audio_frame(40~60ms) ─────────→│
  │         ...                               │
  │←───── asr_result(text="今天", is_final=false)│  中间识别
  │         ...                               │
  │────── Binary:audio_frame(1480~1500ms) ─────→│
  │────── vad_end(duration_ms=1500) ───────────→│  ASR 提交最终识别
  │                                            │
  │←───── asr_result(text="今天天气怎么样", is_final=true)
  │                                            │  LLM 开始生成
  │←───── llm_text(text="今天", is_final=false) │
  │←───── llm_text(text="今天天气", is_final=false)
  │         ...                               │
  │←───── llm_text(text="今天天气不错……", is_final=true)
  │                                            │  TTS 开始合成
  │←───── tts_start(utterance_id="utt-001") ───│
  │←───── Binary:tts_chunk(前 20ms PCM) ────────│
  │←───── Binary:tts_chunk(20~40ms PCM) ────────│
  │         ...                               │
  │←───── tts_end(utterance_id="utt-001") ─────│
  │  [播放完毕]                                │
  │────── tts_played(utterance_id="utt-001") ──→│
  │                                            │

6.9 常见问题

问题:WebSocket 连接被服务端拒绝(403)

通常是跨域问题。服务端需要配置允许前端页面的来源:

# FastAPI + WebSocket 跨域配置
# 第 8 章会详细讲服务端,这里先给一个参考
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["http://localhost:3000"],
    allow_methods=["*"],
)

问题:发送大量音频帧时 WebSocket 缓冲区积压

如果网络慢,发送速度赶不上采集速度,WebSocket 发送缓冲区会积压。

// 检查缓冲区状态,避免积压
sendAudioFrame(frame) {
  if (this._state !== ConnectionState.CONNECTED) return;

  // bufferedAmount 是待发送字节数
  // 如果超过 1MB,说明网络很慢,暂时跳过这帧
  if (this._ws.bufferedAmount > 1024 * 1024) {
    console.warn('[WS] 发送缓冲区积压,跳过此帧');
    return;
  }

  this._ws.send(packAudioFrame(frame));
}

问题:重连后 session 状态丢失

重连后需要重新发送 session_start 消息。这在我们的实现里已经在 _onOpen 里处理了。

如果服务端维护了会话状态(比如对话历史),需要在 session_start 里带上上次的 session_id

// 重连时带上旧的 session_id
this._sendText(makeTextMessage(MessageType.SESSION_START, {
  sample_rate: this.sampleRate,
  language: this.language,
  resume_session_id: this._sessionId || null,  // 如果有,尝试恢复
}));

本章小结

本章设计并实现了 VoiceBot 的音频流传输协议:

  • 协议分层:JSON 文本通道用于控制信令,二进制通道用于音频数据
  • 消息设计:定义了 10 余种消息类型,覆盖 VAD 事件、ASR 结果、TTS 流等
  • 分帧策略:流式传输(每 20ms 一帧)比整句传输延迟低约 0.5 秒
  • 断线重连:指数退避策略,最多重试 N 次,失败后通知用户
  • 心跳检测:定期发送 PING,防止连接被中间代理超时断开
  • 完整封装VoiceBotClient 把所有细节封装起来,主逻辑只关心回调

这一章打通了浏览器和服务端的通信管道。音频能发出去,TTS 能收回来。

下一章,我们来处理最后一块拼图:把从服务端流回来的 TTS 音频 chunk,在浏览器里边接收边播放,实现流畅的语音输出。