在线教室场景下,声音是最重要的内容传输渠道之一,保障声音的稳定可靠,是在线教室质量非常重要的一环。同时在线教室里许多功能模块都与声音有关联,如何处理好各个模块间的声音冲突成为一个重要话题。
在 iOS 端,说到声音的话题就绕不开 AVAudioSession。AVAudioSession 的作用是管理音频这一唯一硬件资源的分配,通过调优合适的 AVAudioSession 来适配我们的 APP 对于音频的功能需求。切换音频场景的时候,需要相应的切换 AVAudioSession。
教育场景下主要使用到的音频场景有:
iOS 提供 AVAudioSessionMode[1] 用于与 AVAudioSessionCategory[2] 搭配使用,教育场景下使用到的音频模式主要有:
我们可以使用 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 声音的主要原因是其他功能在设置 AVAudioSession 时,AVAudioSessionOptions 未包含 AVAudioSessionCategoryOptionMixWithOthers
混音模式,导致 RTC 声音被高优进程打断。比如在非混音模式下播放 webview 的内嵌音频,因为 webview 是使用系统进程来播放声音,优先级最高,所以 APP 进程下的 RTC 声音就会被抑制导致无法正常发声。
这类问题一般都比较隐蔽,因为简单的场景如果有问题,在上线之前一般都能测试出来,而当多个功能场景串起来之后才触发问题,往往就很难在测试期间发现,且如果线上没有完备的日志查询体系,针对线上这类问题排查起来难度也非常大,往往因为定位不到原因而长期遗留。
在通话音量模式下,媒体声音会被压低,导致声音变小。比较常见的场景是在小班场景下,学生在推流时播放课堂音视频等媒体资源,声音会比 RTC 的声音要小,导致媒体声音听不清楚。
通话模式下(连麦时)媒体声音会被压低,原因是 iOS 手机系统会开启回声消除以保证人声体验,因此会压低媒体通道的声音,也会压低背景音效。
教育行业内部分头部 APP 也没有从根本上解决该问题,很多都是通过从产品功能层面上规避问题,通过产品妥协来为技术问题让步。比如在播放课堂音视频资源时,默认将所有学生都强制关麦,关麦时学生处于媒体音量,就不存在被压低的问题了,等到课堂音视频播放结束后,再允许学生开麦。这种通过规避问题场景来解决问题的方式,不具有可复制性。
RTC 声音变小,主要原因是声音通过听筒发声,而没有正常通过扬声器发声,造成声音变小的假象。另外在 iOS14 系统下,使用过 RTC 的通话模式并切回媒体模式后,再调用 setCategory:PlayAndRecord
+ DefaultToSpeaker
就会必现声音小的问题。
针对上述行业痛点,通过底层原理的分析与实际项目经验,从代码规范、问题兜底、问题报警梳理出一套可行的解决方案。
RTC 的声音问题基本是因为其他模块功能对 AVAudioSession 进行了更改,且在功能结束之后,也没有将 AVAudioSession 重置到 RTC 需要的设置。本身音视频 SDK(如 agora、zego 等)对这种情况会有一定的兜底逻辑,但是这种兜底如果存在侵入性,也是不合理的,因此具有一定的局限性。
由于系统无法区分同一个进程中是哪个模块对 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 来实现视频播放与音频外抛。
通过上线上述综合解决方案,声音问题得到了有效的解决,同时也能从容应对快速迭代的教室需求,有效提升了在线教室的体验。
教育技术中台团队诞生于2020年3月,我们为字节跳动教育业务产品线提供强大的中台能力,覆盖产品包括清北网校、瓜瓜龙、大力智能灯、学浪等。我们致力于互联网技术和教育行业的深度整合,提供高效的在线教育解决方案,满足用户多样化、个性化的教育需求。团队技术壁垒高、技术氛围浓,是提升技术竞争力的绝佳机会,期待优秀的你加入我们!
如果你对技术充满热情,喜欢追求极致,渴望为教育事业贡献一份力量,欢迎加入我们,我们期待与你共同成长。我们在北京、杭州均有招聘需求。简历投递邮箱:[email protected],邮件标题:姓名 - 工作年限 - 教育技术中台。
[1]
AVAudioSessionMode: https://developer.apple.com/documentation/avfoundation/avaudiosessionmode?language=objc
[2]AVAudioSessionCategory: https://developer.apple.com/documentation/avfoundation/avaudiosessioncategory
欢迎关注「 字节跳动技术团队 」
简历投递联系邮箱「 [email protected] 」
点击阅读原文,快来加入我们吧!