LoginSignup
16
17

WebRTCを利用した複数人でのビデオ通話サンプル(Mesh)

Last updated at Posted at 2022-10-28

はじめに

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

サーバー

パッケージ設定

以下はパッケージの設定ファイルです。

package.json
{
  "dependencies": {
    "ws": "^8.5.0"
  }
}

サーバープログラム

サーバープログラムはnode.js用のJavaScriptです。
静的ファイル処理とシグナリング用のWebSocket処理が入っています。

リスト(connections)を使って部屋と接続者を管理しています。
接続時に部屋を指定することで、同じ部屋にいる人全員とビデオ通話できるようにしています。
見やすくするためエラー処理や排他処理は入れていません。

webrtc_mesh_server.js
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});
			// Roomメンバーの一覧を返す
			const member = [];
			connections.forEach(data => {
				if (data.room === json.join.room && data.id !== json.join.id && data.ws.readyState === WebSocket.OPEN) {
					member.push({id: data.id});
					// 新規参加者を通知
					data.ws.send(JSON.stringify({join: json.join.id}));
				}
			});
			ws.send(JSON.stringify({start: member}));
			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に関するブラウザの互換性を解決しています。

mesh.html
<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タグの追加などを行っています。

webrtc_mesh.js
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);
	}
}

function gotMessageFromServer(message) {
	const signal = JSON.parse(message.data);
	if (signal.start) {
		// 同じ部屋のすべてのPeerとの接続を開始する(Offer側)
		signal.start.forEach(data => startPeerConnection(data.id, 'offer'));
		return;
	}
	if (signal.join) {
		// 新規参加者通知(Answer側)
		startPeerConnection(signal.join, 'answer');
		return;
	}
	if (signal.ping) {
		sc.send(JSON.stringify({pong: 1}));
		return;
	}
	const pc = peers.get(signal.src);
	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);
}
16
17
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
16
17