Skip to content
实时语音 AI 系统设计:WebSocket 流式音频传输与语音交互架构
概述
实时语音 AI 系统把“听见、理解、生成、说话”这个循环拆成多个流式阶段。一次完整的语音交互通常经过以下数据路径:
text
麦克风
-> getUserMedia + AudioWorklet
-> PCM/Opus 音频块
-> WebSocket 上行
-> 流式 ASR
-> 文本事件
-> LLM 流式生成
-> 文本事件
-> 句子缓冲
-> TTS 流式合成
-> 音频帧
-> WebSocket 下行
-> 扬声器播放在这条链路中,音频不是作为一个完整文件一次性上传,而是持续产生的字节流;ASR 也不是在用户说完后才返回一个最终文本,而是会输出部分识别结果;LLM 可能边生成边输出 token;TTS 可以边合成边返回音频帧。
因此,传输通道需要满足几个条件:
- 双向通信:客户端同时上传音频、接收文本和音频。
- 低延迟:消息一旦产生,应尽快送达对端。
- 支持二进制:PCM/Opus 音频是二进制数据,不适合经过文本编码无损传输。
- 保持顺序:语音数据对顺序敏感,乱序会导致识别错误或播放异常。
WebSocket 满足这些要求。它基于 TCP,提供可靠的、有序的双向消息传输,并且原生支持二进制帧。与 HTTP/REST 相比,WebSocket 不需要客户端发起一次完整请求才得到响应;与 SSE 相比,WebSocket 是双向的,客户端音频可以直接通过同一条连接上行。WebSocket 也因此成为浏览器到服务端语音网关的常用传输层方案。
WebSocket 并不是专门的音频传输协议。它不提供回声消除、丢包恢复、抖动缓冲等媒体能力。音频采集和播放由浏览器 Web Audio API 负责,传输由 WebSocket 负责,识别、理解、合成由服务端负责。这条边界决定了系统各层的职责划分。
WebSocket 帧、分片与流式音频传输基础
WebSocket 协议(RFC 6455)把数据分为“帧”和“消息”。一条消息可以包含一个或多个帧。每个帧有一个 FIN 位、opcode、掩码标志、长度和 payload。[1]
常用 opcode 包括:
| opcode | 类型 | 说明 |
|---|---|---|
| 0x0 | continuation | 消息的后续分片 |
| 0x1 | text | 文本帧,通常用于 JSON |
| 0x2 | binary | 二进制帧,适合音频数据 |
| 0x8 | close | 关闭握手 |
| 0x9 | ping | 心跳探测 |
| 0xA | pong | 对 ping 的回应 |
帧的分片由 FIN 位表达。发送端先发送 FIN=0 的首帧,再发送若干 continuation 帧,最后发送 FIN=1 的结束帧。分片可以让一条大消息不独占网络缓冲,也让控制帧能够插入到长消息中间。但是对于流式音频来说,分片通常不是正确的建模方式。
原因是,WebSocket 应用层 API 以“完整消息”为单位对外暴露数据。RFC 6455 规定,一条消息可以包含多个分片帧,接收端在应用层收到的是重装后的完整消息;分片边界在接收端不可见。[1] 也就是说,如果发送端把一个 1 秒的音频切成 50 个分片帧组成一条消息,接收端必须等全部 50 帧到齐后才会在应用层收到这条消息,不能期待接收端每收到一个分片就回调一次。
因此,实时语音系统不应该把长音频作为一条大消息分片发送,而应该把音频切成小块,每一块作为一条独立的 WebSocket 消息发送。例如客户端每 20ms 发送一个 640 字节的 PCM 块。这样服务端每收到一条消息就能立刻转发给 ASR,而不必等完整音频结束。
WebSocket 本身没有限制消息大小,但浏览器和 Node.js 的 ws 库都有内存和队列限制。实际传输时仍然需要控制单个消息的体积,避免发送超大二进制块。
浏览器端音频采集与编码
getUserMedia 与 AudioWorklet
浏览器端采集麦克风声音需要使用 navigator.mediaDevices.getUserMedia()。[2]
js
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});这段代码请求一个单声道音频流,并启用回声消除、降噪和自动增益。约束条件对浏览器来说是“期望值”,不同设备和浏览器的实际处理效果不同。可以通过 stream.getAudioTracks()[0].getSettings() 查看实际生效的参数。
W3C Media Capture and Streams 规范规定,单次 getUserMedia() 调用返回包含零或一个音频轨、零或一个视频轨的 MediaStream;页面多次调用 getUserMedia() 时,浏览器会合并权限对话框。[2]
getUserMedia() 返回的 MediaStream 不便于按帧读取 PCM 样本。要把音频变成可发送的 PCM 数据,需要把 MediaStream 接进 Web Audio API:
js
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);采集 PCM 的旧方案是 ScriptProcessorNode,它在主线程触发处理,可能造成卡顿,已不推荐。现代方案是 AudioWorklet。AudioWorklet 运行在独立线程,通过 AudioWorkletNode 与主线程通信。process() 是 AudioWorkletProcessor 定义的标准回调,每次音频渲染量子到达时被调用。[3]
先注册一个处理器文件:
js
// pcm-processor.js
class PcmProcessor extends AudioWorkletProcessor {
process(inputs) {
const channel = inputs[0] && inputs[0][0];
if (channel) {
const pcm = toPcm16(channel);
this.port.postMessage(pcm, [pcm.buffer]);
}
return true;
}
}
function toPcm16(samples) {
const buffer = new ArrayBuffer(samples.length * 2);
const view = new DataView(buffer);
for (let i = 0; i < samples.length; i++) {
const s = Math.max(-1, Math.min(1, samples[i]));
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
}
return new Int16Array(buffer);
}
registerProcessor('pcm-processor', PcmProcessor);在主线程中加载并连接:
js
await audioContext.audioWorklet.addModule('/pcm-processor.js');
const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
});
// 通过零增益路由保持音频图活跃,同时避免把输入音播放出来
const gainNode = audioContext.createGain();
gainNode.gain.value = 0;
workletNode.connect(gainNode);
gainNode.connect(audioContext.destination);
source.connect(workletNode);
workletNode.port.onmessage = (event) => {
const pcm = event.data; // Int16Array
sendAudio(pcm);
};AudioWorkletNode 的 process() 是否被持续调用,取决于音频图是否有活跃连接。如果 numberOfOutputs: 1 但不把输出接到 destination,不同浏览器的调度行为可能不一致。这里使用零增益路由:节点输出被接到一个 gain 为 0 的 GainNode,再接 destination。这样 process() 可以持续运行,但不会有可听声音输出。
postMessage(pcm, [pcm.buffer]) 把 PCM 的底层 ArrayBuffer 转移给主线程,避免复制。转移后该 buffer 不再属于 worklet 线程,因此不要在 postMessage 之后继续使用它。
PCM/Opus 分块与发送节奏
PCM16 是最简单的音频编码:每个采样 2 字节,范围是 -32768 到 32767。单声道 16kHz 的 PCM16 数据率为:
text
16000 样本/秒 × 2 字节/样本 = 32000 字节/秒一个 20ms(0.02 秒)的块大小为:
text
32000 字节/秒 × 0.02 秒 = 640 字节分块大小是系统设计的一个关键参数:
- 块越小,延迟越低,但网络包数量越多,服务端处理开销越高。
- 块越大,网络效率越高,但 ASR 和 VAD 需要更长缓冲,延迟增加。
- 常见选择是 20ms、40ms 或 60ms。某些流式 ASR 服务对块大小有特定要求,需要在接入时确认。
AudioWorklet 的 process() 每次调用传入的采样帧数由浏览器决定,通常不是目标块大小的整数倍。可以把样本累积到目标长度后再发送:
js
class PcmBatchProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.pending = [];
this.targetSamples = 320; // 16kHz, 20ms
}
process(inputs) {
const channel = inputs[0] && inputs[0][0];
if (!channel) {
return true;
}
for (let i = 0; i < channel.length; i++) {
this.pending.push(channel[i]);
}
while (this.pending.length >= this.targetSamples) {
const samples = new Float32Array(this.targetSamples);
for (let i = 0; i < this.targetSamples; i++) {
samples[i] = this.pending.shift();
}
const pcm = toPcm16(samples);
this.port.postMessage(pcm, [pcm.buffer]);
}
return true;
}
}实际项目中应避免使用 Array.shift() 累积大数组,可以使用环形缓冲区或按块截断。上面的代码用于展示时序关系。
主线程发送时还要考虑背压问题。如果网络速度低于音频产生速度,WebSocket.send() 会把数据排入内部队列,导致内存无限增长。浏览器可以通过 bufferedAmount 观察尚未发送的字节数:
js
const MAX_BUFFERED = 1 << 20;
function sendAudio(pcm) {
if (ws.readyState !== WebSocket.OPEN) {
return;
}
if (ws.bufferedAmount > MAX_BUFFERED) {
// 网络拥塞时丢弃当前块,或停止采集
return;
}
ws.send(pcm);
}如果持续拥塞,只丢块还不够,应该暂停 AudioWorklet 节点或降低采集码率,例如从 PCM 切换到 Opus。
Opus 可以有效降低网络带宽,但浏览器 Web Audio API 不直接提供 Opus 编码器。如果选择 Opus,需要引入 WASM 编码器,或使用支持 Opus 的流式格式。如果服务端 ASR 只接受 PCM16,就没有必要引入 Opus。传输协议可以在 config 消息中声明 format,以便后续扩展。
回声消除的边界在采集端和播放端。echoCancellation 约束让浏览器对麦克风信号做声学回声处理,但 WebSocket 和音频编码并不负责这一环节。TTS 播放时,如果把播放音频重新接入同一个采集链路,仍然可能形成回声或自激。实际系统中,麦克风采集和 TTS 播放应使用独立的音频路径,并尽量依赖浏览器或设备提供的 AEC。
WebSocket 音频消息协议设计
二进制帧与文本帧的选择
音频数据应该使用二进制帧。直接发送 ArrayBuffer 或 Int16Array 的底层 buffer,可以避免文本编码带来的体积开销。Base64 编码会把每 3 个字节编码为 4 个字符,体积增加约 33%。以 640 字节的 PCM 块为例,Base64 后约为 856 字节;持续的音频流会直接放大这一开销。控制消息则使用文本帧 JSON,便于读取和调试。
在浏览器端,需要显式设置二进制类型:
js
const ws = new WebSocket('wss://voice.example.com');
ws.binaryType = 'arraybuffer';在 Node.js 的 ws 库中,message 事件会提供两个参数:数据和是否为二进制:
js
ws.on('message', (data, isBinary) => {
if (isBinary) {
// data 是 Buffer
session.handleAudio(data);
} else {
const msg = JSON.parse(data.toString());
session.handleText(msg);
}
});音频块与控制消息的封装
系统同时存在上行音频、下行 TTS 音频、ASR 文本事件、LLM 文本事件等消息。为了让双方知道一个二进制块属于什么类型,需要在协议层做简单约定。
一种直接的设计是:控制消息全部走 JSON 文本帧,音频数据全部走二进制帧。音频数据的语义由会话阶段决定:
- 上行二进制帧:总是用户麦克风 PCM/Opus 块。
- 下行二进制帧:总是 TTS 音频块。
如果同一方向需要传输多种二进制流,可以在每个音频块之前发送一条文本帧作为元数据,或者在二进制块开头放一个极小的头部。为了保持示例简单,这里采用“会话阶段 + 文本控制消息”的协议。
协议消息示例:
text
客户端 -> 服务端
{ "type": "config", "sessionId": "...", "format": "pcm16", "sampleRate": 16000, "channels": 1 }
<binary: 20ms PCM chunk>
<binary: 20ms PCM chunk>
{ "type": "vad_stop" }
{ "type": "barge_in" }
服务端 -> 客户端
{ "type": "session_ready", "sessionId": "...", "sampleRate": 16000 }
{ "type": "asr_partial", "text": "你好" }
{ "type": "asr_final", "text": "你好,今天的天气怎么样?" }
{ "type": "llm_delta", "text": "根据" }
<binary: TTS audio chunk>
{ "type": "tts_end" }
{ "type": "error", "code": "timeout", "message": "..." }如果音频块需要严格排序,可以在文本帧中加入 sequence。例如 TTS 每个音频块都由一条文本帧声明:
json
{ "type": "tts_audio", "sessionId": "abc", "seq": 1 }随后紧跟二进制音频块。
服务端流式编排:ASR / LLM / TTS
服务端通常由一个 WebSocket 网关和一个会话协调器组成。网关负责接收浏览器连接、解析消息、发送响应。会话协调器负责把音频交给 ASR、把 ASR 结果交给 LLM、把 LLM 输出交给 TTS,并管理状态。
模块划分可以表示为:
text
WebSocket Gateway
-> Session
-> AsrClient
-> LlmClient
-> TtsClient
-> SentenceBuffer每个外部服务的能力和协议不同。例如流式 ASR 通常允许客户端持续上传二进制音频,并返回 partial/final 文本;LLM 流式接口通常通过 SSE 或 WebSocket 返回增量 token;TTS 服务有的需要完整文本,有的可以流式接收文本并返回音频帧。会话层需要把外部接口差异隔离在 Client 适配器内部。
以流式 ASR 服务端为例,常见的内部流水线是:WebSocket Handler 接收音频,Audio Accumulator 累积音频数据,Preprocessor 计算 Mel 频谱,Streaming Encoder 与 Decoder 生成文本输出;会话状态对象管理音频累积并响应 reset 信号。[4]
一个简化的服务端编排骨架:
js
class Session {
constructor(ws) {
this.ws = ws;
this.asr = null;
this.llm = null;
this.tts = null;
this.sentenceBuffer = new SentenceBuffer();
}
handleText(msg) {
switch (msg.type) {
case 'config':
this.asr = createAsrClient(msg);
this.llm = createLlmClient(msg);
this.tts = createTtsClient(msg);
this.send({ type: 'session_ready' });
break;
case 'vad_stop':
this.asr.finishSegment();
break;
case 'barge_in':
this.handleBargeIn();
break;
default:
this.send({ type: 'error', code: 'unknown_type' });
}
}
handleAudio(chunk) {
if (this.asr) {
this.asr.sendAudio(chunk);
}
}
onAsrFinal(text) {
this.llm.send(text);
}
onLlmDelta(delta) {
const sentences = this.sentenceBuffer.push(delta);
for (const sentence of sentences) {
this.tts.speak(sentence);
}
}
onTtsAudio(audio) {
this.sendAudio(audio);
}
handleBargeIn() {
if (this.tts) this.tts.cancel();
if (this.asr) this.asr.reset();
this.state = 'LISTENING';
}
send(obj) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(obj));
}
}
sendAudio(buffer) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buffer);
}
}
}createAsrClient、createLlmClient、createTtsClient 是外部服务适配器的工厂函数,分别封装 ASR、LLM、TTS 供应商的 SDK 与协议细节。实际项目中,这些函数根据 config 消息中的供应商参数创建对应的 Client 实例。
ASR 的流式接入需要不断把音频推给识别服务,并处理两种事件:
- partial:已识别出一部分文本,但结果可能变化。
- final:一段语音识别结束,结果不再改变。
并不是所有 ASR 服务都提供 partial 结果,也不是所有服务都支持服务端 reset。接入时要确认该服务的流式接口能力,例如是否允许中途 reset、是否支持多个 segment、静音端点由谁检测。如果 ASR 不支持 reset,那么打断时只能重新创建 ASR 连接,旧连接关闭。
LLM 输出是 token 流。如果每收到一个 token 就调用一次 TTS,会合成大量不完整的碎片;如果等 LLM 完整输出后再 TTS,又会明显拉长首字延迟。常见做法是使用句子缓冲:把 LLM 增量文本按句号、问号、感叹号等边界切分,每积累完整一句就送到 TTS。
js
class SentenceBuffer {
constructor() {
this.text = '';
}
push(delta) {
this.text += delta;
const sentences = [];
const re = /[^。!?.!?]+[。!?.!?]/g;
let match;
let lastIndex = 0;
while ((match = re.exec(this.text)) !== null) {
sentences.push(match[0].trim());
lastIndex = re.lastIndex;
}
this.text = this.text.slice(lastIndex).trim();
return sentences;
}
}匹配到的句子必须以句末标点结束,因此不完整的流式片段会留在 this.text 中,直到后续 delta 补齐标点。lastIndex 保存最后一次成功匹配的末尾位置;exec() 在匹配失败时会重置正则的 lastIndex,所以这里用局部变量保存最后一次匹配位置。
TTS 流式接口通常会返回一帧一帧的 PCM/Opus 数据。服务端收到后应立即通过 WebSocket 转发给客户端,不要等整段音频生成完。客户端在播放前仍然需要一个小型 jitter buffer,以抵消网络抖动和音频块之间的间隙。
对话状态管理:VAD、打断与轮流说话
实时语音对话需要定义“轮流说话”的状态。这里的“轮流”不一定指半双工通话,而指系统如何区分用户说话、机器说话、等待、打断等阶段。
下面是一个本文使用的状态机示例,不是任何协议标准:
text
IDLE
-> 用户开始说话/系统提示
-> LISTENING
LISTENING
-> 端点检测到用户说完
-> PROCESSING
PROCESSING
-> TTS 首帧到达
-> SPEAKING
SPEAKING
-> 用户打断(barge-in)
-> LISTENING
PROCESSING 或 SPEAKING
-> 超时/错误
-> IDLE状态转换代码:
js
const STATES = {
IDLE: 'IDLE',
LISTENING: 'LISTENING',
PROCESSING: 'PROCESSING',
SPEAKING: 'SPEAKING',
};
class ConversationState {
constructor() {
this.state = STATES.IDLE;
}
transition(event) {
switch (this.state) {
case STATES.LISTENING:
if (event === 'endpoint') {
this.state = STATES.PROCESSING;
}
break;
case STATES.PROCESSING:
if (event === 'tts_start') {
this.state = STATES.SPEAKING;
}
break;
case STATES.SPEAKING:
if (event === 'barge_in') {
this.state = STATES.LISTENING;
}
break;
case STATES.IDLE:
if (event === 'start') {
this.state = STATES.LISTENING;
}
break;
}
}
}端点检测(VAD/endpointing)用于判断用户是否说完一句话。常见策略包括:
- 静音超时:检测到语音后的连续静音超过一定时长,例如 600ms,认为一句话结束。
- 最大时长:单次说话超过上限,强制 endpointer。
- ASR final:ASR 返回 final 结果,表示一句话已经完成。
VAD 可以放在客户端,也可以放在服务端。
客户端 VAD 的优势是可以在本地识别出用户是否开始说话,从而决定是否上传音频、是否触发打断。缺点是客户端设备性能不同,检测算法需要与浏览器音频处理链配合。服务端 VAD 的优势是统一处理逻辑,缺点是客户端必须持续上传音频,增加了网络流量。混合方案是客户端做基础语音活动检测,服务端做最终 endpointing。
“打断”是全双工语音 AI 的关键功能。当 TTS 正在播放时用户开始说话,系统应停止合成和播放,并立即转入监听模式。流程如下:
- 客户端检测到用户语音活动。
- 客户端发送
{ "type": "barge_in" }文本帧。 - 客户端继续上传该用户音频。
- 服务端停止 TTS,丢弃未发送的 TTS 缓冲。
- 服务端调用 ASR reset,让新音频开启一个新 segment。
- 状态切回 LISTENING。
如果不执行 ASR reset,新用户的语音可能被拼接到上一句话的音频流中,导致识别结果异常。不同 ASR 服务的 reset 接口名称不同,行为也可能不同。接入时需要注意这个边界。
半双工模式更简单:系统在机器说话时不接收用户音频,或直接忽略。这种模式不会产生打断,但交互不够自然。WebSocket 协议本身是全双工的,是否支持打断由应用层状态机决定。
Node.js WebSocket 网关示例
下面是一个使用 ws 库实现的网关骨架。它负责连接管理、心跳、消息分发和会话生命周期。[5]
js
const http = require('http');
const WebSocket = require('ws');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('voice gateway\n');
});
const wss = new WebSocket.Server({ server });
const HEARTBEAT_INTERVAL = 30000;
wss.on('connection', (ws) => {
const session = new Session(ws);
ws.isAlive = true;
ws.on('pong', () => {
ws.isAlive = true;
});
ws.on('message', (data, isBinary) => {
if (isBinary) {
session.handleAudio(data);
return;
}
try {
session.handleText(JSON.parse(data.toString()));
} catch (err) {
session.send({ type: 'error', code: 'bad_json' });
}
});
ws.on('close', () => {
session.close();
});
});
const heartbeat = setInterval(() => {
for (const ws of wss.clients) {
if (ws.isAlive === false) {
ws.terminate();
continue;
}
ws.isAlive = false;
ws.ping();
}
}, HEARTBEAT_INTERVAL);
wss.on('close', () => {
clearInterval(heartbeat);
});
server.listen(8080);这里有几个需要注意的点。
ws 库默认不会自动向客户端发送 ping 帧。上面的代码用 setInterval(Node.js 标准定时器)定期调用 ws.ping()。如果某个连接在下一轮心跳时仍未收到 pong,isAlive 会保持 false,服务端调用 ws.terminate() 断开该连接。
ws 库默认启用 autoPong,收到 ping 帧后会自动回复 pong。如果实例化时设置了 autoPong: false,则需要自己监听 ping 事件并调用 ws.pong()。浏览器端的 WebSocket API 会在协议栈内自动回应 ping,JavaScript 层不能直接发送 ping/pong 帧。
检查连接状态时,应使用 WebSocket.OPEN 静态常量,而不是在某个对象上访问 OPEN 属性:
js
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'session_ready' }));
}readyState 是连接实例的属性,WebSocket.OPEN 是 WebSocket 构造函数提供的常量。
Session 类可以继续细分为 ASR、LLM、TTS 三个适配器。外部服务接口不应直接出现在 WebSocket 消息处理函数中。每个适配器负责连接外部服务、处理二进制协议、把事件转换为内部事件。
js
class Session {
constructor(ws) {
this.ws = ws;
this.state = 'IDLE';
this.asr = null;
this.tts = null;
}
handleText(msg) {
switch (msg.type) {
case 'config':
this.asr = createAsrClient(msg);
this.tts = createTtsClient(msg);
this.state = 'LISTENING';
this.send({ type: 'session_ready' });
break;
case 'vad_stop':
this.asr.finishSegment();
break;
case 'barge_in':
this.handleBargeIn();
break;
default:
this.send({ type: 'error', code: 'unknown_message' });
}
}
handleAudio(chunk) {
if (this.state === 'LISTENING' && this.asr) {
this.asr.sendAudio(chunk);
}
}
handleBargeIn() {
if (this.tts) this.tts.cancel();
if (this.asr) this.asr.reset();
this.state = 'LISTENING';
}
send(obj) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(obj));
}
}
sendAudio(buffer) {
if (this.ws.readyState === WebSocket.OPEN) {
this.ws.send(buffer);
}
}
close() {
if (this.asr) this.asr.close();
if (this.tts) this.tts.close();
}
}浏览器端的发送示例:
js
const ws = new WebSocket('wss://voice.example.com');
ws.binaryType = 'arraybuffer';
const stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true },
});
const audioContext = new AudioContext();
const source = audioContext.createMediaStreamSource(stream);
await audioContext.audioWorklet.addModule('/pcm-processor.js');
const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor', {
numberOfInputs: 1,
numberOfOutputs: 1,
});
const gain = audioContext.createGain();
gain.gain.value = 0;
workletNode.connect(gain);
gain.connect(audioContext.destination);
source.connect(workletNode);
workletNode.port.onmessage = (event) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(event.data);
}
};
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
playTtsAudio(event.data);
} else {
handleTextMessage(JSON.parse(event.data));
}
};ws.send(event.data) 在浏览器端可以接受 Int16Array 或 ArrayBuffer。接收 TTS 音频时,需要把二进制数据放入播放队列,而不是直接交给 AudioContext 的 decodeAudioData,因为流式 TTS 音频不是完整文件。playTtsAudio 的具体实现取决于播放策略,通常是将 Int16Array 数据写入 AudioBufferSourceNode 或 AudioWorklet 播放队列。
延迟优化与异常处理
实时语音 AI 的端到端延迟通常从用户说完话开始计算,到机器人开始发声结束。参考 [4] 的端到端示例,一条典型延迟预算可以是:
text
VAD/端点检测:约 200ms
STT 处理:约 30-50ms
LLM 首 token(缓存命中):约 0-370ms
TTS 首帧:约 100-150ms总和约为 500-700ms。
这个预算中的每一项都有优化空间。
VAD 阶段可以缩短静音窗口。过早判断“用户说完”会导致 ASR 内容不完整,过晚会增加等待时间。可以在客户端收到部分识别结果并且出现明显停顿后,提前把文本送入 LLM,同时继续等待 final 结果。不过这会增加 LLM 纠错成本,属于权衡。
LLM 阶段可以使用上下文缓存。语音对话的输入通常较短,如果能命中前缀缓存,首 token 延迟会显著降低。LLM 流式输出本身可以边生成边发到句子缓冲,不需要等完整回复。
TTS 阶段应使用流式合成接口,逐句合成立即发送。等待完整文本再合成为一句话,会让首字延迟增加数百毫秒。
传输阶段应使用二进制帧直传,避免 Base64 编码。WebSocket 连接应复用,不要在每个环节重新建立连接。
客户端播放阶段要控制 jitter buffer 大小。jitter buffer 越大越抗抖动,但延迟越高。对于语音对话,通常应偏向低的缓冲时长。
异常处理可以从几个层面设计。
第一是网络断线。WebSocket 断开后,客户端需要重连。重连不能每 1 秒重试一次,应使用指数退避:
js
let attempt = 0;
function reconnect(sessionId) {
const delay = Math.min(1000 * 2 ** attempt, 10000);
attempt += 1;
setTimeout(() => {
connect(sessionId);
}, delay);
}重连后,服务端可能仍然保存之前的 session 状态,也可能已经销毁。协议中可以加入 resume 消息,由服务端决定是恢复原会话还是创建新会话。如果没有恢复机制,客户端需要重新发送 config。
第二是发送背压。当 ws.bufferedAmount 超过阈值时,客户端应丢弃旧音频块或跳帧。实时语音对实时性的要求高于完整性。如果连续丢弃过多音频,ASR 可能会产生空结果,服务端应该能处理这种“听不到内容”的边界。
第三是外部服务超时。ASR、LLM、TTS 都是独立服务,任何一方都可能长时间无响应。会话层应该为关键事件设置超时:
- 等待
session_ready超时。 - 等待
asr_final超时。 - 等待
tts_audio超时。 - 等待用户说话超时。
超时后发送 error 文本帧,并重置状态机。服务端应该记住某个 session 是否已经发送过错误,避免重复发送。
第四是音频轨道结束。用户拔掉麦克风或浏览器释放设备时,stream.getAudioTracks()[0].addEventListener('ended', ...) 会触发。此时需要关闭 WebSocket 或通知服务端暂停会话,否则服务端会一直等待永远不会到来的音频块。
第五是 TTS 播放中断。遇到打断时,客户端需要清空播放缓冲。服务端已发送但客户端尚未播放的 TTS 音频块应直接丢弃。如果客户端播放器只有一个顺序队列,要设计一个 flush() 方法来丢弃队列中的剩余数据。
限制与替代方案
WebSocket 方案在实时语音 AI 系统中有明确的位置,但也有一些限制。
WebSocket 基于 TCP,连接建立后提供可靠、有序的数据传输。但 TCP 的丢包重传会造成队头阻塞:一个网络包丢失,后续所有包即使已经到达接收方,也不能立即交给应用层。对于一对 UDP 音频流,这种阻塞通常不可接受。WebSocket 音频方案因此更依赖网络质量。
WebSocket 不提供媒体层能力。它没有抖动缓冲、回声消除、前向纠错、带宽估计、自动增益控制。这些能力都需要在上层单独实现,或者依赖浏览器 getUserMedia 约束、AudioContext 播放逻辑。
WebSocket 在服务端需要维护长连接。每个用户连接都会占用文件描述符和内存,横向扩展时还需要考虑 session 路由。客户端的重连和 session 恢复机制是不可省略的。
替代方案中,WebRTC 是更完整的实时媒体传输方案。WebRTC 基于 RTP/SRTP,内建 ICE 打洞、DTLS 加密、NACK/FEC、jitter buffer、AEC 等模块。它适合真正需要高质量音频传输的场景。但 WebRTC 需要独立的信令通道,浏览器端 API 更复杂,服务端也需要媒体服务器或 SFU/MCU。语音 AI 系统可以同时使用 WebSocket 传控制消息、WebRTC 传音频,但架构复杂度会明显增加。
WebTransport 是基于 QUIC 的新兴传输协议,支持多路复用和不可靠传输,能够避免 TCP 队头阻塞。它未来可能成为实时音频传输的选项,但浏览器支持和服务端生态仍在发展中。对于当前需要浏览器兼容性的系统,WebSocket 依然是更直接的选择。
HTTP/2 与 SSE 可以用于服务端向客户端推送文本和音频块,但上行音频仍然需要额外的通道。gRPC 双向流适合服务端之间的 ASR/TTS 接入,但不适合浏览器直接使用。因此,WebSocket 作为浏览器与服务端之间的语音传输通道,在工程上仍然简洁且实用。
