Fun-ASR-Realtime 出字比声音慢半拍 / 是分片太长还是网络在等?

文章导读
先把问题拆开:出字慢半拍,既可能是分片太长(音频要先攒够一个分片才发得出去),也可能是网络或服务端在等(发出去后首个返回迟迟不来),还可能是识别返回得快、但渲染端在排队。这三种原因在延迟时间轴上的位置不同,只靠改配置猜,很容易来回折腾却得不出定性结论。可操作的做法是先埋三段计时——采集段、网络往返段、识别返回段——再按现象归因,每次只改一个变量复测。
📋 目录
  1. A 在采集回调里打时间戳,记录第一帧音频产生时刻
  2. B 在推流前后记录发送与收到确认的时刻
  3. C 在收到中间结果和最终结果时分别打点
  4. D 把三类时间差放到同一时间轴上对照
  5. E 按现象归因,并只改一个变量再复测
A A

先把问题拆开:出字慢半拍,既可能是分片太长(音频要先攒够一个分片才发得出去),也可能是网络或服务端在等(发出去后首个返回迟迟不来),还可能是识别返回得快、但渲染端在排队。这三种原因在延迟时间轴上的位置不同,只靠改配置猜,很容易来回折腾却得不出定性结论。可操作的做法是先埋三段计时——采集段、网络往返段、识别返回段——再按现象归因,每次只改一个变量复测。

如果首字延迟与分片长度大体同步变化,优先怀疑分片太长;如果发送到首个返回的耗时抖动大、尾部值远高于均值,优先怀疑网络;如果首字正常而最终结果拖后,或日志时间明显早于字幕出现在屏幕上的时间,优先查下游渲染或静音判定。三段计时要在同一时钟源下记录,同一段音频、同一环境复测,每次只改一个变量,否则结论无法复用。

在采集回调里打时间戳,记录第一帧音频产生时刻

采集段要单独量出来,否则后面所有时间差里都掺着「攒音频」的时间。埋点位置放在音频回调刚进入、拿到这一帧数据的那一刻,用单调时钟取时间戳并随帧透传下去。

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 一次,那么从第一帧产生到「攒够一个分片可发送」之间天然存在接近分片长度的等待,这一段是配置决定的,不是网络造成的。

Fun-ASR-Realtime 出字比声音慢半拍 / 是分片太长还是网络在等?

在推流前后记录发送与收到确认的时刻

发送与确认这一段回答的是「网络在等吗」。在真正调用发送之前打点,在收到服务端第一个返回时再打点,两者相减。首个返回具体是确认帧、心跳回包还是首个中间结果,取决于你用的协议,取能最早反映「对方收到了」的那个。

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 排序后看最慢的那几个,甚至只看峰值。保守做法是两者都记:均值确认基线,尾部值确认抖动。样本太少的时候(只发了十几个分片),任何统计都只能当参考,不能当结论。

在收到中间结果和最终结果时分别打点

识别端要分两件事:首个中间结果什么时候来,最终结果什么时候来。前者反映首字延迟,后者反映整句延迟,混在一起就没法判断是「识别慢」还是「在等静音」。两类返回都打点,但归属到同一段音频上。

Fun-ASR-Realtime 出字比声音慢半拍 / 是分片太长还是网络在等?
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 为行、以时刻为列的表里,瓶颈在哪一段就比较直观了。用纯文本表格就够,不需要图表工具。

seqt_capturet_sendt_partialt_final采集段网络段首字段整字段
...............t_send - t_capturet_ack - t_sendt_partial - t_sendt_final - t_partial

填写方式:所有时刻必须来自同一时钟源,例如同一个进程里的单调时钟,或各端都做过时钟校正后的相对时间,不要混用墙上时间和相对时间;差值列按同一行相减即可,单位统一成毫秒更好读。观察顺序建议是先看网络段的尾部值,再看首字段随时间推移与分片长度是否同步变化,最后看整字段里是否存在很长的静音尾巴。

Fun-ASR-Realtime 出字比声音慢半拍 / 是分片太长还是网络在等?

按现象归因,并只改一个变量再复测

拿到时间轴表之后,归因按现象走,不要一次改三个参数。

  • 现象一:首字延迟与分片长度大体同步变化,网络段却是平稳的。优先怀疑分片太长,或服务端在等一个完整分片边界。先只把分片调短,其他不动,用同一段音频复测。
  • 现象二:网络段均值还可以,但尾部值明显抬高,且与说话内容无关。优先怀疑链路或接入点。先换网络环境、换时段复测,不要同时动分片,否则两类变化会互相掩盖。
  • 现象三:首字正常、最终结果明显滞后,或者日志时间明显早于屏幕上字幕出现的时间。优先怀疑下游渲染、前端缓冲或静音判定把整句拖后。先在渲染入口再打一个点确认,再决定是否调识别侧参数。

复测必须保持不变的量:同一段音频、同一采样率与编码格式、同一终端和同一并发量、同一服务端区域、同一份打点代码。每次只改一个变量,改完至少跑两遍看是否稳定,再决定保留还是回退。

如果某次调整既没有让对应的时间差变小,也没有让现象发生变化,就把它退回去——这说明该变量不是瓶颈,留着只会让配置越来越难解释。排查收尾时,把最终生效的那组参数和这张时间轴表一起留下来,比留一段结论更有用。