融雲技術分享:基於WebRTC的實時音影片首幀顯示時間優化實踐

語言: CN / TW / HK

本文由融雲技術團隊原創投稿,作者是融雲WebRTC高階工程師蘇道,轉載請註明出處。

1、引言

在一個典型的IM應用裡,使用實時音影片聊天功能時,影片首幀的顯示,是一項很重要的使用者體驗指標。

本文主要通過對WebRTC接收端的音影片處理過程分析,來了解和優化影片首幀的顯示時間,並進行了總結和分享。

2、什麼是WebRTC?

對於沒接觸過實時音影片技術的人來說,總是看到別人在提WebRTC,那WebRTC是什麼?我們有必要簡單介紹一下。

說到 WebRTC,我們不得不提到 Gobal IP Solutions,簡稱 GIPS。這是一家 1990 年成立於瑞典斯德哥爾摩的 VoIP 軟體開發商,提供了可以說是世界上最好的語音引擎。相關介紹詳見《訪談WebRTC標準之父:WebRTC的過去、現在和未來》。

Skype、騰訊 QQ、WebEx、Vidyo 等都使用了它的音訊處理引擎,包含了受專利保護的回聲消除演算法,適應網路抖動和丟包的低延遲演算法,以及先進的音訊編解碼器。

Google 在 Gtalk 中也使用了 GIPS 的授權。Google 在 2011 年以6820萬美元收購了 GIPS,並將其原始碼開源,加上在 2010 年收購的 On2 獲取到的 VPx 系列影片編解碼器(詳見《即時通訊音影片開發(十七):影片編碼H.264、VP8的前世今生》),WebRTC 開源專案應運而生,即 GIPS 音影片引擎 + 替換掉 H.264 的 VPx 影片編解碼器。

在此之後,Google 又將在 Gtalk 中用於 P2P 打洞的開源專案 libjingle 融合進了 WebRTC。目前 WebRTC 提供了包括 Web、iOS、Android、Mac、Windows、Linux 在內的所有平臺支援。

(以上介紹,引用自《了不起的WebRTC:生態日趨完善,或將實時音影片技術白菜化》)

雖然WebRTC的目標是實現跨平臺的Web端實時音影片通訊,但因為核心層程式碼的Native、高品質和內聚性,開發者很容易進行除Web平臺外的移殖和應用。目前為止,WebRTC幾乎是是業界能免費得到的唯一高品質實時音影片通訊技術。

3、流程介紹

一個典型的實時音影片處理流程大概是這樣:

  • 1)傳送端採集音影片資料,通過編碼器生成幀資料;
  • 2)這資料被打包成 RTP 包,通過 ICE 通道傳送到接收端;
  • 3)接收端接收 RTP 包,取出 RTP payload,完成組幀的操作;
  • 4)之後音影片解碼器解碼幀資料,生成影片影象或音訊 PCM 資料。

如下圖所示:

本文所涉及的引數調整,談論的部分位於上圖中的第 4 步。

因為是接收端,所以會收到對方的 Offer 請求。先設定 SetRemoteDescription 再 SetLocalDescription。

如下圖藍色部分:

4、引數調整

4.1 影片引數調整

當收到 Signal 執行緒 SetRemoteDescription 後,會在 Worker 執行緒中建立 VideoReceiveStream 物件。具體流程為 SetRemoteDescription -> VideoChannel::SetRemoteContent_w 建立 WebRtcVideoReceiveStream。

WebRtcVideoReceiveStream 包含了一個 VideoReceiveStream 型別 stream_ 物件, 通過 webrtc::VideoReceiveStream* Call::CreateVideoReceiveStream 建立。

建立後立即啟動 VideoReceiveStream 工作,即呼叫 Start() 方法。

此時 VideoReceiveStream 包含一個 RtpVideoStreamReceiver 物件準備開始處理 video RTP 包。

接收方建立 createAnswer 後通過 setLocalDescription 設定 local descritpion。

對應會在 Worker 執行緒中 setLocalContent_w 方法中根據 SDP 設定 channel 的接收引數,最終會呼叫到 WebRtcVideoReceiveStream::SetRecvParameters。

WebRtcVideoReceiveStream::SetRecvParameters 實現如下:

void WebRtcVideoChannel::WebRtcVideoReceiveStream::SetRecvParameters(

const ChangedRecvParameters& params) {

bool video_needs_recreation = false;

bool flexfec_needs_recreation = false;

if(params.codec_settings) {

ConfigureCodecs(*params.codec_settings);

video_needs_recreation = true;

}

if(params.rtp_header_extensions) {

config_.rtp.extensions = *params.rtp_header_extensions;

flexfec_config_.rtp_header_extensions = *params.rtp_header_extensions;

video_needs_recreation = true;

flexfec_needs_recreation = true;

}

if(params.flexfec_payload_type) {

ConfigureFlexfecCodec(*params.flexfec_payload_type);

flexfec_needs_recreation = true;

}

if(flexfec_needs_recreation) {

RTC_LOG(LS_INFO) << "MaybeRecreateWebRtcFlexfecStream (recv) because of "

"SetRecvParameters";

MaybeRecreateWebRtcFlexfecStream();

}

if(video_needs_recreation) {

RTC_LOG(LS_INFO)

<< "RecreateWebRtcVideoStream (recv) because of SetRecvParameters";

RecreateWebRtcVideoStream();

}

}

根據上面 SetRecvParameters 程式碼,如果 codec_settings 不為空、rtp_header_extensions 不為空、flexfec_payload_type 不為空都會重啟 VideoReceiveStream。

video_needs_recreation 表示是否要重啟 VideoReceiveStream。

重啟過程為:把先前建立的釋放掉,然後重建新的 VideoReceiveStream。

以 codec_settings 為例:初始 video codec 支援 H264 和 VP8。若對端只支援 H264,協商後的 codec 僅支援 H264。SetRecvParameters 中的 codec_settings 為 H264 不空。其實前後 VideoReceiveStream 的都有 H264 codec,沒有必要重建 VideoReceiveStream。可以通過配置本地支援的 video codec 初始列表和 rtp extensions,從而生成的 local SDP 和 remote SDP 中影響接收引數部分調整一致,並且判斷 codec_settings 是否相等。 如果不相等再 video_needs_recreation 為 true。

這樣設定就會使 SetRecvParameters 避免觸發重啟 VideoReceiveStream 邏輯。

在 debug 模式下,修改後,驗證沒有 “RecreateWebRtcVideoStream (recv) because of SetRecvParameters” 的列印, 即可證明沒有 VideoReceiveStream 重啟。

4.2 音訊引數調整

和上面的影片調整類似,音訊也會有因為 rtp extensions 不一致導致重新建立 AudioReceiveStream,也是釋放先前的 AudioReceiveStream,再重新建立 AudioReceiveStream。

參考程式碼:

bool WebRtcVoiceMediaChannel::SetRecvParameters(

const AudioRecvParameters& params) {

TRACE_EVENT0("webrtc", "WebRtcVoiceMediaChannel::SetRecvParameters");

RTC_DCHECK(worker_thread_checker_.CalledOnValidThread());

RTC_LOG(LS_INFO) << "WebRtcVoiceMediaChannel::SetRecvParameters: "

<< params.ToString();

// TODO(pthatcher): Refactor this to be more clean now that we have

// all the information at once.

if(!SetRecvCodecs(params.codecs)) {

return false;

}

if(!ValidateRtpExtensions(params.extensions)) {

return false;

}

std::vector filtered_extensions = FilterRtpExtensions(

params.extensions, webrtc::RtpExtension::IsSupportedForAudio, false);

if(recv_rtp_extensions_ != filtered_extensions) {

recv_rtp_extensions_.swap(filtered_extensions);

for(auto& it : recv_streams_) {

it.second->SetRtpExtensionsAndRecreateStream(recv_rtp_extensions_);

}

}

return true;

}

AudioReceiveStream 的構造方法會啟動音訊裝置,即呼叫 AudioDeviceModule 的 StartPlayout。

AudioReceiveStream 的析構方法會停止音訊裝置,即呼叫 AudioDeviceModule 的 StopPlayout。

因此重啟 AudioReceiveStream 會觸發多次 StartPlayout/StopPlayout。

經測試,這些不必要的操作會導致進入影片會議的房間時,播放的音訊有一小段間斷的情況。

解決方法:同樣是通過配置本地支援的 audio codec 初始列表和 rtp extensions,從而生成的 local SDP 和 remote SDP 中影響接收引數部分調整一致,避免 AudioReceiveStream 重啟邏輯。

另外 audio codec 多為 WebRTC 內部實現,去掉一些不用的 Audio Codec,可以減小 WebRTC 對應的庫檔案。

4.3 音影片相互影響

WebRTC 內部有三個非常重要的執行緒:

  • 1)woker 執行緒;
  • 2)signal 執行緒;
  • 3)network 執行緒。

呼叫 PeerConnection 的 API 的呼叫會由 signal 執行緒進入到 worker 執行緒。

worker 執行緒內完成媒體資料的處理,network 執行緒處理網路相關的事務,channel.h 檔案中有說明,以 _w 結尾的方法為 worker 執行緒的方法,signal 執行緒的到 worker 執行緒的呼叫是同步操作。

如下面程式碼中的 InvokerOnWorker 是同步操作,setLocalContent_w 和 setRemoteContent_w 是 worker 執行緒中的方法。

bool BaseChannel::SetLocalContent(const MediaContentDescription* content,

SdpType type,

std::string* error_desc) {

TRACE_EVENT0("webrtc", "BaseChannel::SetLocalContent");

returnI nvokeOnWorker(

RTC_FROM_HERE,

Bind(&BaseChannel::SetLocalContent_w, this, content, type, error_desc));

}

bool BaseChannel::SetRemoteContent(const MediaContentDescription* content,

SdpType type,

std::string* error_desc) {

TRACE_EVENT0("webrtc", "BaseChannel::SetRemoteContent");

return InvokeOnWorker(

RTC_FROM_HERE,

Bind(&BaseChannel::SetRemoteContent_w, this, content, type, error_desc));

}

setLocalDescription 和 setRemoteDescription 中的 SDP 資訊都會通過 PeerConnection 的 PushdownMediaDescription 方法依次下發給 audio/video RtpTransceiver 設定 SDP 資訊。

舉例:執行 audio 的 SetRemoteContent_w 執行很長(比如音訊 AudioDeviceModule 的 InitPlayout 執行耗時), 會影響後面的 video SetRemoteContent_w 的設定時間。

PushdownMediaDescription 程式碼:

RTCError PeerConnection::PushdownMediaDescription(

SdpType type,

cricket::ContentSource source) {

const SessionDescriptionInterface* sdesc =

(source == cricket::CS_LOCAL ? local_description()

: remote_description());

RTC_DCHECK(sdesc);

// Push down the new SDP media section for each audio/video transceiver.

for(const auto& transceiver : transceivers_) {

const ContentInfo* content_info =

FindMediaSectionForTransceiver(transceiver, sdesc);

cricket::ChannelInterface* channel = transceiver->internal()->channel();

if(!channel || !content_info || content_info->rejected) {

continue;

}

const MediaContentDescription* content_desc =

content_info->media_description();

if(!content_desc) {

continue;

}

std::string error;

bool success = (source == cricket::CS_LOCAL)

? channel->SetLocalContent(content_desc, type, &error)

: channel->SetRemoteContent(content_desc, type, &error);

if(!success) {

LOG_AND_RETURN_ERROR(RTCErrorType::INVALID_PARAMETER, error);

}

}

...

}

5、其他影響首幀顯示的問題

5.1 Android影象寬高16位元組對齊

AndroidVideoDecoder 是 WebRTC Android 平臺上的影片硬解類。AndroidVideoDecoder 利用 MediaCodec API 完成對硬體解碼器的呼叫。

MediaCodec 有已下解碼相關的 API:

  • 1)dequeueInputBuffer:若大於 0,則是返回填充編碼資料的緩衝區的索引,該操作為同步操作;
  • 2)getInputBuffer:填充編碼資料的 ByteBuffer 陣列,結合 dequeueInputBuffer 返回值,可獲取一個可填充編碼資料的 ByteBuffer;
  • 3)queueInputBuffer:應用將編碼資料拷貝到 ByteBuffer 後,通過該方法告知 MediaCodec 已經填寫的編碼資料的緩衝區索引;
  • 4)dequeueOutputBuffer:若大於 0,則是返回填充解碼資料的緩衝區的索引,該操作為同步操作;
  • 5)getOutputBuffer:填充解碼資料的 ByteBuffer 陣列,結合 dequeueOutputBuffer 返回值,可獲取一個可填充解碼資料的 ByteBuffer;
  • 6)releaseOutputBuffer:告訴編碼器資料處理完成,釋放 ByteBuffer 資料。

在實踐當中發現,傳送端傳送的影片寬高需要 16 位元組對齊,因為在某些 Android 手機上解碼器需要 16 位元組對齊。

大致的原理就是:Android 上影片解碼先是把待解碼的資料通過 queueInputBuffer 給到 MediaCodec。然後通過 dequeueOutputBuffer 反覆檢視是否有解完的影片幀。若非 16 位元組對齊,dequeueOutputBuffer 會有一次MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED。而不是一上來就能成功解碼一幀。

經測試發現:幀寬高非 16 位元組對齊會比 16 位元組對齊的慢 100 ms 左右。

5.2 伺服器需轉發關鍵幀請求

iOS 移動裝置上,WebRTC App應用進入後臺後,影片解碼由 VTDecompressionSessionDecodeFrame 返回 kVTInvalidSessionErr,表示解碼session 無效。從而會觸發觀看端的關鍵幀請求給伺服器。

這裡要求伺服器必須轉發接收端發來的關鍵幀請求給傳送端。若伺服器沒有轉發關鍵幀給傳送端,接收端就會長時間沒有可以渲染的影象,從而出現黑屏問題。

這種情況下只能等待發送端自己生成關鍵幀,傳送個接收端,從而使黑屏的接收端恢復正常。

5.3 WebRTC內部的一些丟棄資料邏輯舉例

Webrtc從接受報資料到、給到解碼器之間的過程中也會有很多驗證資料的正確性。

舉例1:

PacketBuffer 中記錄著當前快取的最小的序號 first_seq_num_(這個值也是會被更新的)。 當 PacketBuffer 中 InsertPacket 時候,如果即將要插入的 packet 的序號 seq_num 小於 first_seq_num,這個 packet 會被丟棄掉。如果因此持續丟棄 packet,就會有影片不顯示或卡頓的情況。

舉例2:

正常情況下 FrameBuffer 中幀的 picture id,時間戳都是一直正增長的。

如果 FrameBuffer 收到 picture_id 比最後解碼幀的 picture id 小時,分兩種情況:

  • 1)時間戳比最後解碼幀的時間戳大,且是關鍵幀,就會儲存下來。
  • 2)除情況 1 之外的幀都會丟棄掉。

程式碼如下:

auto last_decoded_frame = decoded_frames_history_.GetLastDecodedFrameId();

auto last_decoded_frame_timestamp =

decoded_frames_history_.GetLastDecodedFrameTimestamp();

if(last_decoded_frame && id <= *last_decoded_frame) {

if(AheadOf(frame->Timestamp(), *last_decoded_frame_timestamp) &&

frame->is_keyframe()) {

// If this frame has a newer timestamp but an earlier picture id then we

// assume there has been a jump in the picture id due to some encoder

// reconfiguration or some other reason. Even though this is not according

// to spec we can still continue to decode from this frame if it is a

// keyframe.

RTC_LOG(LS_WARNING)

<< "A jump in picture id was detected, clearing buffer.";

ClearFramesAndHistory();

last_continuous_picture_id = -1;

} else{

RTC_LOG(LS_WARNING) << "Frame with (picture_id:spatial_id) ("

<< id.picture_id << ":"

<< static_cast(id.spatial_layer)

<< ") inserted after frame ("

<< last_decoded_frame->picture_id << ":"

<< static_cast(last_decoded_frame->spatial_layer)

<< ") was handed off for decoding, dropping frame.";

return last_continuous_picture_id;

}

}

因此為了能讓收到了流順利播放,傳送端和中轉的服務端需要確保影片幀的 picture_id, 時間戳正確性。

WebRTC 還有其他很多丟幀邏輯,若網路正常且有持續有接收資料,但是影片卡頓或黑屏無顯示,多為流本身的問題。

6、本文小結

本文通過分析 WebRTC 音影片接收端的處理邏輯,列舉了一些可以優化首幀顯示的點,比如通過調整 local SDP 和 remote SDP 中與影響接收端處理的相關部分,從而避免 Audio/Video ReceiveStream 的重啟。

另外列舉了 Android 解碼器對影片寬高的要求、服務端對關鍵幀請求處理、以及 WebRTC 程式碼內部的一些丟幀邏輯等多個方面對影片顯示的影響。 這些點都提高了融雲 SDK 影片首幀的顯示時間,改善了使用者體驗。

因個人水平有限,文章內容或許存在一定的侷限性,歡迎回復進行討論。(本文同步釋出於:http://www.52im.net/thread-3169-1-1.html

7、參考資料

融雲技術分享:融雲安卓端IM產品的網路鏈路保活技術實踐

IM訊息ID技術專題(三):解密融雲IM產品的聊天訊息ID生成策略

融雲技術分享:基於WebRTC的實時音影片首幀顯示時間優化實踐》(* 本文)

即時通訊雲融雲CTO的創業經驗分享:技術創業,你真的準備好了?

「其他文章」