深入淺出音視頻與 WebRTC
常見的音視頻網絡通信協議
普通直播協議
這類直播對實時性要求不那麼高,使用 CDN 進行內容分發,會有幾秒甚至十幾秒的延時,主要關注畫面質量、音視頻是卡頓等問題,一般選用 RTMP 和 HLS 協議
基本概念
- RTMP
RTMP (Real Time Messaging Protocol),即 “實時消息傳輸協議”, 它實際上並不能做到真正的實時,一般情況最少都會有幾秒到幾十秒的延遲,是 Adobe 公司開發的音視頻數據傳輸的實時消息傳送協議,RTMP 協議基於 TCP,包括 RTMP 基本協議及 RTMPT/RTMPS/RTMPE 等多種變種,RTMP 是目前主流的流媒體傳輸協議之一,對 CDN 支持良好,實現難度較低,是大多數直播平臺的選擇,不過 RTMP 有一個最大的不足 —— 不支持瀏覽器,且蘋果 ios 不支持,Adobe 已停止對其更新
RTMP 目前在 PC 上的使用仍然比較廣泛
- HLS
HLS (Http Live Streaming)是由蘋果公司定義的基於 HTTP 的流媒體實時傳輸協議,被廣泛的應用於視頻點播和直播領域,HLS 規範規定播放器至少下載一個 ts 切片才能播放,所以 HLS 理論上至少會有一個切片的延遲
HLS 在移動端兼容性比較好,ios 就不用說了,Android 現在也基本都支持 HLS 協議了,pc 端如果要使用可以使用 hls.js 適配器
HLS 的原理是將整個流分爲多個小的文件來下載,每次只下載若干個,服務器端會將最新的直播數據生成新的小文件,當客戶端獲取直播時,它通過獲取最新的視頻文件片段來播放,從而保證用戶在任何時候連接進來時都會看到較新的內容,實現近似直播的體驗;HLS 的延遲一般會高於普通的流媒體直播協議,傳輸內容包括兩部分:一部分 M3U8 是索引文件,另一部分是 TS 文件,用來存儲音視頻的媒體信息
RTMP 和 HLS 如何選擇
-
流媒體推流,一般使用 RTMP 協議
-
移動端的網頁播放器最好使用 HLS 協議,RTMP 不支持瀏覽器
-
iOS 要使用 HLS 協議,因爲不支持 RTMP 協議
-
點播系統最好使用 HLS 協議,因爲點播沒有實時互動需求,延遲大一些是可以接受的,並且可以在瀏覽器上直接觀看
普通直播基本架構
由直播 客戶端 、 信令 服務器和 CDN 網絡這三部分組成
直播 客戶端主要包括音視頻數據的採集、編碼、推流、拉流、解碼與播放功能,但實際上這些功能並不是在同一個客戶端中實現的,爲什麼呢?因爲作爲主播來說,他不需要看到觀衆的視頻或聽到觀衆的聲音,而作爲觀衆來講,他們與主播之間是通過文字進行交流的,不需要向主播分享自己的音視頻信息
對於主播客戶端來說,它可以設備的攝像頭、麥克風採集數據,然後對採集到的音視頻數據進行編碼,最後將編碼後的音視頻數據推送給 CDN
對於觀衆客戶端來說,它首先需要獲取到主播房間的流媒體地址,觀衆進入房間後從 CDN 拉取音視頻數據,並對獲取到的音視頻數據進行解碼,最後進行音視頻的渲染與播放
信令 服務器,主要用於接收信令,並根據信令處理一些和業務相關的邏輯,如創建房間、加入房間、離開房間、文字聊天等
CDN 網絡,主要用於媒體數據的分發,傳給它的媒體數據可以很快傳送給各地的用戶
實時直播協議
隨着人們對實時性、互動性的要求越來越高,傳統直播技術越來越滿足不了人們的需求,WebRTC 技術正是爲了解決人們對實時性、互動性需求而提出的新技術
- WebRTC
WebRTC(Web Real-Time Communication),即 “網頁即時通信”,WebRTC 是一個支持瀏覽器進行實時語音、視頻對話的開源協議,目前主流瀏覽器都支持 WebRTC,即便在網絡信號一般的情況下也具備較好的穩定性,WebRTC 可以實現點對點通信,通信雙方延時低,使用戶無需下載安裝任何插件就可以進行實時通信
在 WebRTC 發佈之前,開發實時音視頻交互應用的成本很高,需要考慮的技術問題很多,如音視頻的編解碼問題,數據傳輸問題,延時、丟包、抖動、迴音的處理和消除等,如果要兼容瀏覽器端的實時音視頻通信,還需要額外安裝插件, WebRTC 大大降低了音視頻開發的門檻,開發者只需要調用 WebRTC API 即可快速構建出音視頻應用
下面主要通過 WebRTC 的實時通信過程來對 WebRTC 有一個大概的瞭解
WebRTC 音視頻通信的大體過程
音視頻設備檢測
設備的基本原理
音頻設備
音頻輸入設備的主要工作是採集音頻數據,而採集音頻數據的本質就是模數轉換(A/D),即將模似信號轉換成數字信號,採集到的數據再經過量化、編碼,最終形成數字信號,這就是音頻設備所要完成的工作
視頻設備
視頻設備,與音頻輸入設備很類似,視頻設備的模數轉換(A/D)模塊即光學傳感器, 將光轉換成數字信號,即 RGB(Red、Green、Blue)數據,獲得 RGB 數據後,還要通過 DSP(Digital Signal Processer)進行優化處理,如自動增強、色彩飽和等都屬於這一階段要做的事情,通過 DSP 優化處理後獲得 RGB 圖像,然後進行壓縮、傳輸,而編碼器一般使用的輸入格式爲 YUV,所以在攝像頭內部還有一個專門的模塊用於將 RGB 圖像轉爲 YUV 格式的圖像
那什麼是 YUV 呢?
YUV 也是一種色彩編碼方法,它將亮度信息(Y)與色彩信息(UV)分離,即使沒有 UV 信息一樣可以顯示完整的圖像,只不過是黑白的,這樣的設計很好地解決了彩色電視機與黑白電視的兼容問題(這也是 YUV 設計的初衷)相對於 RGB 顏色空間,YUV 的目的是爲了編碼、傳輸的方便,減少帶寬佔用和信息出錯,人眼的視覺特點是對亮度更敏感,對位置、色彩相對來說不敏感,在視頻編碼系統中爲了降低帶寬,可以保存更多的亮度信息,保存較少的色差信息
獲取音視頻設備列表
MediaDevices.enumerateDevices()
此方法返回一個可用的媒體輸入和輸出設備的列表,例如麥克風,攝像機,耳機設備等
navigator.mediaDevices.enumerateDevices().then(function(deviceInfos) {
deviceInfos.forEach(function(deviceInfo) {
console.log(deviceInfo);
});
})
返回的 deviceInfo 信息格式如下:
- 出於安全原因,除非用戶已被授予訪問媒體設備的權限(要想授予權限需要使用 HTTPS 請求),否則 label 字段始終爲空
設備檢測方法
-
返回信息 deviceInfo 中的 kind 字段可以區分出設備是音頻設備還是視頻設備,同時音頻設備能區分出是輸入設備和輸出設備,我們平時使用的耳機它是一個音頻設備,但它同時兼有音頻輸入設備和音頻輸出設備的功能
-
對於音頻設備和視頻設備會設置各自的默認設備, 還是以耳機這個音頻設備爲例,將耳機插入電腦後,耳機就變成了音頻的默認設備,將耳機拔出後,默認設備又切換成了系統的音頻設備
-
在獲取到所有的設備列表後,如果我們不指定某個具體設備,採集音視頻數據時,就會從設備列表中的默認設備上採集數據,如果能從指定的設備上採集到音視頻數據,那說明這個設備就是有效的設備,這樣我們就可以對音視頻設備進行一項一項檢測
-
通過調用 getUserMedia 方法 (下面音視頻採集的時候會講到) 進行設備檢測
-
視頻設備檢測:調用 getUserMedia API 採集視頻數據並將其展示出來,如果用戶能看到自己的視頻,說明視頻設備是有效的,否則,設備無效
-
音頻設備檢測:調用 getUserMedia API 採集音頻數據,由於音頻數據不能直接展示,所以需要使用 JavaScript 將其處理後展示到頁面上,這樣當用戶看到音頻數值的變化後,說明音頻設備也是有效的
音視頻採集
基本概念
- 幀率
幀率表示 1 秒鐘視頻內圖像的數量,一般幀率達到 10~12fps 人眼就會覺得是連貫的,幀率越高,代表着每秒鐘處理的圖像數量越高,因此流量會越大,對設備的性能要求也越高,所以在直播系統中一般不會設置太高的幀率,高的幀率可以得到更流暢、更逼真的動畫,一般來說 30fps 就是可以接受的,但是將性能提升至 60fps 則可以明顯提升交互感和逼真感,但是一般來說超過 75fps 一般就不容易察覺到有明顯的流暢度提升了
- 軌(Track)
WebRTC 中的 “軌” 借鑑了多媒體的概念,兩條軌永遠不會相交,“軌”在多媒體中表達的就是每條軌數據都是獨立的,不會與其他軌相交,如 MP4 中的音頻軌、視頻軌,它們在 MP4 文件中是被分別存儲的
音視頻採集接口
mediaDevices.getUserMedia
const mediaStreamContrains = {
video: true,
audio: true
};
const promise = navigator.mediaDevices.getUserMedia(mediaStreamContrains).then(
gotLocalMediaStream
)
const $video = document.querySelector('video');
function gotLocalMediaStream(mediaStream){
$video.srcObject = mediaStream;
}
function handleLocalMediaStreamError(error){
console.log('getUserMedia 接口調用出錯: ', error);
}
srcObject[1]:屬性設定或返回一個對象,這個對象提供了一個與 HTMLMediaElement 關聯的媒體源,這個對象通常是 MediaStream,根據規範也可以是 MediaSource, Blob 或者 File,但對於 MediaSource, Blob 和 File 類型目前瀏覽器的兼容性不太好,所以對於這幾種類型可以通過 URL.createObjectURL() 創建 URL,並將其賦值給 HTMLMediaElement.src
MediaStreamConstraints 參數,可以指定 MediaStream 中包含哪些類型的媒體軌(音頻軌、視頻軌),並且可爲這些媒體軌設置一些限制
const mediaStreamContrains = {
video: {
frameRate: {min: 15}, // 視頻的幀率最小 15 幀每秒
width: {min: 320, ideal: 640}, // 寬度最小是 320,理想的寬度是 640
height: {min: 480, ideal: 720},// 高度最小是 480,最理想高度是 720
facingMode: 'user', // 優先使用前置攝像頭
deviceId: '' // 指定使用哪個設備
},
audio: {
echoCancellation: true, // 對音頻開啓迴音消除功能
noiseSuppression: true // 對音頻開啓降噪功能
}
}
瀏覽器實現自拍
我們知道視頻是由一幅幅幀圖像和一組音頻構成的,所以拍照的過程其實是從連續播放的視頻流(一幅幅畫面)中抽取正在顯示的那張畫面,上面我們講過可以通過 getUserMedia 獲取到視頻流,那如何從視頻流中獲取到正在顯示的圖片呢?
這裏就要用到 canvas 的 drawImage[2]
const ctx = document.querySelector('canvas');
// 需要拍照時執行此代碼,完成拍照
ctx.getContext('2d').drawImage($video, 0, 0);
function downLoad(url){
const $a = document.createElement("a");
$a.download = 'photo';
$a.href = url;
document.body.appendChild($a);
$a.click();
$a.remove();
}
// 調用 download 函數進行圖片下載
downLoad(ctx.toDataURL("image/jpeg"));
drawImage 的第一個參數支持 HTMLVideoElement
類型,所以可以直接將 $video 作爲第一個參數傳入,這樣就通過 canvas 獲取到照片了
然後通過 a 標籤的 download 將照片下載下來保存到本地
-
通過 canvas 的 toDataURL 方法獲得圖片的 URL 地址
-
利用 a 標籤的 downLoad 屬性來實現圖片的下載
音視頻錄製
基本概念
- ArrayBuffer
ArrayBuffer 對象表示通用的、固定長度的二進制數據緩衝區,可以使用它存儲圖片、視頻等內容,但 ArrayBuffer 對象不能直接進行訪問,ArrayBuffer 只是描述有這樣一塊空間可以用來存放二進制數據,但在計算機的內存中並沒有真正地爲其分配空間,只有當具體類型化後,它才真正地存在於內存中
let buffer = new ArrayBuffer(16); // 創建一個長度爲 16 的 buffer
let view = new Uint32Array(buffer);
- ArrayBufferView
是 Int32Array、Uint8Array、DataView 等類型的總稱,這些類型都是使用 ArrayBuffer 類實現的,因此才統稱他們爲 ArrayBufferView
- Blob
(Binary Large Object)是 JavaScript 的大型二進制對象類型,WebRTC 最終就是使用它將錄製好的音視頻流保存成多媒體文件的,而它的底層是由上面所講的 ArrayBuffer 對象的封裝類實現的,即 Int8Array、Uint8Array 等類型
音頻錄製接口
const mediaRecorder = new MediaRecorder(stream[, options]);
stream 參數是將要錄製的流,它可以是來自於使用 navigator.mediaDevices.getUserMedia
創建的流或者來自於 audio,video 以及 canvas DOM 元素
MediaRecorder.ondataavailable
事件可用於獲取錄製的媒體資源 (在事件的 data
屬性中會提供一個可用的 Blob 對象)
錄製的流程如下:
-
使用 getUserMedia 接口獲取視頻流數據
-
使用 MediaRecorder 接口進行錄製(視頻流數據來源上一步獲取的數據)
-
使用 MediaRecorder 的 ondataavailable 事件獲取錄製的 buffer 數據
-
將 buffer 數據轉成 Blob 類型,然後使用 createObjectURL 生成可訪問的視頻地址
-
利用 a 標籤的 download 屬性進行視頻下載
<video autoplay playsinline controls id="video-show"></video>
<video id="video-replay"></video>
<button id="record">開始錄製</button>
<button id="stop">停止錄製</button>
<button id="recplay">錄製播放</button>
<button id="download">錄製視頻下載</button>
let buffer;
const $videoshow = document.getElementById('video-show');
const promise = navigator.mediaDevices.getUserMedia({
video: true
}).then(
stream => {
console.log('stream', stream);
window.stream = stream;
$videoshow.srcObject = stream;
})
function startRecord(){
buffer = [];
// 設置錄製下來的多媒體格式
const options = {
mimeType: 'video/webm;codecs=vp8'
}
// 判斷瀏覽器是否支持錄製
if(!MediaRecorder.isTypeSupported(options.mimeType)){
console.error(`${options.mimeType} is not supported!`);
return;
}
try{
// 創建錄製對象
mediaRecorder = new MediaRecorder(window.stream, options);
console.log('mediaRecorder', mediaRecorder);
}catch(e){
console.error('Failed to create MediaRecorder:', e);
return;
}
// 當有音視頻數據來了之後觸發該事件
mediaRecorder.ondataavailable = handleDataAvailable;
// 開始錄製
mediaRecorder.start(2000); // 若設置了 timeslice 這個毫秒值,那麼錄製的數據會按照設定的值分割成一個個單獨的區塊
}
// 當該函數被觸發後,將數據壓入到 blob 中
function handleDataAvailable(e){
console.log('e', e.data);
if(e && e.data && e.data.size > 0){
buffer.push(e.data);
}
}
document.getElementById('record').onclick = () => {
startRecord();
};
document.getElementById('stop').onclick = () => {
mediaRecorder.stop();
console.log("recorder stopped, data available");
};
// 回放錄製文件
const $video = document.getElementById('video-replay');
document.getElementById('recplay').onclick = () => {
const blob = new Blob(buffer, {type: 'video/webm'});
$video.src = window.URL.createObjectURL(blob);
$video.srcObject = null;
$video.controls = true;
$video.play();
};
// 下載錄製文件
document.getElementById('download').onclick = () => {
const blob = new Blob(buffer, {type: 'video/webm'});
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.style.display = 'none';
a.download = 'video.webm';
a.click();
};
創建連接
數據採集完成,接下來就要開始建立連接,然後進行數據通信了
要實現一套 1 對 1 的通話系統,通常我們的思路會是在每一端創建一個 socket,然後通過該 socket 與對端相連,當 socket 連接成功之後,就可以通過 socket 向對端發送數據或者接收對端的數據了,WebRTC 中提供了 RTCPeerConnection 類,其工作原理和 socket 基本一樣,不過它的功能更強大,實現也更爲複雜,下面就來講講 WebRTC 中的 RTCPeerConnection
RTCPeerConnection
在音視頻通信中,每一方只需要有一個 RTCPeerConnection 對象,用它來接收或發送音視頻數據,然而在真實的場景中,爲了實現端與端之間的通話,還需要利用信令服務器交換一些信息,比如交換雙方的 IP 和 port 地址,這樣通信的雙方纔能彼此建立連接
WebRTC 規範對 WebRTC 要實現的功能、API 等相關信息做了大量的約束,比如規範中定義瞭如何採集音視頻數據、如何錄製以及如何傳輸等,甚至更細的,還定義了都有哪些 API,以及這些 API 的作用是什麼,但這些約束只針對於客戶端,並沒有對服務端做任何限制,這就導致了我們在使用 WebRTC 的時候,必須自己去實現 信令 服務, 這裏就不專門研究怎麼實現信令服務器了,我們只來看看 RTCPeerConnection 是如何實現一對一通信的
RTCPeerConnection 如何工作呢?
- 獲取本地音視頻流
爲連接的每個端創建一個 RTCPeerConnection 對象,並且給 RTCPeerConnection 對象添加一個本地流,該流是從 getUserMedia 獲取的
// 調用 getUserMedia API 獲取音視頻流
navigator.mediaDevices.getUserMedia(mediaStreamConstraints).
then(gotLocalMediaStream)
function gotLocalMediaStream(mediaStream) {
window.stream = mediaStream;
}
// 創建 RTCPeerConnection 對象
let localPeerConnection = new RTCPeerConnection();
// 將音視頻流添加到 RTCPeerConnection 對象中
localPeerConnection.addStream(stream);
- 交換媒體描述信息
獲得音視頻流後,就可以開始與對端進行媒體協商了(媒體協商就是看看你的設備都支持哪些編解碼器,我的設備是否也支持?如果我的設備也支持,那麼咱們雙方就算協商成功了),這個過程需要通過信令服務器完成
現在假設 A 和 B 需要通訊
-
A 通過 createOffer[3] 方法啓動創建一個 SDP offer,即得到 A 的本地會話描述
-
A 通過 setLocalDescription **** 方法保存本地會話描述
-
A 通過信令服務器發送信令給 B
localPeerConnection.createOffer([options])
.then((description) => {
// 將 offer 保存到本地
localPeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(localPeerConnection);
});
})
-
B 接收到帶有 A offer 的信令,調用 setRemoteDescription,設置遠程會話描述
-
B 通過 createAnswer 方法將本地會話描述成功回調
-
B 調用 setLocalDescription 設置他自己的本地局部描述回調函數中保存本地會話描述
-
B 通過信令服務器發送信令給 A
// B 設置遠程會話描述
remotePeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(remotePeerConnection);
});
remotePeerConnection.createAnswer()
.then((description)=> {
// B 保存本地會話描述
remotePeerConnection.setLocalDescription(description)
.then(() => {
setLocalDescriptionSuccess(remotePeerConnection);
});
});
- A 通過 setRemoteDescription 將 B 的應答 answer 保存爲遠程會話描述
// A 保存 B 的 應答 answer 爲遠程會話描述
localPeerConnection.setRemoteDescription(description)
.then(() => {
setRemoteDescriptionSuccess(localPeerConnection);
});
至此就完成了媒體信息交換和協商
- 端與端建立連接
- 當 A 調用 setLocalDescription 函數成功後,會觸發 icecandidate 事件(在建立通訊之前,我們需要獲得雙方的網絡信息,例如 IP、端口等,candidate 便是用於保存這些東西的)
localPeerConnection.onicecandidate= function(event) {
// 獲取到觸發 icecandidate 事件的 RTCPeerConnection 對象
const peerConnection = event.target;
// 獲取到具體的 candidate
const iceCandidate = event.candidate;
// 將 candidate 包裝成需要的格式,然後通過信令服務器發送給B
}
- B 接收到信令服務器傳遞過來的 A 的關於 candidate 的信息,把消息包裝成 RTCIceCandidate 對象,然後調用 addIceCandidate 保存起來
// 創建 RTCIceCandidate 對象
const newIceCandidate = new RTCIceCandidate(iceCandidate);
remotePeerConnection.addIceCandidate(newIceCandidate);
這樣就收集到了一個新的 Candidate,在真實的場景中,每當獲得一個新的 Candidate 後,就會通過信令服務器交換給對端,對端再調用 RTCPeerConnection 對象的 addIceCandidate() 方法將收到的 Candidate 保存起來,然後按照 Candidate 的優先級進行連通性檢測,如果 Candidate 連通性檢測完成,那麼端與端之間就建立了連接,這時媒體數據就可以通過這個連接進行傳輸了
音視頻編解碼
視頻是連續的圖像序列,由連續的幀構成,一幀即爲一幅圖像,由於人眼的視覺暫留效應,當幀序列以一定的速率播放時,我們看到的就是動作連續的視頻,由於連續的幀之間相似性極高,爲便於儲存傳輸,我們需要對原始的視頻進行編碼壓縮,以去除空間、時間維度的冗餘
視頻編解碼是採用算法將視頻數據的冗餘信息去除,對圖像進行壓縮、存儲及傳輸, 再將視頻進行解碼及格式轉換, 追求在可用的計算資源內,儘可能高的視頻重建質量和儘可能高的壓縮比,以達到帶寬和存儲容量要求的視頻處理技術
視頻流傳輸中最爲重要的編解碼標準有 H.26X 系列(H.261、H.263、H.264),MPEG 系列,Apple 公司的 QuickTime 等
顯示遠端媒體流
通過 RTCPeerConnection 對象 A 與 B 雙方建立連接後,本地的多媒體數據經過編碼以後就可以被傳送到遠端了,遠端收到了媒體數據解碼後,怎麼顯示出來呢,下面以 video 爲例,看看怎麼讓 RTCPeerConnection 獲得的媒體數據與 video 標籤結合起來
當遠端有數據流到來的時候,瀏覽器會回調 onaddstream 函數,在回調函數中將得到的 stream 賦值給 video 標籤的 srcObject 對象,這樣 video 就與 RTCPeerConnection 進行了綁定,video 就能從 RTCPeerConnection 獲取到視頻數據,並最終將其顯示出來了
localPeerConnection.onaddstream = function(event) {
$remoteVideo.srcObject = event.stream;
}
結語
WebRTC 相關的東西非常非常多,這裏只是很淺顯地串講了一下利用 WebRTC 實現實時通信的大體過程,如果感興趣可以詳細研究裏面的細節
參考資料
[1]
srcObject: https://developer.mozilla.org/zh-CN/docs/Web/API/HTMLMediaElement/srcObject#%E6%B5%8F%E8%A7%88%E5%99%A8%E5%85%BC%E5%AE%B9%E6%80%A7
[2]
drawImage: https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/drawImage
[3]
createOffer: https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/createOffer
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/yVnEnA1IhVde1OiNlGKN-w