UV4Lを使う(4)

今回はPCからRaspberry PIに音声を送る事を目指します。先ずはRaspberry PIにUSBスピーカーを繋ぎそのスピーカーから音を出す事から始めます。

開発環境

スピーカーが追加されたので以下の様になっています。

  • ルータの下にRaspberry PI(3 model B+) とPCが接続
  • Raspberry PI には、USBのWebカメラとスピーカーが接続。
  • PCのOSは、Ubuntus 20 LTS。 Raspberry PIのOSは、OS Lite(32-bit)
  • WebブラウザはFireFox 90.0

raspi-configでUSBスピーカーが認識されない

今回使用したUSBスピーカーは、サンワサプライ コンパクトPCスピーカー USB接続 ブラック MM-SPU8BK ごく普通のスピーカーです。

これをUSBプラグに差し込んでRaspberry PIに電源を投入。Raspberry PIのモニター画面で、lsusb USB機器が画面に表示されます。

UBS機器はWebカメラとスピーカーの2つ。Device 5がカメラ、 Device 4がスピーカー 両者共に認識されています。そこでデフォルトのスピーカーをUSBスピーカーにするために、sudo raspi-config を実行。しかしAudioの設定画面で、Headphonesしか表示されません。

HDMIを接続していないのでそれが表示されないのは理解出来ますが、認識されているUSBスピーカー表示されないのは理解出来ません。USBスピーカーを諦めて Headphones で我慢する選択も有りますが、せっかくUSBスピーカー繋いだのでそちらを選択したいところです。

/etc/asound.conf”を編集

以前 ”UV4Lを使う(1)”で音を有効にする時に、/etc/asound.conf を編集していた事を思い出しました。

/etc/asound.conf

pcm.!default {
type asym
playback.pcm "plug:hw:0"
capture.pcm "plug:dsnoop:1"
}

当時はcut&pasteでこのファイルを作成し、音が鳴ったと喜んでいました。/etc/asound.conf はALSA の設定ファイル。 ALSAは Advanced Linux Sound Architecture の略で、Linuxにおける高機能なサウンドシステムを提供するためのソフトウェア群。先ずは、この設定ファイルを理解し、次にUSBスピーカーから音が出せる編集することにしました。

  • 1行:pcm.!default {
    • PCMはRaspberry PIのサウンドデバイスを指しいる。
    • !default と記述するとデフォルトとして再定義する事になる。
  • 2行:type asym
    • asymを指定すると、このデバイスを入出力として宣言。
  • 3行:playback.pcm “plug:hw:0”
    • playback.pcm: 出力先の指定。
    • “plug:hw:0″: カードの0番を指定。
    • つまり、出力先をカードの0番に指定。
  • 4行:capture.pcm “plug:dsnoop:1”
    • capture.pcm: 入力先の指定。
    • “plug:dsnoop:1″: dsnoop の意味が良く分からないのですがカードの入力部を指す様です
    • これで、入力先は、カードの1番に指定となる。

現在使用されているカードの確認ですが、cat /proc/asound/cards で確認出来ます。下記は、Webカメラのみ接続した状態で命令を実行した結果です。

画面一番左の数字が、カード番号を示しています。/etc/asound.conf では、出力にカード0を、入力にカード1を指定しているので、出力はヘッドフォン。入力はWebカメラとなります。これでRaspberry PIの音声入力が有効になり音声がStreamingされた訳です。

この状態でUSBスピーカーをRaspberry PIに挿入して同じく cat /proc/asound/cards を実行すると下記の様になります。上記の結果の下にカード2としてUSBスピーカーが追加されている事が分かります。出力先をカード0のヘッドフォンからカード2のUSBスピーカーに変更するば良いように思えるのですがそうは行きませんでした。

WebカメラとUSBスピーカーを繋いだ状態でRebootすると、カードの順番が以下の様になっていました。スピーカーとカメラの順番が入れ替わっています。電源は入った状態でRaspberry PIにUSB機器を追加した状態と初めからUSB機器を挿入して電源を入れた状態とでUSB機器の番号が違う可能性が高い事が分かりました。

今後WebカメラとUSBスピーカーを繋いだ状態で起動するのが通常となるので、このリストを元に /etc/asound.conf を変更する事にしました。ちなみに、/etc/asound.conf 編集前、WebカメラとUSBスピーカーを繋いだ状態で起動するとRaspberry PIからの音声がなくなりました。編集前の /etc/asound.conf は入力にカード1。USBスピーカー挿入後、カード1はWebカメラからスピーカーに変わり音声入力が出来なくなったのが原因です。

 /etc/asound.conf を下記の様に変更するとUSBスピーカーから音が出ました。出力にカード1を入力にカード2を指定しています。

/etc/asound.conf

pcm.!default {
type asym
playback.pcm "plug:hw:1"
capture.pcm "plug:dsnoop:2"
}

/proc/asound/devices を実行すると カードとDeviceの番号が分かります。

また、 aplay -l と入力すると出力側(再生側)の確認が出来ます。

これらを用いて、 /etc/asound.conf の入出力デバイスを下記の様に設定することも出来ます。こちらの方が直感的かと思います。

/etc/asound.conf

pcm.!default {
  type asym
   playback.pcm {
     type plug
     slave.pcm "hw:1,0"
   }
   capture.pcm {
     type plug
     slave.pcm "hw:2,0"
   }
}

ALSAの使い方はまだ良く分かりませんが、USBスピーカーから音が出るようになったのでこれで良しとします。

PC側から音を送信するには

WebRTCをやりたくて(Access media devices)” で説明していますが、WebRTCで相手に映像や音声を送るには下記の関数を使います。

  • navigator.mediaDivices.getUserMedia() 
    • ユーザーに使用の許可を申請後、Webカメラとマイクを有効にしてデータを取り込みます。
    • 引数は、{video: false, audio: true}の様に指定。false:無効、 true:有効。
    • 今回は音のみ有効とするので、navigator.mediaDivices.getUserMedia({video: false, audio: true})。
  • addTrack()
    • WebRTCのSDPに情報を登録する関数。
    • 使用方法は 
      • stream = navigator.mediaDivices.getUserMedia({video: false, audio: true});
        stream.getTracks().forEach(track => xxx.addTrack(track, stream));
        (xxxは、RTCPeerConnection(); の戻り値)

このコードを何処に入れる

以前に説明した様にコードは以下の様に実行されます。 

  1.  ”Call”ボタンを押されるとfunction start()が実行されRaspberry PIとWebRTC接続を開始します。
  2.  start()関数では最初にソケット通信が作成される
  3.  次にWebRTC通信作成用にcreatePeerConnection()を実行。
  4.  その後、ソケット通信を用いてUV4Lに初期設定を送信。
  5.  これによりUV4LからWebRTC ”offer”がPC側に送られて、P2P接続に必要な情報の交換開始。
  6.  通信が確立した後、Streamingが開始される。

よって音声を登録するなら、createPeerConnection()の

  •  前でnavigator.mediaDivices.getUserMedia()を実行して音声データを取得。
  •  後でかつRaspberry PIと情報交換する前にaddTrack()を行う。

事になります。ただここで、navigator.mediaDivices.getUserMedia()はPromiseであることに注意が必要です。navigator.mediaDivices.getUserMedia()を実行するとポップアップ画面が表示されユーザーにビデオと音声の使用の許可を求めて来ます。ユーザーが許可する間にコードは先に進むので、許可した時点では既にSDPが相手に送られ、音声登録が出来ない可能性が生じます。それを防ぐ為に、awaitを使用します。ただ awaitは非同期関数でのみ使用可能な為、ws.onopen = function () { の関数の定義に async を使用します。これらの変更を加えたコードを以下に示します。


                    ws.onopen = async function () {
                        iceCandidates = [];
                        remoteDesc = false;
                        audio_video_stream = await navigator.mediaDevices.getUserMedia({video: false, audio: true});
                        createPeerConnection();
                        audio_video_stream.getTracks().forEach(track => pc.addTrack(track, audio_video_stream)); 
                        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));
                    };

navigator.mediaDevices.getUserMedia()の戻り値を受けている変数 audio_video_stream は元々コードの中で定義されていました。function stop()で使用されています。stop()ではこの変数を元にTrackの停止を行っています。コードを簡略化した時にこの変数を使用していた関数を消したのか簡略化したコードでは function stop()以外では使われていませんでした。登録した音声を”Hang Up”ボタンを押した後、停止する必要が有ったのでちょうど良い変数でした。

ちなみにfunction stop()は以下の通り。2行目で音声と画像の登録を判断し、8行目までにそれらを削除しています。


            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';
            }

前回簡略化したコードをもう一度見直したら幾つか使っていない箇所が有ったので、それらを削除し上記を追加したコードを以下に示します。

webrtc01.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 mediaConstraints = {
                optional: [],
                mandatory: {
                    OfferToReceiveAudio: true,
                    OfferToReceiveVideo: true
                }
            };
            var iceCandidates = [];

            function createPeerConnection() {
                try {
                    pc = new RTCPeerConnection();
                    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 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 = async function () {
                        iceCandidates = [];
                        remoteDesc = false;
                        audio_video_stream = await navigator.mediaDevices.getUserMedia({video: false, audio: true});
                        createPeerConnection();
                        audio_video_stream.getTracks().forEach(track => pc.addTrack(track, audio_video_stream)); 
                        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>

プログラムの実行

  • Raspberry PI側: 
    • 前回の ”UV4Lを使う(1)” と今回の変更(音声出力関係)が追加されている事を確認しRaspberry PIにUSB機器(カメラとスピーカー)を繋いて立ち上げて下さい。
  • PC側:
    • 今回のHTMLコードを、”webrtc01.html”としてPCのホームディレクトリに保存。
    • PCのFireFoxを使って実行。
  • 実行:
    • FireFoxのURL欄にコードのPathを入力する(ファイルエクスプローラーで、”webrtc01.html”をダブルクリックする)と下記の画面が表示されます。
    • 画面下の、”Call”ボタンをクリックすると音声許可要求のポップアップが表示されます。
    • ここでポップアップの ”許可する”を押すと、Webカメラのランプが点滅します。
    • しばらくすると点滅が点灯に変わりStreamingが開始します。
    • Raspberry PIから音声と映像が、PCから音声が相手に送信されています。
    • ”Hang Up”ボタンをクリックするとStreamingが終了します。

これで、Raspberry PIからの音声と映像がPC側に、PCからの音声がRaspberry PI側にStreaming出来る様になりました。また一歩インターフォンに近づきました。

次回は、オリジナルのHTMLコードにはスナップショット、録画等の機能が有りました。これらの機能をこの簡略化したHTMLに追加して行きたいと思います。