回声消除和轮次对齐都可能让语音“慢半拍”,但两者的表现并不一样:回声消除是处理链路里的固定开销,通常跟输入信号量、有没有远端参考信号有关;轮次等待是策略判断,取决于静音阈值、插话开关和对方的停顿习惯。所以延迟忽高忽低时,先别改参数,先做三件事——把一次往返拆成五段打点、做两组对照实验、把最差的三轮对回音频。哪一段超出本机基线,再动那一层的旋钮。
判断顺序建议:先把采集、编码、上传、生成、播放五段耗时逐轮记录,再用“无声/有声”和“允许插话/忽略插话”两组对照,把回声消除与轮次等待的额外耗时分离出来。上传和生成在抖,先看网络与首包;采集到编码之间随信号量变化,回头查回声消除;从最后一段有效语音到模型首包差异明显,先调轮次策略。阈值以本机安静环境的中位数为基线,不要用固定毫秒一刀切。
把一次往返拆成采集、编码、上传、生成、播放五段打点
这五段的边界要卡在“数据形态发生变化”的那一刻,而不是卡在函数名上。采集段的结束点是音频帧进入编码器之前;编码段的结束点是编码包就绪;上传段的结束点建议取服务端确认收到尾帧,拿不到就退而用请求发出到收到响应头;生成段的结束点是收到首个音频分片;播放段的结束点是首帧真正送进播放设备,不是它排进队列。
每轮至少记这几个字段,缺一个后面就没法对照:
- turn_id:一次往返共用一个,五段都写。
- seg:capture / encode / upload / generate / playback,另加 aec(链路里能取到就取)。
- t_start、t_end、dur:统一用单调时钟,别用墙上时间。
- 上下文项:rms 或 vad_state、编码码率、payload 字节数、aec_enabled、barge_in、首包类型是文本还是音频分片。
# 通用骨架,字段名按自己的接入层替换
turn_id = new_turn()
t0 = mono_now() # 麦克风回调首次拿到一帧
t0b = mono_now() # AEC 处理返回,取不到就跳过
t1 = mono_now() # 一帧送进编码器之前
t2 = mono_now() # 编码包就绪
t3 = mono_now() # 上传完成 / 服务端确认收到尾帧
t4 = mono_now() # 收到首个生成音频分片
t5 = mono_now() # 首帧送进播放设备
log(turn_id, seg='aec', dur=t0b - t0, rms=frame_rms, vad=vad_state)
log(turn_id, seg='capture', dur=t1 - t0b)
log(turn_id, seg='encode', dur=t2 - t1, bytes=payload_len)
log(turn_id, seg='upload', dur=t3 - t2, bytes=payload_len)
log(turn_id, seg='generate', dur=t4 - t3, first=first_token_type)
log(turn_id, seg='playback', dur=t5 - t4)
骨架只负责产生记录,先跑通再谈分析。落盘建议按轮追加为一行 JSON,方便后面排序取最差三轮。
用一段无声和一段有声对比回声消除的耗时
这一组对照只回答一件事:回声消除是不是在无声输入时空转。
两次实验的设置差异只有一处——输入内容。一次播一段完全无声的音频,麦克风保持打开,不要用静音采集去代替;一次播一段正常说话的音频,长度尽量一致。要固定不动的变量:同一台设备、同一采样率与帧长、同一编码参数、同一轮次策略,并且实验期间不要有上一次播放的尾音残留。上一次播放还没结束就切实验,回声消除会继续跟着参考信号跑,结果没有可比性。
观察项看三处:aec 段的 dur 是否随音频内容变化;capture 段是否在无声时反而变长,VAD 判定和重采样都可能带来这种变化;实验期间的 CPU 占用曲线是否与 dur 同步起伏。如果无声输入下 aec 段仍占着接近有声时的耗时,说明它按固定节奏在跑,属于链路固有开销;如果无声时明显下降,说明耗时随信号量变化,重点转为看它在真实对话里被触发得有多频繁。
在轮次等待上做允许插话与忽略插话的对照
轮次带来的额外等待,体现在“最后一段有效语音结束”到“收到首个生成分片”之间。这一段里既有静音判定,也有要不要接受插话的决策。
对照组设计成 A/B 两次,每次都回放同一段录音,不要让人现场说话,否则停顿位置不可控。A 组允许插话,B 组忽略插话、必须等静音阈值满足。控制变量:同一段音频、同一 aec_enabled、同一网络出口、同一 VAD 参数。记录方式:每轮打一个 t_speech_end,算出 t4 减去 t_speech_end 的值,和上一节的 generate 段分开看;同时记被打断次数与轮次重启次数。
如果 A 组这一项明显更长、而且轮次重启多,通常不是轮次本身慢,而是扬声器回放被采回来后触发了新的插话判定;这时把 aec 打开做第三次对照就能确认。B 组的等待量更稳定,代价是用户中途改口时体验差,这个取舍要结合你自己的对话场景定。
记录三个最差时间点并回放对应音频片段
按 turn_id 汇总每轮五段耗时,排序取最差三轮。取的时候分开看“总时长最差”和“某一段最差”,两者经常不是同一轮。
记录模板建议固定成这几列:turn_id、时间戳、五段 dur、aec_enabled、barge_in、rms 或 vad_state、被打断次数、备注。放在同一张表里,方便横向比较,也方便事后回看。
音频回放定位:在采集侧保留一个环形缓冲,按 turn_id 保存从 t0 前若干秒到 t5 后若干秒的原始音频,文件名带上 turn_id。排查时用音频编辑器打开,按时间轴对齐 t_speech_end 和 t4,直接看那段波形里有没有对方的尾音、有没有自己的扬声器回环,再决定是调轮次阈值还是调回声消除的尾音处理。听不出区别的轮次,就不要拿它当依据。
按对照表决定改采集、改轮次还是改缓冲
阈值写法:以本机安静环境下同一配置跑若干轮后的中位数为基线,某一段持续超过基线两到三倍再去查;只出现一两次尖峰、且和网络抖动时间重合的,先按偶发处理,不要动配置。具体倍数结合你的场景定,不要照抄。
| 异常段落 | 常见特征 | 先查什么 | 可动的旋钮 | 不建议先动 |
|---|---|---|---|---|
| 采集段 | 无声时也长,与 rms 无关 | 采样率转换、音频回调线程与优先级 | 采集缓冲长度 | 编码码率 |
| aec 段 | 无声时仍占接近有声时的耗时 | 远端参考信号是否还在、尾音长度设置 | AEC 尾音长度、参考信号接入点 | 网络参数 |
| 编码段 | 与 payload 大小强相关 | 码率与帧长是否偏大 | 码率、帧长 | 轮次阈值 |
| 上传段 | 忽高忽低,与音频内容无关 | RTT、丢包重传、连接是否重建 | 传输方式、重连策略 | 回声消除 |
| 生成段 | 首分片慢,后续正常 | 是否在等整段、首包格式 | 流式返回开关 | 播放缓冲 |
| 播放段 | 慢且稳定 | 播放缓冲深度、设备启动延迟 | 播放缓冲深度 | 编码参数 |
| 轮次等待 | 允许插话时该项明显更长 | 插话判定是否被回环触发 | 静音阈值、打断判定、AEC | 编码器 |
取舍顺序建议:先动改动最小、影响面最窄的那一层。同一段耗时在无声实验里也偏高,说明是链路固有开销,调轮次没用;只有允许插话时才变长的,先调静音阈值和打断判定,别急着换编码器;上传和生成同步抖动、且伴有重传或连接重建的,先看网络路径和首包,不要靠加深缓冲把问题盖住。