Skywayいろいろ @ 2015/10/12 ■触る前の前提知識 ・JavaScriptは一通り。 ・WebRTCについては「P2Pで通話できるのねー」くらいの知識。ICE?SDP?なにそれおいしいの状態。 ■作ってみた https://ffxiv.phone.exdreams.net/ ・ボイスチャット ・テキストチャット ・ホワイトボード ・画面共有(お蔵入り) ・IE対応(プラグイン使って対応。Win7+IE11にて確認) ※ホワイトボードは fabric.js を利用。  これは描画したものを object として扱えるため、そのデータを DataChannel を使ってやり取りする。  以下では触れない。 動けばOKで作ってるのでコードは全然洗練されてない状態。 限界までぐちゃぐちゃのまま作るんだろうなー俺。 ■使ったもの ・MultiParty をベースに作成。 https://github.com/nttcom/SkyWay-MultiParty https://github.com/nttcom/peerjs/wiki/IE-and-Safari-plugin%E5%B0%8E%E5%85%A5%E6%89%8B%E9%A0%86 ■やってみていろいろ @データコネクションの接続直後のシーケンス ・つながらないことがある。 multiparty の dc_open イベント内で send しても相手に届かないことがあるっぽい? 仕方ないので、ポーリングを行って何回もリクエストを送るようにした。 [通信シーケンス] dc_open イベント発生直後 (送信側) (受信側) send: hello ------> recv: hello recv: notifyUserInfo <------ send: notifyUserInfo [対策] ・dc_open 直後に hello 送信 ・3秒ごとに hello の回答(notifyUserInfo)が来ていなかったら hello を再送信 ・notifyUserInfo を受け取ったらフラグを立てる(これでポーリングの停止) 大抵はdc_open直後もしくは1回目再送信で通信が確立できるので、リトライ回数は適当に制限した方が良い。 なお、TURNサーバを経由しないと通信できないユーザに対しては、リトライが止まらなかった。 (が、ポーリングは始まっていたので、TURN経由なしでもデータコネクションは確立できていた?) @画面共有 ・複数人での接続だと重すぎる https://github.com/nttcom/SkyWay-ScreenShare 送信側は chrome + 拡張機能。 受信側のブラウザ環境は不明(適当に人集めたため)。 3人くらいまでであればなんとかなるが、CPU負荷がどんどんあがっていった。 最終的はPC固まりかけた。 →→仮想カメラデバイスでデスクトップ取り込み+getUserMediaでとってくるvideo使って画面共有、 であれば、CPU負荷は比較的抑えられて7−8人まではいけそうな感じ。 ただ、タスクマネージャで見た限りの話で、こっちの方はPCのファンがぶぉーんってなるのが早い気がする。 (GPUが頑張ってるとかでCPU使用率には影響ないところでやばい…とか?) ・実装:共有開始 ---------------------------------------------------------------------------------------------------------- multiparty.startScreenShare(function(stream) { // success callback var ret = addVideoView(multiparty.peer.id, null);  ★ video タグ作って配置するメソッド srcはここでは設定してない。 var vid = multiparty.peer.id + "_video"; attachMediaStream($("#" + vid)[0], stream); ★これで「自分の画面」が自分のブラウザに出る }, function(err) { }); ---------------------------------------------------------------------------------------------------------- ・実装:画面共有を受信 ---------------------------------------------------------------------------------------------------------- multiparty.on('peer_ss', function(ss) { addVideoView(ss.id, ss.src); ★ videoタグ作って配置するメソッド。 srcはここでは設定する。するだけで画面が出る。 自分の画面の方も同じ方法で行けるかも(streamからsrcはどうやって作るかは要確認) }); ---------------------------------------------------------------------------------------------------------- ・実装:共有停止 ---------------------------------------------------------------------------------------------------------- multiparty.stopScreenShare(); ---------------------------------------------------------------------------------------------------------- @聞き専対応 ・初期化時の流れ multiparty では、自分がある部屋に入った時に、先に人がいる場合は、自分からその人に対して call する動きになっている。 のだが、聞き専のユーザ=マイクがないユーザは stream が取れないため、call が呼び出せない。 それに対して、call の回答となる answer メソッドはストリームなしでも動作するため、 データコネクションを利用して、相手にかけてもらうようにする。 ・実装:multiparty.on("dc_open") ※データコネクション確立時 ---------------------------------------------------------------------------------------------------------- multiparty.on("dc_open", function(peerId) { //hello発行 multiparty.sendToPeer({"type": "hello", "data": { ★multiparty#sendを拡張。特定の peer にだけメッセージを送る。 "userName": userName, "requestCall": isRequestCall, ★相手からかけてもらう必要がある場合に true を発行する }}, peerId); ★前項の通り、相手に届くまで再送してる。 }); ---------------------------------------------------------------------------------------------------------- ・実装:hello 受信 ---------------------------------------------------------------------------------------------------------- multiparty.on("message", function(msg) { if ("hello" == msg.data.type) { if (msg.data.data.requestCall) { setupRequestCall(msg.id); } ... function setupRequestCall(peerId) { if (isRequestCall) { //自分もコールが必要=マイクデバイスがないなら何もしない console.log("not has mic device"); return; } multiparty.startCallToPeer(peerId); } ---------------------------------------------------------------------------------------------------------- ・実装:multiparty#startCallToPeer ---------------------------------------------------------------------------------------------------------- //指定の peer に対して call する MultiParty_.prototype.startCallToPeer = function(peerId) { var self = this; if (null == self.peers[peerId]) { console.log("startCallToPeer: unknown peerId: " + peerId); return; } if (null != self.peers[peerId].call) { console.log("startCallToPeer: already calling: " + peerId); return; } console.log("... peer.call to " + peerId + " (called from generic media exchange startCallToPeer)"); var call = self.peer.call(peerId, self.stream); self.peers[peerId].call = call; self.setupStreamHandler_(call); } ---------------------------------------------------------------------------------------------------------- @ボリュームメーター ---------------------------------------------------------------------------------------------------------- var analyzer = null; function checkMediaVolume() { // audio context 確認(これはマイクなくても作ってる) if (null != multiparty.exCtx) { ★ multiparty#startMyStream_ の中で AudioContext を作っているので、それを使い回し。 if (null == analyzer) { analyzer = multiparty.exCtx.createAnalyser(); ★ メソッド名は analy"s"er なので注意。どっちも間違いではない。 } var src = multiparty.exMic; src.connect(analyzer); var len = analyzer.frequencyBinCount; var data = new Uint8Array(len); analyzer.getByteTimeDomainData(data); var sum = 0; for (var i = 0;i < len;i++) { sum = Math.max(Math.abs(data[i] - 128), sum); } } requestAnimationFrame(checkMediaVolume); } requestAnimationFrame(checkMediaVolume); ---------------------------------------------------------------------------------------------------------- requestAnimationFrameは、ざっくり言うと描画のスキマに呼び出すメソッド。 普通にタイマーとかでも十分だと思われる。 ちなみに、相手のボリュームについては、 var peerMic = multiparty.exCtx.createMediaStreamSource(multiparty.peers[peerId].call.localStream); で analyzer かければいける。 のだが、WebRTCの制約?により、リモートストリームに対してはうまく動作しないらしい。 @ボリューム調整 ・自分のマイクボリューム chrome のみ動作。 ---------------------------------------------------------------------------------------------------------- multiparty.gainNode_.gain.value = (0 - 1); ---------------------------------------------------------------------------------------------------------- 0-1の範囲で調整。と見せかけて、もっと上まで設定可能。 chrome では大きい数字を設定すればちゃんとマイクボリュームが大きくなる。 ただしノイズも増えてくるので注意。3までは動作確認済。 ・相手のボリューム ---------------------------------------------------------------------------------------------------------- multiparty.on('peer_ms', function(video) { var vNode = MultiParty.util.createVideoNode(video); ... vNode.volume = (0-1); ---------------------------------------------------------------------------------------------------------- peer_ms イベント(peer からのストリーム到着)で video オブジェクトを作成してやることで、 相手の声が聞こえるようになる。 そのオブジェクトに対して volume を設定する。これは WebRTC とかではなく HTML5 の世界。 これはマイクボリュームと違って 0-1 の範囲のみ。 IEでは動作しなかった。 ・エコー出力(自分の声を自分で聞く) ---------------------------------------------------------------------------------------------------------- multiparty.on('my_ms', function(video) { myVideo = MultiParty.util.createVideoNode(video); ... myVideo.volume = (0-1); ---------------------------------------------------------------------------------------------------------- 相手のボリュームと同じ。 何もせずに video ノード作ると、たぶん volume = 1 になっているので、 不要なときは明示的に 0 を設定してやるように。 @ボイスチェンジャー どこからか引用してきた…どこだったか記憶にない… ---------------------------------------------------------------------------------------------------------- multiparty#startMyStream_ ... self.gainNode_ = audioContext.createGain(); var mic = audioContext.createMediaStreamSource(stream); var peer = audioContext.createMediaStreamDestination(); var scriptProcessor = audioContext.createScriptProcessor(1024, 1, 1); scriptProcessor.addEventListener('audioprocess', onAudioProcess); ... function onAudioProcess(e) { var input = e.inputBuffer.getChannelData(0); var output = e.outputBuffer.getChannelData(0); var len = output.length; var bufferData = new Float32Array(len); for (var i = 0;i < len;i++) { bufferData[i] = ( (input[(i * 2) % len] + input[(i * 2 + 1) % len]) / 2 ); } output.set(bufferData); } ---------------------------------------------------------------------------------------------------------- @getUserMedia周りいろいろ ・IE対応:AudioContextが使えない multiparty#startMyStream_ で getUserMedia でストリームを取った後に GainNode 生成してあれこれやっているが、 IEではそもそも AudioContext が使えないため、ここで止まってしまう。 ボリューム調整は出来ないが、通話はできるようにするための実装↓ ---------------------------------------------------------------------------------------------------------- navigator.getUserMedia_({"video": self.opts.video_stream, "audio": self.opts.audio_stream}, function(stream) { if ("undefined" == typeof AudioContext) { self.stream = stream; } else { //Set up AudioContext and gain for browsers that support createMediaStreamSource properly //Use the regular stream directly if it doesn't. var audioContext = new AudioContext(); self.gainNode_ = audioContext.createGain(); var mic = audioContext.createMediaStreamSource(stream); var peer = audioContext.createMediaStreamDestination(); .... ---------------------------------------------------------------------------------------------------------- ・再現できなくてちょっとうろ覚え:カメラデバイスがない場合の動作 仮想カメラ導入してしまったので、もう確認できない(アンインストール面倒)が、 カメラがなくても stream.getVideoTracks() == true が成立していたっぽい (chrome)。 ---------------------------------------------------------------------------------------------------------- // if(stream.getVideoTracks()){ ★ここを↓こうした if (stream.getVideoTracks() && stream.getVideoTracks().length >= 1) { peer.stream.addTrack(stream.getVideoTracks()[0]); } ---------------------------------------------------------------------------------------------------------- ・IE対応:src作成 ---------------------------------------------------------------------------------------------------------- self.fire_('my_ms', {"src": URL.createObjectURL(self.stream), "id": self.opts.id}); ---------------------------------------------------------------------------------------------------------- IEさんは「SCRIPT16386: インターフェイスがサポートされていません」とのこと。 メソッドがないわけではなく、このストリームの種類?に対応していないっぽい? 仕方ないのでこれもあきらめる。 ただ、あきらめるだけだと出力ができないので、stream を返却するように変更。 ---------------------------------------------------------------------------------------------------------- if ("undefined" == typeof AudioContext) { self.fire_('my_ms', { "src": null, "id": self.opts.id, "stream": self.stream }); } else { self.fire_('my_ms', { "src": URL.createObjectURL(self.stream), "id": self.opts.id, "stream": self.stream }); } ---------------------------------------------------------------------------------------------------------- ※判定に AudioContext 使っているけどあんまり関係ない。URL.createObjectURL からの例外送出有無で判断するほうがいいか? srcがないと、今度は MultiParty#createVideoNode にて video ノードが作れなくなるので、 そこは attachMediaStream してあげる必要がある。 ---------------------------------------------------------------------------------------------------------- if (null == video.src) { //IE, safari document.body.appendChild(v_); $("#" + video.id)[0] = attachMediaStream($("#" + video.id)[0], video.stream); $("#" + video.id).hide(); } else { v_.setAttribute("src", video.src); } ---------------------------------------------------------------------------------------------------------- @IE対応:webRTCReadyを呼び出すタイミング ・AdapterJS.webRTCReady の記述 ドキュメントは以下の通り。 ---------------------------------------------------------------------------------------------------------- WebRTC APIを呼ぶ際は下記のようなコールバック関数(AdapterJS.webRTCReady)を記述します AdapterJS.webRTCReady(function(isUsingPlugin) { // The WebRTC API is ready. //isUsingPlugin: true is the WebRTC plugin is being used, false otherwise getUserMedia(constraints, successCb, failCb); }); ---------------------------------------------------------------------------------------------------------- これ自体に間違いはないのだが、peer.js を使う場合は少し罠があって、 new Peer() をする前に呼び出す必要がある。 Peer のコンストラクタの中で WebRTC をサポートしているかどうかのチェックを行っているため。 multiparty.js を使う場合は、conn2SkyWay_ メソッド内で Peer を作っているので、 それ以前にこの処理を行う必要がある。 ---------------------------------------------------------------------------------------------------------- MultiParty_.prototype.conn2SkyWay_ = function() { var self = this; AdapterJS.webRTCReady(function(isUsingPlugin) { self.peer = new Peer(self.opts.id, self.opts.peerjs_opts); ---------------------------------------------------------------------------------------------------------- @IE対応:データコネクションの扱い https://github.com/nttcom/peerjs/wiki/IE-and-Safari-plugin%E5%B0%8E%E5%85%A5%E6%89%8B%E9%A0%86 ちゃんと書いてあるけど、理解していない状態で見ても意味が分からなかったので見落とした。 「動作対象」に「DataChannel」という項があって、Win7+IE9/10は「Strings Only」と書いてある。 これはつまり、データは文字列でしか扱えないという意味。 https://github.com/nttcom/SkyWay-MultiParty mulriparty 生成時の options に、【selialization: "json"】を指定することで、 文字列でのデータ送信となる。 これをやらないと、IEではデータによって送受信できたりできなかったりする模様。 (チャットメッセージ等は送信可能、少し複雑な構造を持つオブジェクトだと不可など) @IE対応:videoタグ生成直後の状態 ・そもそもIEは video タグではない。 プラグインが object タグに置き換えて頑張っている模様。 で、video#volume が無い。ので音量調整ができない…。 他のやり方はありそうだが未調査。 ・初期状態のステータス chromeとか:volumeが設定されていてすぐに聞ける IE:いわゆるミュート状態 なので、同じコードを書いても動きが微妙に異なる。 タグで明示できればよいが、とりあえず生成直後に以下のコードで対応。これでmute状態ではなくなる。 ---------------------------------------------------------------------------------------------------------- var audioObj = null; try { audioObj = stream.getAudioTracks()[0]; audioObj.enabled = true; audioObj.muted = false; } catch (e) { } ---------------------------------------------------------------------------------------------------------- それ以前が間違っている可能性もあるので、タグ構築時のコードも添付。 ---------------------------------------------------------------------------------------------------------- var html = sprintf( "
" + "{1}" + "" + "
", [ peerId, name, (isStreamMode ? "" : " src='" + src + "'"), (isMyObject ? " video_thumb_me" : ""), (isShow ? "" : "style=\"display: none\"") ]);★sprintfは独自実装。IEの場合は isStreamMode = true。 if (isMyObject) { //自分のオブジェクトであれば一番前に追加 $(html).prependTo("#video_list"); } else { //そうでなければ最後に追加 $(html).insertBefore("#video_none"); } video = $("#" + videoId)[0]; //ストリームモードの場合、ストリームを設定 if (isStreamMode) { video = attachMediaStream(video, stream); } var played = false; video.addEventListener("loadedmetadata", function(ev) { if (!played) { played = true; this.play(); } }, false); // since FF37 sometimes doesn't fire "loadedmetadata" // work around is after 500msec, calling play(); setTimeout(function(ev) { if (!played) { played = true; video.play(); } }, 500); ---------------------------------------------------------------------------------------------------------- @AdapterJSにおけるUA判定(実害はなさそう…?) "mozGetUserMedia"が使えるのは Firefox、"webkitGetUerMedia"が使えるのは chrome、と決め打ちしてる。 実害はおそらく無いが、chrome開発者コンソールにおいて、端末エミュレータで「Galaxy S3」等を選択すると、 UAに「chrome」が入っていないにもかかわらず "webkitGetUserMedia" が利用できるようになるため、 AdapterJSでエラーが発生する。 ---------------------------------------------------------------------------------------------------------- if (navigator.mozGetUserMedia) { webrtcDetectedBrowser = 'firefox'; webrtcDetectedVersion = parseInt(navigator .userAgent.match(/Firefox\/([0-9]+)\./)[1], 10); ... } else if (navigator.webkitGetUserMedia) { webrtcDetectedBrowser = 'chrome'; webrtcDetectedType = 'webkit'; webrtcDetectedVersion = parseInt(navigator .userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)[2], 10); ... ---------------------------------------------------------------------------------------------------------- @おまけその1:あんまり関係ないけど jquery ui ・複数のスライダーを動的に配置すると動作しない https://jqueryui.com/slider/ 結局原因わからなくて自前でスライダー作った…。 @おまけその2:棒読みちゃん chromeのみ。 ---------------------------------------------------------------------------------------------------------- var synthes = new SpeechSynthesisUtterance(); synthes.voiceURI = "Google 日本語"; synthes.lang = "ja-JP" synthes.volume = (0 <= n <= 1); synthes.rate = (0 < n <= 2); synthes.pitch = (0 < n <= 2); ★0 <= n かも。 synthes.text = "読み上げテキスト"; speechSynthesis.speak(synthes); ---------------------------------------------------------------------------------------------------------- @おまけその3:ZIP圧縮 https://stuk.github.io/jszip/ ・実装:圧縮 ---------------------------------------------------------------------------------------------------------- var zip = new JSZip(); var content = null; zip.file(fileName, data); content = zip.generate({ type : "string", compression: "DEFLATE", compressionOptions : {level:6} }); ---------------------------------------------------------------------------------------------------------- ・実装:展開 ---------------------------------------------------------------------------------------------------------- var zip = new JSZip(); var content = null; zip.load(data); content = zip.file(fileName).asText(); ---------------------------------------------------------------------------------------------------------- @おまけその4:ファイル強制ダウンロード ---------------------------------------------------------------------------------------------------------- var a = document.createElement("a"); document.body.appendChild(a); a.download = fileName; a.href = content; a.target = "_self"; a.click(); ---------------------------------------------------------------------------------------------------------- a タグ作ってそれをクリックしているだけ。 download 属性をつけることで、強制ダウンロードとなる。 確か appendChild がないと firefox では動作しなかったはず。 content にデータURLを設定すれば、その内容に応じたファイルのダウンロードが可能となる。 作ったシステムでは以下のような流れ。 [送信側] ファイルドロップ →ZIP圧縮 →したデータをデータURLに変換 →して送信 [受信側] ダウンロードリンク作成。 ※大容量ファイルとか送ると大変なことになるかもなので注意。いいとこ数MB。 ※スマホとかだと通信料金に直接かかわるかもなのでさらに注意。