讨论在线教室 iOS 端声音问题综合解决方案
作者:字节跳动技术团队
背景介绍
在线教室场景下,声音是最重要的内容传输渠道之一,保障声音的稳定可靠,是在线教室质量非常重要的一环。同时在线教室里许多功能模块都与声音有关联,如何处理好各个模块间的声音冲突成为一个重要话题。
AVAudioSession
在 iOS 端,说到声音的话题就绕不开 AVAudioSession。AVAudioSession 的作用是管理音频这一唯一硬件资源的分配,通过调优合适的 AVAudioSession 来适配我们的 APP 对于音频的功能需求。切换音频场景的时候,需要相应的切换 AVAudioSession。
AVAudioSessionCategory
教育场景下主要使用到的音频场景有:
AVAudioSessionMode
iOS 提供 AVAudioSessionMode[1] 用于与 AVAudioSessionCategory[2] 搭配使用,教育场景下使用到的音频模式主要有:
AVAudioSessionOptions
我们可以使用 options 去微调 Category 行为,教育场景下常用的有:
通话音量与媒体音量
一般而言,通话音量指的是进行语音、视频通话时的音量。媒体音量指的是播放音乐、视频或游戏的音效、背景音的音量。
在实际使用中,两者的差异在于,通话音量有较好的回声消除,媒体音量有较好的声音表现力。媒体音量可以调整到 0,而通话音量不可以。
通话音量与媒体音量只能二选一,因此需要区分系统音量走的是通话音量还是媒体音量。系统音量走通话音量,是指在设备上调整音量时,调整的是通话音量。媒体音量同理。媒体音量和通话音量分别属于 2 个不同的、独立的系统,一个设置不会影响到另外一个。
进入通话后,音效的播放音量由通话音量控制。退出通话后,则由媒体音量控制。一般在教育场景下,学生作为观众拉流时,使用的媒体音量,老师说话的声音更加立体饱满,当学生连麦时,使用的通话音量,以保证通话声音的质量。
简单来说,非连麦模式下会使用媒体音量控制,连麦模式下会使用通话音量控制,两者有独立的音量控制机制。
当播放媒体资源时,使用播放器(如 AVPlayer)播放音频,播放器底层 AudioUnit 的 description 为 VoiceProcessingIO
。
RTC SDK 内部维护了一个 AudioUnit,通话音量下 AudioUnit 的 description 为 RemoteIO
,媒体音量下为 VoiceProcessingIO
,当出现模式切换时,会销毁原来的 AudioUnit,再创建新的 AudioUnit,始终保持一个 AudioUnit 来进行音频播放。
通话音量下,AVPlayer 内 VoiceProcessingIO
的 AudioUnit 声音会被抑制。同样的,在媒体音量下,RTC SDK 内的 AudioUnit 的 description 设置为 VoiceProcessingIO
,如果此时其他模块通过设置 AVAudioSession 切换到通话音量,RTC 的声音也会被抑制。
行业现状
在线教室场景下,很多功能都需要播放声音,包括课中音视频直播、课后回放、webview 内嵌课件声音(包括音频、视频、音效)、课堂音频、课堂视频、课堂游戏声音、音效声音等。除此之外,教室内还包括很多需要声音录制的功能,包括连麦、跟读、集体发言、聊天语音输入、语音识别等。
教室内这些功能存在各种组合,且对 AVAudioSession 的设置要求存在差异,而 AVAudioSession 又是一个单例,如果没有一个统一管理的逻辑,很容易就出现设置混乱的问题。
目前行业内碰到的比较多的问题主要是听不见 RTC 声音与媒体声音被抑制。
听不见 RTC 声音
听不见 RTC 声音的主要原因是其他功能在设置 AVAudioSession 时,AVAudioSessionOptions 未包含 AVAudioSessionCategoryOptionMixWithOthers
混音模式,导致 RTC 声音被高优进程打断。比如在非混音模式下播放 webview 的内嵌音频,因为 webview 是使用系统进程来播放声音,优先级最高,所以 APP 进程下的 RTC 声音就会被抑制导致无法正常发声。
这类问题一般都比较隐蔽,因为简单的场景如果有问题,在上线之前一般都能测试出来,而当多个功能场景串起来之后才触发问题,往往就很难在测试期间发现,且如果线上没有完备的日志查询体系,针对线上这类问题排查起来难度也非常大,往往因为定位不到原因而长期遗留。
媒体声音被抑制
在通话音量模式下,媒体声音会被压低,导致声音变小。比较常见的场景是在小班场景下,学生在推流时播放课堂音视频等媒体资源,声音会比 RTC 的声音要小,导致媒体声音听不清楚。
通话模式下(连麦时)媒体声音会被压低,原因是 iOS 手机系统会开启回声消除以保证人声体验,因此会压低媒体通道的声音,也会压低背景音效。
教育行业内部分头部 APP 也没有从根本上解决该问题,很多都是通过从产品功能层面上规避问题,通过产品妥协来为技术问题让步。比如在播放课堂音视频资源时,默认将所有学生都强制关麦,关麦时学生处于媒体音量,就不存在被压低的问题了,等到课堂音视频播放结束后,再允许学生开麦。这种通过规避问题场景来解决问题的方式,不具有可复制性。
RTC 声音变小
RTC 声音变小,主要原因是声音通过听筒发声,而没有正常通过扬声器发声,造成声音变小的假象。另外在 iOS14 系统下,使用过 RTC 的通话模式并切回媒体模式后,再调用 setCategory:PlayAndRecord
+ DefaultToSpeaker
就会必现声音小的问题。
解决方案
针对上述行业痛点,通过底层原理的分析与实际项目经验,从代码规范、问题兜底、问题报警梳理出一套可行的解决方案。
听不见 RTC 声音、RTC 声音变小
RTC 的声音问题基本是因为其他模块功能对 AVAudioSession 进行了更改,且在功能结束之后,也没有将 AVAudioSession 重置到 RTC 需要的设置。本身音视频 SDK(如 agora、zego 等)对这种情况会有一定的兜底逻辑,但是这种兜底如果存在侵入性,也是不合理的,因此具有一定的局限性。
AudioSession 修改规范
由于系统无法区分同一个进程中是哪个模块对 AudioSession 进行了更改,所以为了避免听不见 RTC 声音的问题,在使用 RTC 时,其它模块对 AudioSession 的调用更改,需要遵循以下原则:
- 模块调用
setCategory
前先判断下,当前 AudioSession 如已满足使用需要,不用再次设置,避免触发 iOS 14 系统 Bug - 模块需要录音时,Category 应该使用 PlayAndRecord(为了防止打断正在播放的音频,不要使用仅录音的 CategoryRecord),当前 category 不是 PlayAndRecord 的情况下再调用
setCategory
- 模块仅需要播放时,当前 category 为 PlayAndRecord 或 Playback、Ambient 的情况下不需要
setCategory
- 若当前的 category 不满足模块使用,在
setCategory
之前应该先保存当前的 AudioSession 状态,然后再setCategory
、使用音频功能,使用结束后,应该重新setCategory
恢复到之前的 AudioSession 状态 - 在设置 audioSession 时,categoryOptions 都应该包含
AVAudioSessionCategoryOptionDefaultToSpeaker
与AVAudioSessionCategoryOptionMixWithOthers
,iOS10 系统及以上还应包含AVAudioSessionCategoryOptionAllowBluetooth
。
核心代码如下:
//需要录音时,AudioSession的设置代码如下: if ([AVAudioSession sharedInstance].category != AVAudioSessionCategoryPlayAndRecord) { [RTCAudioSessionCacheManager cacheCurrentAudioSession]; AVAudioSessionCategoryOptions categoryOptions = AVAudioSessionCategoryOptionDefaultToSpeaker | AVAudioSessionCategoryOptionMixWithOthers; if (@available(iOS 10.0, *)) { categoryOptions |= AVAudioSessionCategoryOptionAllowBluetooth; } [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:categoryOptions error:nil]; [[AVAudioSession sharedInstance] setActive:YES error:nil]; } //功能结束时重置audioSession [RTCAudioSessionCacheManager resetToCachedAudioSession];
static AVAudioSessionCategory cachedCategory = nil; static AVAudioSessionCategoryOptions cachedCategoryOptions = nil; @implementation RTCAudioSessionCacheManager //更改audioSession前缓存RTC当下的设置 + (void)cacheCurrentAudioSession { if (![[AVAudioSession sharedInstance].category isEqualToString:AVAudioSessionCategoryPlayback] && ![[AVAudioSession sharedInstance].category isEqualToString:AVAudioSessionCategoryPlayAndRecord]) { return; } @synchronized (self) { cachedCategory = [AVAudioSession sharedInstance].category; cachedCategoryOptions = [AVAudioSession sharedInstance].categoryOptions; } } //重置到缓存的audioSession设置 + (void)resetToCachedAudioSession { if (!cachedCategory || !cachedCategoryOptions) { return; } BOOL needResetAudioSession = ![[AVAudioSession sharedInstance].category isEqualToString:cachedCategory] || [AVAudioSession sharedInstance].categoryOptions != cachedCategoryOptions; if (needResetAudioSession) { dispatch_async(dispatch_get_global_queue(0, 0), ^{ [[AVAudioSession sharedInstance] setCategory:cachedCategory withOptions:cachedCategoryOptions error:nil]; [[AVAudioSession sharedInstance] setActive:YES error:nil]; @synchronized (self) { cachedCategory = nil; cachedCategoryOptions = nil; } }); } } @end
兜底策略
考虑到在线教室场景的复杂度,让教室内所有功能代码都遵循 AVAudioSession 的修改规范,虽然有严格的 codeReview,但是也存在一定的人为因素风险,随着业务功能不断迭代,无法完全保证线上不出问题,因此一套可靠的兜底策略显得非常有必要。
兜底策略的基本逻辑是 hook 到 AVAudioSession 的变化,当各模块对 AVAudioSession 的设置不符合规范要求时,我们在不影响功能的前提下强制进行修正,比如对 options 补充上混音模式。
通过方法交换我们可以 hook 到 AVAudioSession 的更改。比如用 kk_setCategory:withOptions: error:
与系统的 setCategory:withOptions: error:
进行交换,在交换的方法里,我们判断 options 是否包含 AVAudioSessionCategoryOptionMixWithOthers
,如果没有包含我们就进行追加。
- (BOOL)kk_setCategory:(AVAudioSessionCategory)category withOptions:(AVAudioSessionCategoryOptions)options error:(NSError **)outError { //在需要进行对audioSession进行修正的场景下(RTC直播),修改options时未包含mixWithOther,则给options追加mixWithOther BOOL addMixWithOthersEnable = shouldFixAudioSession && !(options & AVAudioSessionCategoryOptionMixWithOthers)]; if (addMixWithOthersEnable) { return [self kk_setCategory:category withOptions:options | AVAudioSessionCategoryOptionMixWithOthers error:outError];; } return [self kk_setCategory:category withOptions:options error:outError]; }
但上述方法只对通过调用 setCategory:withOptions: error:
来设置 audioSession 有效,如果调用了 setCategory:error:
来更改 audioSession,则会造成调用死循环的问题。在 iOS 底层实现中,调用 setCategory:error:
时,内部会再调用 setCategory:withOptions: error:
方法,因为进行了方法交换,从而出现嵌套调用问题。
针对该问题,我们通过监听 AVAudioSessionRouteChangeNotification
通知,来 hookcategory
的变化,AVAudioSessionRouteChangeNotification
在调用 setCategory:error:
时会触发,而不会在调用 setCategory:withOptions: error:
时直接触发,进而与上述方法形成了很好的互补。
//添加对AVAudioSessionRouteChange的监听 [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleRouteChangeNotification:) name:AVAudioSessionRouteChangeNotification object:nil]; - (void)handleRouteChangeNotification:(NSNotification *)notification { NSNumber* reasonNumber = notification.userInfo[AVAudioSessionRouteChangeReasonKey]; AVAudioSessionRouteChangeReason reason = (AVAudioSessionRouteChangeReason)reasonNumber.unsignedIntegerValue; if (reason == AVAudioSessionRouteChangeReasonCategoryChange) { AVAudioSessionCategoryOptions currentCategoryOptions = [AVAudioSession sharedInstance].categoryOptions; AVAudioSessionCategory currentCategory = [AVAudioSession sharedInstance].category; //在需要进行对audioSession进行修正的场景下(RTC直播),修改category时options未包含mixWithOther,则给options追加mixWithOther if (shouldFixAudioSession && !(currentCategoryOptions & AVAudioSessionCategoryOptionMixWithOthers)) { [[AVAudioSession sharedInstance] setCategory:currentCategory withOptions:currentCategoryOptions | AVAudioSessionCategoryOptionMixWithOthers error:nil]; } } }
报警机制
即使有修改规范与兜底策略的保障,随着教室业务迭代与 iOS 系统升级,也无法保证线上完全不出问题,因此我们建立了问题报警机制,当线上出现问题时,能在工作群里及时收到警报,根据警报的问题信息,通过日志进一步排查问题。通过报警机制,我们可以更快速的对线上问题作出反应,不被动依赖于学生的投诉反馈,以最快的速度推进问题解决。
当 RTC 声音被打断时,底层音视频 SDK 会回调警告错误码(如 agora 的 warningCode 为 1025),当出现对应的警告码时,结合 slardar 的报警功能,在飞书群里以消息的形式进行同步。同时在 hook 到 AVAudioSession 的变更时,通过获取堆栈信息,可以定位到是哪个模块触发的更改,结合报警用户信息,可以更方便的定位问题。
媒体声音被抑制
媒体声音在媒体音量下开启播放,播放途中因为连麦而切换到了通话音量,此时因为系统特性,媒体音量会被通话音量抑制而导致声音变小。
针对该问题,我们使用音视频 SDK 提供的混音、混流功能来规避。基本原理是播放媒体资源时,我们拿到资源的 pcm 音频数据,将数据抛给 RTC 的 audioUnit 进行混合,由 RTC 音频播放单元统一播放,如果此时 RTC 使用的是通话音量,则媒体资源也是使用的通话音量播放,反之亦然。以此来保证媒体资源与 RTC 始终保持统一的音量控制机制,而避免声音大小存在差异。
混音是指给到音频的本地文件路径,或者播放的 url,由 SDK 进行数据读取与播放。混流是指针对视频文件,播放器只解码播放视频数据,将音频数据实时抛出来给到 SDK,SDK 将传入的实时音频数据与 RTC 音频数据进行混合与播放。项目中我们使用点播 SDK TTVideoEngine 来实现视频播放与音频外抛。
总结
通过上线上述综合解决方案,声音问题得到了有效的解决,同时也能从容应对快速迭代的教室需求,有效提升了在线教室的体验。
到此,这篇关于讨论在线教室 iOS 端声音问题综合解决方案的文章就介绍到这了,更多相关在线教室IOS端声音解决方案内容,请搜索脚本之家以前的文章或继续浏览下面的相关文章,希望大家以后多多支持脚本之家!