從0搭建一個WebRTC,實現多房間多對多通話,並實現屏幕錄製

語言: CN / TW / HK

這篇文章開始會實現一個一對一WebRTC和多對多的WebRTC,以及基於屏幕共享的錄製。本篇會實現信令和前端部分,信令使用fastity來搭建,前端部分使用Vue3來實現。

為什麼要使用WebRTC

WebRTC全稱Web Real-Time Communication,是一種實時音視頻的技術,它的優勢是低延時。

本片文章食用者要求

環境搭建及要求

廢話不多説,現在開始搭建環境,首先是需要開啟socket服務,採用的是fastify來進行搭建。詳情可以見文檔地址,本例使用的是3.x來啟動的。接下來安裝fastify-socket.io3.0.0插件,詳細配置可以見文檔,此處不做詳細解釋。接下來是搭建Vue3,使用 vite 腳手架搭建簡單的demo。

要求:前端服務運行在localhost或者https下。node需要redis進行數據緩存

獲取音視頻

要實現實時音視頻第一步當然是要能獲取到視頻流,在這裏我們使用瀏覽器提供的API,MediaDevices來進行攝像頭流的捕獲

enumerateDevices

第一個要介紹的API是enumerateDevices,是請求一個可用的媒體輸入和輸出設備的列表,例如麥克風,攝像機,耳機設備等。直接在控制枱執行API,獲取的設備如圖

image.png

我們注意到裏面返回的設備ID和label是空的,這是由於瀏覽器的安全策略限制,必須授權攝像頭或麥克風才能允許返回設備ID和設備標籤,接下來我們介紹如何請求攝像頭和麥克風

getUserMedia

這個API顧名思義,就是去獲取用户的Meida的,那我們直接執行這個API來看看效果

ps: 由於掘金的代碼片段的iframe沒有配置allow="display-capture *;microphone *; camera *"屬性,需要手動打開詳情查看效果

代碼片段

通過上述例子我們可以獲取到本機的音視頻畫面,並且可以播放在video標籤裏,那麼我們可以在獲取了用户的流之後,重新再獲取一次設備列表看看發生了什麼變化

image.png

在獲取了音視頻之後,獲取的設備列表的詳細信息已經出現,我們就可以獲取指定設備的音視頻數據,詳情可以見

這裏介紹一下getUserMedia的參數constraints,

視頻參數配置

ts interface MediaTrackConstraintSet { // 畫面比例 aspectRatio?: ConstrainDouble; // 設備ID,可以從enumerateDevices中獲取 deviceId?: ConstrainDOMString; // 攝像頭前後置模式,一般適用於手機 facingMode?: ConstrainDOMString; // 幀率,採集視頻的目標幀率 frameRate?: ConstrainDouble; // 組ID,用一個設備的輸入輸出的組ID是同一個 groupId?: ConstrainDOMString; // 視頻高度 height?: ConstrainULong // 視頻寬度 width?: ConstrainULong; }

音頻參數配置

ts interface MediaTrackConstraintSet { // 是否開啟AGC自動增益,可以在原有音量上增加額外的音量 autoGainControl?: ConstrainBoolean; // 聲道配置 channelCount?: ConstrainULong; // 設備ID,可以從enumerateDevices中獲取 deviceId?: ConstrainDOMString; // 是否開啟回聲消除 echoCancellation?: ConstrainBoolean; // 組ID,用一個設備的輸入輸出的組ID是同一個 groupId?: ConstrainDOMString; // 延遲大小 latency?: ConstrainDouble; // 是否開啟降噪 noiseSuppression?: ConstrainBoolean; // 採樣率單位Hz sampleRate?: ConstrainULong; // 採樣大小,單位位 sampleSize?: ConstrainULong; // 本地音頻在本地揚聲器播放 suppressLocalAudioPlayback?: ConstrainBoolean; }

一對一連接

當我們採集到了音視頻數據,接下來就是要建立鏈接,在開始之前需要科普一下WebRTC的工作方式,我們常見有三種WebRTC的網絡結構 1. Mesh 2. MCU 3. SFU 關於這三種模式的區別可以查看 文章來了解

在這裏由於設備的限制,我們採用Mesh的方案來進行開發

一對一的流程

我們建立一對一的鏈接需要知道後流程是怎麼流轉的,接下來上一張圖,便可以清晰的瞭解

1825097218-5db028f8d5205.webp

這裏是由ClientA發起B來接受A的視頻數據。上圖總結可以為A創建本地視頻流,把視頻流添加到PeerConnection裏面 創建一個Offer給B,B收到Offer以後,保存這個offer,並響應這個Offer給A,A收到B的響應後保存A的遠端響應,進行NAT穿透,完成鏈接建立。

話已經講了這麼多,我們該怎麼建立呢,光説不做假把式,接下來,用我們的項目創建一個來試試

初始化

首先啟動fastify服務,接下來在Vue項目安裝socket.io-client@4然後連接服務端的socket ts import { v4 as uuid } from 'uuid'; import { io, Socket } from 'socket.io-client'; const myUserId = ref(uuid()); let socket: Socket; socket = io('http://127.0.0.1:7070', { query: { // 房間號,由輸入框輸入獲得 room: room.value, // userId通過uuid獲取 userId: myUserId.value, // 暱稱,由輸入框輸入獲得 nick: nick.value } });

可以查看chrome的控制枱,檢查ws的鏈接情況,如果出現跨域,請查看socket.io的server配置並開啟cors配置。

創建offer

開始創建RTCPeerConnection,這裏採用google的公共stun服務

ts const peerConnect = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] })

根據上面的流程圖我們下一步要做的事情是用上面的方式獲取視頻流,並將獲取到的流添加到RTCPeerConnection中,並創建offer,把這個offer設置到這個rtcPeer中,並把offer發送給socket服務 ```ts let localStream: MediaStream;

stream.getTracks().forEach((track) => { peerConnect.addTrack(track, stream) })

const offer = await peerConnect.createOffer(); await peerConnect.setLocalDescription(offer); socket.emit('offer', { creatorUserId: myUserId.value, sdp: offer }, (res: any) => { console.log(res); });

```

socket 服務收到了這份offer後需要給B發送A的offer

js fastify.io.on('connection', async (socket) => { socket.on('offer', async (offer, callback) => { socket.emit('offer', offer); callback({ status: "ok" }) }) })

處理offer

B需要監聽socket裏面的offer事件並創建RTCPeerConnection,將這個offer設置到遠端,接下來來創建響應。並且將這個響應設置到本地,發送answer事件回覆給A

```ts socket.on('offer', async (offer: { sdp: RTCSessionDescriptionInit, creatorUserId: string }) => { const peerConnect = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] })

await peerConnect.setRemoteDescription(offer.sdp);
const answer = await peerConnect.createAnswer();
await peerConnect.setLocalDescription(answer);
socket.emit('answer', { sdp: answer }, (res: any) => {
  console.log(res);
})

}) ```

處理answer

服務端廣播answer js socket.on('offer', async (offer, callback) => { socket.emit('offer', offer); callback({ status: "ok" }) })

A監聽到socket裏面的answer事件,需要將剛才的自己的RTCpeer添加遠端描述

ts socket.on('answer', async (data: { sdp: RTCSessionDescriptionInit }) => { await peerConnect.setRemoteDescription(data.sdp) })

處理ICE-candidate

接下來A會獲取到ICE候選信息,需要發送給B ts peerConnect.onicecandidate = (candidateInfo: RTCPeerConnectionIceEvent) => { if (candidateInfo.candidate) { socket.emit('ICE-candidate', { sdp: candidateInfo.candidate }, (res: any) => { console.log(res); }) } } 廣播消息是同理這裏就不再贅述了,B獲取到了A的ICE,需要設置候選 ts socket.on('ICE-candidate', async (data: { sdp: RTCIceCandidate }) => { await peerConnect.addIceCandidate(data.sdp) }) 接下來B也會獲取到ICE候選信息,同理需要發送給A,待A設置完成之後便可以建立鏈接,代碼同上,B接下來會收到流添加的事件,這個事件會有兩次,分別是音頻和視頻的數據

處理音視頻數據

ts peerConnect.ontrack = (track: RTCTrackEvent) => { if (track.track.kind === 'video') { const video = document.createElement('video'); video.srcObject = track.streams[0]; video.autoplay = true; video.style.setProperty('width', '400px'); video.style.setProperty('aspect-ratio', '16 / 9'); video.setAttribute('id', track.track.id) document.body.appendChild(video) } if (track.track.kind === 'audio') { const audio = document.createElement('audio'); audio.srcObject = track.streams[0]; audio.autoplay = true; audio.setAttribute('id', track.track.id) document.body.appendChild(audio) } } 到這裏你就可以見到兩個視頻建立的P2P鏈接了。到這裏為止只是建立了視頻的一對一鏈接,但是我們可以通過這些操作進行復制,就能進行多對多的連接了。

多對多連接

在開始我們需要知道,一個人和另一個人建立連接雙方都需要創建自己的peerConnection。對於多人的情況,首先我們需要知道進入的房間裏面當前的人數,給每個人都創建一個RtcPeer,同時收到的人也回覆這個offer給發起的人。對於後進入的人,需要讓已經創建音視頻的人給後進入的人創建新的offer。

基於上面的流程,我們現在先實現一個成員列表的接口

成員列表的接口

在我們登錄socket服務的時候我們在query參數裏面有房間號,userId和暱稱,我們可以通過redis記錄對應的房間號的登錄和登出,從而實現成員列表。

可以在某一個人登錄的時候獲取一下redis對應房間的成員列表,如果沒有這個房間,就把這個人丟進新的房間,並且存儲到redis中,方便其他人登錄這個房間的時候知道現在有多少人。

```js fastify.io.on('connection', async (socket) => { const room = socket.handshake.query.room; const redis = fastify.redis; let userList; // 獲取當前房間的數據 await getUserList()

async function getUserList() {
  const roomUser = await redis.get(room);
  if (roomUser) {
    userList = new Map(JSON.parse(roomUser))
  } else {
    userList = new Map();
  }
}

async function setRedisRoom() {
  await redis.set(room, JSON.stringify([...userList]))
}

function rmUser(userId) {
  userList.delete(userId);
}


if (room) {
  // 將這人加入到對應的socket房間
  socket.join(room);
  await setRedisRoom();
  // 廣播有人加入了
  socket.to(room).emit('join', userId);
}
// 這個人斷開了鏈接需要將這個人從redis中刪除
socket.on('disconnect', async (socket) => {
  await getUserList();
  rmUser(userId);
  await setRedisRoom();
})

}) ```

到上面為止,我們實現了成員的記錄、廣播和刪除。接下來是需要實現一個成員列表的接口,提供給前端項目調用。 js fastify.get('/userlist', async function (request, reply) { const redis = fastify.redis; return await redis.get(request.query.room); })

多對多初始化

由於需要給每個人發送offer,需要對上面的初始化函數進行封裝。

ts /** * 創建RTCPeerConnection * @param creatorUserId 創建者id,本人 * @param recUserId 接收者id */ const initPeer = async (creatorUserId: string, recUserId: string) => { const peerConnect = new RTCPeerConnection({ iceServers: [ { urls: "stun:stun.l.google.com:19302" } ] }) return peerConnect; })

由於存在多份rtc的映射關係,我們這裏可以用Map來實現映射的保存

```ts const peerConnectList = new Map();

const initPeer = () => { // ice,track,new Peer等其他代碼 ...... peerConnectList.set(${creatorUserId}_${recUserId}, peerConnect); } ```

獲取成員列表

上面實現了成員列表。接下來進入了對應的房間後需要輪詢獲取對應的成員列表

```ts let userList = ref([]); const intoRoom = () => { //其他代碼 ......

setInterval(()=>{
  axios.get('/userlist', { params: { room: room.value }}).then((res)=>{
    userList.value = res.data
  })
}, 1000)

} ```

創建多對多的Offer和Answer

在我們獲取到視頻流的時候,可以對在線列表裏除了自己的人都創建一個RTCpeer,來進行一對一連接,從而達到多對多連接的效果。 ```ts // 過濾自己 const emitList = userList.value.filter((item) => item[0] !== myUserId.value); for (const item of emitList) { // item[0]就是目標人的userId const peer = await initPeer(myUserId.value, item[0]); await createOffer(item[0], peer); }

const createOffer = async (recUserId: string, peerConnect: RTCPeerConnection, stream: MediaStream = localStream) => { if (!localStream) return; stream.getTracks().forEach((track) => { peerConnect.addTrack(track, stream) }) const offer = await peerConnect.createOffer(); await peerConnect.setLocalDescription(offer); socket.emit('offer', { creatorUserId: myUserId.value, sdp: offer, recUserId }, (res: any) => { console.log(res); }); } ```

那麼在socket服務中我們怎麼只給對應的人進行事件廣播,不對其他人進行廣播,我們可以用找到這個人userId對應的socketId,進而只給這一個人廣播事件。 ```js // 首先獲取IO對應的nameSpace const IONameSpace = fastify.io.of('/');

// 發送Offer給對應的人 socket.on('offer', async (offer, callback) => { // 重新從reids獲取用户列表 await getUserList(); // 找到目標的UserId的數據 const user = userList.get(offer.recUserId); if (user) { // 找到對應的socketId const io = IONameSpace.sockets.get(user.sockId); if (!io) return; io.emit('offer', offer); callback({ status: "ok" }) } }) ```

其他人需要監聽socket的事件,每個人都需要處理對應自己的offer。

ts socket.on('offer', handleOffer); const handleOffer = async (offer: { sdp: RTCSessionDescriptionInit, creatorUserId: string, recUserId: string }) => { const peer = await initPeer(offer.creatorUserId, offer.recUserId); await peer.setRemoteDescription(offer.sdp); const answer = await peer.createAnswer(); await peer.setLocalDescription(answer); socket.emit('answer', { recUserId: myUserId.value, sdp: answer, creatorUserId: offer.creatorUserId }, (res: any) => { console.log(res); }) }

接下來的步驟其實就是和一對一是一樣的了,後面還需要發起offer的人處理對應peer的offer、以及ICE候選,還有流進行掛載播放。

ts socket.on('answer', handleAnswer) // 應答方回覆 const handleAnswer = async (data: { sdp: RTCSessionDescriptionInit, recUserId: string, creatorUserId: string }) => { const peer = peerConnectList.get(`${data.creatorUserId}_${data.recUserId}`); if (!peer) { console.warn('handleAnswer peer 獲取失敗') return; } await peer.setRemoteDescription(data.sdp) } ......處理播放,處理ICE候選

到目前為止,就實現了一個基於mesh的WebRTC的多對多通信。在這裏附上了一個完整的Demo可供參考 socketServer FontPage

基於WebRTC的屏幕錄製

getDisplayMedia

這個API是在MediaDevices裏面的一個方法,是用來獲取屏幕共享的。

這個 MediaDevices  接口的 getDisplayMedia() 方法提示用户去選擇和授權捕獲展示的內容或部分內容(如一個窗口)在一個  MediaStream 裏. 然後,這個媒體流可以通過使用 MediaStream Recording API 被記錄或者作為WebRTC 會話的一部分被傳輸。 js await navigator.mediaDevices.getDisplayMedia()

MediaRecorder

獲取到屏幕共享流後,需要使用 MediaRecorder這個api來對流進行錄製,接下來我們先獲取屏幕流,同時創建一個MeidaRecord類 ```ts let screenStream: MediaStream; let mediaRecord: MediaRecorder; let blobMedia: (Blob)[] = []; const startLocalRecord = async () => { blobMedia = []; try { screenStream = await navigator.mediaDevices.getDisplayMedia(); screenStream.getVideoTracks()[0].addEventListener('ended', () => { console.log('用户中斷了屏幕共享'); endLocalRecord() })

  mediaRecord = new MediaRecorder(screenStream, { mimeType: 'video/webm' });

  mediaRecord.ondataavailable = (e) => {
    if (e.data && e.data.size > 0) {
      blobMedia.push(e.data);
    }
  };

  // 500是每隔500ms進行一個保存數據
  mediaRecord.start(500)

} catch(e) { console.log(屏幕共享失敗->${e}); } } ```

獲取到了之後可以使用 Blob 進行處理

```ts const replayLocalRecord = async () => { if (blobMedia.length) { const scVideo = document.querySelector('#screenVideo') as HTMLVideoElement; const blob = new Blob(blobMedia, { type:'video/webm' }) if(scVideo) { scVideo.src = URL.createObjectURL(blob); } } else { console.log('沒有錄製文件'); } }

const downloadLocalRecord = async () => { if (!blobMedia.length) { console.log('沒有錄製文件'); return; } const blob = new Blob(blobMedia, { type: 'video/webm' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = 錄屏_${Date.now()}.webm; a.click(); } ```

這裏有一個基於Vue2的完整例子

ps: 由於掘金的代碼片段的iframe沒有配置allow="display-capture *;microphone *; camera *"屬性,需要手動打開詳情查看效果

代碼片段

後續將會更新,WebRTC的自動化測試,視頻畫中畫,視頻截圖等功能