作者:villainthr
摘自:villainhr
WebRTC 全稱為:Web Real-Time Communication
。它是為了解決 Web 端無法捕獲音視頻的能力,並且提供了 peer-to-peer(就是瀏覽器間)的視頻交互。實際上,細分看來,它包含三個部分:
- MediaStream:捕獲音視頻流
- RTCPeerConnection:傳輸音視頻流(一般用在 peer-to-peer 的場景)
- RTCDataChannel: 用來上傳音視頻二進制數據(一般用到流的上傳)
但通常,peer-to-peer 的場景實際上應用不大。對比與去年火起來的直播
業務,這應該才是 WebRTC 常常應用到的地方。那么對應於 Web 直播來說,我們通常需要兩個端:
- 主播端:錄制並上傳視頻
- 觀眾端:下載並觀看視頻
這里,我就不談觀眾端了,后面另寫一篇文章介紹(因為,這是在是太多了)。這里,主要談一下會用到 WebRTC 的主播端。
簡化一下,主播端應用技術簡單可以分為:錄制視頻,上傳視頻。大家先記住這兩個目標,后面我們會通過 WebRTC 來實現這兩個目標。
WebRTC 基本了解
WebRTC 主要由兩個組織來制定。
- Web Real-Time Communications (WEBRTC) W3C 組織:定義瀏覽器 API
- Real-Time Communication in Web-browsers (RTCWEB) IETF 標准組織:定義其所需的協議,數據,安全性等手段。
當然,我們初級目標是先關心基本瀏覽器定義的 API 是啥?以及怎么使用?
然后,后期目標是學習期內部的相關協議,數據格式等。這樣循序漸進來,比較適合我們的學習。
WebRTC 對於音視頻的處理,主要是交給 Audio/Vidoe Engineering 處理的。處理過程為:
- 音頻:通過物理設備進行捕獲。然后開始進行
降噪
,消除回音
,抖動/丟包隱藏
,編碼
。 - 視頻:通過物理設備進行捕獲。然后開始進行
圖像增強
,同步
,抖動/丟包隱藏
,編碼
。
最后通過 mediaStream Object 暴露給上層 API 使用。也就是說 mediaStream 是連接 WebRTC API 和底層物理流的中間層。所以,為了下面更好的理解,這里我們先對 mediaStream 做一些簡單的介紹。
MediaStream
MS(MediaStream)是作為一個輔助對象存在的。它承載了音視頻流的篩選,錄制權限的獲取等。MS 由兩部分構成: MediaStreamTrack 和 MediaStream。
- MediaStreamTrack 代表一種單類型數據流。如果你用過
會聲會影
的話,應該對軌道
這個詞不陌生。通俗來講,你可以認為兩者就是等價的。 - MediaStream 是一個完整的音視頻流。它可以包含 >=0 個
MediaStreamTrack
。它主要的作用就是確保幾個軌道是同時播放的。例如,聲音需要和視頻畫面同步。
這里,我們不說太深,講講基本的 MediaStream
對象即可。通常,我們使用實例化一個 MS 對象,就可以得到一個對象。
// 里面還需要傳遞 track,或者其他 stream 作為參數。 // 這里只為演示方便 let ms = new MediaStream();
我們可以看一下 ms
上面帶有哪些對象屬性:
- active[boolean]:表示當前 ms 是否是活躍狀態(就是可播放狀態)。
- id[String]: 對當前的 ms 進行唯一標識。例如:"f61641ec-ee78-4317-9415-58acac066a4d"
- onactive: 當 active 為 true 時,觸發該事件
- onaddtrack: 當有新的 track 添加時,觸發該事件
- oninactive: 當 active 為 false 時,觸發該事件
- onremovetrack: 當有 track 移除時,觸發該事件
它的原型鏈上還掛在了其他方法,我挑幾個重要的說一下。
- clone(): 對當前的 ms 流克隆一份。該方法通常用於對該 ms 流有操作時,常常會用到。
前面說了,MS 還可以其他篩選的作用,那么它是如何做到的呢?
在 MS 中,還有一個重要的概念叫做: Constraints
。它是用來規范當前采集的數據是否符合需要。因為,我們采集視頻時,不同的設備有不同的參數設置。常用的為:
{
"audio": true, // 是否捕獲音頻 "video": { // 視頻相關設置 "width": { "min": "381", // 當前視頻的最小寬度 "max": "640" }, "height": { "min": "200", // 最小高度 "max": "480" }, "frameRate": { "min": "28", // 最小幀率 "max": "10" } } }
那我怎么知道我的設備支持的哪些屬性的調優呢?
這里,可以直接使用 navigator.mediaDevices.getSupportedConstraints()
來獲取可以調優的相關屬性。不過,這一般是對 video 進行設置。了解了 MS 之后,我們就要開始真正接觸 WebRTC 的相關 API。我們先來看一下 WebRTC 基本API。
WebRTC 的常用 API 如下,不過由於瀏覽器的緣故,需要加上對應的 prefix:
W3C Standard Chrome Firefox
-------------------------------------------------------------- getUserMedia webkitGetUserMedia mozGetUserMedia RTCPeerConnection webkitRTCPeerConnection RTCPeerConnection RTCSessionDescription RTCSessionDescription RTCSessionDescription RTCIceCandidate RTCIceCandidate RTCIceCandidate
不過,你可以簡單的使用下列的方法來解決。不過嫌麻煩的可以使用 adapter.js 來彌補
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia
這里,我們循序漸進的來學習。如果想進行視頻的相關交互,首先應該是捕獲音視頻。
捕獲音視頻
在 WebRTC 中捕獲音視頻,只需要使用到一個 API,即,getUserMedia()
。代碼其實很簡單:
navigator.getUserMedia = navigator.getUserMedia ||
navigator.webkitGetUserMedia || navigator.mozGetUserMedia;
var constraints = { // 設置捕獲的音視頻設置 audio: false, video: true }; var video = document.querySelector('video'); function successCallback(stream) { window.stream = stream; // 這就是上面提到的 mediaStream 實例 if (window.URL) { video.src = window.URL.createObjectURL(stream); // 用來創建 video 可以播放的 src } else { video.src = stream; } } function errorCallback(error) { console.log('navigator.getUserMedia error: ', error); } // 這是 getUserMedia 的基本格式 navigator.getUserMedia(constraints, successCallback, errorCallback);
詳細 demo 可以參考:WebRTC。不過,上面的寫法比較古老,如果使用 Promise 來的話,getUserMedia 可以寫為:
navigator.mediaDevices.getUserMedia(constraints). then(successCallback).catch(errorCallback);
上面的注釋大概已經說清楚基本的內容。需要提醒的是,你在捕獲視頻的同時,一定要清楚自己需要捕獲的相關參數。
有了自己的視頻之后,那如何與其他人共享這個視頻呢?(可以理解為直播的方式)
在 WebRTC 中,提供了 RTCPeerConnection
的方式,來幫助我們快速建立起連接。不過,這僅僅只是建立起 peer-to-peer 的中間一環。這里包含了一些復雜的過程和額外的協議,我們一步一步的來看下。
WebRTC 基本內容
WebRTC 利用的是 UDP 方式來進行傳輸視頻包。這樣做的好處是延遲性低,不用過度關注包的順序。不過,UDP 僅僅只是作為一個傳輸層協議而已。WebRTC 還需要解決很多問題
- 遍歷 NATs 層,找到指定的 peer
- 雙方進行基本信息的協商以便雙方都能正常播放視頻
- 在傳輸時,還需要保證信息安全性
整個架構如下:
上面那些協議,例如,ICE/STUN/TURN 等,我們后面會慢慢講解。先來看一下,兩者是如何進行信息協商的,通常這一階段,我們叫做 signaling
。
signaling 任務
signaling 實際上是一個協商過程。因為,兩端進不進行 WebRTC 視頻交流之間,需要知道一些基本信息。
- 打開/關閉連接的指令
- 視頻信息,比如解碼器,解碼器的設置,帶寬,以及視頻的格式等。
- 關鍵數據,相當於 HTTPS 中的
master key
用來確保安全連接。 - 網關信息,比如雙方的 IP,port
不過,signaling 這個過程並不是寫死的,即,不管你用哪種協議,只要能確保安全即可。為什么呢?因為,不同的應用有着其本身最適合的協商方法。比如:
- 單網關協議(SIP/Jingle/ISUP)適用於呼叫機制(VoIP,voice over IP)。
- 自定義協議
- 多網關協議
我們自己也可以模擬出一個 signaling 通道。它的原理就是將信息進行傳輸而已,通常為了方便,我們可以直接使用 socket.io 來建立 room
提供信息交流的通道。
PeerConnection 的建立
假定,我們現在已經通過 socket.io
建立起了一個信息交流的通道。那么我們接下來就可以進入 RTCPeerConnection
一節,進行連接的建立。我們首先應該利用 signaling
進行基本信息的交換。那這些信息有哪些呢?
WebRTC 已經在底層幫我們做了這些事情-- Session Description Protocol (SDP)
。我們利用 signaling
傳遞相關的 SDP,來確保雙方都能正確匹配,底層引擎會自動解析 SDP (是 JSEP 幫的忙),而不需要我們手動進行解析,突然感覺世界好美妙。。。我們來看一下怎么傳遞。
// 利用已經創建好的通道。 var signalingChannel = new SignalingChannel(); // 正式進入 RTC connection。這相當於創建了一個 peer 端。 var pc = new RTCPeerConnection({}); navigator.getUserMedia({ "audio": true }) .then(gotStream).catch(logError); function gotStream(stream) { pc.addStream(stream); // 通過 createOffer 來生成本地的 SDP pc.createOffer(function(offer) { pc.setLocalDescription(offer); signalingChannel.send(offer.sdp); }); } function logError() { ... }
那 SDP 的具體格式是啥呢?
看一下格式就 ok,這不用過多了解:
v=0
o=- 1029325693179593971 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE audio video
a=msid-semantic: WMS
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
c=IN IP4 0.0.0.0
a=rtcp:9 IN IP4 0.0.0.0
a=ice-ufrag:nHtT
a=ice-pwd:cuwglAha5fBmGljFXWntH1VN
a=fingerprint:sha-256 24:63:EB:DD:18:1B:BB:5E:B3:E8:C5:D7:92:F7:0B:44:EC:22:96:63:64:76:1A:56:64:DE:6B:CE:85:C6:64:78
a=setup:active
a=mid:audio
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level
a=inactive
a=rtcp-mux
...
上面的過程,就是 peer-to-peer 的協商流程。這里有兩個基本的概念,offer
,answer
。
- offer: 主播端向其他用戶提供其本省視頻直播的基本信息
- answer: 用戶端反饋給主播端,檢查能否正常播放
具體過程為:
- 主播端通過 createOffer 生成 SDP 描述
- 主播通過 setLocalDescription,設置本地的描述信息
- 主播將 offer SDP 發送給用戶
- 用戶通過 setRemoteDescription,設置遠端的描述信息
- 用戶通過 createAnswer 創建出自己的 SDP 描述
- 用戶通過 setLocalDescription,設置本地的描述信息
- 用戶將 anwser SDP 發送給主播
- 主播通過 setRemoteDescription,設置遠端的描述信息。
不過,上面只是簡單確立了兩端的連接信息而已,還沒有涉及到視頻信息的傳輸,也就是說 UDP 傳輸。UDP 傳輸本來就是一個非常讓人蛋疼的活,如果是 client-server 的模型話還好,直接傳就可以了,但這偏偏是 peer-to-peer 的模型。想想,你現在是要把你的電腦當做一個服務器使用,中間還需要經歷如果突破防火牆,如果找到端口,如何跨網段進行?所以,這里我們就需要額外的協議,即,STUN/TURN/ICE ,來幫助我們完成這樣的傳輸任務。
NAT/STUN/TURN/ICE
在 UDP 傳輸中,我們不可避免的會遇見 NAT
(Network address translator)服務器。即,它主要是將其它網段的消息傳遞給它負責網段內的機器。不過,我們的 UDP 包在傳遞時,一般只會帶上 NAT 的 host
。如果,此時你沒有目標機器的 entry
的話,那么該次 UDP 包將不會被轉發成功。不過,如果你是 client-server 的形式的話,就不會遇見這樣的問題。但,這里我們是 peer-to-peer 的方式進行傳輸,無法避免的會遇見這樣的問題。
為了解決這樣的問題,我們就需要建立 end-to-end 的連接。那辦法是什么呢?很簡單,就是在中間設立一個 server
用來保留目標機器在 NAT 中的 entry
。常用協議有 STUN, TURN 和 ICE
。那他們有什么區別嗎?
- STUN:作為最基本的
NAT traversal
服務器,保留指定機器的entry
- TURN:當 STUN 出錯的時候,作為重試服務器的存在。
- ICE:在眾多 STUN + TURN 服務器中,選擇最有效的傳遞通道。
所以,上面三者通常是結合在一起使用的。它們在 PeerConnection 中的角色如下圖:
如果,涉及到 ICE 的話,我們在實例化 Peer Connection 時,還需要預先設置好指定的 STUN/TRUN 服務器。
var ice = {"iceServers": [ {"url": "stun:stun.l.google.com:19302"}, // TURN 一般需要自己去定義 { 'url': 'turn:192.158.29.39:3478?transport=udp', 'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', 'username': '28224511:1379330808' }, { 'url': 'turn:192.158.29.39:3478?transport=tcp', 'credential': 'JZEOEt2V3Qb0y27GRntt2u2PAYA=', 'username': '28224511:1379330808' } ]}; var signalingChannel = new SignalingChannel(); var pc = new RTCPeerConnection(ice); // 在實例化 Peer Connection 時完成。 navigator.getUserMedia({ "audio": true }, gotStream, logError); function gotStream(stream) { pc.addStream(stream); // 將流添加到 connection 中。 pc.createOffer(function(offer) { pc.setLocalDescription(offer); }); } // 通過 ICE,監聽是否有用戶連接 pc.onicecandidate = function(evt) { if (evt.target.iceGatheringState == "complete") { local.createOffer(function(offer) { console.log("Offer with ICE candidates: " + offer.sdp); signalingChannel.send(offer.sdp); }); } } ...
在 ICE 處理中,里面還分為 iceGatheringState
和 iceConnectionState
。在代碼中反應的就是:
pc.onicecandidate = function(e) { evt.target.iceGatheringState; pc.iceGatheringState }; pc.oniceconnectionstatechange = function(e) { evt.target.iceConnectionState; pc.iceConnectionState; };
當然,起主要作用的還是 onicecandidate
。
- iceGatheringState: 用來檢測本地 candidate 的狀態。其有以下三種狀態:
- new: 該 candidate 剛剛被創建
- gathering: ICE 正在收集本地的 candidate
- complete: ICE 完成本地 candidate 的收集
- iceConnectionState: 用來檢測遠端 candidate 的狀態。遠端的狀態比較復雜,一共有 7 種: new/checking/connected/completed/failed/disconnected/closed
不過,這里為了更好的講解 WebRTC 建立連接的基本過程。我們使用單頁的連接來模擬一下。現在假設,有兩個用戶,一個是 pc1,一個是 pc2。pc1 捕獲視頻,然后,pc2 建立與 pc1 的連接,完成偽直播的效果。直接看代碼吧:
var servers = null; // Add pc1 to global scope so it's accessible from the browser console window.pc1 = pc1 = new RTCPeerConnection(servers); // 監聽是否有新的 candidate 加入 pc1.onicecandidate = function(e) { onIceCandidate(pc1, e); }; // Add pc2 to global scope so it's accessible from the browser console window.pc2 = pc2 = new RTCPeerConnection(servers); pc2.onicecandidate = function(e) { onIceCandidate(pc2, e); }; pc1.oniceconnectionstatechange = function(e) { onIceStateChange(pc1, e); }; pc2.oniceconnectionstatechange = function(e) { onIceStateChange(pc2, e); }; // 一旦 candidate 添加成功,則將 stream 播放 pc2.onaddstream = gotRemoteStream; // pc1 作為播放端,先將 stream 加入到 Connection 當中。 pc1.addStream(localStream); pc1.createOffer( offerOptions ).then( onCreateOfferSuccess, error ); function onCreateOfferSuccess(desc) { // desc 就是 sdp 的數據 pc1.setLocalDescription(desc).then( function() { onSetLocalSuccess(pc1); }, onSetSessionDescriptionError ); trace('pc2 setRemoteDescription start'); // 省去了 offer 的發送通道 pc2.setRemoteDescription(desc).then( function() { onSetRemoteSuccess(pc2); }, onSetSessionDescriptionError ); trace('pc2 createAnswer start'); pc2.createAnswer().then( onCreateAnswerSuccess, onCreateSessionDescriptionError ); }
看上面的代碼,大家估計有點迷茫,來點實的,大家可以參考 單頁直播。在查看該網頁的時候,可以打開控制台觀察具體進行的流程。會發現一個現象,即,onaddstream
會在 SDP
協商還未完成之前就已經開始,這也是,該 API 設計的一些不合理之處,所以,W3C 已經將該 API 移除標准。不過,對於目前來說,問題不大,因為僅僅只是作為演示使用。整個流程我們一步一步來講解下。
- pc1 createOffer start
- pc1 setLocalDescription start // pc1 的 SDP
- pc2 setRemoteDescription start // pc1 的 SDP
- pc2 createAnswer start
- pc1 setLocalDescription complete // pc1 的 SDP
- pc2 setRemoteDescription complete // pc1 的 SDP
- pc2 setLocalDescription start // pc2 的 SDP
- pc1 setRemoteDescription start // pc2 的 SDP
- pc2 received remote stream,此時,接收端已經可以播放視頻。接着,觸發 pc2 的 onaddstream 監聽事件。獲得遠端的 video stream,注意此時 pc2 的 SDP 協商還未完成。
- 此時,本地的 pc1 candidate 的狀態已經改變,觸發 pc1 onicecandidate。開始通過
pc2.addIceCandidate
方法將 pc1 添加進去。 - pc2 setLocalDescription complete // pc2 的 SDP
- pc1 setRemoteDescription complete // pc2 的 SDP
- pc1 addIceCandidate success。pc1 添加成功
- 觸發
oniceconnectionstatechange
檢查 pc1 遠端 candidate 的狀態。當為completed
狀態時,則會觸發 pc2onicecandidate
事件。 - pc2 addIceCandidate success。
此外,還有另外一個概念,RTCDataChannel
我這里就不過多涉及了。如果有興趣的可以參閱 webrtc,web 性能優化 進行深入的學習。
相關閱讀:
【騰訊雲的1001種玩法】 Laravel 整合微視頻上傳管理能力,輕松打造視頻App后台
此文已由作者授權騰訊雲技術社區發布,轉載請注明文章出處,獲取更多雲計算技術干貨,可請前往騰訊雲技術社區
原文鏈接:https://www.qcloud.com/community/article/522911001489391620
歡迎大家關注騰訊雲技術社區-博客園官方主頁,我們將持續在博客園為大家推薦技術精品文章哦~