はじめに
WebブラウザのWebRTC機能を利用して、1対1でビデオ通話をするサンプルです。
WebRTCについての説明は巷に沢山あるため省きます。
複数人(Mesh)のビデオ通話サンプルはこちら。
「WebRTCを利用した複数人でのビデオ通話サンプル」
環境
-
サーバー
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_server.js
+ package.json
+ index.html
+ webrtc.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_server.js
サーバーの起動が成功したらサーバー側に以下のメッセージが出ます。
Server running.
クライアント側のWebブラウザで以下のURLを開きます。
https://[サーバーのホスト名]:8443/
Local IDとRemote IDの入力メッセージが出るのでそれぞれ異なる適当な値を入れます。
対向となるWebブラウザでも同じURLを開いて上記のLocal IDとRemote IDの値を逆に入れます。
例)
Webブラウザ1ではLocal IDを「aaa」、Remote IDを「bbb」に設定
Webブラウザ2ではLocal IDを「bbb」、Remote IDを「aaa」に設定
そうするとお互いのカメラとマイクがつながってビデオ通話ができます。
ソースコード
ソースコードはGitHubにもあります。
https://github.com/nakkag/webrtc_peer
サーバー
パッケージ設定
以下はパッケージの設定ファイルです。
{
"dependencies": {
"ws": "^8.5.0"
}
}
サーバープログラム
サーバープログラムはnode.js用のJavaScriptです。
静的ファイル処理とシグナリング用のWebSocket処理が入っています。
リスト(connections)を使って複数コネクションを管理していますが、シグナリングは1対1の処理になっています。
接続開始時に自分のセッションIDと接続先のセッションIDをconnectionsに保存して、メッセージが来ると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(function() {
if (ws.readyState === WebSocket.OPEN) {
// 接続確認
ws.send(JSON.stringify({ping: 1}));
}
}, 180000);
ws.on('message', function(message) {
const json = JSON.parse(message);
if (json.open) {
console.log('open: ' + ws._socket.remoteAddress + ': local=' + json.open.local + ', remote=' + json.open.remote);
// 同一IDが存在するときは古い方を削除
connections = connections.filter(data => !(data.local === json.open.local && data.remote === json.open.remote));
// 接続情報を保存
connections.push({local: json.open.local, remote: json.open.remote, ws: ws});
connections.some(data => {
if (data.local === json.open.remote && data.ws.readyState === WebSocket.OPEN) {
// 両方が接続済の場合にstartを通知
data.ws.send(JSON.stringify({start: 'answer'}));
ws.send(JSON.stringify({start: 'offer'}));
return true;
}
});
return;
}
if (json.pong) {
return;
}
if (json.ping) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({pong: 1}));
}
return;
}
// 対向の接続を検索
connections.some(data => {
if (data.local === json.remote && data.ws.readyState === WebSocket.OPEN) {
// シグナリングメッセージの転送
data.ws.send(JSON.stringify(json));
return true;
}
});
});
ws.on('close', function () {
closeConnection(ws);
console.log('close: ' + ws._socket.remoteAddress);
});
ws.on('error', function(error) {
closeConnection(ws);
console.error('error: ' + ws._socket.remoteAddress + ': ' + error);
});
function closeConnection(conn) {
connections = connections.filter(data => {
if (data.ws !== conn) {
return true;
}
connections.some(remoteData => {
if (remoteData.local === data.remote && remoteData.ws.readyState === WebSocket.OPEN) {
// 対向に切断を通知
remoteData.ws.send(JSON.stringify({close: 1}));
return true;
}
});
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(Peer) Sample</title>
<script type="text/javascript" src="https://webrtc.github.io/adapter/adapter-latest.js"></script>
<script type="text/javascript" src="webrtc.js"></script>
<style type="text/css">
video {
width: 100%;
}
</style>
</head>
<body>
Local
<video id="localVideo" playsinline autoplay muted></video>
Remote
<video id="remoteVideo" playsinline autoplay></video>
</body>
</html>
JavaScript
カメラの開始やWebRTCのシグナリングを処理しています。
ICEメッセージはこちらが期待するより前(SDPメッセージ処理前)に来ることがあり、その場合にICEメッセージが中途半端になりエラーとなります。
それを回避するためにQueueでICEメッセージを管理しています。
let localVideo, remoteVideo;
let localId, remoteId;
let sc, pc, queue;
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');
remoteVideo = document.getElementById('remoteVideo');
// Local IDとRemote IDは別々の値を入力する
// Remote IDと対向のLocal IDが一致するとビデオ通話を開始する
while (!localId) {
localId = window.prompt('Local ID', '');
}
while (!remoteId) {
remoteId = window.prompt('Remote ID', '');
}
startVideo(localId, remoteId);
}
function startVideo(localId, remoteId) {
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(localId, remoteId);
}).catch(e => {
alert('Camera start error.\n\n' + e.name + ': ' + e.message);
});
} else {
alert('Your browser does not support getUserMedia API');
}
}
function stopVideo() {
if (remoteVideo.srcObject) {
try {
remoteVideo.srcObject.getTracks().forEach(track => {
track.stop();
});
} catch(error) {
console.error(error);
}
remoteVideo.srcObject = null;
}
}
function startServerConnection(localId, remoteId) {
if (sc) {
sc.close();
}
// サーバー接続の開始
sc = new WebSocket('wss://' + location.hostname + ':' + sslPort + '/');
sc.onmessage = gotMessageFromServer;
sc.onopen = function(event) {
// サーバーに接続情報を通知
this.send(JSON.stringify({open: {local: localId, remote: remoteId}}));
};
sc.onclose = function(event) {
clearInterval(this._pingTimer);
setTimeout(conn => {
if (sc === conn) {
// 一定時間経過後にサーバーへ再接続
startServerConnection(localId, remoteId);
}
}, 5000, this);
}
sc._pingTimer = setInterval(() => {
// 接続確認
sc.send(JSON.stringify({ping: 1}));
}, 30000);
}
function startPeerConnection(sdpType) {
stopPeerConnection();
queue = new Array();
pc = new RTCPeerConnection(peerConnectionConfig);
pc.onicecandidate = function(event) {
if (event.candidate) {
// ICE送信
sc.send(JSON.stringify({ice: event.candidate, remote: remoteId}));
}
};
if (window.stream) {
// Local側のストリームを設定
window.stream.getTracks().forEach(track => pc.addTrack(track, window.stream));
}
pc.ontrack = function(event) {
// Remote側のストリームを設定
if (event.streams && event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
} else {
remoteVideo.srcObject = new MediaStream(event.track);
}
};
if (sdpType === 'offer') {
// Offerの作成
pc.createOffer().then(setDescription).catch(errorHandler);
}
}
function stopPeerConnection() {
if (pc) {
pc.close();
pc = null;
}
}
function gotMessageFromServer(message) {
const signal = JSON.parse(message.data);
if (signal.start) {
// サーバーからの「start」を受けてPeer接続を開始する
startPeerConnection(signal.start);
return;
}
if (signal.close) {
// 接続先の終了通知
stopVideo();
stopPeerConnection();
return;
}
if (signal.ping) {
sc.send(JSON.stringify({pong: 1}));
return;
}
if (!pc) {
return;
}
// 以降はWebRTCのシグナリング処理
if (signal.sdp) {
// SDP受信
if (signal.sdp.type === 'offer') {
pc.setRemoteDescription(signal.sdp).then(() => {
// Answerの作成
pc.createAnswer().then(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が未処理のためキューに貯める
queue.push(message);
return;
}
}
if (queue.length > 0 && pc.remoteDescription) {
// キューのメッセージを再処理
gotMessageFromServer(queue.shift());
}
}
function setDescription(description) {
pc.setLocalDescription(description).then(() => {
// SDP送信
sc.send(JSON.stringify({sdp: pc.localDescription, remote: remoteId}));
}).catch(errorHandler);
}
function errorHandler(error) {
alert('Signaling error.\n\n' + error.name + ': ' + error.message);
}