背景介绍
在线教室场景下,声音是最重要的内容传输渠道之一,保障声音的稳定可靠,是在线教室质量非常重要的一环。同时在线教室里许多功能模块都与声音有关联,如何处理好各个模块间的声音冲突成为一个重要话题。
AVAudioSession
在 iOS 端,说到声音的话题就绕不开 AVAudioSession。AVAudioSession 的作用是管理音频这一唯一硬件资源的分配,通过调优合适的 AVAudioSession 来适配我们的 APP 对于音频的功能需求。切换音频场景的时候,需要相应的切换 AVAudioSession。
Set the audio session *category, mode, and options *.
// Get the singleton instance.
let audioSession = AVAudioSession.sharedInstance()
do {
// Set the audio session *category, mode, and options *.
try audioSession.setCategory(.playback, mode: .moviePlayback, options: [])
} catch {
print("Failed to set audio session category.")
}
AVAudioSession.Category
Category定义了一些列音频行为,每个category都有对应的表现。
如 .mode所述,您可以通过使用音频会话模式来细化由playback、record和playAndRecord类别提供的配置。
首先AVAudioSession将使用音频的场景分成七大类,通过设置Session为不同的类别,可以控制:
- 当App激活Session的时候,是否会打断其他不支持混音的App声音
- 当用户触发手机上的“静音”键时或者锁屏时,是否相应静音
- 当前状态是否支持录音
- 当前状态是否支持播放 每个App启动时都会设置成上面说的默认状态,即其他App会被中断同时相应“静音”键的播放模式。通过下表可以细分每个类别的支持情况:
类别 当按“静音”或者锁屏是是否静音 是否引起不支持混音的App中断 是否支持录音和播放
- | 类别 | 当按“静音”或者锁屏是是否静音 | 是否引起不支持混音的App中断 | 是否支持录音和播放 |
---|---|---|---|---|
1 | .ambient | 是 | 否 | 只支持播放 |
.audioProcessing(Deprecated) | - | 都不支持 | - | |
2 | .multiRoute | 否 | 是 | 既可以录音也可以播放 |
3 | .playAndRecord | 否 | 默认不引起 | 既可以录音也可以播放 |
4 | .playback | 否 | 默认引起 | 只用于播放 |
5 | .record | 否 | 是 | 只用于录音 |
6 | .soloAmbient(default) | 是 | 是 | 只用于播放 |
-
.ambient : 只用于播放音乐时,并且可以和QQ音乐同时播放,比如玩游戏的时候还想听QQ音乐的歌,那么把游戏播放背景音就设置成这种类别。同时,当用户锁屏或者静音时也会随着静音,这种类别基本使用所有App的背景场景。
-
.soloAmbient: 也是只用于播放,但是和".ambient"不同的是,用了它就别想听QQ音乐了,比如不希望QQ音乐干扰的App,类似节奏大师。同样当用户锁屏或者静音时也会随着静音,锁屏了就玩不了节奏大师了。
-
.playback: 如果锁屏了还想听声音怎么办?用这个类别,比如App本身就是播放器,同时当App播放时,其他类似QQ音乐就不能播放了。所以这种类别一般用于播放器类App
-
.record: 有了播放器,肯定要录音机,比如微信语音的录制,就要用到这个类别,既然要安静的录音,肯定不希望有QQ音乐了,所以其他播放声音会中断。想想微信语音的场景,就知道什么时候用他了。
-
.playAndRecord: 如果既想播放又想录制该用什么模式呢?比如VoIP,打电话这种场景,PlayAndRecord就是专门为这样的场景设计的 。
-
.multiRoute: 想象一个DJ用的App,手机连着HDMI到扬声器播放当前的音乐,然后耳机里面播放下一曲,这种常人不理解的场景,这个类别可以支持多个设备输入输出。
-
AudioProcessing: 主要用于音频格式处理,一般可以配合AudioUnit进行使用
AVAudioSession.CategoryOptions
CoreAudio提供的方法是,首先定下七种的一种基调,然后在进行微调。CoreAudio为每种Category都提供了些许选项来进行微调。
选项 | 适用类别 | 作用 |
---|---|---|
mixWithOthers | PlayAndRecord, Playback, and MultiRoute | 是否可以和其他后台App进行混音 |
duckOthers | Ambient, PlayAndRecord, Playback, and MultiRoute | 是否压低其他App声音 |
interruptSpokenAudioAndMixWithOthers | ||
allowBluetooth | Record and PlayAndRecord | 是否支持蓝牙耳机 |
allowBluetoothA2DP | ||
allowAirPlay | ||
defaultToSpeaker | PlayAndRecord | 是否默认用免提声音 |
overrideMutedMicrophoneInterruption |
- mixWithOthers : 如果确实用的AVAudioSessionCategoryPlayback实现的一个背景音,但是呢,又想和QQ音乐并存,那么可以在AVAudioSessionCategoryPlayback类别下在设置这个选项,就可以实现共存了。
- DuckOthers:在实时通话的场景,比如QQ音乐,当进行视频通话的时候,会发现QQ音乐自动声音降低了,此时就是通过设置这个选项来对其他音乐App进行了压制。
- AllowBluetooth:如果要支持蓝牙耳机电话,则需要设置这个选项
- DefaultToSpeaker: 如果在VoIP模式下,希望默认打开免提功能,需要设置这个选项
AVAudioSession.Mode
category设置里基本声音表现,使用modes来对category做一些投诉书操作
模式 | 适用的类别 | 场景 |
---|---|---|
default | 所有类别 | The default audio session mode. |
gameChat | PlayAndRecord | A mode that the GameKit framework sets on behalf of an application that uses GameKit’s voice chat service. |
measurement | PlayAndRecord, record, playback | A mode that indicates that your app is performing measurement of audio input or output.最小系统 |
moviePlayback | playback | A mode that indicates that your app is playing back movie content. 视频播放 |
spokenAudio | A mode used for continuous spoken audio to pause the audio when another app plays a short audio prompt. | |
videoChat | PlayAndRecord | A mode that indicates that your app is engaging in online video conferencing. 视频通话 |
videoRecording | PlayAndRecord, record | A mode that indicates that your app is recording a movie. 录制视频时 |
voiceChat | PlayAndRecord | A mode that indicates that your app is performing two-way voice communication, such as using Voice over Internet Protocol (VoIP). |
voicePrompt | A mode that indicates that your app plays audio using text-to-speech. |
每个模式有其适用的类别,所以,并不是有“七七 四十九”种组合。如果当前处于的类别下没有这个模式,那么是设置不成功的。设置完Category后可以通过:availableModes 属性,查看其支持哪些属性,做合法性校验。
- Default: 每种类别默认的就是这个模式,所有要想还原的话,就设置成这个模式。
- VoiceChat:主要用于VoIP场景,此时系统会选择最佳的输入设备,比如插上耳机就使用耳机上的麦克风进行采集。此时有个副作用,他会设置类别的选项为"AVAudioSessionCategoryOptionAllowBluetooth"从而支持蓝牙耳机。
- VideoChat : 主要用于视频通话,比如QQ视频、FaceTime。时系统也会选择最佳的输入设备,比如插上耳机就使用耳机上的麦克风进行采集并且会设置类别的选项为"AVAudioSessionCategoryOptionAllowBluetooth" 和 "AVAudioSessionCategoryOptionDefaultToSpeaker"。
- GameChat : 适用于游戏App的采集和播放,比如“GKVoiceChat”对象,一般不需要手动设置 另外几种和音频APP关系不大,一般我们只需要关注VoIP或者视频通话即可。 可以在设置Category之后再设置模式。
当然,这些模式只是CoreAduio总结的,不一定完全满足要求,对于具体的模式,
在iOS10中还是可以微调的。通过接口:
(BOOL)setCategory:(NSString *)category mode:(NSString *)mode options:
(AVAudioSessionCategoryOptions)options error:(NSError **)outError
但是在iOS9及以下就只能在Category上调了,其实本质是一样的,可以认为是个API糖,接口封装。
通话音量与媒体音量
一般而言,通话音量指的是进行语音、视频通话时的音量。
媒体音量指的是播放音乐、视频或游戏的音效、背景音的音量。
在实际使用中,两者的差异在于,
- 通话音量有较好的回声消除,媒体音量有较好的声音表现力。
- 媒体音量可以调整到 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: 来设置 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 来实现视频播放与音频外抛。
总结
通过上线上述综合解决方案,声音问题得到了有效的解决,同时也能从容应对快速迭代的教室需求,有效提升了在线教室的体验。
!
作者:字节跳动技术团队 链接:juejin.cn/post/693498… 来源:掘金 著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。