【新規事業】SkyWayでモザイク加工したカメラ映像を送ってみるよ（完結編）【プロト開発】

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/b6d31427-3ef7-8a1a-d68e-896254c9be43.png)

# はじめに
株式会社マイスター・ギルド新規事業部のウサギーです。
弊社新規事業部では、新規サービスの立ち上げを目指して
日々、アイディアの検証やプロトタイプの作成を行っています。

先日記事を書きました。

https://qiita.com/Shinkijigyo_no_Usagi/private/31332c1033d564e34c96

https://qiita.com/Shinkijigyo_no_Usagi/private/ce057ed8bea6bdd6867e

そう、オリジナルで加工した映像が送れるようになったのです！
ウキウキしていたウサギーですが・・・
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/41babb3a-254a-69b1-f021-79a1812cc7c6.png)
そう、別の機能を実装中に気付いてしまったのです。

この実装だと「**オーディオ情報を失っている**」と言うことに:hugging:

# この記事の位置づけ
加工したカメラ映像＋音声データもSkyWayを通じて送るようにします。

JavaScript歴 数か月のペーペー、ウサギーが
迷いながら開発した作業の軌跡となります。

# あらすじ
先日、SkyWayの１：１通信アプリをカスタムして
カメラ映像を加工して、加工した映像をSkyWayを通じて送ることにしたウサギー。

しかし、「オーディオ情報を失っている」ことに気付きました。

今回は加工したカメラ映像＋「**音声データ**」をSkyWayを通じて送れるように修正していこうと思います。

# なぜ音声データが消えちゃったのか

思い当たる節は１つです。
```diff_JavaScript:main.js
-      localStream = stream;
+      localStream = myCvs.captureStream(30);
```
ここ。

動作確認でも`localStream = stream;`のときは音声が通信相手に届いているけれど
`localStream = myCvs.captureStream(30);`のときには音声が無くなっていました。



# MediaStreamのオブジェクトについて知る
これまでの私はMediaStreamのことは
`getUserMedia()で取得できる、映像と音声が取れるやつ`
くらいの認識で使っていました。

今回はそれだけでは理解が弱いと感じたため、
MediaStreamオブジェクトについてすこし調べてみることにしました。

参考：[MediaStream：W3C](https://w3c.github.io/mediacapture-main/#mediastream)
　　　[MediaStream：MDN Web Docs](https://developer.mozilla.org/ja/docs/Web/API/Media_Streams_API)

以下ザクっと箇条書き
* **チャンネル** ⊂ **トラック** ⊂ **ストリーム**
* チャンネルはストリームの最小の単位
 　例）ステレオのleftとrightのそれぞれのオーディオ信号 
* トラックは1つ以上のチャンネルを持つ
　 例）Webカメラから得られるビデオ
* ストリームは、複数のトラックを１ユニットとしてグループ化するためのもの
　・ストリームは0個以上のトラックで構成されている

ふむふむ。
イメージはこんな感じでしょうか？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/b42c5965-5d3d-575a-be9d-d8d354abd55f.png)

# captureStreamについて知る
MediaStreamの構成をなんとなく把握したところで、
CaptureStreamについてももうすこし調べてみます。

前回までの理解はこの程度。
>HTMLCanvasElement.captureStream()は
>canvas の前面をリアルタイムにキャプチャした動画を CanvasCaptureMediaStream (en-US) として返すメソッド
>戻り値としてMediaStream オブジェクトへの参照を返す
>
>参考：[captureStream：MDN Web Docs](https://developer.mozilla.org/ja/docs/Web/API/HTMLCanvasElement/captureStream)

では CanvasCaptureMediaStream (en-US) オブジェクトとは？
リンク先を確認してみると
>[CanvasCaptureMediaStreamTrack：MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStreamTrack)

このインタフェースは、MediaStreamTrackを継承しており
＜canvas＞要素から生成されるMediaStreamに**含まれるMediaTrack**のようです。

つまり‥‥
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/8dcff6de-9848-883e-18d3-a6af33626d1d.png)
videoのデータしかない！？

# 音声データを持ってくる
audioには`getUserMedia()`で取得したローカルのMediaTrackを使えたら、今回の目的は達成できます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/ff4fe31b-b0b8-b91c-983c-f49b11727afb.png)

というわけで、ローカルのMediaStreamからaudioのMediaTrackを取得します。
取得には ↓ を使います。

### getAudioTracks()
>[MediaStream.getAudioTracks()：MDN Web Docs](https://developer.mozilla.org/ja/docs/Web/API/MediaStream/getAudioTracks)
>ストリームの track set の中から、 MediaStreamTrack.kind が audio である MediaStreamTrack を表すオブジェクトの配列を返します

まず、どんなAudioTrackがあるのか確認してみました。

```diff_JavaScript:main.js
navigator.mediaDevices.getUserMedia({ video:{ width: CVS_WIDTH , height: CVS_HEIGHT }, audio: true })
    .then(stream => {
        const audioTracks = stream.getAudioTracks();
        console.log("audioTracks",audioTracks);
```

私の環境ではこんな感じのデータが取得できました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/12e5d327-815f-e324-cffb-ecad06779e0e.png)

captureStream() で作ったMediaStreamのaudioTrackも確認しておきます。
```diff_JavaScript:main.js
        // const audioTracks = stream.getAudioTracks();
        // console.log("audioTracks",audioTracks);
        const audioTracks = localStream.getAudioTracks();
        console.log("audioTracks",audioTracks);
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/a9dad78f-4fde-904f-1932-d513cc0b0109.png)

予想通り、空っぽでした！

### captureStreamに音声データを追加する
`stream.getAudioTracks();`によって取得されるのは
`MediaStreamTrack を表すオブジェクトの配列`です。

私の環境ではヘッドセットを使ってもPCのマイクを使っても返ってくる配列のlengthは１だったので、その一つをそのまま使うことにします。
lengthが2以上で返ってくる場合はデバイスを選択をさせる処理が必要になりそうです。

1行追加しました。
```diff_JavaScript:main.js
navigator.mediaDevices.getUserMedia({ video:{ width: CVS_WIDTH , height: CVS_HEIGHT }, audio: true })
    .then(stream => {
        myVideo.srcObject = stream;
        myVideo.play();
        localStream = myCvs.captureStream(30);
+       localStream.addTrack(stream.getAudioTracks()[0]);
```

コンソールにログを出して確認してみました。
```diff_JavaScript:main.js
        const audioTracks = localStream.getAudioTracks();
        console.log("audioTracks",audioTracks);
```

音声データが追加されました！
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/913784/5b948568-4d86-2497-2199-4eaa7d007f97.png)

これで音声データも他方へ届けられます～！

# コード全体
（過去記事からあまり変わりませんが…）
<details><summary>index.html</summary><div>

```HTML:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>1対1ビデオ通話</title>
    <style type="text/css">
        .container{
            width: 900px;
        }
        .main-wrapper{
            height: 600px;
        }
        .my-section{
            float: left;
            width: 50%;
            height: 100%;
        }
        .their-section{
            float: right;
            width: 50%;
            height: 100%;
        }
        #my-video{
            position:absolute;
            top:140px;
            left:10px;
            z-index:1;
        }
        #my-canvas{
            position:absolute;
            top:140px;
            left:10px;
            z-index:2;
        }
        #my-setting{
            position:absolute;
            top:420px;
            left:10px;
            z-index:3;
        }
    </style>
</head>
<body>
    <h1>1対1ビデオ通話のサンプル</h1>
    <div class="container">
        <div class="my-section">
            <p class="my-id-label">自分のPeerID: <span id="my-id"></span></p>
            <video id="my-video" width="360px" height="270px" autoplay muted playsinline></video>
            <canvas id="my-canvas"></canvas>
            <div id="my-setting" >
                <input type="button" id="mosaic-btn" value="モザイク">
                <span id="mosaic-enabled">OFF</span>
            </div>
        </div>
        <div class="their-section">
            <p>
                <label class="call-id-form-label" for="their-id">相手のPeerID: </label>
                <input id="their-id" class="call-id-form">
                <button type="button" id="call-btn">発信</button>
            </p>
            <video id="their-video"  width="360px" height="270px" autoplay muted playsinline></video>    
        </div>
    </div>
    <script src="//cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
    <script src="./main.js"></script>
</body>
</html>
```
</div></details>
<details><summary>main.js</summary><div>

```js:main.js
// window.__SKYWAY_KEY__ = 'SkyWayのAPI Keyを入れてね';

let localStream;
const myVideo = document.getElementById('my-video');
const theirVideo = document.getElementById('their-video');

const CVS_WIDTH = 360
const CVS_HEIGHT = 270

const myCvs = document.getElementById('my-canvas');
const myCtx = myCvs.getContext('2d');
myCvs.width = CVS_WIDTH;
myCvs.height = CVS_HEIGHT;
myCvs.style.width = `${CVS_WIDTH}px`;
myCvs.style.height = `${CVS_HEIGHT}px`;

navigator.mediaDevices.getUserMedia({ video:{ width: CVS_WIDTH , height: CVS_HEIGHT }, audio: true })
    .then(stream => {

        myVideo.srcObject = stream;
        myVideo.play();
        localStream = myCvs.captureStream(30);
        localStream.addTrack(stream.getAudioTracks()[0]);

        myVideo.onloadeddata = () => {
            setInterval(() => {
                if(isMosaicEnabled){
                    mosaicMyCvs();
                }else{
                    copyToMyCvs();
                }
            }, 1000 / 30);
        }
    }).catch(error => {
        console.error('mediaDevice.getUserMedia() error:', error);
});

const MOSAIC_SIZE = 20;
const mosaicMyCvs = () => {
    myCtx.clearRect(0, 0, CVS_WIDTH, CVS_HEIGHT);
    const mosaicCvs = document.createElement('canvas');
    const mosaicCtx = mosaicCvs.getContext('2d');
    mosaicCvs.width = CVS_WIDTH ;
    mosaicCvs.height = CVS_HEIGHT ;
    mosaicCvs.style.width = `${CVS_WIDTH}px`;
    mosaicCvs.style.height = `${CVS_HEIGHT}px`;
    mosaicCtx.drawImage(myVideo, 0, 0);
    const imageData = mosaicCtx.getImageData(0, 0, CVS_WIDTH, CVS_HEIGHT);
    //モザイクサイズ単位でループ
    for (let y = 0; y < myCvs.height; y = y + MOSAIC_SIZE) {
        for (let x = 0; x < myCvs.width; x = x + MOSAIC_SIZE) {
            // 該当ピクセルの色情報を取得
            const cR = imageData.data[(y * myCvs.width + x) * 4];
            const cG = imageData.data[(y * myCvs.width + x) * 4 + 1];
            const cB = imageData.data[(y * myCvs.width + x) * 4 + 2];
            // モザイクサイズの正方形を描画
            myCtx.fillStyle = `rgb(${cR},${cG},${cB})`;
            myCtx.fillRect(x, y, x + MOSAIC_SIZE, y + MOSAIC_SIZE);
        }
    }
}

const copyToMyCvs = () => {
    myCtx.drawImage(myVideo, 0, 0, CVS_WIDTH, CVS_HEIGHT);
}

let isMosaicEnabled = false;
document.getElementById('mosaic-btn').onclick = () => {
    if (isMosaicEnabled) {
        isMosaicEnabled = false;
        document.getElementById('mosaic-enabled').textContent = "OFF";
    } else {
        isMosaicEnabled = true;
        document.getElementById('mosaic-enabled').textContent = "ON";
    }
}

const peer = new Peer({
    key: window.__SKYWAY_KEY__,
    debug: 3
});

peer.on('open', () => {
    document.getElementById('my-id').textContent = peer.id;
});

document.getElementById('call-btn').onclick = () => {
    const theirID = document.getElementById('their-id').value;
    const mediaConnection = peer.call(theirID, localStream);
    setEventListener(mediaConnection);
};

const setEventListener = mediaConnection => {
    mediaConnection.on('stream', stream => {
        theirVideo.srcObject = stream;
        theirVideo.play();
    });
}

peer.on('call', mediaConnection => {
    mediaConnection.answer(localStream);
    setEventListener(mediaConnection);
});
```
</div></details>

# おわりに
無事、映像だけでなく音も届けることができるようになりました！
次は、これもまた躓いた「マイクON/OFF」や「スピーカーON/OFF」について記事にする予定です。
興味があれば次の記事もよろしくお願いします～:rabbit: