19. 低延迟播放模式:低延迟配置、缓冲区大小优化、实时通信场景
低延迟播放,说白了就是让声音从产生到进入耳朵的时间尽可能短。我最早接触这个需求是在做VoIP通话的时候,那时候用户抱怨最多的一句话就是「你说话怎么像对讲机,我说完三秒你才回」。嗯,那个三秒就是延迟。在实时通信场景里,延迟超过200毫秒,人就能明显感觉到不自然了。
Stagefright这套框架,默认是为媒体播放设计的。播放一个MP4文件,延迟几百毫秒甚至一秒,没人会在意。但换成实时通信,比如微信语音、视频会议、在线K歌,那就不一样了。你想想看,如果唱一句「我爱你」要等半秒才听到自己的声音,那还怎么唱?
低延迟的核心矛盾
低延迟和稳定性,天生就是一对冤家。缓冲区越大,延迟越高,但抗抖动能力越强。缓冲区越小,延迟越低,但稍微有点CPU波动就卡顿。我当年在优化一个实时合唱App时,就踩过这个坑——把缓冲区压到10毫秒,结果用户手机一发热,声音直接变成「电音」。
所以低延迟配置,本质上是在做一道选择题:你要多低的延迟?能承受多大的丢帧风险?
关键指标:实时通信场景下,端到端延迟通常要求 < 150ms。其中音频采集、编码、网络传输、解码、播放各环节加起来,留给播放器的缓冲区时间通常只有20-50ms。
低延迟配置项
Stagefright里跟低延迟相关的配置,主要集中在AudioSink和OMX解码器这两块。我习惯从AudioSink入手,因为它直接决定了数据从解码器出来到扬声器的路径。
1. AudioSink的帧大小设置
默认情况下,AudioSink的帧大小是跟采样率挂钩的。比如48kHz采样率,16位立体声,一帧就是192字节。但低延迟模式下,我们需要更细粒度的控制。
// 低延迟模式下,设置更小的帧大小
audio_sink->setFrameSize(128); // 128帧,约2.67ms @48kHz
audio_sink->setBufferCount(2); // 双缓冲,减少排队延迟
这里有个坑:帧大小设得太小,CPU频繁唤醒,功耗会上升。我曾在某款平板设备上试过64帧,结果播放线程的CPU占用率直接飙到15%。后来折中到128帧,延迟和功耗都还能接受。
2. 解码器输出端口设置
OMX解码器默认会缓存几帧数据,用于解码加速。低延迟模式下,我们需要告诉解码器:别缓存了,来一帧处理一帧。
// 设置解码器为低延迟模式
OMX_PARAM_PORTDEFINITIONTYPE portDef;
portDef.nPortIndex = kOutputPortIndex;
portDef.nBufferCountActual = 2; // 输出端口只保留2个buffer
portDef.nBufferSize = 4096; // 每个buffer大小适当减小
OMX_SetParameter(omx_handle, OMX_IndexParamPortDefinition, &portDef);
我记得有一次,某芯片厂商的解码器不支持动态调整buffer数量,硬设成2个直接崩溃。后来查文档才发现,他们要求最低4个。所以这个配置,最好先查一下芯片的OMX能力集。
缓冲区大小优化策略
缓冲区优化,说白了就是「够用就好」。我总结了一套三步走的策略,在多个项目里验证过,效果还不错。
- 测量最小稳定缓冲区:在目标设备上跑一个压力测试,从大往小调缓冲区,找到刚好不卡顿的临界值。
- 留出余量:在临界值基础上加30%的余量。比如临界值是40ms,那就设52ms。别贪心,余量太少,用户手机一发热就崩。
- 动态调整:根据CPU负载和网络抖动,实时调整缓冲区大小。这个比较复杂,但效果最好。
我的经验:动态调整缓冲区时,不要频繁变化。我一般设一个「调整间隔」,比如每500ms评估一次。变化幅度也有限制,每次最多增减10ms。否则缓冲区忽大忽小,用户能听到明显的「抽吸感」。
实时通信场景的特殊处理
实时通信跟普通播放最大的区别在于:数据是源源不断进来的,而且时间戳必须严格对齐。Stagefright默认的「先缓存再播放」策略,在这里行不通。
1. 时间戳对齐
实时通信场景下,每个音频包都带有发送端的时间戳。播放器必须严格按照这个时间戳来播放,不能自己重新采样或变速。
// 实时模式下,禁用重采样和变速
audio_sink->setPlaybackRate(1.0f, AudioSink::ALLOW_NONE);
audio_sink->setTimestampMode(AudioSink::TIMESTAMP_MODE_RAW);
我曾经遇到过一个诡异的问题:通话时声音越来越快,像开了1.2倍速。查了半天发现是AudioFlinger那边做了采样率转换,把44.1kHz的输入转成了48kHz输出。后来强制设置TIMESTAMP_MODE_RAW,问题才解决。
2. 丢帧补偿
低延迟模式下,网络抖动很容易导致丢帧。直接静音肯定不行,用户会听到「咔咔咔」的断音。我常用的做法是PLC(Packet Loss Concealment,丢包隐藏)。
最简单的PLC就是重复上一帧。但重复多了会有「嗡嗡」的共振声。稍微好一点的做法是:检测到丢帧时,用上一帧和下一帧做线性插值。更高级的会用波形相似性算法,不过那个计算量比较大,低端手机扛不住。
注意:PLC不是万能的。连续丢帧超过3个,再怎么补听起来都怪。这时候不如直接静音,至少不会让用户听到「鬼畜」一样的声音。
低延迟播放的完整流程
我把整个流程画了一张图,方便你理解各个环节的延迟分布。
从图上可以看到,延迟大头其实在网络传输和抖动缓冲上。播放器能优化的部分,主要是解码和AudioSink播放这两块。我个人的经验是:先把播放端的延迟压到30ms以内,再去优化网络端。因为播放端是我们可以完全控制的,网络端受制于运营商和用户环境,优化空间有限。
实战中的避坑指南
最后分享几个我踩过的坑,希望能帮你少走弯路。
- 别信芯片厂商的「低延迟模式」:有些芯片号称支持低延迟,实际测下来延迟还是很高。我遇到过一款芯片,低延迟模式只是把buffer数量从8减到6,延迟只降了10ms。一定要实测,不要只看文档。
- 小心AudioFlinger的混音:Android的AudioFlinger默认会做混音,这个操作本身就会引入几毫秒的延迟。如果只有一路音频流,可以尝试绕过混音,直接走fast track。不过这个需要系统权限,普通App做不到。
- 低延迟和高音质不可兼得:低延迟模式下,我一般用16位PCM,采样率48kHz。别用24位或32位浮点,虽然音质好,但数据量大,延迟会明显增加。实时通信场景,音质够用就行,延迟才是王道。
一个小技巧:调试低延迟时,可以用Android的dumpsys命令查看AudioSink的实时状态。比如 adb shell dumpsys media.audio_flinger 可以看到当前缓冲区的使用情况。如果发现缓冲区经常空,说明设得太小了;如果经常满,说明还有优化空间。
好了,低延迟播放模式的核心内容就这些。说白了就是:缓冲区越小越好,但得留够余量;解码器要配合,别自作主张缓存;实时场景下,时间戳对齐和丢帧补偿是关键。下一章我们会聊到更进阶的话题,不过先把这些基础打牢,后面才能走得更稳。