UV4L 音声を送る(3)

今回は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しか表示されません。

後で分かったのですがUV4Lインストール時にUSBスピーカーが接続されていないと、この様にRaspi-configで表示されなくなる様です。USBスピーカー有効にするにはここで選択するのが一番簡単な方法ですが今回スピーカーが表示されないので他の方法でUSBスピーカーを有効にします。

/etc/asound.conf”を編集

/etc/asound.conf を編集してUSBスピーカーを有効にします。以前 ”UV4Lを使う(1)”で音を有効にする時に、このファイルを編集しています。ファイルの内容を cat /etc/asound.conf で表示すると以下の様になっています。

/etc/asound.conf

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

/etc/asound.confはALSA (Advanced Linux Sound Architecture)の設定ファイル。

  • 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カメラとなります。カメラのマイクが有効になり音声がStreamingされた訳です。

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

WebカメラとUSBスピーカーを繋いだ状態でReboot後、状態を確認するとスピーカーとカメラの順番が入れ替わっています。

WebカメラとUSBスピーカーを繋いだ状態で起動するのが通常となるのでReboot後の状態を元に /etc/asound.conf を設定する事にしました。

 /etc/asound.conf で出力にカード1を入力にカード2を指定するとUSBスピーカーから音が出ました。

/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"
   }
}

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

やっと本題に入ります。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(); の戻り値)

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

各々の特性を考えると

  • navigator.mediaDivices.getUserMedia()
    • P2Pの接続情報に音声を送る事を追加する必要がある
    •  ー> createPeerConnection()の前。
  • addTrack()
    • Raspberry PIと情報交換する前。
    •  ー> createPeerConnection()の後。

が良いと思います。ただ、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()で使用されいて音声の停止を行っています。コードを簡略化した時にこの変数を使用していた関数を消してしまった様です。簡略化したコードでは function stop()以外では使われていませんでした。

下記は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出来る様になりました。また一歩インターフォンに近づきました。

次回は

実はオリジナルHPのソースコードには スナップショット 録画等の機能が有りました。次回はこれらの機能を追加したいと思います。