UV4Lを理解する(2)

UV4L、ソケット通信、P2P通信、WebRTC 各々の関係が良く理解出来ていません。今回はこれらに付いて調べてみます。

そもそも、UV4Lとは

UV4LはUser space Video4Linuxの略です。Video4LinuxはLinuxで映像や音声を扱う時に使用するデバイスの1つです。Video4Linuxを元にそれに付加するプラグイン(例えば、UBSカメラを使う等)の集まり全体をUV4Lと言っているようです。UV4Lをインストールした時にインストールされたパッケージを以下に示します。

uv4lを理解する2

コマンドでインストールしたのは、uv4l、 uv4l-webrtc、 uv4l-uvc の3つ。 

  • uv4l:     Framework Core
  • uv4l-webrtc: WebRTC用のプラグイン
  • uv4l-uvc:   USBカメラ用のプラグイン

その他の4つは自動的にインストールされました。また、UV4Lに関する資料ですが下記を見つけました。

ソケット通信、P2P通信、WebRTC とは

  • WebRTC:”Web Real-Time Communication”
    • Webブラウザやモバイルアプリでリアルタイム通信を実現するAPI。
  • P2P通信:”Peer-to-Peer”
    • サーバを介さずに、端末同士で直接データを通信する技術。
    • WebRTCの通信方式として使用される。
  • ソケット通信
    • 通信プロトコルの1つ。
    • P2Pが確立する前にPC間で情報を交換する通信手段として使用。
  • 3者の関係は以下の様になっている
    • Webブラウザやモバイル間でリアルタイム通信を行いたい
      • ー> 通信方法として ”Peer-to-Peer” が便利
        • ー> P2P接続準備の為にソケット通信が必要
  • 実際にプログラムを書く時には
    • ソケット通信の作製 ー> P2Pの作製 ー> WebRTCの使用

ソケット通信(シグナルサーバ)

P2P接続する為に先ずお互いの情報を交換する必要が有ります。WebRTCを行いたいクライアント同士が特定のサーバにアクセスしサーバを通してお互いの情報を交換します。このサーバをシグナルサーバと呼びます。そして交換される情報は、Session Description Protocol (SDP)とICE Candidateの2つです。

  • Session Description Protocol (SDP)は以下の情報を含んでいます。
    • 通信するメディアの種類(音声、映像)、メディアの形式(コーデック)
    • IPアドレス、ポート番号
    • 暗号化の鍵 等
  • ICE Candidate
    • P2P通信を行う際に使われる通信経路の候補
    • どのような通信経路が使えるかは、お互いのネットワーク環境に依存。
    • 通信相互で経路の候補を挙げ、通信が繋がった時にその経路を使う。

シグナルサーバの説明は、WebRTC signalingに有ります。サーバとの通信はフォーマットが使用されます。

{
   what: "call",
   options: {
      force_hw_vcodec: true,
      vformat: 30,
      trickle_ice: true
   }
}
  • what:リクエストの内容を表す
    • call:  最初にサーバにアクセスする時に使用
    • offer: 自分の通信情報を相手に送りたい時に使用。
    • answer:相手からもらったデータに対する返答をする時に使用。
    • など…
  • options: 実際のデータ。

サーバとの通信はデータを送る時もサーバからデータを受信する時もこのフォーマットにしたがって行われます。

UV4Lとは

WebRTCを使用するためにはP2P接続を開始しする必要が有りますが、P2P接続が出来ればWebRTCを使用出来るとは限りません。WebRTCの実行にはWebRTCに対応したWebブラウザが必要です。今回Raspberry PIはCUIでOSをインストールしているのでWebブラウザを実行出来ません。Webブラウザを持たないRaspberry PIとWebRTCを実行出来るのはUV4Lがブラウザの代わりを務めていると言う事になります。

だいぶ状況が分かって来ました。ここで気付いたのですが、前回の最後で使用したRaspberry PIとのStreamingのHPでRaspberry PIのP2P接続部をそのまま使用すれば、それ以外の部分はカスタマイズ出来る事になります。具体的には

  • Raspberry PIとのStreamingのHPを表示
  • ブラウザの機能を使用してHPのソースコードを取得
  • ソースコードのRaspberry PIのP2P接続部をそのままに、その他の部分をカスタマイズ
  • 新しく出来たコードをRaspberry PIでは無くPCに保存
  • PCのブラウザでそのコードを実行すれば(ソースを拡張子HTMLで保存すれば、ファイルをクリックするだけ)Raspberry PIとのStreaming出来る。

となるはずです。という事でStreamingのHPのカスタマイズをやって見ます。

コードの簡略化

最終的にRaspberry PIを使ってインターフォン(もしくは監視カメラ)を作りたいので、その為に必要な部分を残してコードを簡略化して見ました。

  • ”remote”, “local” と2つ有った画像の表示を、”remote”のみにする。
  • ”Pause” , “Mute”, “Fullscreen”, “Recording” ボタンを廃止。
  • ”Call”、”Hangup” ボタンはそのまま残す。
  • 画面サイズの選択機能は廃止して、width=”640″ height=”480″に固定。
  • Cast local Audio/Video sources機能を廃止。
  • Data Channelsを廃止。
  • Advanced optionsで、”Trickle ICE”をtrueに固定。
  • その他コメントを廃止。

簡略化したコードはRaspberry PI 上では無くPCに”webrtc.html” として保存します。予めRaspberry PIでUV4Lを立ち上げPCに保存した”webrtc.html”をブラウザ(FireFox)を使って表示すると以下の様にちゃんと立ち上がりました。

オリジナルのコードは約1250行でしたが変更後のコードは約230行となりました。このコードでRaspberry PIとの映像と音声のStreamingが出来ることを確認しています。

webrtc.html

<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>UV4L WebRTC</title>
        <script type="text/javascript">

            var ws = null;
            var pc;
            var audio_video_stream;
            var pcConfig = {/*sdpSemantics : "plan-b"*,*/ "iceServers": [
                    {"urls": ["stun:stun.l.google.com:19302", "stun:" + location.hostname + ":3478"]}
                ]};
            var mediaConstraints = {
                optional: [],
                mandatory: {
                    OfferToReceiveAudio: true,
                    OfferToReceiveVideo: true
                }
            };
            var iceCandidates = [];

            const constraints = {
                  audio: true,
                  video: false
            };

            function createPeerConnection() {
                try {
                    var pcConfig_ = pcConfig;
                    console.log(JSON.stringify(pcConfig_));
                    pc = new RTCPeerConnection(pcConfig_);
                    pc.onicecandidate = onIceCandidate;
                    pc.ontrack = onTrack;
                    pc.onremovestream = onRemoteStreamRemoved;
                    console.log("peer connection successfully created!");
                } catch (e) {
                    console.error("createPeerConnection() failed");
                }
            }

            function onIceCandidate(event) {
                if (event.candidate && event.candidate.candidate) {
                    var candidate = {
                        sdpMLineIndex: event.candidate.sdpMLineIndex,
                        sdpMid: event.candidate.sdpMid,
                        candidate: event.candidate.candidate
                    };
                    var request = {
                        what: "addIceCandidate",
                        data: JSON.stringify(candidate)
                    };
                    ws.send(JSON.stringify(request));
                } else {
                    console.log("End of candidates.");
                }
            }

            function addIceCandidates() {
                iceCandidates.forEach(function (candidate) {
                    pc.addIceCandidate(candidate,
                        function () {
                            console.log("IceCandidate added: " + JSON.stringify(candidate));
                        },
                        function (error) {
                            console.error("addIceCandidate error: " + error);
                        }
                    );
                });
                iceCandidates = [];
            }

            function onRemoteStreamAdded(event) {
                console.log("Remote stream added:", event.stream);
                var remoteVideoElement = document.getElementById('remote-video');
                remoteVideoElement.srcObect = event.stream;
            }

            function onTrack(event) {
                console.log("Remote track!");
                var remoteVideoElement = document.getElementById('remote-video');
                remoteVideoElement.srcObject = event.streams[0];
            }

            function onRemoteStreamRemoved(event) {
                var remoteVideoElement = document.getElementById('remote-video');
                remoteVideoElement.srcObject = null;
                remoteVideoElement.src = ''; // TODO: remove
            }

            function start() {
                if ("WebSocket" in window) {
                    document.getElementById("stop").disabled = false;
                    document.getElementById("start").disabled = true;
                    document.documentElement.style.cursor = 'wait';

                    var protocol = location.protocol === "https:" ? "wss:" : "ws:";
                    ws = new WebSocket(protocol + '//raspberrypi.local:8090/stream/webrtc');

                    ws.onopen = function () {
                        iceCandidates = [];
                        remoteDesc = false;
                        createPeerConnection();
                        var request = {
                            what: "call",
                            options: {
                                force_hw_vcodec: false,
                                vformat: "30",
                                trickle_ice: true
                            }
                        };
                        ws.send(JSON.stringify(request));
                        console.log("call(), request=" + JSON.stringify(request));
                    };

                    ws.onmessage = function (evt) {
                        var msg = JSON.parse(evt.data);
                        if (msg.what !== 'undefined') {
                            var what = msg.what;
                            var data = msg.data;
                        }
                        console.log("message =" + what);

                        switch (what) {
                            case "offer":
                                pc.setRemoteDescription(new RTCSessionDescription(JSON.parse(data)),
                                        function onRemoteSdpSuccess() {
                                            remoteDesc = true;
                                            addIceCandidates();
                                            console.log('onRemoteSdpSucces()');
                                            pc.createAnswer(function (sessionDescription) {
                                                pc.setLocalDescription(sessionDescription);
                                                var request = {
                                                    what: "answer",
                                                    data: JSON.stringify(sessionDescription)
                                                };
                                                ws.send(JSON.stringify(request));
                                                console.log(request);

                                            }, function (error) {
                                                alert("Failed to createAnswer: " + error);

                                            }, mediaConstraints);
                                        },
                                        function onRemoteSdpError(event) {
                                            alert('Failed to set remote description (unsupported codec on this browser?): ' + event);
                                            stop();
                                        }
                                );
                                break;

                            case "iceCandidate": // when trickle is enabled
                                if (!msg.data) {
                                    console.log("Ice Gathering Complete");
                                    break;
                                }
                                var elt = JSON.parse(msg.data);
                                let candidate = new RTCIceCandidate({sdpMLineIndex: elt.sdpMLineIndex, candidate: elt.candidate});
                                iceCandidates.push(candidate);
                                if (remoteDesc)
                                    addIceCandidates();
                                document.documentElement.style.cursor = 'default';
                                break;
                        }
                    };

                    ws.onclose = function (evt) {
                        if (pc) {
                            pc.close();
                            pc = null;
                        }
                        document.getElementById("stop").disabled = true;
                        document.getElementById("start").disabled = false;
                        document.documentElement.style.cursor = 'default';
                    };

                    ws.onerror = function (evt) {
                        alert("An error has occurred!");
                        ws.close();
                    };

                } else {
                    alert("Sorry, this browser does not support WebSockets.");
                }
            }

            function stop() {
                if (audio_video_stream) {
                    try {
                        if (audio_video_stream.getVideoTracks().length)
                            audio_video_stream.getVideoTracks()[0].stop();
                        if (audio_video_stream.getAudioTracks().length)
                            audio_video_stream.getAudioTracks()[0].stop();
                        audio_video_stream.stop(); // deprecated
                    } catch (e) {
                        for (var i = 0; i < audio_video_stream.getTracks().length; i++)
                            audio_video_stream.getTracks()[i].stop();
                    }
                    audio_video_stream = null;
                }
                document.getElementById('remote-video').srcObject = null;
                document.getElementById('remote-video').src = ''; // TODO; remove
                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';
            }

        </script>
        <style>
            video {
                background: #eee none repeat scroll 0 0;
                border: 1px solid #aaa;
            }
        </style>
    </head>
    <body>
        <h1><span>WebRTC two-way Audio/Video Intercom</span></h1>
        <video id="remote-video" autoplay="" width="640" height="480">
            Your browser does not support the video tag.
        </video><br>
        <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>
    </body>
</html>

コードの説明

コードの開始はHP上の、”CALL” ボタンを押した所から始めます。

  • ”CALL” ボタンは229行で定義されており、これをクリックすると91行の”start()”へ飛びます。 
  • 91行: ”start()”関数はここから始まる。
  • 92行: ”WebSocket”が使えるか判断。使えれば次は、使えなければ182行へ
  • 98行: 新たなソケット通信を作成。Raspberry PIのシグナルサーバとの接続開始。
  • 100行 :  シグナルサーバとの接続が出来た時点でここが実行される。
  • 103行: P2P接続に必要な処理を開始。詳細は後ほど説明します。
  • 104行: Raspberry PIのシグナルサーバに送るデータの製作
  • 112行: ソケット通信でシグナルサーバにデータを送信
    • このデータをシグナルサーバに送信でP2P接続の準備が開始されます
    • 送っているデータは、Whatが”call”。内容は、画面の解像度やtrickle_iceのオンオフものです。
    • サーバは接続を要求しているクライアントを認識し自信のSDPデータを”offer”とてクライアントに送信します。
  • 116行: シグナルサーバからメッセージが送信されるとここが実行される。
  • 124行: メッセージの解析はここから
    • 先ずRaspberry PI側から”offer”として送られるのでPC側で”answer”の処理は無し
    • ICE Candidateの送信方法を trickle_ice: true としている。これは ”iceCandidate” で処理される
    • 125行: ”offer”の場合の処理。
      • ICE Candidateの追加
      • ”offer”に対する”answer”の送信
    • 152行: ”iceCandidate”の場合の処理。
      • “iceCandidate”の登録が行われます。
  • 何回かICE Candidateのやり取りの後、お互いの条件が合えばP2P接続が開始される。

130行で実行した関数 createPeerConnection(); により以下が並行して行われる。

  • 28行:createPeerConnection()関数。P2P接続に必要な処理を行う。
    • 32行: 新しいP2P通信、PCを作成。
    • 33行: icecandidateイベントが起きたら、onIceCandidate()を実行
    • 34行: trackイベントが起きたら、onTrack()を実行
    • 35行: removestreamイベントが起きたら、onRemoteStreamRemoved()を実行
  • 42行:onIceCandidate(event)。ICE Candidateを相手(Raspberry PI)に送信
    • 32行のRTCPeerConnection(); が実行されると自信のICE Candidateの作製が始まる。
    • ICE Candidateが出来るとトリガーがかかりここが実行される。
    • ”what”ラベルに、”addIceCandidate”。”data”ラベルに、ICE CandidateをセットしRaspberry PIに送信する。
  • 59行: addIceCandidates() ICE Candidateの追加
  • 79行: onTrack(event)。
    • 82行:ここでRaspberry PIから送られて来たデータ(画像と音声)をHTML画面に接続。

その他の説明

  • ”Hang up”ボタンは230行で定義。クリックすると、187行の”stop();”が起動。
  • 187行: stop()。通信の停止作業を行う
    • Streamの停止。
    • ソケット通信の停止
    • P2P通信の停止。

プログラムの実行

最後に実際にプログラムを実行して見ます。実行は以下の様に行いました。

  • Raspberry PI側:
    • UV4Lの起動が必要です。
    • 前回の ”UV4Lを使う(1)” 通りにセットアップしていればRaspberry PIの起動と共にUV4Lも起動します。
  • PC側:
    • PCに保存されたHTMLのコードファイルを実行(ファイルをクリック)します。
    • 今回ブラウザはFireFoxを使っています。
  • 実行結果:
    • FireFoxのURL欄にコードのPathを入力すると下記の画面が表示されます。
    • 画面下の、”Call”ボタンをクリックするとStreamingが始まります。
    • もちろん、音声もStreamingされています。
    • ”Hang Up”ボタンをクリックするとStreamingが終了します。

次は

P2Pの確立する仕組がまだ良く理解出来ていません。ただ、Raspberry PI HPのソースコードでその部分をそのまま使えばP2Pが繋がりWebRTCが出来ること。ソースコードもP2P接続以外の部分は変更出来る事が分かりました。HPのHTMLを書き換えれば自分のニーズに有ったHPが作れる事が分かったのは大きな収穫です。

現時点で音と映像が送られてくる監視カメラレベルになっています。これでPCからRaspberry PIに音が送れれば立派なインターフォンになります。次回はこれに挑戦します。