# ブラウザから操作可能なラジコンを作る【７-２】



## 【７-２】RaspberryPiから低遅延で映像を飛ばす

WebRTCサーバーmomoの環境準備だけで、長くなってしまいましたので
ブラウザ側にWebRTCクライアントを追加する部分を分けます。

<br></br>
## 目次
* [【１】Raspberry pi の、GPIOをTypescriptから操作](https://qiita.com/Yasukosan/items/1bf9480c10de7d69c2a4)
* [【２】DCモーターをPWMで速度制御](https://qiita.com/Yasukosan/items/291c76c10be135fd7595)
* [【３】サーボモーターを制御](https://qiita.com/Yasukosan/items/e81c9c9d0159b32e5893)
* [【４】DualSenseをブラウザに接続](https://qiita.com/Yasukosan/items/bf3a085145db84a48823)
* [【５】DualSenseの情報をRaspberryPiに飛ばす](https://qiita.com/Yasukosan/items/400883be94751966221a)
* [【６】DualSenseのチャタリング？問題対応](https://qiita.com/Yasukosan/items/7e52609699b4b71196ca)
* [【７-１】RaspberryPiから低遅延で映像を飛ばす](https://qiita.com/Yasukosan/items/98426f1ba4dea4c9ec5f)
* [【７-２】RaspberryPiから低遅延で映像を飛ばす](https://qiita.com/Yasukosan/items/2246c8a32602ca77ace9)
* 【８】ThreeJSでVRもどきを作成
* 【９】iPhoneの加速度から頭の向きをVRに反映
* 【９-２】スマホVRゴーグル向けデザインに変える
* 【１０】ラジコン本体の製作
* 【１１】パワーアップとバッテリー問題の解決

<br></br>
## 配信映像をラジコン操作画面に持ってくる

momoのテスト画面で映像の配信がみれましたので
同じ内容を、ラジコン操作側に持ってきます。

本来は、経路情報、デバイスやらチャンネル情報を含めたICE、などを
手動かWebSocket経由で交換する必要がありますが

[OpenAyame/ayame-web-sdk](https://github.com/OpenAyame/ayame-web-sdk-examples)を使用すると

* 交換に必要なシグナリングサーバー（OpenAyameプロジェクトが提供）
* 送受信、受信のみ、送信のみ、のバリエーションでサンプルがすでに用意されている

と言う恩恵が受けられます。
今回は[受信のみのサンプル](https://github.com/OpenAyame/ayame-web-sdk-examples/tree/main/sendonly)を元に、WebRTCクライアントサービスを作って活用します。

ayame-sdk自体は
WebRTC接続にかかる、面倒なプロセスを極力簡素化しつつ
ストリームとデータ配信が簡単に実装できるようにまとめられていますので
WebRTCの取っ掛かりには間違いなく素晴らしい参考アイテムです。

独自に実装し、拡張していく場合は
ayame-sdkを使用すると、型情報の不足からビルドエラーになる部分があるのと
シグナリングサーバーにProxyを挟む、などネットワーク周りで独自問題があると思います。
momo側のサンプルを参考に、自環境に実装するにあたり、不足箇所を補う
という進め方のほうが多分良いと思います。

<br></br>
## AyameLaboのシグナリングキー取得

シグナリングサーバーにayamelaboを拝借しますので
こちらから[https://ayame-labo.shiguredo.app/](https://ayame-labo.shiguredo.app/) 登録を済ませ
シグナリングキーを取得します。

<br></br>
## WebRTCクライアント実装

定番になってますが、まず設定情報系を保持するオブジェクトを作ります。
今回は環境を固定してますが

* ラジコン側の映像を複数の視聴者に送る
* 中継配信サーバーに映像を一旦飛ばす

などの発展形を考える場合は
映像コーデックを揃える。と言う難関が待っていますので注意が要ります。

```typescript
export type ConnectionOptionsType = {
    signalingKey: string
    signalingUrl: string
    roomIdPrefix: string
    roomName: string
    clientId: string
    video: boolean
    audio: boolean
    simulcast: boolean
    videoCodecType: 'VP8' | 'VP9' | 'H264' | 'AV1' | 'H265'
    audioCodecType: 'OPUS' | 'ISAC' | 'G722' | 'PCMU' | 'PCMA' | 'G729' | 'ILBC' | 'AAC' | 'HEAAC'
    iceServers: RTCIceServer[]
}

const ConnectionOptions: ConnectionOptionsType = {
    signalingKey: 'OpenAyameプロジェクトで取得したキー',
    signalingUrl: 'wss://ayame-labo.shiguredo.app/signaling',
    roomIdPrefix: '自分のOpenAyameプロジェクトのID',
    roomName: 'ayame-labo-sample',
    clientId: crypto.randomUUID(),
    video: true,
    audio: true,
    simulcast: true,
    videoCodecType: "VP8",
    audioCodecType: "OPUS",
    iceServers: [
        {
            urls: "stun:stun.l.google.com:19302",
        },
    ],
}

```
<br></br>
### イベント処理

WebRTC特有の問題な気がしますが
httpやwebsocketと異なり、基本的に通信内容が見れません
なので、開発時は発生するすべてのイベントにリスナーを設定し
コンソールに吐くような処理を入れていないと、何が起こっているか分からない。

と言う素敵な出来事を経験出来ます

言い方を変えると
何かしらイベントと必ず紐づくので
該当のイベントに関数を設定するだけで良い

という見方も出来、実際勝手がわかると、接続さえ出来てしまえば
かなりいろんなことが出来ます。
* バイナリベースのファイル共有
* マウス座標を共有化した、ホワイトボードアプリ
* 自分が取得したストリームを、他に流して、ネットワーク集中を防ぐ
  
使い方は無限大

今回の場合

* open：接続
* disconect：切断
* addstream：ストリーム追加
  
の、3件のみ
OpenAI APIのRealtimeで必要な30数個に比べれば非常に簡単です。

分ける必要はありませんが
「接続・切断」と「ストリーム追加」で関数を分けています。

内容は単純に

接続イベント側は
* 接続時に、画面の指定要素にステータスを表示
* 切断時に、リモートビデオを消す

```typescript
const addListenner = () => {
    if (!conn) return
    
    // WebRTC が確立したら connection-state に pc.connectionState 反映する
    conn.on("open", (event: Event) => {
        if (!conn) return;
        
        const pc = conn.peerConnection;
        if (pc) {
            pc.onconnectionstatechange = (event: Event) => {
                const connectionStateElement =
                            document.getElementById("connection-state") as HTMLSpanElement;
                if (connectionStateElement) {
                    connectionStateElement.dataset.connectionState = pc.connectionState;
                }
            };
        }
    });
    
    conn.on("disconnect", (event: Event) => {
        if (!conn) return;

        conn = null;

        const remoteVideoElement = document.getElementById("remote-video",) as HTMLVideoElement;
        if (remoteVideoElement) {
            remoteVideoElement.srcObject = null;
        }
    });
    
    conn.connect(null);
}
```

ストリームイベント側は
* 追加時にリモートビデオにストリームを繋ぐ

```typescript
const addStream = () => {
    if (!conn) return
    
    conn.on("addstream", (event: any) => {
        const remoteVideoElement = document.getElementById("video",) as HTMLVideoElement;
        if (remoteVideoElement) {
            remoteVideoElement.srcObject = event.stream;
        }
    });
}

```

<br></br>
### 接続開始

接続設定、リスナーの処理をまとめて呼び出し
接続を開始する、スタート関数を作ります。

loadViewer()がありますが
最終的に、ステータスを表示していないので無くても良いです。

```typescript
export const connect = () => {
    if (conn !== null) {
        console.log('Ayame connection is already established')
        return
    }

    options.clientId = ConnectionOptions.clientId
    options.signalingKey = ConnectionOptions.signalingKey

    const roomId = `${ConnectionOptions.roomIdPrefix}${ConnectionOptions.roomName}`
    conn = connection(ConnectionOptions.signalingUrl, roomId, options, debug)

    loadViewer()

    addListenner()
    addStream()
}
```

以上を内容を含めて、

<details><summary>webrtc.service.ts</summary>

```javascript
import {
    connection,
} from "@open-ayame/ayame-web-sdk"
import { defaultOptions } from "@open-ayame/ayame-web-sdk"
import Connection from "@open-ayame/ayame-web-sdk/dist/connection"

export type ConnectionOptionsType = {
    signalingKey: string
    signalingUrl: string
    roomIdPrefix: string
    roomName: string
    clientId: string
    video: boolean
    audio: boolean
    simulcast: boolean
    videoCodecType: 'VP8' | 'VP9' | 'H264' | 'AV1' | 'H265'
    audioCodecType: 'OPUS' | 'ISAC' | 'G722' | 'PCMU' | 'PCMA' | 'G729' | 'ILBC' | 'AAC' | 'HEAAC'
    iceServers: RTCIceServer[]
}

const ConnectionOptions: ConnectionOptionsType = {
    signalingKey: 'OpenAyameプロジェクトで取得したキー',
    signalingUrl: 'wss://ayame-labo.shiguredo.app/signaling',
    roomIdPrefix: '自分のOpenAyameプロジェクトのID',
    roomName: 'ayame-labo-sample',
    clientId: crypto.randomUUID(),
    video: true,
    audio: true,
    simulcast: true,
    videoCodecType: "VP8",
    audioCodecType: "OPUS",
    iceServers: [
        {
            urls: "stun:stun.l.google.com:19302",
        },
    ],
}

export type StatusViewElementType = {
    roomId: HTMLSpanElement | string
    roomName: HTMLSpanElement | string
    clientId: HTMLSpanElement | string
    connectionState: HTMLSpanElement | string
    remoteVideo: HTMLVideoElement | string
}

const StatusViewElement = {
    roomId: '',
    roomName: '',
    clientId: '',
    connectionState: '',
    remoteVideo: 'video',
}

const debug = true;
const options = defaultOptions;

let conn: Connection | null = null;

export const setOptions = (options: ConnectionOptionsType) => {
    Object.assign(ConnectionOptions, options);
}

export const setElements = (elements: StatusViewElementType) => {
    Object.assign(StatusViewElement, elements);
}



export const setStatusDOM = (
    roomId: string,
    roomName: string,
    clientId: string,
) => {
    const roomIdElement = document.querySelector(
        "#room-id",
    ) as HTMLSpanElement;
    if (roomIdElement) {
        roomIdElement.textContent = roomId;
    }

    const roomNameElement = document.querySelector(
        "#room-name",
    ) as HTMLSpanElement;
    if (roomNameElement) {
        roomNameElement.textContent = roomName;
    }

    const clientIdElement = document.querySelector(
        "#client-id",
    ) as HTMLSpanElement;
    if (clientIdElement) {
        clientIdElement.textContent = clientId;
    }
}

export const connect = () => {
    if (conn !== null) {
        console.log('Ayame connection is already established')
        return
    }

    options.clientId = ConnectionOptions.clientId
    options.signalingKey = ConnectionOptions.signalingKey

    const roomId = `${ConnectionOptions.roomIdPrefix}${ConnectionOptions.roomName}`
    conn = connection(ConnectionOptions.signalingUrl, roomId, options, debug)

    loadViewer()

    addListenner()
    addStream()
}

export const disconnect = () => {
    if (!conn) {
        console.log('Ayame connection is not established')
        return
    }
    conn.disconnect()
}

const addStream = () => {
    if (!conn) return
    
    conn.on("addstream", (event: any) => {
            const remoteVideoElement = document.getElementById(
                "video",
            ) as HTMLVideoElement;
            if (remoteVideoElement) {
                remoteVideoElement.srcObject = event.stream;
            }
        });
}


const addListenner = () => {
    if (!conn) return
    
    // WebRTC が確立したら connection-state に pc.connectionState 反映する
    conn.on("open", (event: Event) => {
        if (!conn) return

        const pc = conn.peerConnection;
        if (pc) {
            pc.onconnectionstatechange = (event: Event) => {
            const connectionStateElement = document.getElementById(
                "connection-state",
            ) as HTMLSpanElement;
                if (connectionStateElement) {
                    connectionStateElement.dataset.connectionState = pc.connectionState;
                }
            };
        }
    });
    
    conn.on("disconnect", (event: Event) => {
        if (!conn) return;
        
        conn = null;
    
        const remoteVideoElement = document.getElementById("remote-video") as HTMLVideoElement;
        if (remoteVideoElement) {
            remoteVideoElement.srcObject = null;
        }
    });
    
        conn.connect(null);
}

const loadViewer = () => {
    for (const key in StatusViewElement) {
        if (typeof StatusViewElement[key] === 'string' && StatusViewElement[key] !== '') {
            StatusViewElement[key] = document.getElementById(StatusViewElement[key]) as HTMLSpanElement
        }
    }
}

```
</details>

<br></br>
## 配信の再生

テストで用意した画面では、「MOVIE」ボタンに作成した関数を割り当てています。
コレを押すと、このように配信映像がVideoタグで再生されます。

<img
    src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/147738/28b432f4-6c14-4ddf-ad51-cf59b1b8eff2.png"
    width="320"
/>


ビデオ要素は次の工程で非表示になる運命が待っていますので
配信が見れればここではＯＫです。


<br></br>
<br></br>
# UZAYA
Uzayaでは、多分仕事を求めています。
何かの役に立ちそうでしたら、是非お知らせを。