WebRTCで複数人接続してみる
WebRTCをさわってみる&手動シグナリングしてみるの続きです。今回は、WebRTCを使って複数人接続してみたいと思います。
1対1で接続するときとの違いは?
前回のおさらいですが、1対1で接続するとこんな感じになる。
![]() |
図1. 1on1の接続 |
No | 端末A | 端末B |
1 | video要素を作っておく | video要素を作っておく |
2 | Peerを生成 | Peerを生成 |
3 | メディアに接続(カメラの起動を許可すること) ・Peerにストリームを接続し、自身の端末の状態をvideo要素で閲覧できる状態になる | メディアに接続(カメラの起動を許可すること) ・Peerにストリームを接続し、自身の端末の状態をvideo要素で閲覧できる状態になる |
4 | 端末Bへ送信するOfferを生成 | |
5 | 端末Aで生成したOfferを受信 | |
6 | 端末Aへ送信するAnswerを生成 | |
7 | 端末Bで生成したAnswerを受信 | |
8 | 端末Bへ送信する経路情報を出力 | |
9 | 端末Aで生成した経路情報を受信 | |
10 | 端末Aへ送信する経路情報を出力 | |
11 | 端末Bで生成した経路情報を受信 |
じゃあ、複数人接続の場合はどうなるのか?
3端末のときの接続を例として記述します。
上記のように接続するには、
端末Aと端末Bと端末Cがそれぞれ、下記のようなPeerを生成しなければなりません。
・端末Aと端末BをつなぐPeerの生成、端末Aと端末CをつなぐPeerの生成
・端末Bと端末AをつなぐPeerの生成、端末Bと端末CをつなぐPeerの生成
・端末Cと端末AをつなぐPeerの生成、端末Cと端末BをつなぐPeerの生成
そして、4台目が追加になる場合は更に1つずつPeerを生成することになり、
それを動的に通知して接続処理をするようにしなければなりません。
結構複雑っす。
どういう流れで複数接続させるか?
3端末での接続手順を記載します。(灰色のセルは端末未接続時・25〜38、25`〜38`は並行で処理されます)
No | 端末A | 端末B | 端末C |
1 | 特定URLにアクセス | ||
2 | socket.ioでコネクションをはる | ||
3 | コネクションをはってるユーザー全員にコールする | ||
4 | いなかったので終わり。 | ||
5 | 特定URLにアクセス | ||
6 | socket.ioでコネクションをはる | ||
7 | コネクションをはってるユーザー全員にコールする | ||
8 | 端末Bからコールされた | ||
9 | 端末Aのsocketのidを端末Bに返却する | ||
10 | 端末Aのsocketのidを返却される | ||
11 | Peerを生成 | ||
12 | socketのidを元に、端末AへSDPを送信(オファー) | ||
13 | Peerを生成 | ||
14 | 端末BのSDPを受信(オファーをレシーブ) | ||
15 | socketのidを元に、端末BへSDPを送信(アンサー) | ||
16 | 端末AのSDPを受信(アンサーをレシーブ) | ||
17 | 端末Aへ端末Bまでの経路情報を送信 | ||
18 | 端末Bから経路情報を受信 | ||
19 | 端末Bへ端末Aまでの経路情報を送信 | ||
20 | 端末Aから経路情報を受信 | ||
21 | Peer to Peer! | Peer to Peer! | |
22 | 特定URLにアクセス | ||
23 | socket.ioでコネクションをはる | ||
24 | コネクションをはってるユーザー全員にコールする | ||
下記のAとC / BとCのやりとりは並行して行われる | |||
25 | 端末Cからコールされた | ||
26 | 端末Aのsocketのidを端末Cに返却する | ||
27 | 端末Aのsocketのidを返却される | ||
28 | Peerを生成 | ||
29 | socketのidを元に、端末AへSDPを送信(オファー) | ||
30 | Peerを生成 | ||
31 | 端末CのSDPを受信(オファーをレシーブ) | ||
32 | socketのidを元に、端末CへSDPを送信(アンサー) | ||
33 | 端末AのSDPを受信(アンサーをレシーブ) | ||
34 | 端末Aへ端末Cまでの経路情報を送信 | ||
35 | 端末Cから経路情報を受信 | ||
36 | 端末Cへ端末Aまでの経路情報を送信 | ||
37 | 端末Aから経路情報を受信 | ||
38 | Peer to Peer! | Peer to Peer! | |
25` | 端末Cからコールされた | ||
26` | 端末Bのsocketのidを端末Cに返却する | ||
27` | 端末Bのsocketのidを返却される | ||
28` | Peerを生成 | ||
29` | socketのidを元に、端末BへSDPを送信(オファー) | ||
30` | Peerを生成 | ||
31` | 端末CのSDPを受信(オファーをレシーブ) | ||
32` | socketのidを元に、端末CへSDPを送信(アンサー) | ||
33` | 端末CのSDPを受信(アンサーをレシーブ) | ||
34` | 端末Bへ端末Cまでの経路情報を送信 | ||
35` | 端末Cから経路情報を受信 | ||
36` | 端末Cへ端末Bまでの経路情報を送信 | ||
37` | 端末Bから経路情報を受信 | ||
38` | Peer to Peer! | Peer to Peer! |
基本的な流れは1対1の時と一緒ですが、
Peerの生成タイミングや、Peer生成後にローカルのストリームとの接続手順などが変わってきます。
(「コネクションはっているユーザー全員にコールする」のレスポンスでオファー送ることもできた。そうすると、通信回数としては最小になりそう。)
作ってみた
このソースを使うと、↓↓のような画面がでるので、手順にそって作業してみてください。・シグナリングサーバー(Node.js)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* WebRTCのシグナリングサーバー | |
*/ | |
var port = 8888; | |
var io = require('socket.io').listen(port); | |
var messageTypes = { | |
open: "open", | |
allCall: "allCall", | |
callAnswer: "callAnswer", | |
offer: "offer", | |
answer: "answer", | |
candidate: "candidate", | |
}; | |
io.sockets.on('connection', function(socket) { | |
var connectGroup = null; | |
// 接続時の処理 | |
socket.on('open', function(option) { | |
connectGroup = option.connectGroup | |
socket.join(option.connectGroup); | |
socket.emit('open', { | |
messageTypes: messageTypes, | |
userId: socket.id | |
}); | |
}); | |
// 切断時の処理 | |
socket.on('disconnect', function() { | |
console.log("disconnect"); | |
}); | |
// シグナリング処理 | |
socket.on('message', function(message) { | |
// 特定のユーザに送信 | |
var target = message.sendto || message.param.sendto; | |
if (target) { | |
socket.to(target).emit('message', message); | |
return; | |
} | |
// 接続グループ全員に送信 | |
socket.broadcast.to(connectGroup).emit('message', message); | |
}); | |
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
{ | |
"name": "webrtc", | |
"version": "0.0.0", | |
"description": "", | |
"main": "app.js", | |
"scripts": { | |
"test": "echo \"Error: no test specified\" && exit 1" | |
}, | |
"author": "", | |
"license": "ISC", | |
"devDependencies": { | |
"socket.io": "^1.0.6" | |
} | |
} |
$ npm installそして、これで起動する。
$ node app.js
・Webサーバー(Class風実装は特に見ないでいいです。。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE HTML> | |
<html lang="ja"> | |
<head> | |
<meta charset="UTF-8"> | |
<title></title> | |
</head> | |
<body> | |
<input type="button" value="ここを押したら繋がるけどカメラを先に許可しといてね" onclick="webrtcTest.createConnection();"> | |
<style type="text/css"> | |
video { | |
width: 80px; | |
} | |
</style> | |
<script type="text/javascript"> | |
// Class風の実装をするライブラリ | |
(function (window) { | |
'use strict'; | |
/** | |
* メソッド内に_superの記述があるかのチェック | |
* _superがあるメソッドだけ上書き | |
* _superがあるかどうか判断できない場合は[/ .* /]となって全部上書き | |
*/ | |
var fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/; | |
function Class() { /* noop. */ } | |
Class.extend = function (props) { | |
var SuperClass = this; | |
function Class() { | |
if (typeof this.init === 'function') { | |
this.init.apply(this, arguments); | |
} | |
// _superプロパティを書き込み禁止 | |
Object.defineProperty(this, '_super', { | |
value: undefined, | |
enumerable: false, | |
writable: false, | |
configurable: true | |
}); | |
} | |
// prototypeチェーンを作る | |
Class.prototype = Object.create(SuperClass.prototype, { | |
constructor: { | |
value: Class, | |
enumerable: false, | |
writable: true, | |
configurable: true | |
} | |
}); | |
// instanceメソッドをセット | |
Object.keys(props).forEach(function (key) { | |
var prop = props[key], | |
_super = SuperClass.prototype[key], | |
isMethodOverride = (typeof prop === 'function' && typeof _super === 'function' && fnTest.test(prop)); | |
if (isMethodOverride) { | |
Class.prototype[key] = function () { | |
var ret, | |
tmp = this._super; | |
// _superプロパティを設定 | |
Object.defineProperty(this, '_super', { | |
value: _super, | |
enumerable: false, | |
writable: false, | |
configurable: true | |
}); | |
ret = prop.apply(this, arguments); | |
// _superプロパティを書き込み禁止にする | |
Object.defineProperty(this, '_super', { | |
value: tmp, | |
enumerable: false, | |
writable: false, | |
configurable: true | |
}); | |
return ret; | |
}; | |
} else { | |
Class.prototype[key] = prop; | |
} | |
}); | |
Class.extend = SuperClass.extend; | |
return Class; | |
}; | |
window.Class = Class; | |
}(window)); | |
</script> | |
<script src="webrtc.js"> | |
/** | |
* webrtcに関するクラスを実装 | |
*/ | |
var Webrtc = Class.extend({ | |
/** | |
* 初期化 | |
* @param {Object} option { | |
* videoElementId: "", | |
* iceCandidateCallback: function() {} | |
* } | |
*/ | |
init: function(option) { | |
this.option = option; | |
this.peer = null; | |
this.localStream = null; | |
this.isStartVideo = false; | |
}, | |
/** | |
* コネクションをはる | |
* @return {Boolean} true: RTCPeerConnectionが生成済み, false: 生成できなかった | |
*/ | |
connect: function() { | |
if (this.peer) { | |
return true; | |
} | |
if (!this.localStream) { | |
return false; | |
} | |
if (!this.option.videoElementId) { | |
return false; | |
} | |
this.peer = this.createPeer(); | |
this.peer.addStream(this.localStream); | |
var iceCandidateCallback = this.option.iceCandidateCallback; | |
this.peer.onicecandidate = function(event) { | |
iceCandidateCallback && iceCandidateCallback(event.candidate); | |
}; | |
var videoElementId = this.option.videoElementId; | |
this.peer.onaddstream = function(event) { | |
var video = document.getElementById(videoElementId); | |
video.src = window.webkitURL.createObjectURL(event.stream); | |
}; | |
return true; | |
}, | |
/** | |
* Peerを生成 | |
* @return {Object} RTCPeerConnection | |
*/ | |
createPeer: function() { | |
return (new webkitRTCPeerConnection({ | |
"iceServers": [{"url": "stun:stun.l.google.com:19302"}] | |
})); | |
}, | |
/** | |
* videoにカメラを接続 | |
*/ | |
startVideo: function() { | |
var video; | |
if (this.option && this.option.videoElementId) { | |
video = document.getElementById(this.option.videoElementId); | |
} else { | |
console.log("no video!!"); | |
} | |
navigator.webkitGetUserMedia({video: true, audio: true}, | |
(function(stream) { | |
video.src = window.URL.createObjectURL(stream); | |
this.localStream = stream; | |
this.isStartVideo = true; | |
}.bind(this)), | |
(function(err) { | |
this.isStartVideo = false; | |
console.log(err); | |
}.bind(this)) | |
); | |
}, | |
/** | |
* SDPを生成する | |
* @param {String} sdpType: offer or answer | |
* @param {Function} callback | |
*/ | |
createSdp: function(sdpType, callback) { | |
var createSdpCallback = function(sdp) { | |
this.peer.setLocalDescription(sdp, | |
function() { | |
callback(sdp); | |
}, | |
function(err) { | |
console.log(err); | |
} | |
); | |
}.bind(this); | |
if (sdpType === "offer") { | |
this.peer.createOffer(createSdpCallback); | |
} else if (sdpType === "answer") { | |
this.peer.createAnswer(createSdpCallback); | |
} | |
}, | |
/** | |
* SDPを受け取る | |
* @param {Object} sdp | |
*/ | |
receiveSdp: function(sdp) { | |
var remoteSdp = new RTCSessionDescription(sdp); | |
this.peer.setRemoteDescription(remoteSdp, | |
function() { | |
if (remoteSdp.type === "offer") { | |
console.log("receive offer"); | |
} | |
if (remoteSdp.type === "answer") { | |
console.log("receive answer"); | |
} | |
}, | |
function(err) { | |
console.log(err); | |
} | |
); | |
}, | |
/** | |
* 経路情報を受け取る | |
* @param {Object} candidate | |
*/ | |
receiveCandidate: function(candidate) { | |
if (candidate) { | |
var candidate = new RTCIceCandidate(candidate); | |
this.peer.addIceCandidate(candidate); | |
} else { | |
console.log("finish candidate!!"); | |
} | |
} | |
}); | |
</script> | |
<script src="http://localhost:8888/socket.io/socket.io.js"></script> | |
<script type="text/javascript"> | |
var WebrtcTest = Class.extend({ | |
// 初期化 | |
init: function() { | |
this.socket = null; | |
this.userId = null;// 自身のsocket接続ID | |
this.messageTypes = {};// 通信するメッセージのタイプ | |
this.webRtcInstances = {};// WebRTCのインスタンスたち | |
this.localInstanceKey = 'local';// ローカルのWebRTCインスタンスのキー | |
this.socketReadyState = false;// socket接続ステータス | |
this.connectGroup = location.search;// socket接続グループ: 接続するグループは一旦クエリストリングで決める | |
this.connectUrl = 'http://localhost:8888/'; | |
}, | |
// コネクションをはる | |
createConnection: function() { | |
this.socket = io.connect(this.connectUrl); | |
this.socket.on('connect', this.onConnect.bind(this)); | |
this.socket.on('open', this.onOpen.bind(this)); | |
this.socket.on('message', this.onMessage.bind(this)); | |
}, | |
// コネクション確立時の処理 | |
// @param {Object} event | |
onConnect: function(event) { | |
if (!this.socketReadyState) { | |
this.socket.emit('open', {connectGroup: this.connectGroup});// 接続成功時、接続先のグループを通知する | |
} | |
this.socketReadyState = true; | |
}, | |
// コネクション確立後の処理 | |
// @param {Object} event | |
onOpen: function(event) { | |
this.messageTypes = event.messageTypes; | |
this.userId = event.userId; | |
this.sendMessage(this.messageTypes.allCall, {fromUserId: this.userId});// 接続グループ全員に、新たな接続者がいることを通知する | |
}, | |
// ローカルのWebRTCのインスタンスを取得する | |
// @return {Object} WebRTCインスタンス | |
getLocalInstance: function() { | |
return this.getWebrtcInstance(this.localInstanceKey); | |
}, | |
// WebRTCのインスタンスを取得する | |
// @param {String} id | |
// @return {Object} WebRTCインスタンス | |
getWebrtcInstance: function(id) { | |
if (!this.webRtcInstances[id]) { | |
this.webRtcInstances[id] = this.createWebrtcInstance(id); | |
if (id !== this.localInstanceKey) {// 共有するためにローカルのストリームを接続する | |
this.webRtcInstances[id].localStream = this.webRtcInstances[this.localInstanceKey].localStream; | |
} | |
} | |
return this.webRtcInstances[id]; | |
}, | |
// WebRTCのインスタンスを生成する | |
// @param {String} id | |
// @return {Object} WebRTCインスタンス | |
createWebrtcInstance: function(id) { | |
var videoElementId = this.getVideoElementId(id); | |
var video = document.createElement('video'); | |
video.id = videoElementId; | |
video.autoplay = true; | |
document.getElementsByTagName('body')[0].appendChild(video); | |
return new Webrtc({ | |
videoElementId: videoElementId, | |
iceCandidateCallback: (function(candidate) { | |
this.sendMessage(this.messageTypes.candidate, {sendto: id, fromUserId: this.userId, candidate: candidate}); | |
}.bind(this)) | |
}); | |
}, | |
// video要素のIDを取得する | |
// @param {String} id | |
// @return {String} video要素のID | |
getVideoElementId: function(id) { | |
return 'video-' + id; | |
}, | |
// メッセージ受信処理 | |
// @param {Object} event | |
onMessage: function(event) { | |
console.log(event); | |
// 他のユーザーがログインしたときの応答処理 | |
if (this.messageTypes.allCall === event.param.type) { | |
console.log("group all call"); | |
var fromUserId = event.param.fromUserId; | |
this.sendMessage(this.messageTypes.callAnswer, {sendto: fromUserId, fromUserId: this.userId});// 応答する | |
} | |
// ログイン時に他ユーザー全員にコールしたときの応答があったときの処理 | |
if (this.messageTypes.callAnswer === event.param.type) { | |
console.log("call answer"); | |
var fromUserId = event.param.fromUserId; | |
var webrtc = this.getWebrtcInstance(fromUserId); | |
if (!webrtc.connect()) { | |
return false;// videoをオンにしてないときとか | |
} | |
webrtc.createSdp("offer", function(sdp) { | |
this.sendMessage(this.messageTypes.offer, {sendto: fromUserId, fromUserId: this.userId, sdp: sdp});// 応答があった人へオファー | |
}.bind(this)); | |
} | |
// オファーを受け取って、アンサーを返す | |
if (this.messageTypes.offer === event.param.type) { | |
console.log("offer"); | |
var fromUserId = event.param.fromUserId; | |
var sdp = event.param.sdp; | |
var webrtc = this.getWebrtcInstance(fromUserId); | |
if (!webrtc.connect()) { | |
return false;// videoをオンにしてないときとか | |
} | |
webrtc.receiveSdp(sdp); | |
webrtc.createSdp("answer", function(sdp) { | |
this.sendMessage(this.messageTypes.answer, {sendto: fromUserId, fromUserId: this.userId, sdp: sdp});// オファーがきたのでアンサー | |
}.bind(this)); | |
} | |
// アンサーを受け取る | |
if (this.messageTypes.answer === event.param.type) { | |
console.log("answer"); | |
var fromUserId = event.param.fromUserId; | |
var webrtc = this.getWebrtcInstance(fromUserId); | |
var sdp = event.param.sdp; | |
webrtc.receiveSdp(sdp); | |
} | |
// 経路情報を受け取る | |
if (this.messageTypes.candidate === event.param.type) { | |
console.log("receive candidate"); | |
var fromUserId = event.param.fromUserId; | |
var webrtc = this.getWebrtcInstance(fromUserId); | |
webrtc.receiveCandidate(event.param.candidate);// 飛んできた経路情報を取得 | |
} | |
}, | |
// メッセージを通知する | |
// @param {String} messageType | |
// @param {Object} param | |
// @param {Function} callback | |
sendMessage: function(messageType, param, callback) { | |
param.type = messageType; | |
this.socket.emit('message', {messageType: messageType, param: param, callback: callback}); | |
} | |
}); | |
var webrtcTest = new WebrtcTest(); | |
var localVideo = webrtcTest.getLocalInstance(); | |
localVideo.startVideo(); | |
</script> | |
</body> | |
</html> |
上記のソースをDocumentRoot以下に配置し、ブラウザからアクセスして、動作確認をしてみる。
1. 特定のURLへアクセス(筆者はここhttp://localhost/index.html
![]() |
図3. カメラ・マイクへのアクセス確認 |
2. カメラの表示確認
![]() |
図4. カメラ動画表示 |
そして、「ここを押したら繋がるけどカメラを先に許可しといてね」ボタンを押すことで、シグナリングサーバーへアクセスします。
(自分がページを開いてるよ。ってことを通知してるだけで、ここの手順では何も起こりません。)
※上記手順の1〜4です。
3. リモートのカメラ画像を表示する
![]() |
図5. リモートのカメラ動画表示 |
終わりに。
タブを複数開いて通知確認しながらテストしてたのですが、頭がこんがらがるし、結構たいへんでした。シグナリングサーバーを応用! 「WebRTCを使って複数人で話してみよう」を参考にさせて頂きました!
つぎこそはDataChannelやるぞー!
以上!
登録:
コメントの投稿
(
Atom
)
0 件のコメント :
コメントを投稿