WebRTCを用いた遠隔作業支援システムを作ります。 前回 はスマートグラス(Vuzix M100)とサーバサイド(node.js + express + peerjs-server)の環境構築について説明しました。今回はCoffeeScriptで記述されたモジュールの全体構成と、PeerJS & MediaStreamの初期化処理について解説します。
CoffeeScript
WebRTCを用いた遠隔作業支援システムの中心的なロジックは、ブラウザで動作します。今回はコールバックを駆使するそれなりに複雑な処理を実装するため、画面操作をハンドリングするロジック、PeerJSの操作とコールバックをハンドリングするロジック、シグナリング等の共通ロジック、と責務を分割して実装したほうが見通しが良くなるでしょう。
ただしJavaScriptはプロトタイプベースのオブジェクト指向言語のため、JavaやC#のようなクラスベースのオブジェクト指向言語が備えるクラス継承によるモデル化を行うのは面倒です(できないわけではありませんが)。またメソッド呼び出しとコールバック処理が混在するため、コンテキストによって this が指し示すオブジェクトが変わるというJavaScriptの特性にも注意を払う必要があります。
そこで今回は、 CoffeeScript でロジックを実装することにします。CoffeeScriptはコンパイルによってJavaScriptに変換される小さな高級言語で、クラス宣言構文や便利な演算子などJavaScriptの言語仕様を拡張し、変数の巻き上げやコンテキストによるthisの違いなどJavaScript特有のクセのある仕様を隠蔽してくれるため、今回の実装にはうってつけです。
検証したデバイス
本記事のソースコードは、下記の環境で検証しています。
- 監視端末
デバイス | OS | ブラウザ | バージョン |
---|---|---|---|
Macbook Air Mid 2011 | Mac OS X 10.7.5 | Google Chrome | 35.0.1916.153 |
- スマートグラス(ブラウザ対応状況の確認とデバッグ用にスマートフォンでも検証)
デバイス | OS | ブラウザ | バージョン |
---|---|---|---|
Vuzix M100 | Android 4.0.4 | Google Chrome for Android | 35.0.1916.141 |
HTC J One HTL22 | Android 4.2.2 | Google Chrome for Android | 35.0.1916.141 |
Firefox for Android | 30.0 | ||
Opera for Android | 22.0.1485.78487 |
モジュール構成
今回の遠隔作業支援システムのクライアント側コードは、下記のファイルから構成されています。
※詳細なソースコードは github をご確認ください。
device.html | スマートグラス用のHTMLファイル |
monitor.html | 監視端末用のHTMLファイル |
javascripts/remote-monitor.js | PeerJS関連のロジック BaseClass、DeviceClass、MonitorClassから構成される remote-monitor.coffee から生成 |
javascripts/device.js | スマートグラスの画面操作をハンドリングするロジック device.coffee から生成 |
javascripts/monitor.js | 監視端末の画面操作をハンドリングするロジック monitor.coffee から生成 |
javascripts/lib/peer.js | PeerJS自身 |
stylesheets/style.css | MediaQuery付きでスタイルシート |
remote-monitor.js、device.js、monitor.jsは、coffee-script 1.7.1を用いて下記コマンドでCoffeeScriptのソースコードから生成します。
$ coffee -c xxx.coffee
CoffeeScriptの詳細な文法やコンパイルオプションは 公式サイト を確認してください。
HTMLファイル
device.html や monitor.html にはロジックを記述しません。ロードすべきJavaScriptと画面構成を定義することがHTMLファイルの責務です。(詳細はdevice.htmlとmonitor.htmlのソースコードを参照ください)。
JavaScriptのロード
ロードすべきJavaScriptは、HTMLファイルのheadに定義しています。
CDNからjQuery2.1.1、自身のhttpdからpeer.js、remote-monitor.jsをロードした後、device.htmlであればdevice.js、monitor.htmlであればmonitor.jsをロードします。
※今回は利用しませんでしたが、 RequireJS 等のモジュールローダーを利用しても良いでしょう。
画面構成
スマートグラスや監視端末の画面表示は状況によって変化しますが、全ての画面コンポーネントはdevice.htmlやmonitor.htmlに押し込められており、何らかのイベント発生時に画面コンポーネントの表示/非表示を適切に切り替えることで画面表示を切り替えています。
remote-monitor.coffee
remote-monitor.coffeeでは、PeerJSの操作を行うクラスを定義します。
シグナリング処理等のスマートグラス側・監視端末側双方で共通で利用するロジックはBaseClassに、スマートグラス側だけで利用するロジックはBaseClassを継承したDeviceClassへ、監視端末側だけで利用するロジックはMonitorClassへ実装します。
HOST = 'xxx.xxx.xxx.xxx' # シグナリングサーバのIPアドレスやホスト名 PORT = 9000 # シグナリングサーバが立ち上がっているポート PATH = '/remote-monitor' # シグナリングサーバ立ち上げ時に指定したAPI Prefix DEBUG = 3 CONF = iceServers: [{ url: 'stun:stun.l.google.com:19302' }, { url: 'turn:homeo@turn.bistri.com:80', credential: 'homeo' }] this.ns = {} # 公開オブジェクトのホルダー class BaseClass # BaseClassの定義 ... class DeviceClass extends BaseClass # BaseClassを継承したDeviceClassの定義 ... class MonitorClass extends BaseClass # BaseClassを継承したMonitorClassの定義 ... this.ns.DeviceClass = DeviceClass # 公開オブジェクトホルダーにDeviceClassをセット this.ns.MonitorClass = MonitorClass # 公開オブジェクトホルダーにMonitorClassをセット
DeviceClassとMonitorClassの公開
JavaScriptの言語仕様には名前空間を設定する機能がありません。そのため何も考えずに変数を宣言すると、グローバルな名前空間にその変数が設定されるため、他のスクリプトへ悪影響を及ぼす場合があります。そこでCoffeeScriptは通常、スクリプト全体を即時関数に閉じ込め、スクリプト内で生成したオブジェクトはグローバルな名前空間には公開しないJavaScriptを生成します(--bareオプションを付けてコンパイルすることでこの機能を抑制することも可能ですが)。
ただし今回は、特定のオブジェクト(DeviceClassとMonitorClass)だけは他のスクリプトから参照できなければなりません。上記のようにremote-monitor.coffeeを記述した場合、トップレベルのthisはグローバルオブジェクト(ブラウザの場合はwindow)を指しますので、 this.ns = {} として公開オブジェクトを保持するためのホルダー ns をグローバル名前空間に設定し、他スクリプトから参照したいDeviceClassとMonitorClassを ns のプロパティとして持たせることでこの問題を解決します。
シグナリングサーバの定義
シグナリングサーバのIPアドレス(やホスト名)とポートは、remote-monitor.cofeeの冒頭に定義します。 前回 構築したサーバのIPアドレス(やホスト名)とポートを適切に設定してください。
STUNサーバやTURNサーバの定義
スマートグラスと監視端末が同一ネットワークに所属していない場合、STUNサーバやTURNサーバが必要となります。上記のようにSTUNサーバやTURNサーバのURLを配列として定義してください。
※STUNサーバやTURNサーバを明示的に与えなかった場合、現在のPeerJSの実装ではGoogleのSTUNサーバが利用されます。
device.coffee
スマートグラスの画面操作をハンドリングするロジックを記述します。
画面コンポーネントを操作するコールバック関数とボタンクリック時の処理を定義した後、DeviceClassの各種イベント処理へコールバック関数を設定し、MediaStreamの初期化処理を呼び出します。
$ -> dc = new ns.DeviceClass() # DeviceClassのインスタンス化 ## 画面コンポーネントを操作するコールバック関数の定義 ... ## ボタンクリック時の処理定義 ... ## DeviceClassの各種イベント処理へ画面コンポーネントを操作するコールバック関数を設定 ... ## MediaStreamの初期化処理を呼び出す ...
"$ ->" というのはCoffeeScriptでjQueryを用いる際の定石で、以下のよく見るコードと同様、Documentのロードが完了後に記述した処理が実行されることになります。
- device.js相当(device.coffeeから実際にコンパイルされたJavaScriptとは異なります)
$(function() { ... });
monitor.coffee
監視端末の画面操作をハンドリングするロジックを記述します。
device.coffeeとほぼ同様の構成ですが、キャプチャした画面にフリーハンドで指示を描く機能を実装するために、各種ローカル変数の定義や画面キャプチャ処理、マウス操作に追随して線を描く処理などが追加実装されています。
$ -> mc = new ns.MonitorClass() # MonitorClassのインスタンス化 ## ローカル変数の定義 ... ## 画面コンポーネントを操作するコールバック関数の定義 ... ## ボタンクリック時の処理定義 ... ## カメラ映像のキャプチャとフリーハンドでの指示を描く処理 ... ## MonitorClassの各種イベント処理へ画面コンポーネントを操作するコールバック関数を設定 ... ## MediaStreamの初期化処理を呼び出す ...
PeerJSの初期化とMediaStream取得までの処理フロー
お待たせしました。それでは遠隔作業支援システムの処理フローと実装をステップを追って確認しましょう。
まずはPeerJSの初期化とMediaStream取得までです。以下の3つのステップで処理が行われます。
- HTMLとJavaScriptの取得
- PeerJSの初期化
- MediaStreamの取得
1. HTMLとJavaScriptの取得
第二回 で構築したnode.jsとexpressから、必要なHTML/JavaScript/CSSを取得します。
上記モジュール構成のファイル群をpublicディレクトリ配下に置くだけで、expressがリクエストに応じて配信します。
スマートグラスと監視端末それぞれで行う処理で前後関係はありませんので、お互い非同期に実行してかまいません。
2. PeerJSの初期化
device.coffeer冒頭の new ns.DeviceClass() や、monitor.coffee冒頭の new ns.MonitorClass() によってDeviceClsssやMonitorClassがインスタンス化されます。その際各々のクラスのconstructorが実行されますが、実質の処理はBaseClassのconstructorに委譲されています。
... class BaseClass constructor: -> @peer = new Peer {host:HOST, port:PORT, path:PATH, debug:DEBUG, config:CONF} ... class DeviceClass extends BaseClass constructor: -> super() ... class MonitorClass extends BaseClass constructor: -> super() ...
BaseClassのconstructorでPeerオブジェクトを生成しインスタンス変数( @peer )へ保持しますが、PeerJSはこのタイミングでpeerjs-serverに自身を登録し、WebSocketを開設します。
peerjs-serverでは接続してきたPeerごとにユニークIDを採番した後、WebSocket経由で @peer.on('open', callback) イベントが発生し、=>で定義したcallback関数が呼び出されます。
class BaseClass ... onOpen: (peerIDsetting = null) -> @peer.on 'open', => console.log "peer.on 'open' peer.id=#{@peer.id}" peerIDsetting(@peer.id) if peerIDsetting? ...
device.coffeeでは、画面に採番されたユニークIDを表示するコールバック関数( peerIDsetting() )を DeviceClass.onOpen() の引数として渡しているため、ユニークIDが画面に表示されます。
$ -> dc = new ns.DeviceClass() peerIDsetting = (id) -> $('#device-id').text(id) ... dc.onOpen(peerIDsetting)
一方monitor.coffeeではコールバック関数を渡していないため、デバッグコンソールにログが出るだけで画面に変化は起きません。
$ -> dc = new ns.MonitorClass() ... dc.onOpen()
これらのPeerJS初期化処理もスマートグラス・監視端末の実行順序に依存関係はありませんので、お互い非同期に実行してかまいません。
「->(アロー)」と「=>(ファットアロー)」
この @peer.on('open', callback) のcallback関数が、=>(ファットアロー)で定義されていることに注意してください。CoffeeScriptの関数定義構文には「->(アロー)」と「=>(ファットアロー)」がありますが、=>(ファットアロー)で定義された関数では「呼び出し元のコンテキストのthis」が関数実行時のthisにアタッチされます。
上記のonOpenの場合、 @peer.on('open', callback) を実行したthisはDeviceClassやMonitorClassから生成されたインスタンスを指しますので、=>(ファットアロー)でcallbackを定義した場合は @peer (@はthisの省略表記) でインスタンス変数として保持しているpeerオブジェクトが参照できます。
JavaScriptでは、以下のイディオムに相当します。
- remote-monitor.js相当(remote-monitor.coffeeから実際にコンパイルされたJavaScriptとは異なります)
BaseClass.prototype.onOpen = function(peerIDsetting) { var self = this; this.peer.on('open', function() { if (peerIDsetting != null) { peerIDsetting(self.peer.id); } }); };
一方->(アロー)でcallbackを定義した場合、呼び出し元のthisはアタッチされないため、このcallbackが所属するPeerオブジェクトがthisとなります。そのため @peer はundefinedになり、期待した動作とはなりません。
3. MediaStreamの取得
次にMediaStreamの取得を実装します。MediaStreamの取得はPeerの初期化とは並列して実行できるため、 @peer.on('open', callback) イベントの発生を待たずに処理を開始してかまいません。実際の処理はinitializeメソッドに定義されています。
class BaseClass ... initialize: (video, initializing, waiting) -> initializing() # MediaStream初期化画面の表示(device画面①、monitor画面①) # getUserMediaのブラウザ間差異の吸収 navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia # MediaStreamの取得 navigator.getUserMedia {audio:true, video:true} , (stream) => # MediaStreamの取得に成功 video.prop 'src', URL.createObjectURL(stream) # HTML5のvideoタグのsrcにMediaStreamを接続 @ls = stream # 取得したMediaStreamをインスタンス変数として保持 @ls.getAudioTracks()[0].enabled = false # 自身のマイクをOFF waiting() # 接続待ち画面の表示(device画面②、monitor画面②) , => # MediaStreamの取得に失敗 @eh "getUserMedia fail" if @eh? #エラーハンドラにメッセージを通知
MediaStream初期化画面の表示
最初にMediaStreamの初期化画面を表示します。スマートグラスではdevice画面①(下図左)、監視端末ではmonitor画面①(下図右)が表示されます(実際の画面はブラウザによって異なります。)
getUserMediaのブラウザ間差異の吸収
MediaStreamを取得するAPIはブラウザごとに異なり、chromeやOpera for Androidはnavigator.webkitGetUserMedia、firefox for Androidではnavigator.mozGetUserMediaになります。その実装差異を吸収しなければなりません。
MediaStreamの取得
ブラウザより映像と音声のMeidaStreamを取得します。カメラが複数存在するデバイスの場合、映像を取得するカメラを選択できる場合もあれば、どれか一つのカメラに決め打ちされてしまう場合もあります。これはブラウザの実装依存です。
またこれらのHTMLやJavaScriptをHTTPで取得した場合、カメラやマイクへのアクセスには毎回明示的に許可を与えなければなりません。不正なサイトのJavaScriptからカメラやマイクが知らない間に操作されると困りますので。
一方で適切な証明書が与えられたサイトからHTTPSでHTMLとJavaScriptを取得した場合、MediaStreamへのアクセス許可/拒否をブラウザが記憶する仕様になっています。
カメラやマイクへのアクセスに許可を与え、JavaScriptがMediaStreamの取得に成功すれば、引数で渡されたvideoタグのsrcにMediaStreamを接続し、一旦自身のマイクをOFFにした後に接続待ち画面(device画面②(下図左)とmonitor画面②(下図右))を表示します。取得に失敗した場合、エラーハンドラにメッセージを通知します。
MediaStreamの取得もスマートグラス・監視端末の実行順序に依存関係はありませんので、やはりお互い非同期に実行してかまいません。
次回は
ここまででスマートグラスと監視端末の初期化が終了しました。次回はWebRTCの肝となるシグナリング処理からスマートグラスのカメラ映像をリアルタイムで監視端末に表示する部分までを解説します。