1.引言
1.1目的和对象
该文档用于Android音频流畅性,聚焦优化音频卡顿、杂音问题。适用于Android音频开发人员查看。
1.2背景
游戏、k歌、直播等一些使用场景,音频对时延有较高的要求,保障低延迟,就需要更小的buffer,降低整个链路数据传递时延,但是抗性能抖动能力就会下降。如果音频线程CPU调度延迟,生产数据不及时,系统就会错过buffer周期,产生补0噪声。
一方面,Android音频系统框架对于音频性能策略设计偏保守,没有提供类似IOS,Windows系统那样的一套API 用于标记音频线程,系统也就不知道APP的线程哪些是音频线程,哪些不是。无法对APP音频线程在CPU资源上做一些适度地倾斜。
另一方面,Android生态控制力较弱,多数APP在音频逻辑的实现过程中,忽略了CPU调度因素,没有按照Google的建议设置音频线程的优先级,也就是默认的优先级(Nice 120)。在整机CPU重载场景下,APP内部音频关联线程,特别是解码器线程CPU调度延迟,导致数据生产不及时,触发声音卡顿、杂音问题,这样的问题越来越突出。
一些APP在实现过程中,在关键的播放线程中,调用AudioManager耗时接口,阻塞了数据传递,导致了声音卡顿。
还有一些应用面在主线程/UI渲染线程中,调用Audio的耗时接口,导致游戏画面渲染帧率降低。
与APP开发伙伴交流过程中,发现APP开发者和系统开发者,存在一些理解上的GAP,APP开发者对音频系统不熟悉,而系统开发者对APP内部业务逻辑不熟悉。遇到问题debug起来,耗时较长,不利于提升用户体验。音频流畅性体验是Android基础体验的一个核心,也是一个痛点。本文聚焦音频流畅性分析,希望能有助于音频APP开发者和音频系统开发者联合调优,提升用户音频基础体验。
1.3音频格式
人耳的听觉范围是在20Hz到20KHz之间的频段,不同于人眼的“暂留”生理特性,人耳鼓膜的灵敏度远高于人眼。两耳间距产生的微秒级时延(700us)带来的细微声压、声相变化,可辨别远处的音源方位。普通人可清晰辨识低至10db的响度变化,经过专业训练的“金耳朵”,可识别更低db的响度变化,人耳对时延敏感,对声音卡顿敏感。这也是音频采样率(48000 fps)远高于画面的帧率(60 fps)的原因。通过大幅增加音频流采样次数,使得响度曲线变化更平滑,避免出现邻帧之间有较大的db变化,避免听觉上不适感。
声道数:声场还原,主要用于表达声音方位立体感。例如 单声道(1ch,mono),双声道立体声(2ch,stereo),5.1声道, 7.1声道
图1.1 室内5.1声道声场还原
位宽:也称位深,即一个采样点用几个字节编码
表1.1 PCM编码类型
音频帧:即一个采样点,音频帧大小计算方法:声道数据 * 位宽,例如:
16bit,2ch 音乐,它的音频帧大小为:2 * 2 = 4 字节。
32bit,5.1ch 音乐,它的音频帧大小为:4 * 6 = 24 字节。
采样率:也称音频帧率fps,每秒需要取样多少次,一个采样点就是一个音频帧。通过采样对模拟型号离散化成数字信号,采样率越高,声音信息越丰富。人耳听觉频段范围限制,无法听到低频声音,Android支持的采样率通常范围在闭区间[8000HZ, 384000HZ]。
Android最常见采样率:
通话:8k,16k, 32k
视频及游戏:24k, 44.1k,48k
高清音乐:96k, 192k
图1.2 采样与量化
音频需要足够高的采样率,使得数据更平滑。
码率:1秒时间播放了多少字节数据(或多少bit数据)。
计算公式:bRate = 采样率 * 位宽 * 声道数
例如 48k,7.1声道,16bit pcm编码 码率为:48000 * 8 * 2 = 768000 Bps
2.常见杂音类型
杂音是主观体验的概念,技术上常称为音频卡顿,“闻香可以识女人,看波形也能知音”,不同原因,有不同的杂音波形特征。例如写0,断点,重复数据,削顶,截断,高频/低频截止,白噪声,无规律断点数据。
2.1pop音
常常称为“破音”,是属于断音的一类。数据不连续,有明显跳变发生。听起来,“啪!啪!啪!”的破音,耳朵有不适感。常见的有seek pop noise,在快进,快退,或者倍速播放时,更容易遇到这类杂音。
2.2 补0
特征是,删除重点的0数据,数据就是连续的了,因为有连续两次跳变,听起来杂音特征比pop音更明显一些。
删除0后:
2.3截断音
属于断音的一种,与pop音的区别是,启播的第一帧,没有做淡入,或者停播的最后一帧,没有做淡出。
常见场景例如刷短视频,切换视频之间,有时候会听到“啪!啪!啪!”的破音。简单来说就是,需要做淡入淡出。FadeInFadeOut
2.3.1切换截断音
2.3.2起始截断音
开始播放阶段,没有做淡入(Fade In),产生的杂音。
2.3.3 结尾截断音
2.4 削顶
削顶,又称为“削波”,原因是音频信号的响度,超过了编码范围,即0db,并不是说音源存在问题,只是音源信号,幅度太大,超过手机音频系统的表达能力,无法进行还原。超出的部分,被统一削减到0db。听起来,声音有连续卡顿的感觉,不自然。
2.5 蜂鸣音
听感起来,像蜂鸣器,汽笛声。波形上,4ms重复数据,常见于游戏声音卡顿。
2.6 啸叫
2.6.1 什么是啸叫?
啸叫现象是指音频信号通过扬声器播放后,经过一定的传播路径,再次被麦克风拾取,经过放大器的处理后,最后经由扬声器播放,倘若在 “扬声器-麦克风-扬声器”的闭环电路中,存在某种正反馈导致某些音频频率发生自激振荡,就会产生啸叫现象。
常见于VOIP会议,通话双方,如果距离太近,就容易产生啸叫。听起来,响度非常大,声音尖锐,感官非常难受。啸叫的产生会掩盖正常语音,给人的听感也不好,而且啸叫频点能量很高,严重时甚至能破坏会议中的扩声设备,因此我们需要对啸叫进行抑制。受限于尺寸,和算法功耗,手机侧啸叫抑制能力有限。
从波形上,可以看到,响度非常大。
2.6.2 啸叫的原理
正反馈系统,信号一次又一次的被循环放大。
2.7 环境底噪
主要是指mic录到的环境噪声,经过降噪算法后,通常比较轻微,不仔细几乎听不出来。但是如果降噪算法调音的不均衡,可能导致底噪大,或者音质损失。
2.8 其他无规律噪声
白噪声。
3. 音频通路基础
3.1 播放流程拆解
对系统来说,APP是黑盒子,不同的应用有不同的逻辑实现方式,特别是游戏,可能有更复杂的业务逻辑,多达100+个线程,系统不知道那个是音频播放相关的。音频能识别到的只有AudioTrack/AudioRecord调用入口,涉及到最多两个线程。以赖线程之间的唤醒关系推测,梳理大概的流程如下:
音乐、视频类三方APP:
游戏音频流程:
3.2 音频通路
3.2.1 音频播放链路
3.2.2 音频通路
限于篇幅,这里不对每个通路进行展开介绍,可自行阅读代码和google官方文档。
3.2.3 AAudio通路
Aaudio是google基于mmap思路构建的,音频通路。尽量减少数据传递环节,以降低负载和功耗,同时降低层层传递带来的时延。这个通路是google强烈推荐使用的API,可替代OpenSL ES,支持音频低延迟。也可以通过设置参数,走到低功耗通路,例如deep buffer通路。
以下以低延迟播放实例来分析AAUDIO工作原理,从systrace上看,aaudio并没有经过audiohal write数据,可以看到audiohal的writer线程并没有被唤醒产生负载。可以证明:aaudio是通过共享内存方式来传递数据,这种方式非常高效,避免了buffer数据层层Copy。
这里简单介绍一下AAudio性能相关的内容:
aaudio流程图如下:
AAudio设置性能模式:
每个 AAudioStream 都具有性能模式,而这对应用行为的影响很大。共有三种模式:
AAUDIO_PERFORMANCE_MODE_NONE 是默认模式。这种模式使用在延迟时间与节能之间取得平衡的基本流。
AAUDIO_PERFORMANCE_MODE_LOW_LATENCY 使用较小的缓冲区和经优化的数据路径,以减少延迟时间。
AAUDIO_PERFORMANCE_MODE_POWER_SAVING 使用较大的内部缓冲区,以及以延迟时间为代价换取节能优势的数据路径。
可以通过调用 setPerformanceMode() 来选择性能模式,并通过调用 getPerformanceMode() 来获得当前模式。
音频低延迟场景,需要设置AAUDIO_PERFORMANCE_MODE_LOW_LATENCY。系统路由到AAUDIO低延迟通路,通路buffer 只有1ms,控制周期为2ms。
如果对低延迟没有要求,尽可能的节省功耗,可设置AAUDIO_PERFORMANCE_MODE_POWER_SAVING。例如播放音乐,会走到deep buffer通路上。
在当前版本的 AAudio 中,为了尽量减少延迟时间,必须将 AAUDIO_PERFORMANCE_MODE_LOW_LATENCY 性能模式与高优先级回调配合使用。请参阅以下示例:
关于AAUDIO的详细介绍,请移步Android SPEC,这里不再详细展开:
https://developer.android.google.cn/ndk/guides/audio/aaudio/aaudio?hl=zh-cn
3.2.4 Low Latency通路
也就是常说的Fast通路,有的平台和primary通路放在一起,有的是独立的Low Latency通路,这样实现依赖平台的性能,特别是ADSP性能,都有各自的优缺点。
FastMixer的buffer 通常设置为4-5ms,通路时延较低,但更容易受到整机性能的影响,产生性能杂音。
FastMixer的使用,通常是通过OpenSL ES,或者Sound pool API。
3.2.5 ULL 通路
极致低延迟通路ULL实质上,为lowlatency通路的进一步压缩buffer,buffer甚至压缩到1ms,Android非实时系统,因此CPU调度上,无法保障1ms内得到调度,因此,这个通路,很少有开放使用。如果追求极致低延迟,推荐使用AAudio来实现。
4. 音频与CPU调度
4.1 TASK运行状态机
R:(TASK_RUNNING ,running + runnable),运行状态,并不意味着进程一定在运行中,也可以在运行队列里;
S:( TASK_INTERRUPTIBLE,sleeping),可中断的睡眠状态,进程在等待事件完成;(浅度睡眠,可以被唤醒)
D:( TASK_UNINTERRUPTIBLE ),不可中断睡眠(深度睡眠,不可以被唤醒,通常在磁盘写入时发生,也有非IO的内核原子操作)
T:( TASK_STOPPED or TASK_TRACED),停止状态,或者跟踪状态,可以通过发送SIGSTOP信号给进程来停止进程,可以发送SIGCONT信号让进程继续运行
X:( TASK_DEAD – EXIT_DEAD ),退出状态,进程即将被销毁;
Z:( TASK_DEAD – EXIT_ZOMBIE ),僵尸状态,子进程退出,父进程还在运行,但是父进程没有读到子进程的退出状态,子进程进入僵尸状态;
4.2 调度器
4.2.1 调度器分类
Stop调度器,
stop_sched_class:优先级最高的调度类,可以抢占其他所有进程,不能被其他进程抢占;
Deadline调度器, dl_sched_class:使用红黑树,把进程按照绝对截止期限进行排序,选择最小进程进行调度运行;
RT调度器, rt_sched_class:实时调度器,为每个优先级维护一个队列;
CFS调度器, cfs_sched_class:完全公平调度器,采用完全公平调度算法,引入虚拟运行时间概念;
IDLE-Task调度器, idle_sched_class:空闲调度器,每个CPU都会有一个idle线程,当没有其他进程可以调度时,调度运行idle线程;
4.2.2 调度优先级策略
linux内核的三种调度方法:1,SCHED_OTHER 分时调度策略,2,SCHED_FIFO实时调度策略,先到先服务3,SCHED_RR实时调度策略,时间片轮转
实时线程将得到优先调用,实时进程根据实时优先级决定调度权值,分时进程则通过nice和counter值决定权值,nice越小,counter越大,被调度的概率越大,也就是曾经使用了cpu最少的进程将会得到优先调度。
4.2.3 CFS调度类
cfs是绝对公平调度算法,理想情况下,优先级相同的两个task,运行时间应该各占cpu的50%,同理3个则cpu利用率为1/3。但是cfs中弱化了优先级的概念而是使用权重weight来决定任务的运行时间。
例如:3个任务A,B,C权重(priority)分别1,2,3;则总权重,一个调度周期为6单位时间,理想状态下,A应占用1单位,B为2,C为3。
cfs中使用虚拟时间vruntime来决定运行的task,nice值-20到20 --> weight -->vruntime; cfs中使用rbtree来管理调度实体se。每次选取vruntime最小的task进行执行。在rb tree中vruntime最小的se在rbtree的最左侧。
cfs是通过限制当前task的运行时间来实现公平的,task的vruntime单调递增,它在rbtree中向右移动,让出cpu使用权给vruntime更小的task。
Task数据结构
4.2.4 RT 调度类
实现调度分为RR和FIFO两类
SHCED_RR和SCHED_FIFO的不同:
当采用SHCED_RR策略的进程的时间片用完,系统将重新分配时间片,并置于就绪队列尾。放在队列尾保证了所有具有相同优先级的RR任务的调度公平。
SCHED_FIFO一旦占用cpu则一直运行。一直运行直到有更高优先级任务到达或自己放弃。
如果有相同优先级的实时进程(根据优先级计算的调度权值是一样的)已经准备好,FIFO时必须等待该进程主动放弃后才可以运行这个优先级相同的任务。而RR可以让每个任务都执行一段时间。
相同点:
RR和FIFO都只用于实时任务。
创建时优先级大于0(1-99)。
按照可抢占优先级调度算法进行。
就绪态的实时任务立即抢占非实时任务。
rt 调度类数据结构
4.3 CGroup与TimerSlack
Android平台的进程会根据运行状态,在不同的cgroup进行迁移,不同cgroup设置的timerslack不同,导致后台进程的定时器超时唤醒变长。Oomadj会触发推组,切后台,activity会从top group 推组到background组,相应的timerslack会从0.5ms 提高到40ms。如果播放器,实现上没有使用service,那么就可能会遇到Android应用切换到后台播放音频卡顿。
4.3.1 TimerSlack基本原理
以下简单介绍一下timerslack基本原理:
4.3.2 音频播放录制请使用Service
Service在Android定义中,一直处于foreground,因此不会有切后台timerslack变大,导致的卡音问题。因此,APP实现中,特别注意,音频解码器,播放关联线程,需要放到service中。避开前后台切换,cgroup迁移的问题。
查看app的timerslack方法:/proc/pid/timerslack_ns
例如,pid为11871:查看timerslack为:0.5ms
当按home按键,这项数值就会变成40000000
4.4 Android音频优先级
AOSP定义上,音频定义为CFS最高优先级Nice。
Android默认优先级为120, 数量越小,优先级越高。例如最高的 ANDROID_PRIORITY_URGENT_AUDIO 为 120 -19 =101, 对应cfs线程级别的101。
总体优先级为:Audio > Video > UI
5. 性能卡音案例
有了以上的音频性能基础,分析起问题来,就不那么困难了。
分析音频卡顿,最有效的还是systrace,以下介绍几个案例:
5.1 APP播放线程调度延迟
通过systrace我们可以看到,APP的Aplayer为音频关键线程,出现了调度上的延迟,我们可以看到问题点,缓存buffer已经消化空了,CPU已经满载,但是有明显的压频行为。因此最终原因为:
APP的线程优先级设置不合理,默认nice的120,没有按着google的标准来设置,至少设置为104,必要时设置为101。如果nice设置的过低,CPU满载时,就容易抢不到cpu。
负载太高,触发温升限频。
5.2 游戏音频线程调度延迟
5.2.1 audio dump发现蜂鸣杂音
在track阶段就dump到了杂音,可以确定问题发生在app内部。
持续4ms重复数据
5.2.2 Systrace发现游戏音频线程调度延迟
5.2.3 根因:NativeThread优先级设置太低
NativeThread使用OpenSL ES发起了FastTrack播放,实时性要求较高,当前优先是默认的120,优先级非常低。推荐APP参考google的建议来将NativeThread线程设置为nice 101即 ANDROID_PRIORITY_URGENT_AUDIO级别。
5.3 音频系统线程调度
Android音频优先级设置偏保守,IOS,Windows系统调度上,对音频有明显的倾斜。在加上链路太长,多个进程周转数据,所以Android音频流畅性体验,要低于IOS和windows。
5.4 APP在播放线程调用音频耗时接口
最常见的接口是isBluetoothScoOn(), 这个接口在系统没有切换时候的时候,会很快返回,但是如果再插拔耳机,或者接听,挂断电话/VOIP电话时,就会耗时很长,导致caller线程,产时间阻塞,不产生数据。而fastmixer callback,一遍又一遍的吧,4ms的buffer重复callback过来,导致了蜂鸣杂音的产生。类似的耗时API还有,isWiredHeadsetOn(),isSpeakerPhoneOn()。
从systrace上看,nativeThread被阻塞,根据唤醒关系结合logcat,可以定位到,此时正在做isBluetoothScoOn接口调用。由于lock被切换设备的线程持有,而切换设备又比较耗时,从而导致,nativethread被卡住,无法生产数据。Audio系统callback到了重复的杂音数据。
误区:AudioManager接口可以立刻返回。事实上,AudioService内部有复杂的逻辑,例如切换设备,有很多业务逻辑要处理。一个简单的场景,voip来电,这时候,需要和蓝牙耳机建立链路,握手过程,可能就需要150-300ms,整个过程非常耗时。如果这时候在主线程去调用,getMode,isBluetoothScoOn等接口,都会被阻塞掉,直到耳机返回,或者连接超时。
5.5 渲染线程调用AudioTrack导致帧率低
例如这份日志里,在渲染线程中,去执行AudioTrack.start(),结果阻塞580ms,导致画面卡顿。应尽量避免在UI线程,图形render线程中,调用AudioTrack接口。
5.6 FrameCount设置过大过小
FrameCount的作用是控制APP起播的起播缓存大小,设置越大,时延越高,越不容易卡音。设置越小,时延越低,越容易卡音。
5.6.1 短促音FrameCount设置过大导致无声
5.6.2 FrameCount设置过小卡音
常见的问题是使用OpenSL ES播放,framecount设置的太小,缓存buffer提效,当cpu调度不及时,就容易触发性能卡音。
5.7 频繁调用Audio接口,导致整机重启
Android系统只提供了31个Binder线程池,也就是系统最大并发数为31个,如果APP频繁调用Audio耗时接口,binder被占用,甚至被耗尽,导致整机重启。以下某APP,持续不停的去查音量,导致系统Binder资源被耗尽,最终触发重启。
SystemServer Binder线程池设置为31个
6.音频联合调优建议
6.1规范设置音频优先级
APP 需要参考AOSP标准,将音频数据关联线程,例如解码器,至少nice设置为104级别。
6.2 高实时的线程,避免调用耗时的音频API
尽量不要在音频解码器,以及主线程中调用音频耗时接口,例如isBluetoothScoOn(),getMode等等接口,这些接口大都会持锁,导致切换设备时,耗时较长。
6.3播放逻辑进Service
尽量将音频播放逻辑,放在service中,避免切后台,timerslack被改大,CPU被限频限核,导致的声音卡顿。
6.4 非必要避免频繁调用耗时的Audio接口
例如查询音量,音频设备连接状态。
7. 参考文献
[1]https://developer.apple.com/documentation/audiotoolbox/workgroup_management/understanding_audio_workgroups/#3625591
[2] https://learn.microsoft.com/en-us/windows/win32/procthread/multimedia-class-scheduler-service
[3] https://zhuanlan.zhihu.com/p/556295381?utm_id=0
[4]https://zhuanlan.zhihu.com/p/658488322
往
期
推
荐
长按关注内核工匠微信
Linux内核黑科技| 技术文章| 精选教程