從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的自動化測試,視訊畫中畫,視訊截圖等功能