WebRTC P2Pを使って3つ以上のマシンで双方向通信を行う
前回はWebRTC P2Pで2つのマシンを接続するところまでを紹介しました。今回はWebRTC P2Pで3つ以上のマシンを接続する方法です。
本サンプルの環境は次のとおりです。
- シグナリングサーバ、WebサーバーはNode.jsを使用
- 実行ブラウザはGoogle Chromeを使用
- シグナリングサーバとWebサーバーは同一マシンに配置
今回はシグナリングにWebSocket
を利用します。次のコマンドを実行してWebSocket用のモジュール(socket.io
)をインストールしてください。
npm install socket.io
シグナリングサーバを使って3つ以上デバイスを接続する
シグナリングサーバのソース
シグナリングサーバとして次のファイルを用意します。
同じ部屋に入った人を繋げたり、メッセージをしてあげるだけです。
"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ファイルを用意します。
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...')
<!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の接続と同じです。通信していた内容(Offer
やAnswer
)をそれぞれの相手と実行します。ソースコードでは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つ開き、それぞれでアクセスします。