WebRTC P2Pを使って3つ以上のマシンで双方向通信を行う

前回はWebRTC P2Pで2つのマシンを接続するところまでを紹介しました。今回はWebRTC P2Pで3つ以上のマシンを接続する方法です。
本サンプルの環境は次のとおりです。

  • シグナリングサーバ、WebサーバーはNode.jsを使用
  • 実行ブラウザはGoogle Chromeを使用
  • シグナリングサーバとWebサーバーは同一マシンに配置
本記事ではシグナリングサーバのみ、サーバと記載しているため、他のサーバーと表記揺れになっています。

今回はシグナリングにWebSocketを利用します。次のコマンドを実行してWebSocket用のモジュール(socket.io)をインストールしてください。

npm install socket.io

シグナリングサーバを使って3つ以上デバイスを接続する

シグナリングサーバのソース

シグナリングサーバとして次のファイルを用意します。
同じ部屋に入った人を繋げたり、メッセージをしてあげるだけです。

ssl-signaling-multi.js
"use strict";

const https = require('https');
const fs = require('fs');
var server = https.createServer({
    key: fs.readFileSync('server-key.pem'),
    cert: fs.readFileSync('server-crt.pem')
});

let io = require('socket.io')(server);
const port = 3010;
server.listen(port);
console.log('socket.io server start. port=' + port);

io.on('connection', function(socket) {
    socket.on('enter', function(roomName) {
        socket.join(roomName);
        console.log('id=' + socket.id + ', enter room:' + roomName);
        socket.roomname = roomName;
    });

    function broadcastMessage(type, message) {
        if (socket.roomname) {
            // ルーム内全員に送る
            socket.broadcast.to(socket.roomname).emit(type, message);
        } else {
            // ルーム未入室の場合は、全体に送る.
            socket.broadcast.emit(type, message);
        }
    }

    socket.on('message', function(message) {
        message.from = socket.id;

        let target = message.sendto;
        if (target) {
            // 特定の相手に送る場合.
            socket.to(target).emit('message', message);
            return;
        }

        broadcastMessage('message', message);
    });

    socket.on('disconnect', function() {
        console.log('id=' + socket.id + ' disconnect');
        broadcastMessage('user disconnected', {id: socket.id});

        if (socket.roomname) {
            socket.leave(socket.roomname);
        }
    });
});

Webサーバーのソース

Webサーバー(フロントエンド)に次の2ファイルを用意します。

https.js
const https = require('https');
const fs = require('fs');
const sslServerKey = 'server-key.pem';
const sslServerCrt = 'server-crt.pem';

const options = {
    key: fs.readFileSync(sslServerKey),
    cert: fs.readFileSync(sslServerCrt)
};

const server = https.createServer(options,
    (request, response) => {
        fs.readFile('./multi.html', 'UTF-8',
        (error, data) => {
            response.writeHead(200, {'Content-Type':'text/html'});
            response.write(data);
            response.end();
        });
    }
);
server.listen(3001);
console.log('server running...')
multi.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC P2P</title>
    <script src="https://127.0.0.1:3010/socket.io/socket.io.js"></script>
    <style>
        .video-box {
            border: 1px solid #000;
            height: 240px;
            width: 320px;
            transform: scaleX(-1);
        }

        .outlined-button {
            background-color: #2196f3;
            border-radius: 4px;
            border: none;
            color: #fff;
            font-size: 16px;
            font-weight: 600;
            line-height: 36px;
            margin-left: 4px;
            outline: none;
            padding: 0 16px;
            text-align: center;
        }
    </style>
</head>
<body>
    <div>
        <header>
            <p>WebRTC Sample. 複数人接続.</p>
        </header>
    </div>
    <div id="main-container">
        <button onclick="startVideo();" class="outlined-button">Start</button>
        <button onclick="stopVideo();" class="outlined-button">Stop</button>
        <button type="button" onclick="connect();" class="outlined-button">Connect</button>
        <button type="button" onclick="hangUp();" class="outlined-button">Hang Up</button> 
        <section class="video">
            <video id="local-video" autoplay control class="video-box"></video>
            <div id="remote-videos"></div>
        </section>
    </div>
<script type="text/javascript">
    let localVideo = document.getElementById('local-video');
    let localStream = null;
    let remoteVideos = [];
    let remoteVideoContainer = document.getElementById('remote-videos');
    let peerConnections = [];
    const MAX_CONNECTIONS = 8;

    /** Functions of peer connection. */

    function getConnectionCount() {
        return peerConnections.length;
    }

    function canConnect() {
        return getConnectionCount() < MAX_CONNECTIONS;
    }

    function isConnected(id) {
        return peerConnections[id] ? true : false;
    }

    function addConnection(id, peer) {
        peerConnections[id] = peer;
    }

    function getConnection(id) {
        if (isConnected(id)) {
            return peerConnections[id];
        } else {
            return null;
        }
    }

    function deleteConnection(id) {
        if (isConnected(id)) {
            let peer = getConnection(id);
            peer.close();
            delete peerConnections[id];
        }
    }

    function stopConnection(id) {
        detachRemoteVideo(id);
        deleteConnection(id);
    }

    function stopAllConnection() {
        for (let id in peerConnections) {
            stopConnection(id);
        }
    }

    /** Functions of remote video. */

    function addRemoteVideoElement(id) {
        let videoElement = createVideoElement('remote-video-' + id);
        remoteVideos[id] = videoElement;
        return videoElement;
    }

    function getRemoteVideoElement(id) {
        if (remoteVideos[id]) {
            return remoteVideos[id];
        } else {
            return null;
        }
    }

    function deleteRemoteVideoElement(id) {
        removeVideoElement('remote-video-' + id);
        delete remoteVideos[id];
    }

    function createVideoElement(id) {
        let video = document.createElement('video');
        video.width = 160;
        video.height = 120;
        video.id = id;

        video.style.border = '1px solid black';

        remoteVideoContainer.appendChild(video);

        return video;
    }

    function removeVideoElement(id) {
        let video = document.getElementById(id);
        remoteVideoContainer.removeChild(video);
    }

    function attachRemoteVideo(id, stream) {
        let remoteVideo = addRemoteVideoElement(id);
        remoteVideo.srcObject = stream;
        remoteVideo.play();
    }

    function detachRemoteVideo(id) {
        let remoteVideo = getRemoteVideoElement(id);
        if (remoteVideo) {
            remoteVideo.pause();
            remoteVideo.srcObject = null;
            deleteRemoteVideoElement(id);
        }
    }

    function isRemoteVideoAttached(id) {
        return remoteVideos[id] ? true : false;
    }

    const port = 3010;
    let socket = io.connect('https://127.0.0.1:' + port + '/');
    let room = getRoomName();

    socket.on('connect', function(event) {
        socket.emit('enter', room);
    });

    socket.on('message', function(message) {
        let from = message.from;

        // 厳密比較. {} で変数スコープ指定.
        switch (message.type) {
            case 'offer': {
                console.log('--- offer from ' + from)
                let offer = new window.RTCSessionDescription(message);
                setOffer(from, offer);
                break;
            }
            case 'answer': {
                console.log('--- answer from' + from)
                let answer = new window.RTCSessionDescription(message);
                setAnswer(from, answer);
                break;
            }
            case 'candidate': {
                console.log('--- candidate from ' + from)
                let candidate = new window.RTCIceCandidate(message.ice);
                addIceCandidate(from, candidate);
                break;
            }
            case 'call me':
                console.log('--- call me from ' + from);
                startConnection(from);
                break;
            case 'bye':
                console.log('--- bye from ' + from);
                stopConnection(from);
                break;
        }
    });

    socket.on('user disconnected', function(event) {
        stopConnection(event.id);
    });

    function startConnection(from) {
        if (!isReadyToConnect()) {
            console.log('Not ready connecting.');
            return;
        }
        if (!canConnect()) {
            console.warn('Too many connections.');
            return;
        }
        if (isConnected(from)) {
            console.log('already connecting.');
            return;
        }
        makeOffer(from);
    }

    // ルーム内の全員にメッセージを送る.
    function messageToRoom(message) {
        socket.emit('message', message);
    }

    // 特定の相手にメッセージを送る.
    function messageToOne(id, message) {
        message.sendto = id;
        socket.emit('message', message);
    }

    function getRoomName() {
        return 'thirteen';
    }

    function isReadyToConnect() {
        return localStream ? true : false;
    }

    function startVideo() {
        navigator.mediaDevices.getUserMedia({
            video: {
                width: {min: 320, ideal: 640},
                height: {min: 240, ideal: 480}
            },
            audio: true
        })
        .then(function(stream) {
                localStream = stream;
                localVideo.srcObject = stream;
                localVideo.play();
                console.log(stream.getAudioTracks()[0].getSettings());
        }).catch(function(error) {
            console.error('mediaDevice.getUserMedia() error:', error);
            return;
        });
    }

    function stopVideo() {
        if (localStream == null) {
            return;
        }
        for (track of localStream.getTracks()) {
            track.stop();
        }
        localStream = null;

        // kill local video.
        localVideo.pause();
        localVideo.srcObject = null;
    }

    function connect() {
        if (!isReadyToConnect()) {
            return;
        }
        if (!canConnect()) {
            return;
        }
        callMe();
    }

    function makeOffer(id) {
        let peerConnection = prepareNewConnection(id);
        addConnection(id, peerConnection);

        peerConnection.createOffer()
        .then(function(sessionDescription) {
            console.log('-- createOffer() succsess in promise');
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function() {
            console.log('-- setLocalDescription() succsess in promise');

            // Trickle ICE > 初期SDPを送る.
            sendSdp(id, peerConnection.localDescription);

            // Vanilla ICE > まだSDPを送らない.
        }).catch(function(error) {
            console.error(error);
        });
    }

    function setAnswer(id, sessionDescription) {
        let peerConnection = getConnection(id);
        if (!peerConnection) {
            return;
        }
        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
            console.log('setRemoteDescription(answer) succsess in promise');
        }).catch(function(error) {
            console.error('setRemoteDescription(answer) ERROR: ', error);
        });
    }

    function setOffer(id, sessionDescription) {
        let peerConnection = prepareNewConnection(id);
        addConnection(id, peerConnection);

        peerConnection.setRemoteDescription(sessionDescription)
        .then(function() {
            makeAnswer(id);
        }).catch(function(error) {
            console.error('setRemoteDescription(offer) ERROR: ', error);
        });
    }

    function makeAnswer(id) {
        let peerConnection = getConnection(id);
        if (!peerConnection) {
            return;
        }

        peerConnection.createAnswer()
        .then(function(sessionDescription) {
            console.log('createAnswer() succsess in promise');
            return peerConnection.setLocalDescription(sessionDescription);
        }).then(function() {
            console.log('setLocalDescription() succsess in promise');

            // Trickle ICE > 初期SDPを送る.
            sendSdp(id, peerConnection.localDescription);

            // Vanilla ICE > まだSDPを送らない.
        }).catch(function(error) {
            console.log(error);
        });
    }

    function prepareNewConnection(id) {
        let pcConfig = {"iceServers":[]};
        let peer = new window.RTCPeerConnection(pcConfig);

        if ('ontrack' in peer) {
            console.log('-- ontrack');
            peer.ontrack = function(event) {
                if (isRemoteVideoAttached(id)) {
                    // video と audioで2回届くので2回目を無視する.
                    console.log('already stream attached.')
                } else {
                    let stream = event.streams[0];
                    attachRemoteVideo(id, stream);
                }
            };
        } else {
            console.log('-- onaddstream');
            peer.onaddstream = function(event) {
                let stream = event.stream;
                attachRemoteVideo(id, stream);
            }
        }

        peer.onicecandidate = function(event) {
            if (event.candidate) {
                // Trickle ICE > ICE candidateを送る.
                sendIceCandidate(id, event.candidate);

                // Vanilla ICE > 何もしない.
            } else {
                // Trickle ICE > 何もしない.

                // Vanilla ICE > candidateを含んだSDPを送る.
                //sendSdp(id, peer.localDescription);
            }
        }

        peer.oniceconnectionstatechange = function() {
            switch (peer.iceConnectionState) {
                case 'closed':
                case 'failed':
                    stopConnection(id);
                    break;
                case 'disconnected':
                    break;
            }
        };

        peer.onremovestream = function(event) {
            detachRemoteVideo(id);
        }

        // localStreamの追加.
        if (localStream) {
            peer.addStream(localStream);
        }

        return peer;
    }

    function sendSdp(id, sessionDescription) {
        // sending server.
        let message = { type: sessionDescription.type, sdp: sessionDescription.sdp };
        messageToOne(id, message);
    }

    function sendIceCandidate(id, candidate) {
        let message = { type: 'candidate', ice: candidate };
        if (isConnected(id)) {
            messageToOne(id, message);
        }
    }

    function addIceCandidate(id, candidate) {
        if (!isConnected(id)) {
            console.warn('Not connected or already closed. id=' + id);
            return;
        }

        let peerConnection = getConnection(id);
        if (!peerConnection) {
            console.error('PeerConnection is not exist');
            return;
        }
        peerConnection.addIceCandidate(candidate);
    }

    function hangUp() {
        messageToRoom({ type: 'bye' });
        stopAllConnection();
    }

    function callMe() {
        messageToRoom({ type: 'call me' });
    }

</script>
</body>
</html>

解説

scriptタグで指定しているsocket.io.jsのアドレスとio.connect()で指定しているアドレスは上記シグナリングサーバのアドレスにします。
本サンプルはWebサーバーとシグナリングサーバを同じマシンに配置しているため、ローカルホストのアドレスを指定しています。

部屋の名前は「thirteen」固定にしています。(getRoomName()の箇所)

やっていることは1:1の接続と同じです。通信していた内容(OfferAnswer)をそれぞれの相手と実行します。ソースコードでは8人まで繋げれるようにしていますが(MAX_CONNECTIONS)、実際は3,4人でマシンが悲鳴をあげます(;'∀')

実行

シグナリングサーバ側で次のコマンドを実行します。

node ssl-signaling-multi.js

Webサーバー側で次のコマンドを実行します。シグナリングサーバと同じマシンを使っている場合はコマンドプロンプトなどを2つ起動して実行します。

node https.js

https://{サーバー名}:3001/にGoogle Chromeでアクセスします。
ローカルマシンの場合はhttps://localhost:3001/

Startボタンを押すと自分の映像が表示されます。音声を出力した場合、ハウリングするので注意してください。
一方の画面でConnectボタンを押すとお互いの映像が表示されます。
1台のマシンで試す場合は、Google Chromeのタブを2つ開き、それぞれでアクセスします。

参考

シグナリングサーバーを動かそう ーWebRTC入門2016

このエントリーをはてなブックマークに追加
にほんブログ村 IT技術ブログへ

コメント

メールアドレスが公開されることはありません。 が付いている欄は必須項目です