在线教室 iOS 端声音问题综合解决方案

背景介绍

在线教室场景下,声音是最重要的内容传输渠道之一,保障声音的稳定可靠,是在线教室质量很是重要的一环。同时在线教室里许多功能模块都与声音有关联,如何处理好各个模块间的声音冲突成为一个重要话题。ios

AVAudioSession

在 iOS 端,说到声音的话题就绕不开 AVAudioSession。AVAudioSession 的做用是管理音频这一惟一硬件资源的分配,经过调优合适的 AVAudioSession 来适配咱们的 APP 对于音频的功能需求。切换音频场景的时候,须要相应的切换 AVAudioSession。web

AVAudioSessionCategory

教育场景下主要使用到的音频场景有:缓存

AVAudioSessionMode

iOS 提供 AVAudioSessionMode 用于与 AVAudioSessionCategory 搭配使用,教育场景下使用到的音频模式主要有:markdown

AVAudioSessionOptions

咱们可使用 options 去微调 Category 行为,教育场景下经常使用的有:session

通话音量与媒体音量

通常而言,通话音量指的是进行语音、视频通话时的音量。媒体音量指的是播放音乐、视频或游戏的音效、背景音的音量。app

在实际使用中,二者的差别在于,通话音量有较好的回声消除,媒体音量有较好的声音表现力。媒体音量能够调整到 0,而通话音量不能够。async

通话音量与媒体音量只能二选一,所以须要区分系统音量走的是通话音量仍是媒体音量。系统音量走通话音量,是指在设备上调整音量时,调整的是通话音量。媒体音量同理。媒体音量和通话音量分别属于 2 个不一样的、独立的系统,一个设置不会影响到另一个。ide

进入通话后,音效的播放音量由通话音量控制。退出通话后,则由媒体音量控制。 通常在教育场景下,学生做为观众拉流时,使用的媒体音量,老师说话的声音更加立体饱满,当学生连麦时,使用的通话音量,以保证通话声音的质量。oop

简单来讲,非连麦模式下会使用媒体音量控制,连麦模式下会使用通话音量控制,二者有独立的音量控制机制。测试

当播放媒体资源时,使用播放器(如 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 的调用更改,须要遵循如下原则:

  1. 模块调用 setCategory 前先判断下,当前 AudioSession 如已知足使用须要,不用再次设置,避免触发 iOS 14 系统 Bug

    1. 模块须要录音时,Category 应该使用 PlayAndRecord(为了防止打断正在播放的音频,不要使用仅录音的 CategoryRecord),当前 category 不是 PlayAndRecord 的状况下再调用 setCategory
    2. 模块仅须要播放时,当前 category 为 PlayAndRecord 或 Playback、Ambient 的状况下不须要 setCategory
  2. 若当前的 category 不知足模块使用,在 setCategory 以前应该先保存当前的 AudioSession 状态,而后再 setCategory、使用音频功能,使用结束后,应该从新 setCategory 恢复到以前的 AudioSession 状态

  3. 在设置 audioSession 时,categoryOptions 都应该包含 AVAudioSessionCategoryOptionDefaultToSpeakerAVAudioSessionCategoryOptionMixWithOthers,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: 来设置 AVAudioSession 有效,若是某个模块调用setCategory:error: 方法来设置 AVAudioSession,setCategory:error: 方法默认会将options设置为 0(未包含AVAudioSessionCategoryOptionMixWithOthers)。咱们 hook 到 setCategory:error: 方法后,没法经过调整参数的方式来为options追加混音模式选项,可是能够在交换的方法内改成调用 setCategory:withOptions:error: 方法,并将 options 参数传入AVAudioSessionCategoryOptionMixWithOthers,来知足咱们的需求。可问题在于调用 setCategory:withOptions:error: 时,底层会再嵌套调用 setCategory:error: 方法,而此时setCategory:error: 已经被咱们hook而且在交换的方法内调用了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月,咱们为字节跳动教育业务产品线提供强大的中台能力,覆盖产品包括清北网校、瓜瓜龙、大力智能灯、学浪等。咱们致力于互联网技术和教育行业的深度整合,提供高效的在线教育解决方案,知足用户多样化、个性化的教育需求。团队技术壁垒高、技术氛围浓,是提高技术竞争力的绝佳机会,期待优秀的你加入咱们!

若是你对技术充满热情,喜欢追求极致,渴望为教育事业贡献一份力量,欢迎加入咱们,咱们期待与你共同成长。咱们在北京、杭州均有招聘需求。简历投递邮箱:tech@bytedance.com,邮件标题:姓名 - 工做年限 - 教育技术中台

相关文章
相关标签/搜索