GPT-Realtime-2.1 接入实时语音链路,先把端到端延迟拆成四段量一遍

文章导读
接上 GPT-Realtime-2.1 之后觉得对话慢,先不要急着调模型参数。端到端延迟通常是四段时间叠加:采集(麦克风缓冲区加回调调度)、编码(重采样与音频编码)、发送(上行链路加服务端处理)、回包播放(下行链路加播放缓冲加音频设备)。这四段里,采集受硬件与系统调度影响,编码受本机 CPU 影响,发送受链路影响,播放段则多半是本地缓冲区配置决定的。合理的做法是先用同一段音频分别量这四段,看哪一段
📋 目录
  1. 一 在客户端给采集、编码、发送各打一个时间戳
  2. 二 在回包路径上记录首包到达与首帧播放的时间差
  3. 三 用同一段音频连跑三次,比较每段数字的波动
  4. 四 隔离抖动最大的一段,一次只改一个参数再测
A A

接上 GPT-Realtime-2.1 之后觉得对话慢,先不要急着调模型参数。端到端延迟通常是四段时间叠加:采集(麦克风缓冲区加回调调度)、编码(重采样与音频编码)、发送(上行链路加服务端处理)、回包播放(下行链路加播放缓冲加音频设备)。这四段里,采集受硬件与系统调度影响,编码受本机 CPU 影响,发送受链路影响,播放段则多半是本地缓冲区配置决定的。合理的做法是先用同一段音频分别量这四段,看哪一段数值最大、哪一段三次重复波动最大,再决定动哪个参数,而不是凭感觉挨个试。

这套拆分的适用场景是:你能在客户端插桩、能用同一段音频重放、至少有一个缓冲区或编码参数可改。操作顺序是先补上行三点与回包两点,再用同一段音频连跑三次看极差,最后只改抖动最大那一段的一个参数复测。验证方式是日志里的时间戳能按 seq 对齐、同一参数两次结果接近。边界也很明确:服务端排队、链路拥塞、音频驱动这类因素不在本机参数可控范围内,量出来是为了判断该改哪里,而不是保证改完一定变快。

在客户端给采集、编码、发送各打一个时间戳

只看“从说话到对方听到”的总时长,判断不出该改哪一段。上行至少打三个点:麦克风回调拿到 PCM 帧的时刻、编码(含重采样)返回的时刻、这一帧写进 socket 或被发送队列接受的时刻。这三段分别对应采集调度、本机 CPU、上行链路三种不同原因,混在一起就只能靠猜。

时间戳建议用单调时钟,浏览器用 performance.now(),Node 用 process.hrtime.bigint(),不要用墙上时间,避免系统校时导致负值。字段名保持固定,方便后续按 seq 对齐上行与下行:

  • session_id:本次会话标识,用于和 SDK 自身日志合并
  • seq:帧序号,用它对上行与回包
  • frame_ms:这一帧的时长,例如 20ms、40ms
  • t_capture / t_encode_done / t_send_done:三个单调时间戳
  • capture_ms、encode_ms、queue_ms:由相邻时间戳相减得到,不要另行估算

下面是一段通用骨架,把 encodeFrame 与 sendFrame 替换成你所用 SDK 的对应调用即可;如果 SDK 把编码和发送包在一个方法里无法插桩,就先只量 t_capture 与发送回调两点,并在记录里注明这两段没有分开。

GPT-Realtime-2.1 接入实时语音链路,先把端到端延迟拆成四段量一遍
// 上行打点骨架,替换三处 SDK 调用
const now = () => performance.now();
const marks = [];

function onCaptureFrame(pcm, seq) {
  const tCapture = now();            // 麦克风回调拿到这一帧
  const encoded = encodeFrame(pcm);  // 你的重采样 + 编码
  const tEncodeDone = now();
  sendFrame(encoded, () => {
    const tSendDone = now();         // 写完 socket 或交给发送队列
    marks.push({
      session_id: SESSION_ID,
      seq: seq,
      frame_ms: FRAME_MS,
      t_capture: tCapture,
      t_encode_done: tEncodeDone,
      t_send_done: tSendDone
    });
  });
}

在回包路径上记录首包到达与首帧播放的时间差

下行要区分两件事:服务端回包到得快不快,以及本地播放缓冲等了多久。取两个时间点就够了。

  • t_first_packet:收到服务端第一个音频数据包、或第一个音频 delta 事件的时刻。在 SDK 的数据回调里打点,不要在 UI 更新回调里打,UI 更新通常晚于数据到达。
  • t_first_play:本机音频设备真正开始输出这一帧的时刻。SDK 提供播放回调或帧出队回调时用那个;只有 WebAudio 时,用 AudioContext.currentTime 与调度时刻换算,不要用 setTimeout 估算。

缓冲区相关的配置项名称各家不同,常见的有 jitterBufferTarget、playoutDelayHint、jitter buffer size,以及浏览器只读的 baseLatency / outputLatency。需要结合你实际使用的 SDK 确认哪个可写、单位是毫秒还是秒,不要照抄别处的数值。

[rt] seq=42 t_first_packet=1234.5 t_first_play=1298.1 packet_to_play_ms=63.6
[rt] buf_jitterBufferTarget=... buf_playoutDelayHint=... out_latency=...

判断方式很直接:如果 t_first_packet 与上一句 t_send_done 的差值明显偏大,方向在下行链路或服务端排队;如果首包很快到达但 t_first_play 晚很多,主要就是本地播放缓冲与播放队列造成的等待。

GPT-Realtime-2.1 接入实时语音链路,先把端到端延迟拆成四段量一遍

用同一段音频连跑三次,比较每段数字的波动

一次测量只能说明当时是这么多,说明不了哪一段不稳。建议用同一段 5 到 10 秒的固定音频回放,不要用真人即兴说话,连跑三次,把每段耗时填进同一张表,每次仍取中位数与极差。

段位                 第1次   第2次   第3次   极差    中位数
采集 capture_ms
编码 encode_ms
发送 queue_ms
首包→播放 play_ms
端到端 total_ms

波动判断依据:先看每一段三次的极差。哪一段的极差明显大于其他段,就先查那一段。某段中位数正常但极差大,通常指向调度或缓冲问题,例如 GC、后台进程抢占、jitter buffer 自适应;三次都稳定但整体偏高,则更像固定配置带来的常量延迟,比如帧长、采样率、缓冲目标值本身设得偏大。这两种情况的处理方向不一样,不要混着改。

GPT-Realtime-2.1 接入实时语音链路,先把端到端延迟拆成四段量一遍

需要固定的环境条件:同一台设备、同一音频文件、同一采样率与声道数、同一网络接入方式、同一服务端区域、关闭其它占用麦克风与带宽的程序、保持电源模式不变,省电模式会改变线程调度。

隔离抖动最大的一段,一次只改一个参数再测

确定抖动最大的那一段之后,只动它对应的一个参数,其余全部保持与上一轮一致,这样参数变化和延迟变化才能对应起来。需要保持不变的项包括:音频文件与播放时长、设备与系统电源模式、网络环境、服务端区域与模型配置、采样率与声道数、帧长(除非这次要改的就是帧长)。

轮次 | 改动项(只填一个)      | 旧值→新值 | capture_ms | encode_ms | queue_ms | play_ms | 结论
1    | 不改(基线)            | -         |            |           |          |         |
2    | jitterBufferTarget      | 200→100   |            |           |          |         |
3    | 帧长 frame_ms           | 40→20     |            |           |          |         |

填写方法:每轮只写一个改动项,其余列留空表示沿用上一轮设置;每次仍然跑三次,记录中位数与极差。只有“改动后该段中位数下降、且极差没有变大”才算这个参数起了作用。如果改了之后某一段变快、另一段反而变慢,说明延迟只是被转移了,这时要看端到端合计,不能只看单段数字。所有结论都以可复现的日志为准,服务端与链路侧的变化,本机参数是解决不了的。