こんにちは! がねこまさしです。前回はWebRTCの通信を手動でつなぎましたが、今回は仲介役のサーバーを作ってみましょう。
※今回の内容は、Node学園祭2013で発表した内容(の一部)とほぼ同じです。その時の資料もご参照ください。
※こちらの記事は2014年に書かれました。2016年7月のアップデート記事がありますので、そちらもご参照ください。
シグナリングサーバーを立てよう
前回は手動でコピー&ペーストしてシグナリングを実現しました。今回はそれを楽にしましょう。
シグナリングサーバーはどうして必要なの?
シグナリングの過程では、お互いのIPアドレスやポート番号を渡す必要があります。この段階ではお互いIPアドレスを知らないので直接やりとりできません。そこで、仲介役となるシグナリングサーバーが必要となります。このサーバーは、どちらブラウザもIPアドレスを知っていることが前提となります。
つまり、Peer-to-Peer通信の開始前には、普通のサーバー/クライアント型の通信が行わることになります。
Node.jsを準備しよう
今回はシグナリング処理をWebSocketを使って実現してみます。ソケットの処理が実現できればどのような言語でも構わないのですが、メッセージング処理が得意なNode.jsを使うことにします。 Node.jsのインストーラーをこちらのサイトから入手し、手順に従ってインストールしてください。Windows,Mac OS X,Linux用のバイナリが用意されています。今回のサンプルはv0.10.15で動作確認していますが、v0.10.x系ならばそのまま動くはずです。
Node.jsのインストールが完了したら、こんどはWebSocket用のモジュールをインストールします。コマンドプロンプト/ターミナルから、 次のコマンドを実行してください。 ※必要に応じて、sudoなどをご利用ください。
npm install socket.iosocket.ioは、異なる種類のブラウザ間の通信を簡単に行えるようにしてくれるモジュールです。異なる複数の通信方式をサポートしています。
- ‘websocket’ , ‘flashsocket’ , ‘htmlfile’ , ‘xhr-polling’ , ‘jsonp-polling’
シグナリングサーバーを動かそう
次のコードを好きなファイル名で保存してください。(例えば signaling.js)
var port = 9001; var io = require('socket.io').listen(port); console.log((new Date()) + " Server is listening on port " + port);起動するにはコマンドプロンプト/ターミナルから、 次のコマンドを実行してください。io.sockets.on('connection', function(socket) { socket.on('message', function(message) { socket.broadcast.emit('message', message); });
socket.on('disconnect', function() { socket.broadcast.emit('user disconnected'); }); });
node signaling.jsシグナリングサーバーの動作は単純で、右からきたメッセージをそのまま左に流すだけです。
シグナリング処理を変更しよう
それでは前回のHTMLを、少しずつ変更して行きましょう。まず、socket.ioのクライアント用JavaScriptを読み込みます。localhostの部分は、実際のシグナリングサーバーに変更してください。
<script src="http://localhost:9001/socket.io/socket.io.js"></script>
次に、socket.ioの接続、通信処理をJavaScriptに追加します。
// ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://localhost:' + port + '/'); // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage);重要なのはonMessage()の処理で、Offer SDP,Answer SDP,ICE Candidateのそれぞれに対応して、前回用意したOnOffer(), onAnswer(), onCandidate()を呼び出しています。function onOpened(evt) { console.log('socket opened.'); socketReady = true; }
// socket: accept connection request function onMessage(evt) { if (evt.type === 'offer') { console.log("Received offer, set offer, sending answer....") onOffer(evt);
} else if (evt.type === 'answer' && peerStarted) { console.log('Received answer, settinng answer SDP'); onAnswer(evt); } else if (evt.type === 'candidate' && peerStarted) { console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'user dissconnected' && peerStarted) { console.log("disconnected"); stop(); } }
今度は実際にSDP/ICE Candidateを送る部分を変更します。前回はテキストエリアに表示するだけでしたが、今回はそれをsocket.io経由で送信します。
function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); textForSendSDP.value = text;// send via socket socket.json.send(sdp); // <--- ここを追加
}
function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); textForSendICE.value = (textForSendICE.value + CR + iceSeparator + CR + text + CR); textForSendICE.scrollTop = textForSendICE.scrollHeight;
// send via socket socket.json.send(candidate); // <--- ここを追加
}
最後は、ちょっとしたフラグの処理の追加です。
function onOffer(evt) { console.log("Received offer...") console.log(evt); setOffer(evt); sendAnswer(evt); peerStarted = true; // <--- ここを追加 }// start the connection upon user request function connect() { if (!peerStarted && localStream && socketReady) { // <--- ここを変更 sendOffer(); peerStarted = true; } else { alert("Local stream not running yet - try again."); } }
function stop() { peerConnection.close(); peerConnection = null; peerStarted = false; // <--- ここを追加 }
実際に動かしてみよう
シグナリングサーバーが動いてることを確認したら、Chromeのウィンドウを2つ開いて修正したHTMLを読み込んでください。
(1) 両方のウィンドウで[Start video]ボタンをクリックします。カメラのアクセスを許可すると、それぞれリアルタイムの映像が表示されます。
(2) どちらかのウィンドウで[Connect]ボタンを押します。SDP, ICE Candidateが自動で交換され、ビデオ通信が始まります。
前回の14ステップに比べて、ぐっと減って2ステップになりました。これなら使えそうですね。
シグナリングの流れを追ってみる
シグナリングの流れを追跡してみましょう。前回はコード上を追っかけたので、今回は流れを図で見てみます。
SDPの交換
SDPのOffer, Answerが、シグナリングサーバー経由で交換されます。
ICE Candidateの交換
複数のICE Candidateが飛び交い、すべての交換が終わるとPeer-to-Peer通信が始まります。
次回は
今回はシグナリングサーバーを動かして、Peer-to-Peer通信確立までを自動化しました(それが普通ですけど)。実は今回の仕組みでは、一つのシグナリングサーバーで同時に2人までしか通信できません。まったく実用的ではありません。次回は複数人での通信にチャレンジする予定です。
今回のソース
最後に、今回使ったHTMLを掲載しておきます。
<!DOCTYPE html> <html> <head> <title>WebRTC 1 to 1 signaling</title>
</head> <body> <button type="button" onclick="startVideo();">Start video</button> <button type="button" onclick="stopVideo();">Stop video</button> <button type="button" onclick="connect();">Connect</button> <button type="button" onclick="hangUp();">Hang Up</button> <br /> <div> <video id="local-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video> <video id="remote-video" autoplay style="width: 240px; height: 180px; border: 1px solid black;"></video> </div><p> SDP to send:<br /> <textarea id="text-for-send-sdp" rows="5" cols="100" disabled="1">SDP to send</textarea> </p> <p> SDP to receive:<br /> <textarea id="text-for-receive-sdp" rows="5" cols="100"></textarea><br /> <button type="button" onclick="onSDP();">Receive SDP</button> </p>
<p> ICE Candidate to send:<br /> <textarea id="text-for-send-ice" rows="5" cols="100" disabled="1">ICE Candidate to send</textarea> </p> <p>
ICE Candidates to receive:<br /> <textarea id="text-for-receive-ice" rows="5" cols="100"></textarea><br /> <button type="button" onclick="onICE();">Receive ICE Candidates</button> </p><!---- socket ------> <script src="http://localhost:9001/socket.io/socket.io.js"></script>
<script> var localVideo = document.getElementById('local-video'); var remoteVideo = document.getElementById('remote-video'); var localStream = null; var peerConnection = null; var peerStarted = false; var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':true }};
// ---- socket ------ // create socket var socketReady = false; var port = 9001; var socket = io.connect('http://localhost:' + port + '/'); // socket: channel connected socket.on('connect', onOpened) .on('message', onMessage);
function onOpened(evt) { console.log('socket opened.'); socketReady = true; }
// socket: accept connection request function onMessage(evt) { if (evt.type === 'offer') { console.log("Received offer, set offer, sending answer....") onOffer(evt);
} else if (evt.type === 'answer' && peerStarted) { console.log('Received answer, settinng answer SDP'); onAnswer(evt); } else if (evt.type === 'candidate' && peerStarted) { console.log('Received ICE candidate...'); onCandidate(evt); } else if (evt.type === 'user dissconnected' && peerStarted) { console.log("disconnected"); stop(); } }// ----------------- handshake -------------- var textForSendSDP = document.getElementById('text-for-send-sdp'); var textForSendICE = document.getElementById('text-for-send-ice'); var textToReceiveSDP = document.getElementById('text-for-receive-sdp'); var textToReceiveICE = document.getElementById('text-for-receive-ice'); var iceSeparator = '------ ICE Candidate -------'; var CR = String.fromCharCode(13);
function onSDP() { var text = textToReceiveSDP.value; var evt = JSON.parse(text); if (peerConnection) { onAnswer(evt); } else { onOffer(evt); }
textToReceiveSDP.value ="";
}
//--- multi ICE candidate --- function onICE() { var text = textToReceiveICE.value; var arr = text.split(iceSeparator); for (var i = 1, len = arr.length; i < len; i++) { var evt = JSON.parse(arr[i]); onCandidate(evt); }
textToReceiveICE.value ="";
}
function onOffer(evt) { console.log("Received offer...") console.log(evt); setOffer(evt); sendAnswer(evt); peerStarted = true; // ++ }
function onAnswer(evt) { console.log("Received Answer...") console.log(evt); setAnswer(evt); }
function onCandidate(evt) { var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate}); console.log("Received Candidate...") console.log(candidate); peerConnection.addIceCandidate(candidate); }
function sendSDP(sdp) { var text = JSON.stringify(sdp); console.log("---sending sdp text ---"); console.log(text); textForSendSDP.value = text;
// send via socket socket.json.send(sdp);
}
function sendCandidate(candidate) { var text = JSON.stringify(candidate); console.log("---sending candidate text ---"); console.log(text); textForSendICE.value = (textForSendICE.value + CR + iceSeparator + CR + text + CR); textForSendICE.scrollTop = textForSendICE.scrollHeight;
// send via socket socket.json.send(candidate);
}
// ---------------------- video handling ----------------------- // start local video function startVideo() { navigator.webkitGetUserMedia({video: true, audio: false}, function (stream) { // success localStream = stream; localVideo.src = window.webkitURL.createObjectURL(stream); localVideo.play(); localVideo.volume = 0; }, function (error) { // error console.error('An error occurred: [CODE ' + error.code + ']'); return; } ); }
// stop local video function stopVideo() { localVideo.src = ""; localStream.stop(); }
// ---------------------- connection handling ----------------------- function prepareNewConnection() { var pc_config = {"iceServers":[]}; var peer = null; try { peer = new webkitRTCPeerConnection(pc_config); } catch (e) { console.log("Failed to create peerConnection, exception: " + e.message); }
// send any ice candidates to the other peer peer.onicecandidate = function (evt) { if (evt.candidate) { console.log(evt.candidate); sendCandidate({type: "candidate", sdpMLineIndex: evt.candidate.sdpMLineIndex, sdpMid: evt.candidate.sdpMid, candidate: evt.candidate.candidate} ); } else { console.log("End of candidates. ------------------- phase=" + evt.eventPhase); } }; console.log('Adding local stream...'); peer.addStream(localStream); peer.addEventListener("addstream", onRemoteStreamAdded, false); peer.addEventListener("removestream", onRemoteStreamRemoved, false) // when remote adds a stream, hand it on to the local video element function onRemoteStreamAdded(event) { console.log("Added remote stream"); remoteVideo.src = window.webkitURL.createObjectURL(event.stream); } // when remote removes a stream, remove it from the local video element function onRemoteStreamRemoved(event) { console.log("Remove remote stream"); remoteVideo.src = ""; } return peer;
}
function sendOffer() { peerConnection = prepareNewConnection(); peerConnection.createOffer(function (sessionDescription) { // in case of success peerConnection.setLocalDescription(sessionDescription); console.log("Sending: SDP"); console.log(sessionDescription); sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Offer failed"); }, mediaConstraints); }
function setOffer(evt) { if (peerConnection) { console.error('peerConnection alreay exist!'); } peerConnection = prepareNewConnection(); peerConnection.setRemoteDescription(new RTCSessionDescription(evt)); }
function sendAnswer(evt) { console.log('sending Answer. Creating remote session description...' ); if (! peerConnection) { console.error('peerConnection NOT exist!'); return; }
peerConnection.createAnswer(function (sessionDescription) { // in case of success peerConnection.setLocalDescription(sessionDescription); console.log("Sending: SDP"); console.log(sessionDescription); sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Answer failed"); }, mediaConstraints);
}
function setAnswer(evt) { if (! peerConnection) { console.error('peerConnection NOT exist!'); return; } peerConnection.setRemoteDescription(new RTCSessionDescription(evt)); }
// -------- handling user UI event ----- // start the connection upon user request function connect() { if (!peerStarted && localStream && socketReady) { // ** //if (!peerStarted && localStream) { // -- sendOffer(); peerStarted = true; } else { alert("Local stream not running yet - try again."); } }
// stop the connection upon user request function hangUp() { console.log("Hang up."); stop(); }
function stop() { peerConnection.close(); peerConnection = null; peerStarted = false; }
</script> </body> </html>