深入淺出音視頻與 WebRTC

常見的音視頻網絡通信協議

普通直播協議

這類直播對實時性要求不那麼高,使用 CDN 進行內容分發,會有幾秒甚至十幾秒的延時,主要關注畫面質量、音視頻是卡頓等問題,一般選用 RTMP 和 HLS 協議

基本概念

  1. RTMP

RTMP (Real Time Messaging Protocol),即 “實時消息傳輸協議”, 它實際上並不能做到真正的實時,一般情況最少都會有幾秒到幾十秒的延遲,是 Adobe 公司開發的音視頻數據傳輸的實時消息傳送協議,RTMP 協議基於 TCP,包括 RTMP 基本協議及 RTMPT/RTMPS/RTMPE 等多種變種,RTMP 是目前主流的流媒體傳輸協議之一,對 CDN 支持良好,實現難度較低,是大多數直播平臺的選擇,不過 RTMP 有一個最大的不足 —— 不支持瀏覽器,且蘋果 ios 不支持,Adobe 已停止對其更新

RTMP 目前在 PC 上的使用仍然比較廣泛

  1. HLS

HLS (Http Live Streaming)是由蘋果公司定義的基於 HTTP 的流媒體實時傳輸協議,被廣泛的應用於視頻點播和直播領域,HLS 規範規定播放器至少下載一個 ts 切片才能播放,所以 HLS 理論上至少會有一個切片的延遲

HLS 在移動端兼容性比較好,ios 就不用說了,Android 現在也基本都支持 HLS 協議了,pc 端如果要使用可以使用 hls.js 適配器

HLS 的原理是將整個流分爲多個小的文件來下載,每次只下載若干個,服務器端會將最新的直播數據生成新的小文件,當客戶端獲取直播時,它通過獲取最新的視頻文件片段來播放,從而保證用戶在任何時候連接進來時都會看到較新的內容,實現近似直播的體驗;HLS 的延遲一般會高於普通的流媒體直播協議,傳輸內容包括兩部分:一部分 M3U8 是索引文件,另一部分是 TS 文件,用來存儲音視頻的媒體信息

RTMP 和 HLS 如何選擇

普通直播基本架構

直播 客戶端 信令 服務器和 CDN 網絡這三部分組成

直播 客戶端主要包括音視頻數據的採集、編碼、推流、拉流、解碼與播放功能,但實際上這些功能並不是在同一個客戶端中實現的,爲什麼呢?因爲作爲主播來說,他不需要看到觀衆的視頻或聽到觀衆的聲音,而作爲觀衆來講,他們與主播之間是通過文字進行交流的,不需要向主播分享自己的音視頻信息

對於主播客戶端來說,它可以設備的攝像頭、麥克風採集數據,然後對採集到的音視頻數據進行編碼,最後將編碼後的音視頻數據推送給 CDN

對於觀衆客戶端來說,它首先需要獲取到主播房間的流媒體地址,觀衆進入房間後從 CDN 拉取音視頻數據,並對獲取到的音視頻數據進行解碼,最後進行音視頻的渲染與播放

信令 服務器,主要用於接收信令,並根據信令處理一些和業務相關的邏輯,如創建房間、加入房間、離開房間、文字聊天等

CDN 網絡,主要用於媒體數據的分發,傳給它的媒體數據可以很快傳送給各地的用戶

實時直播協議

隨着人們對實時性、互動性的要求越來越高,傳統直播技術越來越滿足不了人們的需求,WebRTC 技術正是爲了解決人們對實時性、互動性需求而提出的新技術

  1. 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 信息格式如下:

  1. 出於安全原因,除非用戶已被授予訪問媒體設備的權限(要想授予權限需要使用 HTTPS 請求),否則 label 字段始終爲空

設備檢測方法

音視頻採集

基本概念

幀率表示 1 秒鐘視頻內圖像的數量,一般幀率達到 10~12fps 人眼就會覺得是連貫的,幀率越高,代表着每秒鐘處理的圖像數量越高,因此流量會越大,對設備的性能要求也越高,所以在直播系統中一般不會設置太高的幀率,高的幀率可以得到更流暢、更逼真的動畫,一般來說 30fps 就是可以接受的,但是將性能提升至 60fps 則可以明顯提升交互感和逼真感,但是一般來說超過 75fps 一般就不容易察覺到有明顯的流暢度提升了

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 將照片下載下來保存到本地

音視頻錄製

基本概念

ArrayBuffer 對象表示通用的、固定長度的二進制數據緩衝區,可以使用它存儲圖片、視頻等內容,但 ArrayBuffer 對象不能直接進行訪問,ArrayBuffer 只是描述有這樣一塊空間可以用來存放二進制數據,但在計算機的內存中並沒有真正地爲其分配空間,只有當具體類型化後,它才真正地存在於內存中

let buffer = new ArrayBuffer(16); // 創建一個長度爲 16 的 buffer
let view = new Uint32Array(buffer);

是 Int32Array、Uint8Array、DataView 等類型的總稱,這些類型都是使用 ArrayBuffer 類實現的,因此才統稱他們爲 ArrayBufferView

(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 對象)

錄製的流程如下:

<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.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 如何工作呢?

  1. 獲取本地音視頻流

爲連接的每個端創建一個 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);
  1. 交換媒體描述信息

獲得音視頻流後,就可以開始與對端進行媒體協商了(媒體協商就是看看你的設備都支持哪些編解碼器,我的設備是否也支持?如果我的設備也支持,那麼咱們雙方就算協商成功了),這個過程需要通過信令服務器完成

現在假設 A 和 B 需要通訊

localPeerConnection.createOffer([options])
  .then((description) ={
        // 將 offer 保存到本地
      localPeerConnection.setLocalDescription(description)
        .then(() ={
          setLocalDescriptionSuccess(localPeerConnection);
        });
   })
// B 設置遠程會話描述
remotePeerConnection.setRemoteDescription(description)
.then(() ={
  setRemoteDescriptionSuccess(remotePeerConnection);
});

remotePeerConnection.createAnswer()
.then((description)={
  // B 保存本地會話描述
  remotePeerConnection.setLocalDescription(description)
    .then(() ={
      setLocalDescriptionSuccess(remotePeerConnection);
    });
});
// A 保存 B 的 應答 answer 爲遠程會話描述
  localPeerConnection.setRemoteDescription(description)
    .then(() ={
      setRemoteDescriptionSuccess(localPeerConnection);
    });

至此就完成了媒體信息交換和協商

  1. 端與端建立連接
localPeerConnection.onicecandidate= function(event) {
  // 獲取到觸發 icecandidate 事件的 RTCPeerConnection 對象
  const peerConnection = event.target;
  // 獲取到具體的 candidate
  const iceCandidate = event.candidate;
  // 將 candidate 包裝成需要的格式,然後通過信令服務器發送給B
  
}
// 創建 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