8┃音視訊直播系統之 WebRTC 信令系統實現以及通訊核心並實現視訊通話

autofelix發表於2022-05-17

一、信令系統

  • 信令系統主要用來進行信令的交換

  • 在通訊雙方彼此連線、傳輸媒體資料之前,它們要通過信令伺服器交換一些資訊,如規範協商

  • 若 A 與 B 要進行音視訊通訊,那麼 A 要知道 B 已經上線了,同樣,B 也要知道 A 在等著與它通訊呢

  • 只有雙方都知道彼此存在,才能由一方向另一方發起音視訊通訊請求,並最終實現音視訊通話

  • 客戶端程式碼如下:

  • 第一步:首先彈出一個輸入框,要求使用者寫入要加入的房間

  • 第二步:通過 io.connect() 建立與服務端的連線

  • 第三步:再根據 socket 返回的訊息做不同的處理

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>信令系統</title>
</head>

<body>

</body>
<script src="/socket.io/socket.io.js"></script>
<script>
    var isInitiator;

    // 彈出一個輸入視窗
    room = prompt('Enter room name:');

    // 與服務端建立 socket 連線
    const socket = io.connect();

    // 如果房間不空,則傳送 "create or join" 訊息
    if (room !== '') {
        console.log('Joining room ' + room);
        socket.emit('create or join', room);
    }

    // 如果從服務端收到 "full" 訊息
    socket.on('full', (room) => {
        console.log('Room ' + room + ' is full');
    });

    // 如果從服務端收到 "empty" 訊息
    socket.on('empty', (room) => {
        isInitiator = true;
        console.log('Room ' + room + ' is empty');
    });

    // 如果從服務端收到 “join" 訊息
    socket.on('join', (room) => {
        console.log('Making request to join room ' + room);
        console.log('You are the initiator!');
    });

    // 如果從服務端收到 “log" 訊息
    socket.on('log', (array) => {
        console.log.apply(console, array);
    });
</script>

</html>
  • 服務端程式碼如下:

  • 需要通過 npm install socket.io 安裝socket模組

  • 需要通過 npm install node-static 安裝socket模組,使伺服器具有釋出靜態檔案的功能

  • 服務端偵聽 2022 這個埠,對不同的訊息做相應的處理

const static = require('node-static');
const http = require('http');
const file = new (static.Server)();

const app = http.createServer(function (req, res) {
    file.serve(req, res);
}).listen(2022);

// 偵聽 2022
const io = require('socket.io').listen(app);

io.sockets.on('connection', (socket) => {
    // convenience function to log server messages to the client
    function log() {
        const array = ['>>> Message from server: '];
        for (var i = 0; i < arguments.length; i++) {
            array.push(arguments[i]);
        }
        socket.emit('log', array);
    }

    socket.on('message', (message) => {
        // 收到 message 時,進行廣播
        log('Got message:', message);
        // for a real app, would be room only (not broadcast)
        socket.broadcast.emit('message', message); // 在真實的應用中,應該只在房間內廣播
    });

    socket.on('create or join', (room) => {
        // 收到 “create or join” 訊息
        var clientsInRoom = io.sockets.adapter.rooms[room];
        var numClients = clientsInRoom ? Object.keys(clientsInRoom.sockets).length : 0;

        log('Room ' + room + ' has ' + numClients + ' client(s)');
        log('Request to create or join room ' + room);

        if (numClients === 0) {
            // 如果房間裡沒人
            socket.join(room);
            // 傳送 "created" 訊息
            socket.emit('created', room);
        } else if (numClients === 1) {
            // 如果房間裡有一個人
            io.sockets.in(room).emit('join', room);
            socket.join(room);
            // 傳送 “joined”訊息
            socket.emit('joined', room);
        } else {
            // max two clients
            // 傳送 "full" 訊息
            socket.emit('full', room);
        }

        socket.emit('emit(): client ' + socket.id + ' joined room ' + room);
        socket.broadcast.emit('broadcast(): client ' + socket.id + ' joined room ' + room);
    });
});

 

二、RTCPeerConnection

  • RTCPeerConnection 類是在瀏覽器下使用 WebRTC 實現 1 對 1 實時互動音視訊系統最核心的類

  • 它是WebRTC傳輸音視訊和交換資料的API

  • RTCPeerConnection 就與普通的 socket 一樣,在通話的每一端都至少有一個RTCPeerConnection 物件。在 WebRTC 中它負責與各端建立連線,接收、傳送音視訊資料,並保障音視訊的服務質量

 

三、實現視訊通話

  • 為連線的每個端建立一個 RTCPeerConnection 物件,並且給 RTCPeerConnection 物件新增一個本地流,該流是從 getUserMedia() 獲取的

  • 獲取本地媒體描述資訊,即 SDP 資訊,並與對端進行交換

  • 獲得網路資訊,即 Candidate(IP 地址和埠),並與遠端進行交換

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>實現視訊通話</title>
</head>

<body>
    <video id="localVideo" playsinline autoplay muted></video>
    <video id="remoteVideo" playsinline autoplay></video>

    <div class="box">
        <button onclick="start()">Start</button>
        <button onclick="call()">Call</button>
        <button onclick="hangup()">Hang Up</button>
    </div>
</body>
<script>
    // 獲取元素
    var localVideo = document.getElementById('localVideo');
    var remoteVideo = document.getElementById('remoteVideo');

    // 定義全域性變數
    var localStream;
    var pc1;
    var pc2;

    function start() {
        console.log('Requesting local stream');

        // 開始採集音視訊
        navigator.mediaDevices.getUserMedia({ audio: true, video: true })
            .then(function (stream) {
                // 這個全域性localStream是為了後面我們去新增流用的
                localStream = stream;

                // 相容性監測
                if (window.URL) {
                    // 掛在資料在本地播放
                    localVideo.src = window.URL.createObjectURL(stream)
                } else {
                    localVideo.srcObject = stream
                }
            })
            .catch(function (e) {
                // 如果獲取視訊失敗,在這裡進行錯誤處理 
                console.dir(e);
                alert(`getUserMedia() error: ${e.message}`);
            });
    }

    function call() {
        // 建立offerOption, 指定建立本地的媒體的時候,都包括哪些資訊
        // 可以有視訊流和音訊流,因為我們這裡沒有采集音訊所以offerToReceiveAudio是0
        var offerOptions = {
            offerToReceiveAudio: 0,
            offerToReceiveVideo: 1
        }

        // 這裡的 RTCPeerConnection 可以有可選引數, 進行一些網路傳輸的配置
        // 由於是我們在本機內進行傳輸,所以在這裡我們就不需要設定引數, 所以它這裡就會使用本機host型別的candidate
        pc1 = new RTCPeerConnection();
        pc1.onicecandidate = (e) => {
            console.log('pc1 ICE candidate:', e.candidate);

            // 我們A呼叫者收到candidate之後,它會將這個candidate傳送給這個信令伺服器
            // 那麼信令伺服器會中轉到這個B端,那麼這個B端會呼叫這個AddIceCandidate這個方法,將它存到對端的candidate List裡去
            // 所以整個過程就是A拿到它所有的可行的通路然後都交給B,B形成一個列表
            // 那麼B所以可行的通路又交給A,A拿到它的可行列表,然後雙方進行這個連通性檢測
            // 那麼如果通過之後那就可以傳資料了,就是這樣一個過程
            // 所以我們收到這個candidate之後就要交給對方去處理,所以pc1要呼叫pc2的這個
            // 因為是本機這裡就沒有信令了,假設信令被傳回來了,這時候就給了pc2
            // pc2收到這個candidate之後就呼叫addIceCandidate方法,傳入的引數就是e.candidate
            pc2.addIceCandidate(e.candidate)
                .catch(function (e) {
                    console.log("Failed to call getUserMedia", e);
                });
        }

        pc1.iceconnectionstatechange = (e) => {
            console.log(`pc1 ICE state: ${pc.iceConnectionState}`);
            console.log('ICE state change event: ', e);
        }

        // 建立一個pc2這樣我們就建立了兩個連線
        pc2 = new RTCPeerConnection();

        // 對於pc2也是同樣道理,那它就交給p1
        pc2.onicecandidate = (e) => {
            console.log('pc2 ICE candidate:', e.candidate);

            // 所以它就呼叫pc1.addIceCandidate
            pc1.addIceCandidate(e.candidate)
                .catch(function (e) {
                    console.log("Failed to call getUserMedia", e);
                });
        }

        pc2.iceconnectionstatechange = (e) => {
            console.log(`pc2 ICE state: ${pc.iceConnectionState}`);
            console.log('ICE state change event: ', e);
        }

        // pc2是被呼叫方,被呼叫方是接收資料的,所以對於pc2它還有個ontrack事件
        // 當雙方通訊連線之後,當有流從對端過來的時候,會觸發這個onTrack事件
        pc2.ontrack = gotRemoteStream;

        // 將本地採集的資料新增到第一新增到第一個pc1 = new RTCPeerConnection()中去
        // 這樣在建立媒體協商的時候才知道我們有哪些媒體資料,這個順序不能亂,必須要先新增媒體資料再做後面的邏輯
        // 另外不能先做媒體協商然後在新增資料,因為你先做媒體協商的時候它知道你這裡沒有資料那麼在媒體協商的時候它就沒有媒體流
        // 就是說在協商的時候它知道你是沒有的,那麼它在底層就不設定這些接收資訊發收器,那麼這個時候即使你後面設定了媒體流傳給這個PeerConnection,它也不會進行傳輸的,所以我們要先新增流
        // 新增流也比較簡單,通過localStream呼叫getTracks就能呼叫到所有的軌道(音訊軌/視訊軌)
        // 那對於每個軌道我們新增進去就完了,也就是forEach遍歷進去,每次迴圈都能拿到一個track
        // 當我們拿到這個track之後直接呼叫pc1.addTrack新增就好了,第一個引數就是track,第二個引數就是這個track所在的流localStream
        // 這樣就將本地所採集的音視訊流新增到了pc1 這個PeerConnection
        localStream.getTracks().forEach((track) => {
            pc1.addTrack(track, localStream);
        });

        // 那麼這個時候我們就可以去建立這個pc1去媒體協商了
        // 媒體協商第一步就是建立createOffer
        pc1.createOffer(offerOptions)
            .then(function (desc) {
                // 當我們拿到這個描述資訊之後呢,還是回到我們當時協商的邏輯
                // 對於A來說它首先建立Offer,建立Offer之後它會呼叫setLocalDescription
                // 將它設定到這個PeerConnection當中去,那麼這個時候它會觸發底層的ICE的收集candidate的這個動作
                // 所以這裡要呼叫pc1.setLocalDescription這個時候處理完了它就會收集candidate
                // 這個處理完了之後按照正常邏輯它應該send desc to signal到信令伺服器
                pc1.setLocalDescription(desc);

                // 到了信令伺服器之後,信令伺服器會發給第二個人
                // 所以第二個人就會receive
                // 所以第二個人收到desc之後呢首先pc2要呼叫setRemoteDescription,這時候將desc設定成它的遠端
                pc2.setRemoteDescription(desc);

                // 設成遠端之後, pc2就要呼叫createAnswer
                pc2.createAnswer().then(function (desc) {
                    // 當遠端它得到這個Answer之後,它也要設定它的setLocalDescription
                    // 當它呼叫了setLocalDescription之後它也開始收集candidate了
                    pc2.setLocalDescription(desc);

                    // 完了之後它去進行send desc to signal與pc1進行交換,pc1會接收recieve desc from signal
                    // 那麼收到之後他就會設定這個pc1的setRemoteDescription
                    // 那麼經過這樣一個步驟整個協商就完成了
                    // 當所有協商完成之後,這些底層對candidate就收集完成了
                    // 收集完了進行交換形成對方的列表然後進行連線檢測
                    // 連線檢測完了之後就開始真正的資料傳送過來了
                    pc1.setRemoteDescription(desc);
                })
                    .catch(function (e) {
                        console.log("Failed to call getUserMedia", e);
                    });
            })
            .catch(function (e) {
                console.log("Failed to call getUserMedia", e);
            });

    }

    // 當傳送ontrack的時候也就是資料通過的時候, 將遠端的音視訊流傳給了remoteVideo
    function gotRemoteStream(e) {
        if (remoteVideo.srcObject !== e.streams[0]) {
            remoteVideo.srcObject = e.streams[0];
        }
    }

    // 結束通話,將pc1和pc2分別關閉
    function hangup() {
        console.log('Ending call');
        pc1.close();
        pc2.close();
        pc1 = null;
        pc2 = null;
    }
</script>

</html>

 

四、視訊通話流程詳解

  • 視訊通話本是不同的端與端連線,上面的程式碼在同一個瀏覽器中模擬多端連線的情況,可以通過開兩個標籤頁,來模擬pc1端和pc2端

  • 所以大家會看到兩個視訊是一摸一樣的,但是它的整個底層都是從本機自己IO的那個邏輯網路卡轉過來的

  • 當呼叫 call 的時候就會呼叫雙方的 RTCPeerConnection

  • 當這個兩個 PeerConnection 建立完成之後,它們會作協商處理

  • 協商處理完成之後進行 Candidate 採集,也就是說有效地址的採集

  • 採集完了之後進行交換,然後形成這個Candidate pair再進行排序

  • 然後再進行連線性檢測,最終找到最有效的那個鏈路

  • 之後就將 localVideo 展示的這個資料通過 PeerConnection 傳送到另一端

  • 另一端收集到資料之後會觸發 onAddStream 或者 onTrack 就是說明我收到資料了,那當收到這個事件之後

  • 我們再將它設定到這個 remoteVideo 裡面去

  • 這樣遠端的這個 video 就展示出來了,顯示出我們本地採集的資料了

相關文章