背景介紹
線上教室場景下,聲音是最重要的內容傳輸渠道之一,保障聲音的穩定可靠,是線上教室質量非常重要的一環。同時線上教室裡許多功能模組都與聲音有關聯,如何處理好各個模組間的聲音衝突成為一個重要話題。
AVAudioSession在 iOS 端,說到聲音的話題就繞不開 AVAudioSession。AVAudioSession 的作用是管理音訊這一唯一硬體資源的分配,透過調優合適的 AVAudioSession 來適配我們的 APP 對於音訊的功能需求。切換音訊場景的時候,需要相應的切換 AVAudioSession。
AVAudioSessionCategory教育場景下主要使用到的音訊場景有:
AVAudioSessionModeiOS 提供 AVAudioSessionMode 用於與 AVAudioSessionCategory 搭配使用,教育場景下使用到的音訊模式主要有:
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 來實現影片播放與音訊外拋。
總結透過上線上述綜合解決方案,聲音問題得到了有效的解決,同時也能從容應對快速迭代的教室需求,有效提升了線上教室的體驗。
關於我們教育技術中臺團隊誕生於2020年3月,我們為位元組跳動教育業務產品線提供強大的中臺能力,覆蓋產品包括清北網校、瓜瓜龍、大力智慧燈、學浪等。我們致力於網際網路技術和教育行業的深度整合,提供高效的線上教育解決方案,滿足使用者多樣化、個性化的教育需求。團隊技術壁壘高、技術氛圍濃,是提升技術競爭力的絕佳機會,期待優秀的你加入我們!