HTML5Experts.jp

WebRTCに触ってみたいエンジニア必見!手動でWebRTC通信をつなげてみよう

こんにちは! がねこまさしです。前回はWebRTCでカメラを使いましたが、今回は通信をしてみましょう。

※こちらの記事は2014年に書かれました。2016年6月のアップデート記事がありますので、そちらもご参照ください。

WebRTCの通信はどうなっているの?

WebRTCでは、映像や音声などリアルタイムに取得されたデータ(バイトストリーム)を、ブラウザ間で送受信することができます。それを司るのが RTCPeerConnection です。 RTCPeerConnectionには2つの特徴があります。

多少の情報の欠落があっても許容する替わりに、通信のリアルタイム性を重視しています。UDPのポート番号は動的に割り振られ、49152 ~ 65535の範囲が使われるようです。

P2P通信が確立するまで

ブラウザ間でP2P通信を行うには、相手のIPアドレスを知らなくてはなりません。また、動的に割り振られるUDPのポート番号も知る必要があります。そのためP2P通信が確立するまでに、WebRTCではいくつかの情報をやり取りしています。

Session Description Protocol (SDP)

各ブラウザの情報を示し、文字列で表現されます。例えば次のような情報を含んでいます。


Interactive Connectivity Establishment (ICE)

可能性のある通信経路に関する情報を示し、文字列で表現されます。次のような複数の経路を候補としてリストアップします。

候補が出そろったら、ネットワーク的に近い経路(オーバーヘッドの少ない経路)が選択されます。リストの上から順に優先です。※STUNやTURNについては、別の回で触れたいと思います。

手動シグナリングを実験してみる

このように、P2Pを始めるまでの情報のやり取りを「シグナリング」と言います。実は、WebRTCではシグナリングのプロトコルは規定されていません(自由に選べます)。シグナリングを実現するには複数の方法がありますが、今回は最も原始的な方法を試してみましょう。(理論的にできることは皆知っていますが、実際に試した人は少ないと思います。本邦初公開?)
ちょっと長いですが、こちらのHTMLをお好きなWebサーバーに配置してください。

<!DOCTYPE html>
<html>
<head>
  <title>WebRTC 1 to 1 handshake</title>
</head> <body> <button type="button" onclick="startVideo();">Start video</button> <button type="button" onclick="stopVideo();">Stop video</button> &nbsp;&nbsp;&nbsp;&nbsp; <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>

<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':true, 'OfferToReceiveVideo':true }};

// ----------------- 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); }

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;

}

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;

}

// ---------------------- 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 && channelReady) { 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>

Chromeを起動し、2つのウィンドウでWebサーバ上のHTMLにアクセスしてください。同一ウィンドウで複数タブを開くよりも、別のウィンドウで横に並べたほうが作業しやすいです。 以降、間違えやすいので慎重に操作してくださいね。

※ 2014/04/03 訂正 掲載したソースの45行目に誤りがありました。大変申し訳ありません。

誤 : var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':false, 'OfferToReceiveVideo':true }}; 正 : var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};

SDPのやり取り

(1) 両方のウィンドウで[Start video]ボタンをクリックします。カメラのアクセスを許可すると、それぞれリアルタイムの映像が表示されます。

(2) 左のウィンドウで[Connect]ボタンをクリックしてください。すると[SDP to send:]のテキストエリアに、ドバドバっとSDPの文字列が表示されます。

(3) これを全選択してコピーし、(4)右のウィンドウの[SDP to receive:]のテキストエリアにペーストします。

(5) 右のウィンドウの[Receive SDP]ボタンをクリックすると、今度は右のウィンドウの[SDP to send:]のテキストエリアに、ドバドバっとSDPの文字列が表示されます。

(6) 右のウィンドウの[SDP to send:]のテキストエリアを全選択してコピー、(7)左に戻って[SDP to receive:]のテキストエリアにペーストします。

(8) 左のウィンドウの[Receive SDP]ボタンをクリックします。ここまででSDPのやり取りが終わりました。

ICE Candidateのやり取り

通信経路を示すICE Candidateは、本来は1つずつやり取りされます。手動でやりとりするのは大変なので、今回は複数経路分まとめて渡してしまいましょう。

(9) 左のウィンドウの[ICE Candidate to send:]のテキストエリアを全選択してコピー、(10)右のウィンドウの[ICE Candidates to receive:]のテキストエリアにペーストします。

(11) 右のウィンドウの[Receive ICE Candidate]ボタンをクリックします。

(12) 右のウィンドウの[ICE Candidate to send:]のテキストエリアを全選択してコピー、(13)左に戻って[ICE Candidates to receive:]のテキストエリアにペーストします。

(14) 左のウィンドウの[Receive ICE Candidate]ボタンをクリックします。

これで上手くいけば、P2P通信が始まり両方のウィンドウに2つ目の動画が表示されるはずです。上手くいかない場合は、手順が抜けているか間違っている可能性があります。深呼吸してから両方のブラウザをリロードし、もう一度試してみてください。(私のソースのバグや、説明の間違いの可能性もあります。気がつかれたらご指摘ください)

SDPのやり取りを追ってみる

[Connect]ボタンを押してから、SDPのやり取りをソースで追ってみましょう。 ※ソースは抜粋しています。

function connect() {
  sendOffer();
}

function sendOffer() { peerConnection = prepareNewConnection(); // webkitRTCPeerConnection を生成し、コールバックを設定している peerConnection.createOffer( function (sessionDescription) { // in case of success peerConnection.setLocalDescription(sessionDescription); sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Offer failed"); }, mediaConstraints); }

発信側で[Connect]ボタンを押すと connect() → sendOffer() という順に呼び出されます。中ではRTCPeerConnectionのインスタンスを生成し、そのインスタンスにSDPの作成を依頼します。SDPには通信開始を依頼するOfferと、応答するAnswerの2種類があり、ここではOfferを使います。

SDPはコールバック関数に渡されるので、何らかの手段を使って相手に渡します。今回のsendSDP()ではテキストエリアにSDPを表示するところまでになります。

function onOffer(evt) {
  setOffer(evt);
  sendAnswer(evt);
}

function setOffer(evt) { peerConnection = prepareNewConnection(); // webkitRTCPeerConnection を生成し、コールバックを設定している peerConnection.setRemoteDescription(new RTCSessionDescription(evt)); }

function sendAnswer(evt) { peerConnection.createAnswer( function (sessionDescription) { // in case of success peerConnection.setLocalDescription(sessionDescription); sendSDP(sessionDescription); }, function () { // in case of error console.log("Create Answer failed"); }, mediaConstraints); }

応答側にSDPを貼り付け、[Receive SDP]ボタンをクリックすると、onSDP() → onOffer() → setOffer() と呼び出されます。setOffer()の中ではRTCPeerConnectionのインスタンスを生成し、受け取ったOffer SDPを覚えさせます。

次にsendAnswer()で今度は応答用のAnswer SDPの生成を依頼し、コールバックからsendSDP()で発信側に送り返します。ここでもテキストエリアにSDPを表示するところまでになります。

function onAnswer(evt) {
  setAnswer(evt);
}

function setAnswer(evt) { peerConnection.setRemoteDescription(new RTCSessionDescription(evt)); }

発信側にSDPを貼り付け、[Receive SDP]ボタンをクリックすると、onSDP() → onAnswer() → setAnswer() と呼び出されます。setAnswer()の中では生成済のRTCPeerConnectionのインスタンスに受け取ったAnswer SDPを覚えさせます。

ICEのやり取りも追ってみる

同様に、ICE Candidateのやり取りも見てみましょう。まず、prepareNewConnection()の中身から。

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);
  }
};
// ... 省略

}

ICE Candidateの生成は、非同期に発生します。そのためRTCPeerConnection.onicecandidateに、コールバック関数を指定します。今回は sendCandidate()を呼び出していますが、その中では例によってテキストエリアに追記する処理を行います。※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);
  }
}

function onCandidate(evt) { var candidate = new RTCIceCandidate({sdpMLineIndex:evt.sdpMLineIndex, sdpMid:evt.sdpMid, candidate:evt.candidate}); peerConnection.addIceCandidate(candidate); }

ICE Candidateをペーストして[Receive ICE Candidates]ボタンをクリックすると、onICE()でテキストエリアの内容を分割し、1つ1つのICE Candidateを取り出します。それをonCandidate()に渡すと、RTCPeerConnectionのインスタンスにセットします。すべての経路の候補(ICE Candidate)の交換が終わると、P2P通信が開始されます。

手動シグナリングの改良版ソース(2014年4月21日追加)

手動シグナリングは操作が面倒なので、ちょっとでも楽になるように必要なテキストを自動で選択するようにしました。あとはコピー&ペーストでできます。

<!DOCTYPE html>
<html>
<head>
  <title>WebRTC 1 to 1 handshake V2</title>
</head> <body> <button type="button" onclick="startVideo();">Start video</button> <button type="button" onclick="stopVideo();">Stop video</button> &nbsp;&nbsp;&nbsp;&nbsp; <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" onclick="this.focus(); this.select();">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>

<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 }}; var mediaConstraints = {'mandatory': {'OfferToReceiveAudio':true, 'OfferToReceiveVideo':true }};

// ----------------- 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);

  textForSendICE.focus();
  textForSendICE.select();
}
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 ="";

textForSendICE.focus();
textForSendICE.select();

}

function onOffer(evt) { console.log("Received offer...") console.log(evt); setOffer(evt); sendAnswer(evt); }

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;
textForSendSDP.focus();
textForSendSDP.select();

}

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;

}

// ---------------------- 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 && channelReady) { 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>

※手動シグナリングはなぜか動作が不安定で、端末によっては通信が確立しないケースがあります(今のところ成功は4台/7台)。原因がさっぱりわからないのですが、もし心当たりがあったら教えていただけると助かります。情報求む!

次回は

以上、今回は原始的なビデオチャットを動かしてみました。同一PC上ではなく異なるPC間でも(FirewallやNATを挟まない場合は)、SDP/ICEの文字列をチャットなどで渡せば「原理上は」P2P通信は成立するはずです。(私はやってません…)

実際には手動でシグナリングなんかやってられません。次回はシグナリングサーバーを Node.js + socket.io で動かしてみたいと思います。

バックナンバーを読む