LoginSignup
20
6

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-08-09

image.png

はじめに

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

先日記事を書きました。

そう、オリジナルで加工した映像が送れるようになったのです!
ウキウキしていたウサギーですが・・・
image.png
そう、別の機能を実装中に気付いてしまったのです。

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

この記事の位置づけ

加工したカメラ映像+音声データもSkyWayを通じて送るようにします。

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

あらすじ

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

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

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

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

思い当たる節は1つです。

main.js
-      localStream = stream;
+      localStream = myCvs.captureStream(30);

ここ。

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

MediaStreamのオブジェクトについて知る

これまでの私はMediaStreamのことは
getUserMedia()で取得できる、映像と音声が取れるやつ
くらいの認識で使っていました。

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

参考:MediaStream:W3C
   MediaStream:MDN Web Docs

以下ザクっと箇条書き

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

ふむふむ。
イメージはこんな感じでしょうか?
image.png

captureStreamについて知る

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

前回までの理解はこの程度。

HTMLCanvasElement.captureStream()は
canvas の前面をリアルタイムにキャプチャした動画を CanvasCaptureMediaStream (en-US) として返すメソッド
戻り値としてMediaStream オブジェクトへの参照を返す

参考:captureStream:MDN Web Docs

では CanvasCaptureMediaStream (en-US) オブジェクトとは?
リンク先を確認してみると

CanvasCaptureMediaStreamTrack:MDN Web Docs

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

つまり‥‥
image.png
videoのデータしかない!?

音声データを持ってくる

audioにはgetUserMedia()で取得したローカルのMediaTrackを使えたら、今回の目的は達成できます。

image.png

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

getAudioTracks()

MediaStream.getAudioTracks():MDN Web Docs
ストリームの track set の中から、 MediaStreamTrack.kind が audio である MediaStreamTrack を表すオブジェクトの配列を返します

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

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

captureStream() で作ったMediaStreamのaudioTrackも確認しておきます。

main.js
        // const audioTracks = stream.getAudioTracks();
        // console.log("audioTracks",audioTracks);
        const audioTracks = localStream.getAudioTracks();
        console.log("audioTracks",audioTracks);

image.png

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

captureStreamに音声データを追加する

stream.getAudioTracks();によって取得されるのは
MediaStreamTrack を表すオブジェクトの配列です。

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

1行追加しました。

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]);

コンソールにログを出して確認してみました。

main.js
        const audioTracks = localStream.getAudioTracks();
        console.log("audioTracks",audioTracks);

音声データが追加されました!
image.png

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

コード全体

(過去記事からあまり変わりませんが…)

index.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>
main.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);
});

おわりに

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

20
6
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
20
6