1. 实时语音交互到底难在哪:从一次踩坑说起
去年年底我接了个需求,要给一个内部知识库做语音问答入口。用户按住按钮说话,松开后系统把语音转成文字,检索知识库,再把答案用语音播报出来。听起来就是个标准的语音助手流程,我一开始想得很简单:前端录音,传给后端,后端调语音识别,拿到文本走检索,再把结果合成语音返回。结果真动手才发现,这套流程里最要命的不是识别准确率,也不是检索效果,而是延迟。
用户说完一句话,如果等三秒才听到回应,体验就崩了。传统做法是"录音完整段→上传→识别→处理→合成→下载→播放",每一步都是一个独立的网络往返,光链路耗时就能堆到两三秒。而人跟人对话的响应间隔通常在几百毫秒以内,超过一秒就会觉得对方"卡了"。这个差距不是靠优化某一环能补上的,它是架构本身的问题——你把语音当成一个"文件"来处理,就注定要等文件完整才能开始下一步。
OpenAI Realtime API 解决的就是这个架构层面的问题。它把语音交互从"文件传输"模式改成了"流式会话"模式:客户端通过 WebSocket 建立一条长连接,音频以小块的形式持续推给服务端,服务端一边接收一边理解,理解完直接以音频流的形式推回来。整个过程里没有"完整文件"这个概念,只有持续流动的数据块。这就好比打电话和发语音消息的区别——打电话是实时的,你说一句对方马上能接;发语音消息是异步的,对方得等你整条录完才能听。
这篇文章我想把基于 Realtime API 做实时语音交互的完整实战过程讲清楚。包括 WebSocket 连接怎么建、音频流怎么采集和编码、事件协议怎么处理、前后端怎么配合、延迟怎么压、踩过哪些坑。适合已经有一定前端和后端基础、想动手做一个实时语音应用的开发者。如果你只是想了解概念,那看个大概就行;如果你想真的跑起来一个能用的东西,那这篇里的参数和代码你可以直接抄。
2. 整体架构设计:为什么是 WebSocket 而不是 HTTP
2.1 实时语音交互对通信协议的真实要求
要理解为什么 Realtime API 选 WebSocket,得先想清楚实时语音交互对通信协议提了什么要求。我把它拆成三条:
第一,双向持续通信。语音交互不是"请求-响应"的单向模式,而是双方都在持续发数据。用户说话的时候音频往上走,模型回话的时候音频往下走,而且这两个方向可能同时有数据在传(比如用户打断模型说话的场景)。HTTP 的请求-响应模型天然不支持这种双向同时通信,你只能用轮询或者长轮询去模拟,但那会引入额外的延迟和连接开销。
第二,低延迟的小块传输。音频流是按时间切片的,比如每 20 毫秒一个块。如果用 HTTP,每个块都得走一次完整的请求头、TCP 握手(或复用)、响应头,光协议开销就比数据本身大。WebSocket 在建立连接后,每一帧数据的额外开销只有几个字节,非常适合这种高频小包场景。
第三,服务端主动推送。模型什么时候开始回话、什么时候说完,是服务端决定的,客户端没法预知。HTTP 下客户端只能不停地问"好了没",WebSocket 下服务端可以直接推一个事件过来说"我开始说了"。
这三条要求叠加起来,WebSocket 几乎是唯一合理的选择。它不是"能用",而是"只有它合适"。
2.2 一次完整会话的数据流向拆解
我把一次完整的语音问答会话拆成下面这条链路,后面所有代码和配置都是围绕这条链路展开的:
麦克风采集 → AudioWorklet 处理 → PCM16 编码 → WebSocket 发送 → Realtime API 服务端(VAD 检测 → 语音识别 → 模型推理 → 语音合成) → WebSocket 接收 → 音频块解码 → 音频队列 → 扬声器播放这条链路里有两个关键的设计决策点,我单独说一下。
第一个决策点:音频在哪里编码。浏览器原生的MediaRecorder输出的是 WebM/Opus 格式,而 Realtime API 的音频输入要求是 PCM16 单声道、24kHz 采样率。你可以在前端用MediaRecorder录完再转码,但转码本身有延迟,而且MediaRecorder是按"块"输出的,块的大小不受你控制,可能几百毫秒才给你一块,这对实时性很不利。更好的做法是用AudioWorklet直接拿到原始 PCM 采样,自己按固定大小切片发送。这样每一块的延迟是可控的,我实测下来用 20ms 一块,端到端延迟能压到 500ms 以内。
第二个决策点:音频在哪里播放。服务端推回来的音频也是 PCM16 的块,浏览器不能直接播放 PCM,得先转成AudioBuffer塞进音频上下文。这里有个坑:如果你收到一块就播一块,块与块之间的间隙会导致声音断断续续。正确做法是维护一个播放队列,收到块先入队,用一个独立的调度器按时间轴依次播放,保证连续性。
2.3 前后端职责划分与连接管理策略
Realtime API 的官方设计是让客户端直连 OpenAI 的服务端,用 API Key 做鉴权。但把 API Key 放在前端是绝对不行的,任何人打开开发者工具就能拿到。所以实际项目里必须有一个后端做中转,前端连你的后端,后端再连 OpenAI。
这里有两种中转方案,我对比一下:
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 纯代理转发 | 后端建一条 WebSocket 到 OpenAI,前端连后端,后端原样转发所有帧 | 实现简单,前端代码几乎不用改 | 后端要维护大量长连接,内存和连接数压力大 |
| 事件级中转 | 后端解析事件,只转发必要的事件,音频数据做二次封装 | 可以加业务逻辑、做鉴权、做审计 | 实现复杂,要理解完整事件协议 |
我一开始用的是纯代理转发,因为快。但后来发现两个问题:一是后端连接数一多,内存涨得厉害,每条连接都要缓存音频缓冲;二是没法在中间加业务逻辑,比如我想在用户说话时同时触发一个检索请求,纯转发模式下做不到。后来改成了事件级中转,后端解析input_audio_buffer.speech_started这类事件,在合适的时机插入自己的逻辑。
连接管理上有个细节要注意:WebSocket 长连接会被中间的网络设备(负载均衡、防火墙)因为空闲而断开。Realtime API 官方建议是定期发心跳,但它的协议里没有标准的 ping/pong 帧,你得用session.update或者发一个空的音频块来保活。我实测下来,如果 60 秒没有任何数据往来,连接大概率会被断。所以我在后端加了一个定时器,每 30 秒发一次session.update,把当前会话配置原样再发一遍,既保活又不影响会话状态。
3. 音频采集与编码:从麦克风到 PCM16 的完整链路
3.1 getUserMedia 的参数选择与常见坑
采集音频的第一步是拿到麦克风权限,用navigator.mediaDevices.getUserMedia。这个 API 的参数看起来简单,但选错了会直接影响后面的音频质量。
const stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 24000, echoCancellation: true, noiseSuppression: true, autoGainControl: true } });这里有几个参数我要单独解释。
channelCount: 1是必须的,Realtime API 只接受单声道。如果你传立体声,服务端会报格式错误。sampleRate: 24000是 Realtime API 要求的采样率,但这里有个坑:浏览器不一定会听你的。getUserMedia的sampleRate只是一个"期望值",实际采样率取决于硬件和浏览器实现。我实测在 Chrome 上,即使你写 24000,拿到的AudioContext默认还是 48000。所以你不能依赖这个参数,必须在AudioWorklet里自己做重采样,或者用AudioContext的sampleRate参数强制指定。
echoCancellation、noiseSuppression、autoGainControl这三个我建议都开。回声消除能防止模型的声音被麦克风重新采集进去形成回环,噪声抑制能过滤环境噪音,自动增益能让音量稳定。但要注意,这三个功能在不同浏览器上的实现质量差异很大,Chrome 上效果不错,某些浏览器上开了反而会让声音失真。如果你的应用对音质要求高,建议做成可配置项让用户自己调。
还有一个常见的坑:权限被拒绝后的处理。用户第一次拒绝麦克风权限后,浏览器会记住这个决定,你再调getUserMedia会直接抛错,不会再次弹窗。这时候你得引导用户去浏览器设置里手动开启,或者给一个明确的提示。我见过很多应用在这里直接白屏,用户体验很差。
3.2 AudioWorklet 处理音频帧的核心逻辑
拿到MediaStream后,常规做法是创建一个AudioContext,把流接进去,然后用ScriptProcessorNode处理音频。但ScriptProcessorNode已经被废弃了,而且它运行在主线程上,音频处理会跟 UI 渲染抢资源,导致卡顿。正确做法是用AudioWorklet,它运行在独立的音频线程上,不阻塞主线程。
AudioWorklet的使用分两步。第一步是注册一个处理器:
// pcm-processor.js class PCMProcessor extends AudioWorkletProcessor { constructor() { super(); this.bufferSize = 480; // 20ms @ 24kHz this.buffer = new Float32Array(this.bufferSize); this.offset = 0; } process(inputs) { const input = inputs[0]; if (!input || !input[0]) return true; const channel = input[0]; for (let i = 0; i < channel.length; i++) { this.buffer[this.offset++] = channel[i]; if (this.offset === this.bufferSize) { this.port.postMessage(this.buffer.slice(0)); this.offset = 0; } } return true; } } registerProcessor('pcm-processor', PCMProcessor);这段代码的核心是按固定大小切片。bufferSize = 480对应 24kHz 下 20 毫秒的采样数(24000 * 0.02 = 480)。每次攒够 480 个采样就通过postMessage发给主线程。为什么是 20ms?因为这是实时语音的常用切片大小,太小了网络包太碎开销大,太大了延迟高。20ms 是一个平衡点。
第二步是在主线程里加载这个处理器并连接:
const audioContext = new AudioContext({ sampleRate: 24000 }); await audioContext.audioWorklet.addModule('pcm-processor.js'); const source = audioContext.createMediaStreamSource(stream); const workletNode = new AudioWorkletNode(audioContext, 'pcm-processor'); source.connect(workletNode); workletNode.port.onmessage = (e) => { const float32 = e.data; const pcm16 = float32ToPCM16(float32); sendAudioChunk(pcm16); };注意new AudioContext({ sampleRate: 24000 })这个参数。指定它之后,AudioContext会尝试以 24kHz 运行,如果硬件不支持,浏览器会自动重采样。这样你在AudioWorklet里拿到的就是 24kHz 的数据,不用自己再做重采样。但前面说过,不是所有浏览器都支持指定采样率,所以稳妥起见,你还是应该在AudioWorklet里检查一下sampleRate全局变量,如果跟 24000 不一致,就自己做个线性插值重采样。
3.3 Float32 到 PCM16 的转换与字节序处理
AudioWorklet给出来的是 Float32 数组,取值范围是 -1.0 到 1.0。Realtime API 要的是 PCM16,也就是 16 位有符号整数,取值范围是 -32768 到 32767。转换逻辑不复杂,但有几个细节容易出错。
function float32ToPCM16(float32Array) { const pcm16 = new Int16Array(float32Array.length); for (let i = 0; i < float32Array.length; i++) { const s = Math.max(-1, Math.min(1, float32Array[i])); pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF; } return pcm16; }这里的关键是钳位和非对称缩放。Math.max(-1, Math.min(1, ...))是防止浮点误差导致值超出范围。负数和正数用不同的缩放系数(0x8000 和 0x7FFF)是因为 Int16 的负数范围比正数多一个值,用对称缩放会导致正数溢出。
转换完之后,Int16Array的底层是ArrayBuffer,但 WebSocket 发送时你需要的是ArrayBuffer或者Uint8Array。这里有个字节序问题:PCM16 是小端序(little-endian),而Int16Array在大多数平台上也是小端序,所以直接pcm16.buffer就能用。但如果你在特殊平台上(比如某些 ARM 设备),可能需要手动处理字节序。稳妥做法是用DataView显式写入:
function pcm16ToBuffer(pcm16) { const buffer = new ArrayBuffer(pcm16.length * 2); const view = new DataView(buffer); for (let i = 0; i < pcm16.length; i++) { view.setInt16(i * 2, pcm16[i], true); // true = little-endian } return buffer; }最后发送的时候,Realtime API 要求音频数据用 Base64 编码放在 JSON 事件里,而不是直接发二进制。这一点很多人第一次会搞错,以为 WebSocket 可以直接发二进制音频。实际上 Realtime API 的协议是全 JSON 事件,音频数据要 Base64 编码后作为audio字段的值。
function sendAudioChunk(pcm16) { const base64 = btoa(String.fromCharCode(...new Uint8Array(pcm16.buffer))); ws.send(JSON.stringify({ type: 'input_audio_buffer.append', audio: base64 })); }注意String.fromCharCode(...new Uint8Array(...))这个写法在数据量大时会栈溢出,因为展开运算符把每个字节都当成一个参数。480 个采样是 960 字节,还好;但如果你一次发更大的块,就得改成分批处理或者用TextDecoder之类的技巧。我一般建议每块不超过 4096 字节,超过就拆开发。
4. WebSocket 连接与事件协议实战
4.1 建立连接与鉴权:为什么不能把 Key 放前端
Realtime API 的连接地址是wss://api.openai.com/v1/realtime,鉴权通过Authorization头传 API Key。但浏览器的 WebSocket API不支持自定义请求头,你没法在new WebSocket()的时候加Authorization。官方给的方案是用子协议(subprotocol)传,或者用查询参数传临时 token。
const ws = new WebSocket( 'wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview', ['realtime', 'openai-insecure-api-key.' + apiKey] );这个openai-insecure-api-key子协议的名字里带 "insecure",就是在提醒你:这种方式只适合本地测试,生产环境绝对不能用。因为 API Key 会出现在浏览器的网络面板里,任何人拿到都能盗用你的额度。
生产环境的正确做法是后端签发临时 token。流程是:前端先请求你的后端,后端用真正的 API Key 调 OpenAI 的接口换一个短期有效的临时 token,返回给前端,前端用这个 token 建连接。临时 token 有效期通常几分钟,过期就失效,即使泄露了损失也有限。
如果你像我一样用后端中转,那就更简单了:前端连你自己的后端 WebSocket,后端连 OpenAI,API Key 只存在于后端。前端完全接触不到 Key。这也是我最终采用的方案。
4.2 会话配置:session.update 事件详解
连接建立后,服务端会先推一个session.created事件,告诉你会话建好了。这时候你要发一个session.update事件来配置会话参数。这个事件决定了整个会话的行为,参数很多,我挑几个关键的讲。
ws.send(JSON.stringify({ type: 'session.update', session: { modalities: ['audio', 'text'], instructions: '你是一个知识库助手,回答要简洁,控制在三句话以内。', voice: 'alloy', input_audio_format: 'pcm16', output_audio_format: 'pcm16', input_audio_transcription: { model: 'whisper-1' }, turn_detection: { type: 'server_vad', threshold: 0.5, prefix_padding_ms: 300, silence_duration_ms: 500 }, temperature: 0.8 } }));modalities决定模型输出什么。如果你只要语音,写['audio'];如果要语音加文字转录,写['audio', 'text']。我建议加上text,因为文字转录对调试和日志很有用,而且有些场景下用户可能想看到文字。
voice是音色,可选值有alloy、echo、shimmer等。不同音色的风格差异挺大,alloy比较中性,shimmer偏柔和。这个只能试,没有绝对的好坏。
turn_detection是最关键的配置,它决定服务端怎么判断"用户说完了"。server_vad是服务端语音活动检测,threshold是音量阈值,超过这个值认为是说话;prefix_padding_ms是在检测到说话前多保留多少毫秒的音频(防止把开头吃掉);silence_duration_ms是静音多久算说完。这三个参数直接影响交互体验。
我调这几个参数调了很久。silence_duration_ms设太小(比如 200ms),用户说话中间稍微停顿一下就被判定为说完了,体验很割裂;设太大(比如 1000ms),用户说完要等一秒才有反应,很迟钝。我最后定在 500ms,感觉比较自然。threshold默认 0.5,在安静环境下够用,但如果你在嘈杂环境用,得调高到 0.6 或 0.7,否则背景噪音会误触发。
4.3 事件处理:从 speech_started 到 response.done 的完整循环
Realtime API 的事件是双向的,客户端发事件,服务端也发事件。理解这个事件循环是写好交互逻辑的关键。我把一次完整问答涉及的事件按顺序列出来:
| 事件类型 | 方向 | 含义 | 处理建议 |
|---|---|---|---|
| session.created | 服务端→客户端 | 会话建立 | 发 session.update 配置 |
| session.updated | 服务端→客户端 | 配置生效 | 可以开始发音频 |
| input_audio_buffer.speech_started | 服务端→客户端 | 检测到用户开始说话 | 如果模型正在说话,发 response.cancel 打断 |
| input_audio_buffer.speech_stopped | 服务端→客户端 | 检测到用户说完 | 等待服务端自动触发响应 |
| response.audio.delta | 服务端→客户端 | 模型音频块 | 解码入播放队列 |
| response.audio_transcript.delta | 服务端→客户端 | 模型文字转录块 | 追加到界面显示 |
| response.done | 服务端→客户端 | 响应完成 | 清理状态,准备下一轮 |
这里最需要处理的是打断逻辑。当模型正在说话时,用户突然开口,你应该立即停止播放模型的声音,并给服务端发response.cancel取消当前响应。如果不处理,用户会听到模型继续说完,然后才轮到自己的问题,体验很怪。
case 'input_audio_buffer.speech_started': if (isPlaying) { stopPlayback(); ws.send(JSON.stringify({ type: 'response.cancel' })); } break;另一个要注意的是response.audio.delta的处理。这个事件推的是 Base64 编码的 PCM16 音频块,你要解码后塞进播放队列。解码逻辑跟编码反过来:
function base64ToPCM16(base64) { const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return new Int16Array(bytes.buffer); }拿到Int16Array后,转成 Float32 塞进AudioBuffer,然后调度播放。这里有个细节:AudioBuffer的创建需要指定采样率,服务端推回来的音频是 24kHz,所以AudioContext也应该是 24kHz,否则播放速度会不对。
5. 音频播放与延迟优化:让声音不卡顿
5.1 播放队列的设计与调度算法
服务端推回来的音频块是一个个独立的response.audio.delta,每个块大概几十毫秒。如果你收到一块就创建一个AudioBufferSourceNode播放,块与块之间会有间隙,听起来像结巴。正确做法是维护一个播放队列,用一个调度器按时间轴依次播放。
我的做法是这样的:维护一个nextPlayTime变量,记录下一块应该开始播放的时间。每收到一块,创建一个AudioBufferSourceNode,设置start(nextPlayTime),然后nextPlayTime += buffer.duration。这样块与块之间是无缝衔接的。
let nextPlayTime = 0; function enqueueAudio(pcm16) { const float32 = pcm16ToFloat32(pcm16); const audioBuffer = audioContext.createBuffer(1, float32.length, 24000); audioBuffer.copyToChannel(float32, 0); const source = audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(audioContext.destination); const now = audioContext.currentTime; if (nextPlayTime < now) { nextPlayTime = now + 0.05; // 留 50ms 缓冲 } source.start(nextPlayTime); nextPlayTime += audioBuffer.duration; }nextPlayTime < now这个判断是处理"队列空了"的情况。如果队列里的音频都播完了,nextPlayTime会小于当前时间,这时候要重置为now + 0.05,留一点缓冲防止下一块来不及。这个 50ms 的缓冲很关键,太小了容易断,太大了增加延迟。我试过 20ms 和 100ms,最后定在 50ms 比较稳。
5.2 端到端延迟的构成与压缩手段
端到端延迟是指从用户说完最后一个字,到听到模型第一个字的时间。这个延迟由几部分构成:
- VAD 检测延迟:服务端判断"用户说完了"需要的时间,等于
silence_duration_ms,我设的 500ms。 - 网络往返延迟:音频块从客户端到服务端、响应从服务端到客户端的网络时间,取决于你的网络质量,通常 50-200ms。
- 模型推理延迟:模型理解输入、生成第一个音频块的时间,这个不可控,通常 200-500ms。
- 播放缓冲延迟:我设的 50ms。
加起来大概 800ms 到 1.2 秒。这个数字听起来不理想,但实际体验比数字好,因为用户在说话的时候,前面的音频已经在传了,模型可能已经开始理解了,真正"干等"的时间没那么长。
要压缩延迟,能动的只有两个地方:silence_duration_ms和播放缓冲。silence_duration_ms我试过降到 300ms,响应快了不少,但误判率上升,用户说话中间停顿就被打断。最后我做了个折中:默认 500ms,但提供一个"快速模式"开关,开了之后降到 300ms,适合说话流利、不常停顿的用户。
播放缓冲从 50ms 降到 20ms 也能省 30ms,但网络稍微抖一下就会断音。我建议在网络好的环境下用 20ms,网络差的环境用 80ms 甚至 100ms。可以做一个自适应逻辑:监测断音次数,断得多就自动加大缓冲。
5.3 回声消除与打断处理的配合
回声消除(AEC)在实时语音里特别重要,因为模型的声音从扬声器出来,会被麦克风重新采集,如果不处理,服务端会以为用户在说话,形成死循环。浏览器的echoCancellation: true能处理大部分情况,但它不是万能的,特别是在扬声器音量很大或者设备有硬件回声的情况下。
我的经验是,除了开echoCancellation,还要在应用层做一层保护:模型说话的时候,暂停发送麦克风音频。具体做法是监听response.audio.delta,收到第一个块时把isModelSpeaking设为 true,收到response.done时设为 false。在isModelSpeaking为 true 期间,AudioWorklet的postMessage不发送数据。
但这样有个问题:如果用户想打断模型,麦克风被暂停了就检测不到。所以更好的做法是降低发送音量而不是完全暂停,或者用一个独立的轻量级 VAD 在本地检测用户是否在说话,检测到就恢复发送并触发打断。这个逻辑稍微复杂一点,但体验最好。
我实际项目里用的是简化版:模型说话时正常发送音频,但把echoCancellation开到最强,同时在服务端配置里把threshold调高一点,减少回声误触发。实测下来在大多数设备上够用,只有少数扬声器音量特别大的场景需要额外处理。
6. 常见问题与排查技巧实录
6.1 连接建立失败与鉴权报错排查
连接建不起来是最常见的问题,报错信息往往很模糊,我整理了一个排查顺序。
第一步,确认网络能通到 OpenAI。在终端里curl https://api.openai.com/v1/models带上你的 Key,看能不能返回。如果这一步就失败,那是网络问题,跟代码无关。
第二步,确认 Key 有效且有额度。401 是 Key 无效,429 是额度用完或限流。这两个错误在 WebSocket 里会表现为连接被立即关闭,错误信息在close事件的reason里。
第三步,确认子协议格式正确。如果你用子协议传 Key,格式必须是openai-insecure-api-key.加上 Key,中间那个点不能少。我见过有人写成openai-insecure-api-key:或者漏了点,结果一直 401。
第四步,确认 model 参数正确。查询参数里的model必须是有效的模型名,写错了会 404。当前可用的是gpt-4o-realtime-preview系列,具体名字以官方文档为准。
如果这四步都过了还连不上,那可能是你的运行环境对 WebSocket 有限制。有些企业网络会拦截 WebSocket 连接,或者要求走特定的代理。这种情况你只能换网络环境,或者用后端中转绕过。
6.2 音频格式不匹配导致的静音问题
音频格式不匹配是最隐蔽的问题,因为连接是正常的,事件也在正常收发,就是没声音。我遇到过几次,总结下来有几个常见原因。
采样率不对。如果你发的是 48kHz 的音频,但配置里写的是pcm16(默认 24kHz),服务端会按 24kHz 解读,结果就是播放速度慢一倍,声音变得又低又慢。反过来如果发 24kHz 但服务端按 48kHz 解读,声音会快一倍,像快进。排查方法是录一段自己的声音,发过去,听转录出来的文字对不对。如果文字是乱的,大概率是采样率问题。
声道数不对。Realtime API 只接受单声道,如果你发了立体声,服务端会把左右声道的数据当成连续的单声道数据,结果就是声音被"拉长"了。排查方法是检查getUserMedia的channelCount和AudioWorklet里取的input[0]是不是只有一个声道。
字节序不对。PCM16 是小端序,如果你用了大端序,声音会变成噪音。这个在 x86 和 ARM 上一般不会错,但在某些嵌入式设备上要注意。
Base64 编码错误。如果你用btoa编码的时候没有正确处理二进制数据,编码出来的字符串是错的,服务端解码后就是噪音。排查方法是把编码前后的数据打印出来对比长度,Base64 编码后的长度应该是原始字节数的 4/3 左右。
6.3 播放断续与延迟过高的调优经验
播放断续通常有两个原因:网络抖动和缓冲不足。网络抖动是客观存在的,你只能通过加大缓冲来吸收。缓冲不足是配置问题,可以调。
我的调优顺序是这样的:先把播放缓冲从 50ms 加到 100ms,如果断续消失,说明是缓冲问题,然后逐步往下降,找到不断音的最小值。如果加到 100ms 还断,那可能是网络问题,或者服务端推流本身就不连续。
延迟过高的话,先测一下各段耗时。在客户端记录"用户说完"到"收到第一个音频块"的时间,如果这个时间超过 1.5 秒,那瓶颈在服务端或网络。如果这个时间正常,但"收到第一个块"到"听到声音"的时间长,那是播放缓冲的问题。
还有一个容易被忽略的点:AudioContext的状态。浏览器为了省电,会把不活跃的AudioContext挂起,状态变成suspended。如果你不处理,音频就播不出来。正确做法是在用户第一次交互(比如点击按钮)时调audioContext.resume(),确保它是running状态。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 连接立即关闭 | Key 无效或额度用完 | 看 close 事件的 reason | 换有效 Key,检查额度 |
| 连接建立但无响应 | session.update 没发或格式错 | 打印发送的事件 | 确认事件格式符合协议 |
| 有声音但语速不对 | 采样率不匹配 | 对比配置和实际采样率 | 统一为 24kHz |
| 声音是噪音 | 字节序或编码错误 | 检查 PCM16 转换和 Base64 | 用 DataView 显式小端序 |
| 播放断续 | 缓冲不足或网络抖动 | 加大缓冲测试 | 调到 50-100ms |
| 模型声音被自己触发 | 回声消除不足 | 检查 AEC 配置 | 开 echoCancellation,调高 threshold |
| 打断不生效 | 没发 response.cancel | 检查 speech_started 处理 | 加取消逻辑 |
| 长时间无交互后断开 | 连接空闲超时 | 看断开时间间隔 | 每 30 秒发心跳保活 |
7. 我踩过的几个坑和最后的建议
第一个坑是在AudioWorklet里做重采样。我一开始想省事,在AudioWorklet里直接把 48kHz 的数据线性插值成 24kHz。结果发现音质明显下降,高频部分有金属感。后来改成用AudioContext({ sampleRate: 24000 })让浏览器自己做重采样,音质好很多。浏览器的重采样算法比我自己写的线性插值好得多,能用原生的就用原生的。
第二个坑是Base64 编码的性能。我一开始用btoa(String.fromCharCode(...new Uint8Array(buffer))),小块数据没问题,但有一次我尝试一次发 200ms 的音频(9600 字节),直接栈溢出。后来改成分块编码,每块不超过 4096 字节,问题解决。如果你要发大块音频,一定要分块。
第三个坑是忘记处理response.done。我一开始只处理response.audio.delta,收到就播,没管什么时候结束。结果isModelSpeaking一直是 true,麦克风一直被暂停,用户说不了话。后来加上response.done的处理,把状态重置,才正常。
最后一个建议:先用官方提供的示例代码跑通,再改。Realtime API 的协议细节很多,自己从零写很容易在某个事件格式上卡住。官方仓库里有完整的前端示例,你先把它跑起来,听到声音了,再基于它改造成你的需求。这样能省很多时间。
如果你要做生产级应用,后端中转是必须的,别图省事把 Key 放前端。中转层还能帮你做限流、审计、日志,这些在出问题的时候特别有用。我现在的项目里,每条会话的完整事件流都会落库,排查问题的时候直接查日志,比在浏览器里抓包方便多了。