接上 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 与发送回调两点,并在记录里注明这两段没有分开。
// 上行打点骨架,替换三处 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 晚很多,主要就是本地播放缓冲与播放队列造成的等待。
用同一段音频连跑三次,比较每段数字的波动
一次测量只能说明当时是这么多,说明不了哪一段不稳。建议用同一段 5 到 10 秒的固定音频回放,不要用真人即兴说话,连跑三次,把每段耗时填进同一张表,每次仍取中位数与极差。
段位 第1次 第2次 第3次 极差 中位数
采集 capture_ms
编码 encode_ms
发送 queue_ms
首包→播放 play_ms
端到端 total_ms
波动判断依据:先看每一段三次的极差。哪一段的极差明显大于其他段,就先查那一段。某段中位数正常但极差大,通常指向调度或缓冲问题,例如 GC、后台进程抢占、jitter buffer 自适应;三次都稳定但整体偏高,则更像固定配置带来的常量延迟,比如帧长、采样率、缓冲目标值本身设得偏大。这两种情况的处理方向不一样,不要混着改。
需要固定的环境条件:同一台设备、同一音频文件、同一采样率与声道数、同一网络接入方式、同一服务端区域、关闭其它占用麦克风与带宽的程序、保持电源模式不变,省电模式会改变线程调度。
隔离抖动最大的一段,一次只改一个参数再测
确定抖动最大的那一段之后,只动它对应的一个参数,其余全部保持与上一轮一致,这样参数变化和延迟变化才能对应起来。需要保持不变的项包括:音频文件与播放时长、设备与系统电源模式、网络环境、服务端区域与模型配置、采样率与声道数、帧长(除非这次要改的就是帧长)。
轮次 | 改动项(只填一个) | 旧值→新值 | capture_ms | encode_ms | queue_ms | play_ms | 结论
1 | 不改(基线) | - | | | | |
2 | jitterBufferTarget | 200→100 | | | | |
3 | 帧长 frame_ms | 40→20 | | | | |
填写方法:每轮只写一个改动项,其余列留空表示沿用上一轮设置;每次仍然跑三次,记录中位数与极差。只有“改动后该段中位数下降、且极差没有变大”才算这个参数起了作用。如果改了之后某一段变快、另一段反而变慢,说明延迟只是被转移了,这时要看端到端合计,不能只看单段数字。所有结论都以可复现的日志为准,服务端与链路侧的变化,本机参数是解决不了的。