先把问题拆开:出字慢半拍,既可能是分片太长(音频要先攒够一个分片才发得出去),也可能是网络或服务端在等(发出去后首个返回迟迟不来),还可能是识别返回得快、但渲染端在排队。这三种原因在延迟时间轴上的位置不同,只靠改配置猜,很容易来回折腾却得不出定性结论。可操作的做法是先埋三段计时——采集段、网络往返段、识别返回段——再按现象归因,每次只改一个变量复测。
如果首字延迟与分片长度大体同步变化,优先怀疑分片太长;如果发送到首个返回的耗时抖动大、尾部值远高于均值,优先怀疑网络;如果首字正常而最终结果拖后,或日志时间明显早于字幕出现在屏幕上的时间,优先查下游渲染或静音判定。三段计时要在同一时钟源下记录,同一段音频、同一环境复测,每次只改一个变量,否则结论无法复用。
在采集回调里打时间戳,记录第一帧音频产生时刻
采集段要单独量出来,否则后面所有时间差里都掺着「攒音频」的时间。埋点位置放在音频回调刚进入、拿到这一帧数据的那一刻,用单调时钟取时间戳并随帧透传下去。
import time
def on_audio_frame(frame):
t_capture = time.perf_counter() # 第一帧音频产生时刻,单调时钟
frame['t_capture'] = t_capture
frame['seq'] = next_seq()
ring_buffer.append(frame)
# 只在排查期打印,按序号抽样,避免刷屏反过来影响采集
if frame['seq'] % 50 == 0:
print('capture seq=%s t=%.3f bytes=%s'
% (frame['seq'], t_capture, len(frame['data'])))
替换项:回调函数名、帧字段名按实际 SDK 改;next_seq() 是自增序号,后面要和发送、返回的日志对齐用。验证方式:连续看若干个采集时间戳,相邻差值应接近你的帧长(例如 20ms 一帧就该在 0.020 上下浮动)。如果这个差值本身忽大忽小,先解决采集端阻塞,再谈分片。
同时把分片时长记下来。如果分片设成 500ms、而回调每 20ms 一次,那么从第一帧产生到「攒够一个分片可发送」之间天然存在接近分片长度的等待,这一段是配置决定的,不是网络造成的。
在推流前后记录发送与收到确认的时刻
发送与确认这一段回答的是「网络在等吗」。在真正调用发送之前打点,在收到服务端第一个返回时再打点,两者相减。首个返回具体是确认帧、心跳回包还是首个中间结果,取决于你用的协议,取能最早反映「对方收到了」的那个。
sent = {} # seq -> t_send
def push_chunk(chunk):
t_send = time.perf_counter()
sent[chunk['seq']] = t_send
ws.send(build_payload(chunk)) # 发送动作放在打点之后
print('send seq=%s t=%.3f' % (chunk['seq'], t_send))
def on_message(msg):
t_recv = time.perf_counter()
seq = msg.get('seq')
if seq in sent:
rtt = t_recv - sent.pop(seq)
print('ack seq=%s t=%.3f rtt=%.3f' % (seq, t_recv, rtt))
取平均还是取峰值:网络平稳、只是整体偏高时,用一段时间的均值判断趋势就够;如果现象是「偶尔卡一下、整句字幕突然跳出来」,均值会把这个特征抹平,这时要看尾部值,例如把收集到的 rtt 排序后看最慢的那几个,甚至只看峰值。保守做法是两者都记:均值确认基线,尾部值确认抖动。样本太少的时候(只发了十几个分片),任何统计都只能当参考,不能当结论。
在收到中间结果和最终结果时分别打点
识别端要分两件事:首个中间结果什么时候来,最终结果什么时候来。前者反映首字延迟,后者反映整句延迟,混在一起就没法判断是「识别慢」还是「在等静音」。两类返回都打点,但归属到同一段音频上。
import json, time
def log_event(ev, **kw):
rec = {'ev': ev, 'ts': round(time.perf_counter(), 3)}
rec.update(kw)
print(json.dumps(rec, ensure_ascii=False))
def on_result(msg):
if msg['type'] == 'partial':
log_event('partial', seq=msg['seq'], text=msg.get('text', '')[:12])
elif msg['type'] == 'final':
log_event('final', seq=msg['seq'], text=msg.get('text', '')[:12])
把两类时刻归到一行日志的关键是同一个 seq(或 request id、音频段 id)。如果协议里的中间结果不带 seq,就在发送时给每段音频分配一个本地 id,并确认服务端是否回传;不能回传时,退一步用「发送后最近的一次 partial」近似对齐,但要接受由此带来的对齐误差。渲染侧若还有自己的缓冲区,也在渲染入口补一个点,否则你量到的可能只是识别返回时间,而不是用户看到字幕的时间。
把三类时间差放到同一时间轴上对照
三类时间差分开看,容易得出互相矛盾的印象;放到同一张以 seq 为行、以时刻为列的表里,瓶颈在哪一段就比较直观了。用纯文本表格就够,不需要图表工具。
| seq | t_capture | t_send | t_partial | t_final | 采集段 | 网络段 | 首字段 | 整字段 |
|---|---|---|---|---|---|---|---|---|
| ... | ... | ... | ... | ... | t_send - t_capture | t_ack - t_send | t_partial - t_send | t_final - t_partial |
填写方式:所有时刻必须来自同一时钟源,例如同一个进程里的单调时钟,或各端都做过时钟校正后的相对时间,不要混用墙上时间和相对时间;差值列按同一行相减即可,单位统一成毫秒更好读。观察顺序建议是先看网络段的尾部值,再看首字段随时间推移与分片长度是否同步变化,最后看整字段里是否存在很长的静音尾巴。
按现象归因,并只改一个变量再复测
拿到时间轴表之后,归因按现象走,不要一次改三个参数。
- 现象一:首字延迟与分片长度大体同步变化,网络段却是平稳的。优先怀疑分片太长,或服务端在等一个完整分片边界。先只把分片调短,其他不动,用同一段音频复测。
- 现象二:网络段均值还可以,但尾部值明显抬高,且与说话内容无关。优先怀疑链路或接入点。先换网络环境、换时段复测,不要同时动分片,否则两类变化会互相掩盖。
- 现象三:首字正常、最终结果明显滞后,或者日志时间明显早于屏幕上字幕出现的时间。优先怀疑下游渲染、前端缓冲或静音判定把整句拖后。先在渲染入口再打一个点确认,再决定是否调识别侧参数。
复测必须保持不变的量:同一段音频、同一采样率与编码格式、同一终端和同一并发量、同一服务端区域、同一份打点代码。每次只改一个变量,改完至少跑两遍看是否稳定,再决定保留还是回退。
如果某次调整既没有让对应的时间差变小,也没有让现象发生变化,就把它退回去——这说明该变量不是瓶颈,留着只会让配置越来越难解释。排查收尾时,把最终生效的那组参数和这张时间轴表一起留下来,比留一段结论更有用。