ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

OpenAI Realtime API 实时语音交互实战:WebSocket 架构与延迟优化

OpenAI Realtime API 实时语音交互实战:WebSocket 架构与延迟优化 1. 实时语音交互到底难在哪从一次踩坑说起去年年底我接了个需求要给一个内部知识库做语音问答入口。用户按住按钮说话松开后系统把语音转成文字检索知识库再把答案用语音播报出来。听起来就是个标准的语音助手流程我一开始想得很简单前端录音传给后端后端调语音识别拿到文本走检索再把结果合成语音返回。结果真动手才发现这套流程里最要命的不是识别准确率也不是检索效果而是延迟。用户说完一句话如果等三秒才听到回应体验就崩了。传统做法是录音完整段→上传→识别→处理→合成→下载→播放每一步都是一个独立的网络往返光链路耗时就能堆到两三秒。而人跟人对话的响应间隔通常在几百毫秒以内超过一秒就会觉得对方卡了。这个差距不是靠优化某一环能补上的它是架构本身的问题——你把语音当成一个文件来处理就注定要等文件完整才能开始下一步。OpenAI Realtime API 解决的就是这个架构层面的问题。它把语音交互从文件传输模式改成了流式会话模式客户端通过 WebSocket 建立一条长连接音频以小块的形式持续推给服务端服务端一边接收一边理解理解完直接以音频流的形式推回来。整个过程里没有完整文件这个概念只有持续流动的数据块。这就好比打电话和发语音消息的区别——打电话是实时的你说一句对方马上能接发语音消息是异步的对方得等你整条录完才能听。这篇文章我想把基于 Realtime API 做实时语音交互的完整实战过程讲清楚。包括 WebSocket 连接怎么建、音频流怎么采集和编码、事件协议怎么处理、前后端怎么配合、延迟怎么压、踩过哪些坑。适合已经有一定前端和后端基础、想动手做一个实时语音应用的开发者。如果你只是想了解概念那看个大概就行如果你想真的跑起来一个能用的东西那这篇里的参数和代码你可以直接抄。2. 整体架构设计为什么是 WebSocket 而不是 HTTP2.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?modelgpt-4o-realtime-preview, [realtime, openai-insecure-api-key. apiKey] );这个openai-insecure-api-key子协议的名字里带 insecure就是在提醒你这种方式只适合本地测试生产环境绝对不能用。因为 API Key 会出现在浏览器的网络面板里任何人拿到都能盗用你的额度。生产环境的正确做法是后端签发临时 token。流程是前端先请求你的后端后端用真正的 API Key 调 OpenAI 的接口换一个短期有效的临时 token返回给前端前端用这个 token 建连接。临时 token 有效期通常几分钟过期就失效即使泄露了损失也有限。如果你像我一样用后端中转那就更简单了前端连你自己的后端 WebSocket后端连 OpenAIAPI 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 放前端。中转层还能帮你做限流、审计、日志这些在出问题的时候特别有用。我现在的项目里每条会话的完整事件流都会落库排查问题的时候直接查日志比在浏览器里抓包方便多了。
返回列表