逐提供方解析实时语音 Barge-in:Friend 桌面端 OpenAI 与 Gemini 打断机制的 Mac/Windows 实现对照 逐提供方解析实时语音 Barge-inFriend 桌面端 OpenAI 与 Gemini 打断机制的 Mac/Windows 实现对照【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend导读实时语音会话中用户打断 AI 正在播放的回复barge-in是决定交互自然度的关键路径。本文以本仓库desktop/windows/docs/mac-parity-audit/track2-groundtruth/03-per-provider-bargein.md这份逐提供方per-providerground-truth 审计为骨架对照 MacPTT 推按通话、逐轮架构与 Windows持续实时会话、无回合边界两侧的真实源码逐行讲清 OpenAI 与 Gemini 两条打断路径的机制差异、Mac 端 session 替换的完整时序、后端统一 token 铸造契约以及 Windows 侧 Gemini 打断尾流音频缺陷的复现假设与最终修复落地。读完本文你将能独立定位任意桌面端实时语音实现中的打断归属问题——即打断决策到底该由客户端显式发出还是完全依赖提供方服务端 VAD。1. 架构框架这不是一次苹果对苹果的移植Mac 端是 PTTpush-to-talk、逐轮per-turn架构。每一次用户话语都是一次显式的beginTurn()→beginInputTurn()→commitInputTurn()循环由物理 PTT 按键驱动。Mac 语义下的 barge-in 指上一条回复仍在生成/播放时用户再次按下 PTT。RealtimeHubController.swift 会显式检测打断状态providerResponseInFlight、本地播放活跃、语音播放活跃并按提供方分支处理。Windowstrack2-voice-bar是持续的、始终打开的实时会话在 session-machine 层不存在 PTT/回合边界。sessionMachine.ts 只有四个状态idle/connecting/live/error完全没有 turn 概念voiceController.ts 中也没有beginTurn/cancelActiveResponse之类的回合身份逻辑。Windows 当前完全依赖各提供方自己的服务端 VAD来检测并通知一次打断——任何一条车道上都不存在客户端主动发起的我要开始新回合请杀掉旧的调用。这是整份审计最重要的结论Mac 的 barge-in 机制是一个绑定在 PTT 回合边界上的系统而 Windows 尚无可挂载打断决策的回合边界。下面各节描述的就是在各自拥有什么的前提下两侧分别做了什么。2. OpenAI 打断客户端显式取消 vs 服务端 VAD 全权代理2.1 MacPTT 触发、会话内取消Mac 的RealtimeHubSession.bargeInStrategyRealtimeHubSession.swift按提供方硬编码var bargeInStrategy: RealtimeHubBargeInStrategy { provider .gemini ? .freshSession : .inSessionCancel }即 OpenAI 永远是.inSessionCancel。在beginTurn()时若判定providerResponseInFlight有回复仍在流式传输且策略为.inSessionCancel控制器走session?.cancelActiveResponse()分支RealtimeHubControllerPushToTalk.swift。该调用RealtimeHubSession.swift在同一条温热的 socket 上依次发送{type: response.cancel}随后清除任何未提交的麦克风缓冲{type: input_audio_buffer.clear}并重置本地簿记openAIResponseActive false、清空挂起的工具调用 ID、移除过期的 response 身份。源码注释点明了设计意图——OpenAI exposes an explicit response.cancel path, so the warm socket and conversation context survive while the next input buffer starts cleanOpenAI 提供了显式 response.cancel 路径因此温 socket 与对话上下文得以存活而下一个输入缓冲从干净状态开始。没有重连、没有重新铸造 token、没有音频缓冲——这是成本最低的路径。2.2 Windows仅服务端 VAD无显式取消openaiSession.ts 的文件头注释直接声明了设计立场Barge-in is the providers server VAD: the mic is NEVER gated locally.Windows 使用openai/agents-realtime的RealtimeSessionOpenAIRealtimeWebRTC传输走 WebRTCSDK 自带的服务端 VAD 回合检测在检测到用户语音覆盖正在进行的回复时会在内部发出取消——应用层只观察其结果output_audio_buffer.cleared被映射为一次说话结束边界用于回声门控的 speaking-end 边openaiSession.tsaudio_interrupted事件同样被接到 speaking-endopenaiSession.ts。2.3 两者等价吗功能上结果相似机制上结构不同。Mac 是针对客户端观察到的事件PTT 按下且responding显式触发取消Windows 没有这样的客户端触发器100% 依赖 OpenAI 实时服务端 VAD 同时完成检测打断与服务端取消。这之所以成立是因为 OpenAI WebRTC 会话默认开启了连续的服务端 VAD 回合检测。由于 Windows 从不运行 PTT/回合边界层客户端层面根本不存在新回合开始时是否已有回复在途这个问题——它不是选择跳过 Mac 的显式取消而是在结构上就没有会触发它的回合边界。对 OpenAI 而言这很可能是正确选择WebRTC 会话内置的服务端 VAD 已端到端掌控全双工打断这是 Realtime API 在server_vad/语义 VAD 回合检测模式下的标准行为Mac 的显式response.cancel本质上是一种双保险/PTT 特化优化在显式用户动作上跳过等待服务端 VAD 检测的往返延迟而非在补救一个坏掉的默认行为。3. Gemini 打断Mac 的 session 替换全流程3.1 为什么没有会话内取消.gemini对应的cancelActiveResponse()是 no-opRealtimeHubSession.swiftcase .gemini: // Gemini cant cleanly cancel a streaming reply (it keeps speaking), so the // controller interrupts Gemini by reconnecting a fresh socket instead. breakGemini Live 的手动 VAD 协议要求每次用户回合都必须用activityStart…activityEnd括起来在旧回合模型仍在生成中途时无法安全地打开新的activityStart——否则有服务端以策略码1008悬空/重复 activity 窗口关闭 socket 的风险。源码注释同样明确RealtimeHubControllerPushToTalk.swiftGemini Live has no reliable in-session cancel for a streaming reply. Reusing that socket can leave the next PTT turn queued behind the old generation…。因此 Mac 的应对是RealtimeHubBargeInStrategy.freshSession——直接丢弃整条 socket 并开一条新的。3.2 决策点RealtimeHubBargeInAction.decide()RealtimeHubSessionPolicies.swiftif providerResponseInFlight { return strategy .freshSession ? .replaceSession : .cancelInSession } return playbackActive ? .stopPlaybackTail : .none若providerResponseInFlight且策略为.freshSession→.replaceSession否则若本地播放活跃 → 仅停止播放尾部否则无动作。3.3 逐步时序RealtimeHubController.beginTurn捕获被打断回合的负载captureInterruptedTurnPayloadIfNeeded()在任何拆除动作之前快照即将被终止的回复的部分用户/助手文本与一个幂等键。立即停止本地播放pcmPlayer?.stop()仅限真正的 barge-in 打断语音播放服务——这一步是同步的先于 socket 置换开始。restartSessionForBargeIn(interruptedTurnTask:)→prepareBargeInReplacement()从当前会话捕获provider、authBYOK key 或临时 token、turnID、responseID立即session?.detach()然后session?.stop()、session nil。detach()会置空 session 的 delegate使旧 socket 后续任何 close/error/message 事件都无法再触达控制器——这也是serverContent.interrupted事件处理在 PTT 打断路径上基本形同虚设的原因携带该事件的 socket 在事件可能触发时已被 detach设置pendingBargeInReplacement PendingBargeInReplacementTurn(turnID:, responseID:)——这正是开始缓冲新回合音频的起点缓冲上限maxBufferedAudioBytes 3_840_000约 120 秒 16kHz s16le。completeBargeInReplacementAfterContinuity()派生异步任务运行RealtimeHubBargeInContinuity.prepareReplacementSession()resolveInterruptedTurn()等待第 1 步的负载recordInterruptedTurn()将该被打断回合持久化到聊天内核被打断的部分回复保存进历史标记interrupted: true循环refreshVoiceSeed()等待回合持久化围栏后刷新 voice seed 上下文重试直至成功startReplacementSession()按认证类型分支.byokKey→startReplacementSessionForBargeIn(provider:auth:)→ 直接startSession(provider:, auth:)复用同一把静态用户 key不重新铸造.ephemeral→remintReplacementSessionForBargeIn(provider:)——铸造一枚全新的临时 token。新会话连接期间进入的用户音频被缓冲而非丢弃feedAudio/sendAudio路径检查pendingBargeInReplacement ! nil改为pendingBargeInReplacement?.appendAudio(pcm16k)而不是写入尚不存在的socket。若用户在新 socket 就绪前就说完话语PTT 释放 → commit则记录pending.pendingCommit true而非真正发送activityEnd。会话就绪时finishBargeInReplacementAfterSessionReady()清除pendingBargeInReplacement若pendingBegin调用live.beginInputTurn(turnID:, responseID:, interrupting: false)——在新socket 上打开一个全新的activityStart窗口interrupting: false因为这条 socket 上并没有需要重置的旧生成按序冲刷缓冲音频flushBargeInReplacementAudioBuffer()→ 对每个 chunk 调用sendAudio(pcm16k, to: s)若pendingCommit现在发送commitInputTurn()→activityEnd置responding true通知VoiceTurnCoordinator。失败路径failBargeInReplacement()——若铸造或连接失败含经failoverBargeInReplacement的提供方故障切换尝试清除替换状态、停止本地播放、退出语音 UI并以.providerFailed原因结束该回合。3.4 服务端interrupted次级/纵深防御路径RealtimeHubSession.handleGemini()仍会处理serverContent.interrupted true事件清除geminiResponsePending与挂起的工具调用 ID——但这针对的是 Gemini自身对仍挂接、仍当前会话的自动打断而非上面 detach 后注定报废的 socket 情形。关键是其对后续回复音频的硬门控emitAudio仅在geminiResponsePending true时才转发modelTurn.parts音频而interrupted会立即将该标志置false。这是对被打断生成尾随音频的硬闸而不只是一次性的本地缓冲清空。3.5 Token 重新铸造需要且已实现——但仅限临时托管会话remintReplacementSessionForBargeIn通过APIClient.shared.mintRealtimeToken(provider:)铸造全新临时 token——与初始连接用的是同一个mint 调用并非专门的替换端点。BYOK 会话跳过铸造直接复用静态 keykey 非单次使用/短时有效无需重铸。铸造过程带minting互斥保护并按代际打标签bargeInReplacementGeneration使已过期的替换请求对应的陈旧 mint 响应被丢弃/重新驱动失败时先尝试提供方故障切换再放弃。4. Windows Gemini 现状已有门控且修复已落地4.1 审计时的表面只有一次性的缓冲清空审计文档记录的原始状态是 geminiSession.ts 的onmessage处理器中打断面只有const sc msg.serverContent if (sc?.interrupted) { // Barge-in: stale audio must never keep playing over the user. player?.clear() } for (const part of sc?.modelTurn?.parts ?? []) { const data part.inlineData?.data if (typeof data string data.length 0) { player?.enqueuePcm16(base64ToBytes(data)) } }没有 PTT 边界、没有 session 替换、没有音频缓冲、没有 re-mint 调用点。4.2 当前工作树interruptedTurnActive门控已落地重要更新当前仓库工作树中审计文档第 5 节给出的最快可测修复已经实现。geminiSession.ts 现包含一个逐回合的interruptedTurnActive布尔门控其注释明确说明镜像 Mac 的geminiResponsePending硬闸// Barge-in gate. Gemini keeps streaming a few trailing PCM parts AFTER it // signals serverContent.interrupted for the barged-in generation; player.clear() // only flushes what is already queued, so without this gate those later chunks // get re-enqueued and bleed stale audio over the user. Mirrors Macs // geminiResponsePending hard gate (RealtimeHubSession.swift): closed the instant // interrupt fires, re-opened at the next turn boundary (turnComplete) ... let interruptedTurnActive false在onmessage中if (sc?.interrupted) { interruptedTurnActive true // 关闸本代际后续音频一律丢弃 player?.clear() // 冲刷已排队内容 } for (const part of sc?.modelTurn?.parts ?? []) { const data part.inlineData?.data if (typeof data string data.length 0 !interruptedTurnActive) { player?.enqueuePcm16(base64ToBytes(data)) } } if (sc?.turnComplete) { interruptedTurnActive false // 回合边界为下一条生成开闸 player?.flush() ... }注意enqueuePcm16前的!interruptedTurnActive条件——这正是审计文档中load-bearing gap承载性缺口的修复不仅冲刷已排队音频还硬性阻止同代际后续onmessage中的尾随 PCM 块被重新入队turnComplete处重新开闸保证下一条生成的音频正常播放。这与 Mac 侧geminiResponsePending的commit 时置位 / interrupt 时清除 / turnComplete 时清除生命周期RealtimeHubSession.swift 一带一一对应。4.3 其余差距项的当前状态Session 替换/socket 丢弃Windows 的连续会话不发送activityStart/activityEnd手动 VAD 帧仅持续sendRealtimeInput({ audio: ... })Mac 要规避的 1008 关闭风险忙碌 socket 上第二个activityStart造成悬空 activity 窗口在 Windows 连续模式下可能根本不适用——审计文档判定这更可能是非问题而非缺失功能需实测验证而非假定需要移植。替换时重新铸造 token因上一条不适用而暂为 moottokenMint.ts 的mintRealtimeToken(provider)是无状态、无副作用的纯函数仅接收provider并返回{provider, token, expiresAt}未来若加 replace 路径可直接复用它铸造会话中新鲜 token无需改动。被打断回合的持久化Mac 在替换 session 前会把被杀掉的局部回复以interrupted: true记入聊天历史。Windows 的聊天持久化层是否等价超出本文件集范围若单独审计 Windows 的连续性/持久化路径值得再做一轮 ground-truth 核查。5. 后端契约POST /v2/realtime/sessionMac 与 Windows 共享同一个 Python 后端 desktop_realtime.py桌面 paywall 认证无平台分支。这正是 Mac 的remintReplacementSessionForBargeIn在 Gemini session 替换时重新调用的端点也正是 Windows tokenMint.ts 头部注释中已封装验证过的契约。5.1 请求与响应请求体MintRequestdesktop_realtime.py{ provider: openai | gemini }。200 响应{ provider, token, expires_at? }OpenAItoken ek_…OpenAI 的client_secretsvalue字段作为 Bearer/WebRTC client secret 使用Geminitoken auth_tokens/…Gemini 的auth_tokens.name作为?access_token/SDKapiKey使用v1alpha。5.2 错误分类mint_error_body所有错误响应均带backend_route: /v2/realtime/session与retryable: bool状态码原因语义400bad_providerprovider 不是openai/gemini503provider_not_configured该提供方服务端 key 缺失响应中provider字段为大写如OpenAI/Gemini与其它位置的小写provider字段大小写不一致——这是源码与测试共同确认的细节见 desktop_realtime.py 与missing_key_error_body_includes_provider测试429/配额provider_quota_exceeded上游 429 或消息含 quota401/403provider_auth_failed上游 401/403 或消息含 invalid api key/permission denied 等5xxprovider_mint_unavailable上游 5xx其他 4xxprovider_mint_rejected其余上游 4xx502provider_mint_transport_error调用上游提供方时的网络/传输失败retryable 429 or 5xx。402/403trial_expired/BYOK 不匹配发生在本 handler之前由PaywalledAuthUser提取器处理Windows 侧的 tokenMint.ts 对同样的错误族做了对称分类401 引导登录、402 提示试用到期、provider-scoped 原因允许切换到另一提供方。5.3 Gemini token 的 TTL服务端强制客户端不可控后端以_SESSION_MAX_MIN 30与启动窗口参数desktop_realtime.py构造 Gemini 的auth_tokensnewSessionExpireTime mint 后的一小段时间窗口审计文档记载为mint 2 分钟——这是开始使用该 token 打开新会话的窗口expireTimemint 30 分钟_SESSION_MAX_MIN 30已确认——会话最大运行时长。这对任何缓冲慢速重连的修复方案都很关键重新铸造的 token 必须在铸造后约 2 分钟内用于打开新会话否则作废而整条会话生命周期不超过 30 分钟。6. 复现假设与验证方法audit 方法论留存尽管门控修复已落地审计文档第 5 节给出的复现假设与验证路径仍值得完整保留——它是理解该缺陷为何存在、以及 Mac 硬闸为何必要的依据。原始声明Gemini 发出serverContent.interrupted之后若同一现已被打断的生成还有后续modelTurn.parts音频在更晚的onmessage中到达Windows 旧实现仍会播放这段打断代际的音频——因为player?.clear()只冲刷看到 interrupted 那一刻已排队的内容无法阻止之后到达的任何数据。复现步骤启动 Windows Gemini 语音会话让 Omi 回复一段足够长的多句回答使 TTS 音频在pcmPlayer中持续流式排队在 Omi 仍在说话时开始说话barge-in音量/清晰度要足以让 Gemini 服务端 VAD 检测到并发出serverContent.interrupted: true插桩观察记录该回合首次interrupted: true之后的每一次onmessage——特别是同一代际后续消息是否仍携带带inlineData音频mimeTypeaudio/pcm的modelTurn.parts以及该音频是否真的到达player.enqueuePcm16()预期缺陷行为旧实现旧回复的可听残响在用户新话语之上/之后响起——因为player?.clear()已执行而下方循环无条件重新入队后续到达的modelTurn.parts没有任何该代际是否还应播放的检查Mac 对照mini oracle对 Mac 参考构建复现同一序列确认 Gemini 在interrupted: true后确实会为同代际继续发送尾随音频Mac 源码注释对turnComplete/generationComplete情形断言了这一点而 barge-in 的interrupted情形仅由geminiResponsePending闸的存在来佐证。若可临时绕过/记录 Mac 的geminiResponsePending闸或借助RealtimeHubSession.swift的#if DEBUG快照钩子可确认尾随音频是否真的在interrupted之后到达以及 Mac 的 freshSession 替换是否让该问题在实践中消失旧 socket 在更多音频到达前已被 detach——这决定了 Windows 具体修复是加一个geminiResponsePending等价闸轻量、就地还是也需要某种形式的会话/代际失效更大工程。结论当前仓库选择了前者。interruptedTurnActive门控正是文档建议的最小修复——不需要移植 Mac 的 socket 替换机制直接封死了第 4.3 节中的承载性缺口其余差距项session 替换、re-mint、被打断回合持久化优先级更低其中 session 替换对 Windows 连续会话架构很可能本就不适用。7. 可继续深入阅读的仓库入口Mac 打断策略与取消实现RealtimeHubSession.swiftbargeInStrategy、cancelActiveResponse、detach、RealtimeHubSessionPolicies.swiftdecide()、RealtimeHubControllerPushToTalk.swiftbeginTurn打断分支Windows 提供方会话openaiSession.ts、geminiSession.ts、sessionMachine.ts、tokenMint.ts后端铸造契约desktop_realtime.py测试佐证AgentPillLifecycleTests.swifttestRealtimeBargeInUsesProviderInterruptionStrategy等验证freshSession/inSessionCancel策略与remintReplacementSessionForBargeIn分支、RealtimeHubBargeInContinuityTests.swift替换连续性、geminiSession.test.tsWindows 侧消息处理器单测一句话总结OpenAI 的打断是显式取消可用时优先显式取消Gemini 的打断是无法会话内取消时整条 socket 换新 本地硬闸挡尾流——理解这一对偶关系是设计任何实时语音产品打断体验的起点。【免费下载链接】FriendAI that sees your screen, listens to your conversations and tells you what to do项目地址: https://gitcode.com/GitHub_Trending/fr/Friend创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考