はじめに
WebブラウザのWebRTC機能を利用して、Mesh接続で複数人とビデオ通話をするサンプルです。
WebRTCについての説明は巷に沢山あるため省きます。
1対1のビデオ通話サンプルはこちら。
「WebRTCを利用した1対1のビデオ通話サンプル」
環境
-
サーバー
CentOS Linux release 7.6.1810 (Core)
(Windowsでも動作可能)
node.js v16.18.0 -
クライアント(動作確認環境)
Chrome バージョン: 106.0.5249.119
iOS 16.0.2
Android 12
カメラとマイクが必要です。
インストール
サーバーはnode.jsを利用します。
node.jsで静的ファイルとWebSocketを処理します。WebSocketではクライアント同士でメッセージをやり取り(WebRTCのシグナリング)をします。
ファイルの配置は以下の通りです。(ファイルの内容は後述)
WebRoot
+ webrtc_mesh_server.js
+ package.json
+ mesh.html
+ webrtc_mesh.js
Chromeでカメラとマイクを使うためにはHTTPSで通信をする必要があります。外向けのSSL証明書はLet's Encryptで取得できます。
開発環境では自己署名証明書を利用すると動作できます。
以下は自己署名証明書を作成するコマンドの例です。
openssl req -x509 -newkey rsa:2048 -keyout privkey.pem -out cert.pem -nodes -days 365
node.jsのインストールについては省略します。
サーバーにファイルを配置したら、配置したディレクトリで以下のコマンドを使ってパッケージをインストールします。
npm install
サーバーの起動は以下のコマンドになります。
sudo node webrtc_mesh_server.js
サーバーの起動が成功したらサーバー側に以下のメッセージが出ます。
Server running.
クライアント側のWebブラウザで以下のURLを開きます。
https://[サーバーのホスト名]:8443/mesh.html
Room IDの入力メッセージが出るので適当な値を入れます。
同じRoom IDを指定した全員とビデオ通話ができます。
ソースコード
ソースコードはGitHubにもあります。
https://github.com/nakkag/webrtc_mesh
サーバー
パッケージ設定
以下はパッケージの設定ファイルです。
{
"dependencies": {
"ws": "^8.5.0"
}
}
サーバープログラム
サーバープログラムはnode.js用のJavaScriptです。
静的ファイル処理とシグナリング用のWebSocket処理が入っています。
リスト(connections)を使って部屋と接続者を管理しています。
接続時に部屋を指定することで、同じ部屋にいる人全員とビデオ通話できるようにしています。
見やすくするためエラー処理や排他処理は入れていません。
const path = require('path');
const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');
const sslPort = 8443;
const serverConfig = {
// SSL証明書、環境に合わせてパスを変更する
key: fs.readFileSync('privkey.pem'),
cert: fs.readFileSync('cert.pem')
};
// 接続リスト
let connections = [];
// WebSocket処理
const socketProc = function(ws, req) {
ws._pingTimer = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
// 接続確認
ws.send(JSON.stringify({ping: 1}));
}
}, 180000);
ws.on('message', function(message) {
const json = JSON.parse(message);
if (json.join) {
console.log(ws._socket.remoteAddress + ': join room=' + json.join.room + ', id=' + json.join.id);
// 同一IDが存在するときは古い方を削除
connections = connections.filter(data => !(data.room === json.join.room && data.id === json.join.id));
// 接続情報を保存
connections.push({room: json.join.room, id: json.join.id, ws: ws});
connections.forEach(data => {
if (data.room === json.join.room && data.id !== json.join.id && data.ws.readyState === WebSocket.OPEN) {
// 新規参加者を通知
data.ws.send(JSON.stringify({join: json.join.id}));
}
});
return;
}
if (json.pong) {
return;
}
if (json.ping) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({pong: 1}));
}
return;
}
// Peerを検索
connections.some(data => {
if (data.room === json.room && data.id === json.dest && data.ws.readyState === WebSocket.OPEN) {
// メッセージの転送
data.ws.send(JSON.stringify(json));
return true;
}
});
});
ws.on('close', function () {
closeConnection(ws);
});
ws.on('error', function(error) {
closeConnection(ws);
console.error(ws._socket.remoteAddress + ': error=' + error);
});
function closeConnection(conn) {
connections = connections.filter(data => {
if (data.ws !== conn) {
return true;
}
console.log(ws._socket.remoteAddress + ': part room=' + data.room + ', id=' + data.id);
connections.forEach(roomData => {
if (data.room === roomData.room && data.id !== roomData.id && roomData.ws.readyState === WebSocket.OPEN) {
// 退出を通知
roomData.ws.send(JSON.stringify({part: 1, src: data.id}));
}
});
data.ws = null;
return false;
});
if (conn._pingTimer) {
clearInterval(conn._pingTimer);
conn._pingTimer = null;
}
}
};
// 静的ファイル処理
const service = function(req, res) {
const url = req.url.replace(/\?.+$/, '');
const file = path.join(process.cwd(), url);
fs.stat(file, (err, stat) => {
if (err) {
res.writeHead(404);
res.end();
return;
}
if (stat.isDirectory()) {
service({url: url.replace(/\/$/, '') + '/index.html'}, res);
} else if (stat.isFile()) {
const stream = fs.createReadStream(file);
stream.pipe(res);
} else {
res.writeHead(404);
res.end();
}
});
};
// HTTPSサーバの開始
const httpsServer = https.createServer(serverConfig, service);
httpsServer.listen(sslPort, '0.0.0.0');
// WebSocketの開始
const wss = new WebSocket.Server({server: httpsServer});
wss.on('connection', socketProc);
console.log('Server running.');
クライアント
HTML
HTMLにはローカルビデオとリモートビデオの追加先を配置しています。
「adapter-latest.js」を使ってWebRTCに関するブラウザの互換性を解決しています。
<html>
<head>
<title>WebRTC(Mesh) Sample</title>
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script type="text/javascript" src="webrtc_mesh.js"></script>
<style type="text/css">
video {
width: 100%;
}
</style>
</head>
<body>
Local
<video id="localVideo" playsinline autoplay muted></video>
Remote
<div id="remote"></div>
</body>
</html>
JavaScript
カメラの開始やWebRTCのシグナリングを処理しています。
Mapで同じ部屋のPeerを管理しています。
新しい参加者が来るとPeerを作成して、VIDEOタグの追加などを行っています。
let localVideo;
let localId, roomId;
let sc;
let peers = new Map();
const sslPort = 8443;
const peerConnectionConfig = {
iceServers: [
// GoogleのパブリックSTUNサーバーを指定しているが自前のSTUNサーバーがあれば変更する
{urls: 'stun:stun.l.google.com:19302'},
{urls: 'stun:stun1.l.google.com:19302'},
{urls: 'stun:stun2.l.google.com:19302'},
// TURNサーバーがあれば指定する
//{urls: 'turn:turn_server', username:'', credential:''}
]
};
window.onload = function() {
localVideo = document.getElementById('localVideo');
localId = Math.random().toString(36).slice(-4) + '_' + new Date().getTime();
while (!roomId) {
roomId = window.prompt('Room ID', '');
}
startVideo(roomId, localId);
}
function startVideo(roomId, localId) {
if (navigator.mediaDevices.getUserMedia) {
if (window.stream) {
// 既存のストリームを破棄
try {
window.stream.getTracks().forEach(track => {
track.stop();
});
} catch(error) {
console.error(error);
}
window.stream = null;
}
// カメラとマイクの開始
const constraints = {
audio: true,
video: true
};
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
window.stream = stream;
localVideo.srcObject = stream;
startServerConnection(roomId, localId);
}).catch(e => {
alert('Camera start error.\n\n' + e.name + ': ' + e.message);
});
} else {
alert('Your browser does not support getUserMedia API');
}
}
function startServerConnection(roomId, localId) {
if (sc) {
sc.close();
}
// サーバー接続の開始
sc = new WebSocket('wss://' + location.hostname + ':' + sslPort + '/');
sc.onmessage = gotMessageFromServer;
sc.onopen = function(event) {
// サーバーに接続情報を通知
this.send(JSON.stringify({join: {room: roomId, id: localId}}));
};
sc.onclose = function(event) {
clearInterval(this._pingTimer);
setTimeout(conn => {
if (sc === conn) {
// 一定時間経過後にサーバーへ再接続
startServerConnection(roomId, localId);
}
}, 5000, this);
}
sc._pingTimer = setInterval(() => {
// 接続確認
sc.send(JSON.stringify({ping: 1}));
}, 30000);
}
function startPeerConnection(id, sdpType) {
if (peers.has(id)) {
peers.get(id)._stopPeerConnection();
}
let pc = new RTCPeerConnection(peerConnectionConfig);
// VIDEOタグの追加
document.getElementById('remote').insertAdjacentHTML('beforeend', '<video id="' + id + '" playsinline autoplay></video>');
pc._remoteVideo = document.getElementById(id);
pc._queue = new Array();
pc._setDescription = function(description) {
if (pc) {
pc.setLocalDescription(description).then(() => {
// SDP送信
sc.send(JSON.stringify({sdp: pc.localDescription, room: roomId, src: localId, dest: id}));
}).catch(errorHandler);
}
}
pc.onicecandidate = function(event) {
if (event.candidate) {
// ICE送信
sc.send(JSON.stringify({ice: event.candidate, room: roomId, src: localId, dest: id}));
}
};
if (window.stream) {
// Local側のストリームを設定
window.stream.getTracks().forEach(track => pc.addTrack(track, window.stream));
}
pc.ontrack = function(event) {
if (pc) {
// Remote側のストリームを設定
if (event.streams && event.streams[0]) {
pc._remoteVideo.srcObject = event.streams[0];
} else {
pc._remoteVideo.srcObject = new MediaStream(event.track);
}
}
};
pc._stopPeerConnection = function() {
if (!pc) {
return;
}
if (pc._remoteVideo && pc._remoteVideo.srcObject) {
try {
pc._remoteVideo.srcObject.getTracks().forEach(track => {
track.stop();
});
} catch(error) {
console.error(error);
}
pc._remoteVideo.srcObject = null;
}
if (pc._remoteVideo) {
// VIDEOタグの削除
pc._remoteVideo.remove();
}
pc.close();
pc = null;
peers.delete(id);
};
peers.set(id, pc);
if (sdpType === 'offer') {
// Offerの作成
pc.createOffer().then(pc._setDescription).catch(errorHandler);
}
return pc;
}
function gotMessageFromServer(message) {
const signal = JSON.parse(message.data);
if (signal.join) {
// 新規参加者にofferを送る
startPeerConnection(signal.join, 'offer');
return;
}
if (signal.ping) {
sc.send(JSON.stringify({pong: 1}));
return;
}
let pc = peers.get(signal.src);
if (!pc && (signal.sdp || signal.ice)) {
// answer側のPeerConnectionを追加
pc = startPeerConnection(signal.src, 'answer');
}
if (!pc) {
return;
}
if (signal.part) {
// 退出通知
pc._stopPeerConnection();
return;
}
// 以降はWebRTCのシグナリング処理
if (signal.sdp) {
// SDP受信
if (signal.sdp.type === 'offer') {
pc.setRemoteDescription(signal.sdp).then(() => {
// Answerの作成
pc.createAnswer().then(pc._setDescription).catch(errorHandler);
}).catch(errorHandler);
} else if (signal.sdp.type === 'answer') {
pc.setRemoteDescription(signal.sdp).catch(errorHandler);
}
}
if (signal.ice) {
// ICE受信
if (pc.remoteDescription) {
pc.addIceCandidate(new RTCIceCandidate(signal.ice)).catch(errorHandler);
} else {
// SDPが未処理のためキューに貯める
pc._queue.push(message);
return;
}
}
if (pc._queue.length > 0 && pc.remoteDescription) {
// キューのメッセージを再処理
gotMessageFromServer(pc._queue.shift());
}
}
function errorHandler(error) {
alert('Signaling error.\n\n' + error.name + ': ' + error.message);
}