【ラズパイDIY講座】ラズパイゼロで作る監視カメラ② 〜 UV4LのビルドインシグナリングサーバでWebRTC


2021/07/05
蛸壺の中の工作室|ラズパイゼロで作る監視カメラ② 〜 UV4LのビルドインシグナリングサーバでWebRTC

これからWebRTCを本格的に学習していきたい人にとっては、WebRTCの仕組みをじっくり理解することは避けて通れない道です。

既に
前回で基本的な監視カメラ機能をUV4Lで構築していたため、「ラズパイでお手軽監視カメラ」のハードウェア面でのセットアップはほぼ完了していました。

今回からWebRTC通信の勉強も兼ねながら、UV4Lを利用したWebRTCのシグナリングする際のWebSocketの使い方を中心に解説していきます。


WebRTCの概要

まずはいきなりWebRTCの具体的なコードの実装をお見せする前に、この本稿前半でWebRTCの基礎となる知識をざっとおさらいしてみます。

HTTPとWebSocketとWebRTCと

まずWebRTCを最初に勉強するにあたって混乱必至なのが、『WebRTCはプロトコルのるつぼ』と呼べるほど多くの通信プロトコルを組み合わせた技術であることを念頭におかないといけません。複数の通信プロトコルがそれぞれの役割を担って登場するので、WebRTCを使ったアプリケーションを作成したい場合、何をどうやっていつ使うの?が良く分かりません。

多くの方が、そもそもなんで普通のHTTP通信だけでWebRTCは出来ないのだろうか?程度の状態から学習が始まるのでは無いかと思います。ネットワークプロトコルを詳しく勉強しだすとととても難しい分野ですので、ここではほんの一部を掻い摘んで説明します。

ネットワークプロトコルの重要な構成(レイヤー)の一つに登場する
TCPUDPという2つのプロトコルが存在し、HTTPやWebSocketはTCPに属し、WebRTCはUDPに属しています。

TCPを使う場合、通信相手とのコネクションの確立を確実かつ正確に保証するために、スリーウェイハンドシェイク、相互応答確認、フロー制御、輻輳制御などなどを通信前に事前処理を行っています。デメリットとして、コネクション確実性を担保する反面、大量のデータを相互にやりとりしなければならないのでデータ転送や処理時間もかかり、通信のリアルタイム性が損なわれてしまいます。

これに対してUDPはコネクションの確立などにかける処理を可能な限り行わず、多少のデータ欠損などが生じてもエラーハンドリングも無視するなど、通信自体の信頼性には目を瞑り、データだけを高速に転送することに専念したプロトコルです。既にピンと来られているかも知れませんが、動画や音声配信などのリアルタイム性の求められるマルチメディアストリーミングにはUDPプロトコルを使う方が圧倒的に有利になります。ただし、UDPだけは例えばテレビ会議参加ユーザーが確実にコネクションされているか確認しようもないですし、途中参加したいユーザーを追加したいときなどに困ってしまいます。

そこでTCPの一つである双方向通信を可能にする
WebSocketというプロトコルと、UDPでデータをブロードキャストするWebRTCを併用するようなやり方が求められてきました。

WebSocketはWebアプリケーションの双方向通信を実現するために生まれた比較的まだ新しい技術で、HTTPの持つ欠点を改良したプロトコルです。なので、基本にはHTTPでやれることはWebSockertでも可能で、URLアドレスでアクセスする際には
http://....ws://....で読み替えることができます。(なおhttps://....の場合にはwss://....とする。)

WebSocketは
「HTTPの双方向通信機能の強化版」という位置づけと考えてもらっても良いでしょう。通常HTTPでは双方向通信とはいってもクライアントからサーバへ情報をリクエストしたら、サーバーが何か処理をしてレスポンスを返しせばそれっきりで処理は終わりです。対して、WebSocketではクライアントから明確なリクエストがされていなくても、サーバー側で何かクライアントが更新すべき情報があった際に、サーバーからクライアント側へリクエストが送られます。

例えば自作のチャットアプリで沢山のユーザーが参加しているとしましょう。

一人のユーザーが書き込みを行ったら、その書き込みはサーバー側に書き込まれ、同時に他のユーザーのアプリ画面にもその書き込みがリアルタイムで反映してほしいと思うはずです。

HTTPではユーザーからの明確なリクエストがなければ、更新のタイミングと見なされないので、書き込んだユーザーの内容は、他のユーザーがアプリ画面を次回更新した際に反映されることになるのですが、WebSocketでは双方向通信の仕組みがあるため、サーバー側から全てのユーザーに再描画されるようにリクエストがなされます。

リアルタイム性が重要視されるようなアプリケーション開発では、HttpではなくWebSocketで実装されるのはこのような理由が存在しているからです。WebRTCアプリケーション開発においても、後述するP2Pコネクションの確立や、シグナリングチャンネルなどにWebSocketが利用されています。

なので今回紹介するWebRTCアプリは、WebRTC+WebSocketアプリと呼んだほうが正しいかも知れませんが、場合によってはWebSocketの代わりに、よりWebRTC向けに機能を強化された
JanusXMPPも使うことが出来るようです。

例えばNode.jsを利用してWebSocketサーバーを立てる際において、以下のソースコードのように見かけ上HTTPサーバーを立てているのと同じ呼び出しになってしまいます。

            const http = require('http');
const webSocketServer = require('websocket').server;
const webSocketsServerPort = 12345;

const server = http.createServer((request, response) => {});
server.listen(webSocketsServerPort, () => {
    console.log(`WebSocket Server is listening on port ${webSocketsServerPort}`);
});

//👇WebSocketサーバー
const wsServer = new webSocketServer({httpServer: server});

///....
        
つまる話、Node.jsではWebSocketサーバーが動いている場合、HTTPサーバー(Webサーバー)も同時に動いている必要があります。反対に、HTTPサーバーが稼働しているとは言っても、WebSocketサーバーとして立ち上げていなければWebSocket通信は使うことが出来ません。

細かいところですが、HTTPとWebSocketの関係性は密接で表裏一体ですので、後述するプロトコルの話でゴチャゴチャしてくると混乱するといけないので、ここで頭の片隅において置かれると良いでしょう。

本家WebRTC?

WebRTCは、正規にはW3Cが取り決めた規格に従うプロトコルのことを指しています。

ここでは「正規」という言葉に多少の含みを持たせた表現にしましたが、オープンソースとして公開されているWebRTCアプリケーションで独自の実装をされている場合があるので、サードパーティ製のライブラリの利用には少し注意が必要です。
今回紹介するUV4LでもWebRTCに関連した使用方法では大分簡略化されたWebRTCの利用法が含まれていましたが、WebRTCという技術はあくまでもW3Cの規格がベースですので、本家をしっかり理解していく必要があります。

本家のドキュメントは少し読みにくいので、最初に参考にするのであればMozillaが公開している
MDNドキュメント | WebRTC connectivityのほうが、WebRTCの詳しい概要も説明されています。

本稿ではJavascript(Node.js)をベースにWebRTC機能を実装していきます。c++やpythonなどの他の言語でも実装できるようですが、ここでは扱いませんのでご了承ください。

ちなみに以下が今回の内容で考える「ご自宅監視カメラ」の模式図です。

合同会社タコスキングダム|蛸壺の中の工作室

今回の動作確認したネットワーク環境(ハード版):

            + Raspberry Pi Zero W + カメラモジュールV2 (※1)
+ Debian Linux パソコン (というかブラウザが動けばなんでもOK)
+ 家庭用の無線LANルータ (※2)
        
※1: 前回のブログでセットアップした内容を参照のこと。

※2: 後日、外部のネットワークからNAT越えしてVPN接続を試みるため、ポートフォワーディング機能があったほうが良い。

WebRTCのコネクションについて

WebRTCの基本はPeer-to-Peer(ピア・ツー・ピア; P2P)型の通信ネットワークとして考えられます。

これはWebRTC通信を行うピア(Peer; 対等なものの意味)の一つ一つが通信をすることを意味しており、WebRTCネットワークに接続されたピアはクライアントともサーバーとも区別がありません。

WebRTCのP2Pアーキテクチャを実現している仕組みにおいて、
Session Description Protocol(SDP)Interactive Connectivity Establishment(ICE)という2つのプロトコルを理解することが重要になります。

SDPはWebRTCを構成するプロトコルの一つで、ピアの持つIPアドレス・ポート番号などのセッション情報が記述する技術です。

またICEはピアからピアまでの通信経路情報を保持するためのプロトコルになります。

WebSocket通信を使って、ネットワーク間でSDP(セッション情報)を交換して、お互いを繋ぐICE(経路情報)を確立させることで、最終的にコネクションが成立させることができます。

WebRTCアプリケーションの実装での一番の難関が
「このピア同士のコネクションを確立させる」ことであり、ピア間でコネクションさえ確立してしまったら、後はもう動画や音声などのメディアストリーミングがブロードキャストされるだけなので開発者としてさほど考えることはありません。

WebRTC APIの利用

動画や音声をリアルタイムで双方配信できる、なんて聞くとWebRTCを使うための大層なプログラムやライブラリのインストールする必要があるんではと勘繰りたくもなりますが、実際は最近の主要なブラウザでWebRTCを使う機能が必要最低限用意されているため、大抵のパソコンでは何もしなくてもWebRTCが使える状況になっています。

ただしブラウザでWebRTCを使えるようにするには、
WebRTC APIと呼ばれるAPI関数を使って正しくスクリプトを実装する必要があります。

ドキュメントにもある通り、WebRTC APIは一つの完結した関数ではなく、目的に応じて複数のAPI関数を使い分ける必要があります。

代表格として、2つのピア間の接続を行う
RTCPeerConnectionクラスから提供された関数などを利用して処理を行っていきます。

RTCPeerConnectionによってピア間の接続が一旦確立すると、この接続に
データチャンネルを追加するとファイルデータのような任意のバイナリデータを交換できるようになり、またメディアストリームを追加してデータの通信を行うと音声や画像などのマルチメディア配信も可能になります。

RTCPeerConnectionは、本ブログの首題でもあるピア間のコネクションの確立に密接に関わってくるので、実装も含めて詳しく後述します。

さらに、2つのピア間の接続を無事に確立させた後でデータチャンネルを操作するのがWebRTC APIの中の
RTCDataChannelクラスです。

データチャンネルを実装したい場合にはだいたい以下のように利用します。

            //👇2つのピア間で確立したコネクション
const peerConnection = new RTCPeerConnection(configuration);

const dataChannel = peerConnection.createDataChannel();

//👇データチャンネルからデータを送信
dataChannel.send(JSON.stringify({
    "message": "hogehoge piyopiyo",
    "timestamp": new Date()
}));

//👇データチャンネルを閉じる
dataChannel.close();
        
このAPIは様々なデータ転送を扱うためのプライマリチャンネルとしても使用されます。

対して、メディアストリームはWebRTC APIとは別に
Media Streams APIから提供されているMediaStreamクラスで操作できるオブジェクトです。それゆえ、Media Capture & Streams APIはWebRTC APIと強く結びつきがあり、WebRTCを語るには切っても切れない関係があります。

主にこのAPIは強力なマルチメディア機能をブラウザから提供することができます。

メディアストリームは、任意の数からなるメディア情報単位の
トラックで構成されています。

このトラックは、Media Capture & Streams APIの
MediaStreamTrackインターフェイスに基づいたオブジェクトであり、オーディオ・ビデオ・テキスト(字幕など)を含むメディアデータタイプのうちのどれか1つを表現しています。

もっとも一般的には映像データを保持した
ビデオトラックか音声データを保持したオーディオトラックのどちらかでメディアストリームが作成され、それをピア間で送受信に使用することがWebRTCの主要な利用方法の一つです。

メディアストリームは
new MediaStream()でインスタンス化して使うものではなく、以下のような作法でブラウザ内のグローバル変数・navigatorから引き出して利用することになります。

            const openMediaDevices = async (constraints) => {
    return await navigator.mediaDevices.getUserMedia(constraints);
}

try {
    const stream = openMediaDevices({'video':true,'audio':true});
    console.log('Got MediaStream:', stream);
} catch(error) {
    console.error('Error accessing media devices.', error);
}
        
もう少し的確に言うと、パソコンやタブレットに接続されているマルチメディアデバイスがあった場合に、navigator.mediaDevices.getUserMedia関数がデバイスを取得し、適切なメディアストリームとしてPromiseで返す仕様です。

シグナリングサーバー

冒頭の節の説明で「WebRTCはP2P型ネットワークだからサーバとかクライアントとかの区別がない」のような印象を持たれたかも知れませんが、実際のWebRTC通信は様々なサーバーを組み合わせて利用することがほとんどであり、完全なピュアP2Pではありません。

WebRTC通信ネットワークで欠かせない存在に、
「シグナリングサーバー」があります。リファレンス先によっては、シグナリングチャンネルシグナリングサービスとも呼ばれることもありますが、本ブログではシグナリングサーバーと呼ぶことにしましょう。

シグナリングサーバーは、ネットワークに存在するピア同士の情報を仲立ちすのが主な役割で、ネットワーク上にある通信を開始したいピアをシグナリングサーバーに伝えることで、シグナリングサーバーが送信元に代って相手のピアに情報を転送し、必要な情報を収拾して、送信元に返してくれる機能を持っています。

シグナリングサーバーはWebRTCネットワークに接続しているピアの1つで起動していれば良く、全てのピアでシグナリングサーバーが起動しているしている必要はありません。

Nodejsによるシグナリングサーバーの構築方法は後述します。

シグナリング

WebRTCにおいてコネクション確立などで利用されるピアからピアへ交換する情報はオファーアンサーと呼ばれています。この2つの情報の内容は、SDPの取り決めに則って構成されています。

ここで2つのピアA-B間の単純なシグナリングのライフサイクルを考えてみます。

まず最初にピアAが接続を初期化すると、ピアAは
オファーを作成します。

オファーはまずシグナリングサーバーに送られ、シグナリングサーバーがピアBにオファーを送り出します。

するとピアBはシグナルサーバーからオファーを受け取り、自分の情報を
アンサーとして作成します。

そしてピアBは再びシグナリングサーバーへアンサーを送り返し、シグナリングサーバーからピアAへアンサーを送り出します。

コネクションが成功することで、次に説明する
ICE candidateをピア間で情報交換することができるようになります。

ICE candidate

上のシグナリングでピア間のコネクションが確認できた後で、ICEに従ったピア間の経路情報が確定され、それに従ってWebRTC通信がようやく開始されるという流れになります。このICE candidateはいわば、情報を流すための道路地図ということになります。なお、Candidate(候補)がという名前付くのは、複雑なネットワークトポロジーをもったWebRTCなどを想定すると、最適な経路が一意に決まらない可能性があることを考慮した話のようですので、ここではあまり気にする必要はありません。

経路が確定されて、ICE candidateが各ピアに提供されて、WebRTCが開始されるまでの方式が以下のように2つ存在し、利用者は正しく選択する必要があります。

Vanilla ICE方式

全てのICE candidateを収集してからピア同士を接続します。

今回はカメラ画像を取り込むのに1対1でしか利用しないため、こちらの形式は利用しません。

Trickle ICE方式

ICE candidateが見つかったらすぐに接続する方式です。

新規のピアがネットワークに割り込んで入ってくる際にも対応したい場合にはこちらの方式が便利かと思います。

今回はこちらの方式を利用します。


【配信側】UV4Lのビルドイン・シグナリングサーバーの利用

基礎的なWebRTCにまつわる専門用語の解説で前置きが長くなりましたが、前回やった内容との違いは、UV4Lのビルドイン・シグナリングサーバーをどう使うのか調べただけ...ですので過度な期待をさせてしまった方には恐縮です。

Node.jsで実装した単独のシグナリングサーバーの自作方法に関してはまた後日説明しようかと思います。

ここでUV4Lを使うメリットは、ラズパイゼロW(armv6l)のカメラドライバーが使えて、WebRTC用のビルドイン・シグナリングサーバーも立ち上げることが出来る仕様になっていることです。これによって、ラズパイからのリアルタイム画像配信機能を実装するハードルがかなり下がります。

合同会社タコスキングダム|蛸壺の中の工作室

なおここでは一例としてシグナリングサーバーのローカルIPが
192.168.0.10で、サービスポートとして4300に割り振られているものとします。

UV4Lのビルドイン・シグナリングサーバーも例にもれずWebSocketによって実装されており、今回のWebRTCのシグナリングサービスの代表お問い合わせ先が、

            wss://192.168.0.10:4300/stream/webrtc
        
となります。(※SSL証明の付与のやり方は前回の手順を参照のこと。)

ただし、ブラウザはhttpとhttpsのアクセスを区別して、httpの方を利用禁止いますが、APIで使う分にはwsとwssに区別はありません。開発テスト中もSSLをつけてWebSocketを操作するのが面倒ですので、

            ws://192.168.0.10:4300/stream/webrtc
        
以下のコマンドからUV4Lサーバーを起動しておきます。今回はデフォルトのドライバで表示されるウォーターマークを消す方法を使う理由で、外部デバイスドライバを利用しています。

            $ uv4l --external-driver --device-name=video0 \
    --server-option '--port=4300' \
    --enable-server
        

余談〜ラズパイゼロとWebRTCのハードウェアコーディックを使うときの罠

先程のようにuv4l --external-driverとしてデバイスドライバを読み込む場合、画面解像度もハードウェアコーディックの設定も外部ドライバに依存するので、u4l2-ctlコマンド辺りでハードウェアコーディックを叩けばそのままWebRTCでも使えるのか?と甘く考えていましたが、ラズパイゼロ(つまりarmv6lアーキテクチャ)では、以下のようにuv4l-serverコマンドを正しく受け取ってくれないようです。

            $ uv4l --external-driver --device-name=video0 \
    --enable-server \
    --server-option '--enable-webrtc' \
    --server-option '--webrtc-enable-hw-codec' \
    --server-option '--webrtc-preferred-vcodec=3' \
    --server-option '--port=4300'
<notice> [core] Trying to load the the Streaming Server plug-in...
<notice> [server] HTTP/HTTPS Streaming & WebRTC Signalling Server v1.1.129 built on Feb 22 2021
<warning> [server] SSL is not enabled for the Streaming Server. Using unsecure HTTP.
<notice> [core] Streaming Server loaded!
<notice> [server] Web Streaming Server listening on port 4300
<notice> [driver] Using video device /dev/video0
<notice> [webrtc] WebRTC Renderer extension successfully loaded
<notice> [server] WebRTC, Signalling Server and STUN Server extensions successfully loaded
#....
<warning> [server] Inappropriate ioctl for device
#....
        
uv4l-serverのオプションでコマンドからコーディックを有効化して、http://[ラズパイのIP]:4300/stream/video.h264を叩いてみても画像が何も出せないし、uv4lの警告としてInappropriate ioctl for deviceが出ています。

少しこの症状を調べてみると、
UV4L deletes all /dev/video* and can't pick H.264 stream of the UVC USB cameraというような同じようなとことで躓いている方が掲示板に書き込まれて議論されているのを拝見しました。

この内容を掻い摘むと、uv4lのハードウェアコーディックはuv4l専用のraspicamとの組み合わせで動作するように調整されているので、外部のドライバを読み込んでuv4lコマンドを組み合わせではハードウェアエンコードが正常に機能しなさそう...ということでした。

UV4LをWebRTCで利用したいなら、外部ドライバとハードウェアコーディックの利用は諦めて、30秒くらいの遅延には目を瞑りつつ無圧縮の映像を配信するくらいの用途で使いしかないかな?と思います。


WebRTCクライアントの実装

こちらはクライアント側のローカルPCに以下のindex.htmlを保存してブラウザで開くだけです。

合同会社タコスキングダム|蛸壺の中の工作室

ちなみに以下のコードはUV4LのWebRTC接続のデモにあったhtmlファイルを無駄な記述を圧縮して、もう少しモダンなjsで手直ししただけのものです。

            <!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>UV4L-WebRTC</title>
        <script type="text/javascript">
            let ws = null, pc, trickle_ice = true, remoteDesc = false, iceCandidates = [];

            //👇①シグナリングサーバーのIPアドレスとポートを設定
            const signalling_server_address = "192.168.0.10:4300";
            const pcConfig = { iceServers: [] };
            const pcOptions = { optional: [] };
            const mediaConstraints = {
                optional: [],
                mandatory: {
                    OfferToReceiveAudio: false,
                    OfferToReceiveVideo: true
                }
            };

            //👇②ブラウザ組込のWebRCT APIを呼び出し
            RTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection;
            RTCSessionDescription = window.RTCSessionDescription;
            RTCIceCandidate = window.RTCIceCandidate;

            function createPeerConnection() {
                try {
                    pc = new RTCPeerConnection(pcConfig, pcOptions);
                    pc.onicecandidate = (event) => {
                        if (event.candidate && event.candidate.candidate) {
                            const candidate = {
                                sdpMLineIndex: event.candidate.sdpMLineIndex,
                                sdpMid: event.candidate.sdpMid,
                                candidate: event.candidate.candidate
                            };
                            const request = {
                                what: "addIceCandidate",
                                data: JSON.stringify(candidate)
                            };
                            ws.send(JSON.stringify(request));
                        } else {
                            console.log("ICE candidateの情報取得完了");
                        }
                    }
                    if ('ontrack' in pc) {
                        pc.ontrack = (event) => {
                            console.log("トラックを削除");
                            document.getElementById('remote-video').srcObject = event.streams[0];
                        }
                    } else {
                        pc.onaddstream = (event) => {
                            console.log("リモートストリームを追加: ", event.stream);
                            document.getElementById('remote-video').srcObect = event.stream;
                        }
                    }
                    pc.onremovestream = (event) => {
                        document.getElementById('remote-video').srcObject = null;
                        document.getElementById('remote-video').src = '';
                    }
                    console.log("ピアのコネクションか確立しました!");
                } catch (e) {
                    console.error("ピアのコネクションが失敗...");
                }
            }

            function addIceCandidates() {
                iceCandidates.forEach((candidate) => {
                    pc.addIceCandidate(candidate, () => {
                            console.log("ICE candidateが追加されました: " + JSON.stringify(candidate));
                        }, (error) => {
                            console.error("ICE追加中にエラーが発生: " + error);
                        }
                    );
                });
                iceCandidates = [];
            }

            function start() {
                // [UV4L WebRTC シグナリング](https://www.linux-projects.org/webrtc-signalling/)
                if ("WebSocket" in window) {
                    document.getElementById("stop").disabled = false;
                    document.getElementById("start").disabled = true;
                    document.documentElement.style.cursor = 'wait';

                    //👇③シグナリングを担当するWebSocketオブジェクトを生成
                    ws = new WebSocket('ws://' + signalling_server_address + '/stream/webrtc');

                    //👇④WebSocket開始と同時にピアコネクションも作成する
                    ws.onopen = (stream) => {
                        console.log("WebSocket: onopenイベント発生");
                        iceCandidates = [];
                        remoteDesc = false;
                        createPeerConnection();
                        const request = {
                            what: "call",
                            options: {
                                //👇外部ドライバではハードウェアコーディックが有効化できない
                                force_hw_vcodec: false,
                                vformat: 10,
                                trickle_ice: true
                            }
                        };
                        ws.send(JSON.stringify(request));
                        console.log("コールされたリクエスト: " + JSON.stringify(request));
                    };

                    ws.onmessage = (evt) => {
                        const msg = JSON.parse(evt.data);
                        const what = msg.what !== undefined ? msg.what : null;
                        const data = msg.what !== undefined ? msg.data : null;
                        switch (what) {
                            //👇⑤ピアコネクション確立(セッション)を確認したら、ICE情報の取得を試みる
                            case "offer":
                                pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)),
                                    () => {
                                        remoteDesc = true;
                                        addIceCandidates();
                                        console.log('SDP情報: onRemoteSdpSuccesイベント');
                                        pc.createAnswer(
                                            (sessionDescription) => {
                                                pc.setLocalDescription(sessionDescription);
                                                const request = {
                                                    what: "answer",
                                                    data: JSON.stringify(sessionDescription)
                                                };
                                                ws.send(JSON.stringify(request));
                                            },
                                            (error) => {
                                                alert("アンサーでエラーが発生: " + error);
                                            },
                                            mediaConstraints
                                        );
                                    },
                                    (event) => {
                                        alert('ピアコネクションエラー発生: ' + event);
                                        stop();
                                    }
                                );
                                break;
                            case "answer":
                                break;
                            case "message":
                                alert(msg.data);
                                break;
                            //👇⑥ICE candidateを受け取ったらICE情報の保存する
                            case "iceCandidate": //👈trickleICEの場合
                                if (!msg.data) {
                                    console.log("ICE情報取得完了");
                                    break;
                                }
                                const elt = JSON.parse(msg.data);
                                const candidate = new RTCIceCandidate({sdpMLineIndex: elt.sdpMLineIndex, candidate: elt.candidate});
                                iceCandidates.push(candidate);
                                if (remoteDesc) addIceCandidates();
                                document.documentElement.style.cursor = 'default';
                                break;
                            case "iceCandidates": //👈vanillaICEの場合
                                const candidates = JSON.parse(msg.data);
                                for (let i = 0; candidates && i < candidates.length; i++) {
                                    const elt = candidates[i];
                                    const candidate = new RTCIceCandidate({sdpMLineIndex: elt.sdpMLineIndex, candidate: elt.candidate});
                                    iceCandidates.push(candidate);
                                }
                                if (remoteDesc) addIceCandidates();
                                document.documentElement.style.cursor = 'default';
                                break;
                        }
                    };

                    ws.onclose = (evt) => {
                        if (pc) {
                            pc.close();
                            pc = null;
                        }
                        document.getElementById("stop").disabled = true;
                        document.getElementById("start").disabled = false;
                        document.documentElement.style.cursor = 'default';
                        console.log("WebSocket切断完了");
                    };

                    ws.onerror = (evt) => {
                        alert("WebSocketエラー発生中!");
                        ws.close();
                    };
                } else {
                    alert("このブラウザはWebSocketに対応していません");
                }
            }

            function stop() {
                document.getElementById('remote-video').srcObject = null;
                document.getElementById('remote-video').src = '';
                if (pc) {
                    pc.close();
                    pc = null;
                }
                if (ws) {
                    ws.close();
                    ws = null;
                }
                document.getElementById("stop").disabled = true;
                document.getElementById("start").disabled = false;
                document.documentElement.style.cursor = 'default';
            }

            window.onbeforeunload = () => {
                if (ws) {
                    ws.onclose = () => {};
                    stop();
                }
            };
        </script>
        <style>
            #container {
                display: flex;
                flex-flow: row nowrap;
                align-items: flex-end;
            }
            video {
                background: #eee none repeat scroll 0 0;
                border: 1px solid #aaa;
            }
        </style>
    </head>
    <body>
        <div id="container">
            <div class="overlayWrapper">
                <video id="remote-video" autoplay="" width="640" height="480"></video>
            </div>
        </div>
        <div id="commands">
            <button id="start" style="background-color: green; color: white" onclick="start();">Call</button>
            <button disabled id="stop" style="background-color: red; color: white" onclick="stop();">Hang up</button>
        </div>
    </body>
</html>
        
ソースコード中に注目すべきポイントにコメントを付けておきましたので、理解するための参考にしていただくとして、UV4Lを使うと本系のWebRTCとはすこしだけシグナリングサーバーへオファーする作法が異なります。詳しくはUV4L WebRTC シグナリング規約のほうでご確認ください。

これでブラウザから開き、接続すると配信された映像が映し出されるようになります。

合同会社タコスキングダム|蛸壺の中の工作室


まとめ

今回はUV4Lに内蔵されているビルドイン・シグナリングサーバーを使って、WebRTCのシグナリングの基礎を解説していきました。

UV4LのWebRTCシグナリングは正規のシグナリングと若干文法が異なりますが、自分でシグナリングサーバーを実装しなくても済むので、最初のとっかかりとしてWebRTCを学び触れるのには良いと思います。

次回は再びWebRTCのP2Pシグナリングを深堀りして、オリジナルの自作シグナリングサービスを作るにはどうすると良いかを考えて行く予定です。

参考サイト

WebRTC over WebSocket in Node.js

WebRTCでビデオチャットアプリを作ってみた!

WebRTC P2Pを使って2つのマシンを接続する