HTML5Experts.jp

手動でWebRTCの通信をつなげよう ーWebRTC入門2016

連載: WebRTC入門2016 (2)

こんにちは! がねこまさしです。2014年に連載した「WebRTCを使ってみよう!」シリーズを、2016年6月の最新情報に基づき、内容をアップデートして改めてお届けしています。1回目はカメラにアクセスしてみました。2回目となる今回は、WebRTCの通信の仕組みを実感するために、「手動」でP2P通信をつなげてみましょう。

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

WebRTCでは、映像/音声/アプリケーションデータなどをリアルタイムにブラウザ間で送受信することができます。それをつかさどるのが「RTCPeerConnection」です。 RTCPeerConnectionには3つの特徴があります。

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

P2P通信を確立するまで

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

Session Description Protocol (SDP)

各ブラウザが通信した内容を示し、テキストで表現されます。例えば次のような情報を含んでいます。

ICE Candidate

P2P通信を行う際にどのような通信経路が使えるかは、お互いのネットワーク環境に依存します。通信経路を定めるための仕組みが「Interactive Connectivity Establishment (ICE)」で、その通信経路の候補が「ICE Candidate」になります。WebRTCの通信を始める前に、可能性のある候補がリストアップされます。

候補が見つかったら順次通信を試み、最初につながった経路が採用されます。

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

このように、P2Pを始めるまでの情報のやり取りを「シグナリング」と言います。実は、WebRTCではシグナリングのプロトコルは規定されていません(自由に選べます)。シグナリングを実現するにはWebSocketを使うなど複数の方法がありますが、今回は最も原始的な方法であるコピー&ペーストを試してみましょう。

ちょっと長いですが、こちらのHTMLをお好きなWebサーバーに配置してください。

<!doctype html>
<html>
<head>
 <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
 <title>Hand Signaling</title>
</head>
<body>
  Hand Signaling 2016<br />
  <button type="button" onclick="startVideo();">Start Video</button>
  <button type="button" onclick="stopVideo();">Stop Video</button>
  &nbsp;
  <button type="button" onclick="connect();">Connect</button>
  <button type="button" onclick="hangUp();">Hang Up</button> 
  <div>
    <video id="local_video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
    <video id="remote_video" autoplay style="width: 160px; height: 120px; border: 1px solid black;"></video>
  </div>
  <p>SDP to send:<br />
    <textarea id="text_for_send_sdp" rows="5" cols="60" readonly="readonly">SDP to send</textarea>
  </p>
  <p>SDP to receive:&nbsp;
    <button type="button" onclick="onSdpText();">Receive remote SDP</button><br />
    <textarea id="text_for_receive_sdp" rows="5" cols="60"></textarea>
  </p>
</body>
<script type="text/javascript">
  let localVideo = document.getElementById('local_video');
  let remoteVideo = document.getElementById('remote_video');
  let localStream = null;
  let peerConnection = null;
  let textForSendSdp = document.getElementById('text_for_send_sdp');
  let textToReceiveSdp = document.getElementById('text_for_receive_sdp');

// --- prefix ----- navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia; RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection || window.mozRTCPeerConnection; RTCSessionDescription = window.RTCSessionDescription || window.webkitRTCSessionDescription || window.mozRTCSessionDescription;

// ---------------------- media handling ----------------------- // start local video function startVideo() { getDeviceStream({video: true, audio: false}) .then(function (stream) { // success localStream = stream; playVideo(localVideo, stream); }).catch(function (error) { // error console.error('getUserMedia error:', error); return; }); }

// stop local video function stopVideo() { pauseVideo(localVideo); stopLocalStream(localStream); }

function stopLocalStream(stream) { let tracks = stream.getTracks(); if (! tracks) { console.warn('NO tracks'); return; }

for (let track of tracks) {
  track.stop();
}

}

function getDeviceStream(option) { if ('getUserMedia' in navigator.mediaDevices) { console.log('navigator.mediaDevices.getUserMadia'); return navigator.mediaDevices.getUserMedia(option); } else { console.log('wrap navigator.getUserMadia with Promise'); return new Promise(function(resolve, reject){
navigator.getUserMedia(option, resolve, reject ); });
} }

function playVideo(element, stream) { if ('srcObject' in element) { element.srcObject = stream; } else { element.src = window.URL.createObjectURL(stream); } element.play(); element.volume = 0; }

function pauseVideo(element) { element.pause(); if ('srcObject' in element) { element.srcObject = null; } else { if (element.src && (element.src !== '') ) { window.URL.revokeObjectURL(element.src); } element.src = ''; } }

// ----- hand signaling ---- function onSdpText() { let text = textToReceiveSdp.value; if (peerConnection) { console.log('Received answer text...'); let answer = new RTCSessionDescription({ type : 'answer', sdp : text, }); setAnswer(answer); } else { console.log('Received offer text...'); let offer = new RTCSessionDescription({ type : 'offer', sdp : text, }); setOffer(offer); } textToReceiveSdp.value =''; }

function sendSdp(sessionDescription) { console.log('---sending sdp ---'); textForSendSdp.value = sessionDescription.sdp; textForSendSdp.focus(); textForSendSdp.select(); }

// ---------------------- connection handling ----------------------- function prepareNewConnection() { let pc_config = {"iceServers":[]}; let peer = new RTCPeerConnection(pc_config);

// --- on get remote stream ---
if ('ontrack' in peer) {
  peer.ontrack = function(event) {
    console.log('-- peer.ontrack()');
    let stream = event.streams[0];
    playVideo(remoteVideo, stream);
  };
}
else {
  peer.onaddstream = function(event) {
    console.log('-- peer.onaddstream()');
    let stream = event.stream;
    playVideo(remoteVideo, stream);
  };
}

// --- on get local ICE candidate
peer.onicecandidate = function (evt) {
  if (evt.candidate) {
    console.log(evt.candidate);

    // Trickle ICE の場合は、ICE candidateを相手に送る
    // Vanilla ICE の場合には、何もしない
  } else {
    console.log('empty ice event');

    // Trickle ICE の場合は、何もしない
    // Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る
    sendSdp(peer.localDescription);
  }
};


// -- add local stream --
if (localStream) {
  console.log('Adding local stream...');
  peer.addStream(localStream);
}
else {
  console.warn('no local stream, but continue.');
}

return peer;

}

function makeOffer() { peerConnection = prepareNewConnection(); peerConnection.createOffer() .then(function (sessionDescription) { console.log('createOffer() succsess in promise'); return peerConnection.setLocalDescription(sessionDescription); }).then(function() { console.log('setLocalDescription() succsess in promise');

  // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
  // -- Vanilla ICE の場合には、まだSDPは送らない --
  //sendSdp(peerConnection.localDescription);
}).catch(function(err) {
  console.error(err);
});

}

function setOffer(sessionDescription) { if (peerConnection) { console.error('peerConnection alreay exist!'); } peerConnection = prepareNewConnection(); peerConnection.setRemoteDescription(sessionDescription) .then(function() { console.log('setRemoteDescription(offer) succsess in promise'); makeAnswer(); }).catch(function(err) { console.error('setRemoteDescription(offer) ERROR: ', err); }); }

function makeAnswer() { console.log('sending Answer. Creating remote session description...' ); if (! peerConnection) { console.error('peerConnection NOT exist!'); return; }

peerConnection.createAnswer()
.then(function (sessionDescription) {
  console.log('createAnswer() succsess in promise');
  return peerConnection.setLocalDescription(sessionDescription);
}).then(function() {
  console.log('setLocalDescription() succsess in promise');

  // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
  // -- Vanilla ICE の場合には、まだSDPは送らない --
  //sendSdp(peerConnection.localDescription);
}).catch(function(err) {
  console.error(err);
});

}

function setAnswer(sessionDescription) { if (! peerConnection) { console.error('peerConnection NOT exist!'); return; }

peerConnection.setRemoteDescription(sessionDescription)
.then(function() {
  console.log('setRemoteDescription(answer) succsess in promise');
}).catch(function(err) {
  console.error('setRemoteDescription(answer) ERROR: ', err);
});

}

// start PeerConnection function connect() { if (! peerConnection) { console.log('make Offer'); makeOffer(); } else { console.warn('peer already exist.'); } }

// close PeerConnection function hangUp() { if (peerConnection) { console.log('Hang up.'); peerConnection.close(); peerConnection = null; pauseVideo(remoteVideo); } else { console.warn('peer NOT exist.'); } }

</script> </html>

GitHub Pagesでも公開していますので、すぐに試すことができます。

次に、PCにWebカメラを接続してからFirefox 47 またはChrome 51でアクセスしてみてください。(※Chromeの場合はカメラ映像取得の制限があるので、https://~か http://localhost/~のWebサーバーが必要になります)残念ながら今回はEdgeでは利用できません。

通信するために2つページを開く必要があります。同一ウィンドウで複数タブを開くよりも、別のウィンドウで横に並べたほうが作業しやすいです。

接続手順

接続手順は2014年のものよりも簡略化しました。それでも間違えやすいので慎重に操作してくださいね。

(1) 映像の取得

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

(2) 通信の開始

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

(3)(4) SDPの送信(左→右)

(3)左の[SDP to send:]の内容をコピーし、(4)右の[SDP to receive:]の下のテキストエリアにペーストします。

(5) SDPの受信(右)

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

(6)(7) SDPの返信(左←右)

さっきと反対に(6)右の[SDP to send:]の内容をコピーし、(7)左の[SDP to receive:]の下のテキストエリアにペーストします。

(8) SDPの受信(左)

左の[Receive remote SDP]ボタンをクリックします。しばらくすると(~数秒)P2P通信が始まり両方のウィンドウに2つ目の動画が表示されるはずです。

上手くいかなかった場合は、コピー範囲が欠けているか、手順が抜けている可能性があります。深呼吸してから両方のブラウザをリロードし、もう一度試してみてください。

それでも通信できない場合は、実はネットワーク環境の問題の可能性があります。この記事の「トラブルシューティング」の章をご覧ください。(あるいはソースのバグや、説明の不備の可能性もあります。何かお気づきの際にはご指摘ください)

トラブルシューティング

これまで何度も手動シグナリングを試して/試していただいて、通信ができないケースがありました。当初は原因が分からなかったのですが、その後に判明したケースを説明します。

外部ネットワークにつながっていない場合

PCが全くネットワークに接続されていない状態では、カメラ映像の取得に成功しても通信ができません。これはネットワークに接続されていない状態では、通信経路の情報であるICE Candidateが収集できないためです。
例え同一PC内で通信を行う場合にも、外部に接続できる状態で利用する必要があります。

ハンズオン等で手動シグナリングを試してもらうことがあるのですが、長いことこの制約に気が付かず通信できないで悩んでいました。

Chrome – Firefox 間での通信

WebRTCではChrome – Chrome間や、Firefox – Firefox 間のように同一種類のブラウザ同士だけでなく、Chrome – Firefox間でも通信することができます。もちろん手動シグナリングでも同様です。
ところが実際に1台のPCで Firefox – Chrome 間で手動シグナリングを行おうとすると、カメラ映像の取得で衝突してしまうケースがあります。この場合は次のどちらかをお試しください

裏側で起こっていること

それでは映像通信に成功したところで、その裏側で起きていることを見てみましょう。

Vanilla ICE と Trickle ICE

WebRTCのP2P通信を確立するためのシグナリングでは、次の2種類の情報を交換する必要があると説明しました。

ところが今回の手動シグナリングではSDPしか交換していません。いったいぜんたい、ICE candidateの情報はどうなっているのでしょうか?

実はICE Candidateの情報は、今回交換しているSDPの中に含まれています。実際に私のPCで取得したSDPの一部を掲載します。(※IPアドレスは一部マスクしています)

m=video 58461 UDP/TLS/RTP/SAVPF 100 101 116 117 96 97 98
c=IN IP4 192.168.xxx.xxx
a=rtcp:58465 IN IP4 192.168.xxx.xxx
a=candidate:2999745851 1 udp 2122260223 192.168.xxx.xxx 58461 typ host generation 0 network-id 4
a=candidate:2747735740 1 udp 2122194687 192.168.xxx.xxx 58462 typ host generation 0 network-id 3
a=candidate:1606961068 1 udp 2122129151 10.2.xxx.xxx 58463 typ host generation 0 network-id 2
a=candidate:1435463253 1 udp 2122063615 192.168.xxx.xxx 58464 typ host generation 0 network-id 1
a=candidate:2999745851 2 udp 2122260222 192.168.xxx.xxx 58465 typ host generation 0 network-id 4
a=candidate:2747735740 2 udp 2122194686 192.168.xxx.xxx 58466 typ host generation 0 network-id 3
a=candidate:1606961068 2 udp 2122129150 10.2.xxx.xxx 58467 typ host generation 0 network-id 2
a=candidate:1435463253 2 udp 2122063614 192.168.xxx.xxx 58468 typ host generation 0 network-id 1
a=candidate:4233069003 1 tcp 1518280447 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 4
a=candidate:3980714572 1 tcp 1518214911 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 3
a=candidate:290175836 1 tcp 1518149375 10.2.xxx.xxx 9 typ host tcptype active generation 0 network-id 2
a=candidate:453808805 1 tcp 1518083839 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 1
a=candidate:4233069003 2 tcp 1518280446 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 4
a=candidate:3980714572 2 tcp 1518214910 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 3
a=candidate:290175836 2 tcp 1518149374 10.2.xxx.xxx 9 typ host tcptype active generation 0 network-id 2
a=candidate:453808805 2 tcp 1518083838 192.168.xxx.xxx 9 typ host tcptype active generation 0 network-id 1
a=ice-ufrag:xxxxxxxxxxxxx
a=ice-pwd:xxxxxxxxxxxx
a=candidate: で始まる行がICE candidateになります。(※仮想化ソフトを入れている影響で複数のネットワークが候補になっています)。 SDPを最初に取得したときにはICE candidateの行は含まれず、その後ICE candidateが収集されるにしたがって、SDPの中に追加されます。
今回は全てのICE candidateが出そろった後に、SDPとまとめて交換しています。このような方式を “Vanilla ICE” と呼びます。

これに対して、初期のSDPを交換し、その後ICE Candidateを順次交換する方式を “Trickle ICE” と呼びます。すべてのICE candidateを交換し終わる前にP2P通信が始まることがあるので、Trickle ICEの方が一般的に早く接続が確立します。

Offer と Answer

SDPには通信を始める側(Offer)と、通信を受け入れる側(Answer)があります。必ずOffer → Answerの順番でやりとりする必要があります。

ソースコードを追いかけてみよう

Offer SDPの生成

それでは、SDP(+ ICE candidate)のやり取りをソースコードで見てみましょう。まずは[Connect]ボタンを押してSDPを生成するところまでです。(ソースコードは抜粋しています)

  // start PeerConnection
  function connect() {
      makeOffer();
  }

// Offer SDPを生成する function makeOffer() { peerConnection = prepareNewConnection(); // RTCPeerConnectionを生成し、必要なメッセージハンドラを設定

peerConnection.createOffer()
.then(function (sessionDescription) {
  return peerConnection.setLocalDescription(sessionDescription);
}).then(function() {
  // -- Trickle ICE の場合は、初期SDPを相手に送る -- 
  // -- Vanilla ICE の場合には、まだSDPは送らない --
  //sendSdp(peerConnection.localDescription);  &lt;-- Vanilla ICEなので、まだ送らない
}).catch(function(err) {
  console.error(err);
});

}

発信側で[Connect]ボタンをクリックすると、次の処理が行われます。

createOffer(), setLocalDescription()は非同期で処理が行われます。従来はコールバックで後続処理を記述していましたが、現在はPromiseを返すので、then()の中に処理を記述します。
2014年の記事ではsetLocalDescription()が非同期であることを意識しおらず、誤った記述になっていました。

ICE candidateの収集

次は ICE candidateの収集です。ICE candidateの収集も非同期に行われるため、RTCPeerConnectionのイベントハンドラで行います。

  function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

// --- on get local ICE candidate
peer.onicecandidate = function (evt) {
  if (evt.candidate) { // ICE candidate が収集された場合
    console.log(evt.candidate);

    // Trickle ICE の場合は、ICE candidateを相手に送る
    // Vanilla ICE の場合には、何もしない
  } else { // ICE candidateの収集が完了した場合
    console.log('empty ice event');

    // Trickle ICE の場合は、何もしない
    // Vanilla ICE の場合には、ICE candidateを含んだSDPを相手に送る
    sendSdp(peer.localDescription);
  }
};

// ... 省略 ....

// 通信対象の映像/音声ストリームを追加する
if (localStream) {
  console.log('Adding local stream...');
  peer.addStream(localStream);
}


return peer;

}

今回のコードではprepareNewConnection()の中でRTCPeerConnectionオブジェクトを生成し、各種イベントハンドラを設定しています。ICE candidateのためRTCPeerConnection.onicecandidateにイベントハンドラを記述しています。このイベントは複数回発生します。
全てのICE candidateを収集し終わると空のイベントが渡ってきます。このタイミングで最終的なSDPを相手に送信します。今回の手動シグナリングではsendSdp()の中でテキストエリアに表示しています。

Offser SDPの受信

応答側にOffer SDPをペーストして[Receive remote SDP]ボタンをクリックすると、onSdpText() → setOffer() と呼び出されます。

  function onSdpText() {
    let text = textToReceiveSdp.value;
    if (peerConnection) { // Answerの場合
      // ... 省略 ...
    }
    else { // Offerの場合
      let offer = new RTCSessionDescription({
        type : 'offer',
        sdp : text,
      });
      setOffer(offer);
    }
    textToReceiveSdp.value ='';
  }
 

さらに setOffer()の中では次の処理が行われています。

Answer SDPの生成→送信

makeAnswer()の中ではOfferの時と同様な処理が行われます。

  function makeAnswer() {
peerConnection.createAnswer() .then(function (sessionDescription) { return peerConnection.setLocalDescription(sessionDescription); }).then(function() { // -- Trickle ICE の場合は、初期SDPを相手に送る -- // -- Vanilla ICE の場合には、まだSDPは送らない -- //sendSdp(peerConnection.localDescription); }).catch(function(err) { console.error(err); }); }

この後 RTCPeerConnection.onicecandidate()でICE candidateを収集し、すべて揃ったらsendSdp()でOffer側に送り返します。

Answer SDPの受信

発信側にAnser SDPをペーストして[Receive remote SDP]ボタンをクリックすると、onSdpText() → setAnswer() と呼び出されます。

  function onSdpText() {
    let text = textToReceiveSdp.value;
    if (peerConnection) { // Answerの場合
      let answer = new RTCSessionDescription({
        type : 'answer',
        sdp : text,
      });
      setAnswer(answer);
    }
    else { // Offerの場合
      // ... 省略 ...
    }
    textToReceiveSdp.value ='';
  }

function setAnswer(sessionDescription) { peerConnection.setRemoteDescription(sessionDescription) .then(function() { console.log('setRemoteDescription(answer) succsess in promise'); }).catch(function(err) { console.error('setRemoteDescription(answer) ERROR: ', err); }); }

setAnswer()の中ではRTCPeerConnection.setRemoteDescription()で受け取ったSDPを覚えます。

映像/音声の送受信

PeerConnectionのオブジェクトを生成した際に、送信する映像/音声ストリームをRTCPeerConnection.addStream()で指定しておきます。

  function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

// ... 省略 ...

// -- add local stream --
if (localStream) {
  peer.addStream(localStream);
}

return peer;

}

SDPの交換が終わると、P2P通信に相手の映像/音声が含まれていればイベントが発生します。従来はRTCPeerConnection.onaddstream() にハンドラを記述していましたが、新しいイベントが策定されRTCPeerConnection.ontrack() にハンドラを記述するようになっています。Firefoxはontrack()がすでに使えるようになっていて、onaddstream()は非推奨になっています。(Chromeは未対応です)

  function prepareNewConnection() {
    let pc_config = {"iceServers":[]};
    let peer = new RTCPeerConnection(pc_config);

// --- on get remote stream ---
if ('ontrack' in peer) {
  peer.ontrack = function(event) {
    let stream = event.streams[0];
    playVideo(remoteVideo, stream);
  };
}
else {
  peer.onaddstream = function(event) {
    let stream = event.stream;
    playVideo(remoteVideo, stream);
  };
}

// ... 省略 ....

}

以上で主要な処理の解説は終わりです。

次回は

今回は手動で情報交換を行い、原始的なビデオチャットを動かしてみました。P2P通信が確立するまでの動きを実感していただけたのではないでしょうか?

実際の利用場面では手動シグナリングなんかやってらません。次回はシグナリングサーバーを使って、通信を行ってみたいと思います。