Open22

【自分向け】WebRTCの勉強

TKTK

WebRTCについて調べつつ、Reactで実装してみる。

TKTK

WebRTCは、Web Real Time Communicationの略で、オープンソースで仕様が決められている様子。

https://webrtc.org/

現状、ブラウザ経由でカメラやオーディオデバイスを操作し、P2Pなどでビデオ通話が実現できる、くらいのイメージ。

TKTK

一旦適当に叩いてみると、値が取れた。

const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });

MediaStream 型が取得できる。
I/F はこんな感じっぽい。

/** A stream of media content. A stream consists of several tracks such as video or audio tracks. Each track is specified as an instance of MediaStreamTrack. */
interface MediaStream extends EventTarget {
    readonly active: boolean;
    readonly id: string;
    onaddtrack: ((this: MediaStream, ev: MediaStreamTrackEvent) => any) | null;
    onremovetrack: ((this: MediaStream, ev: MediaStreamTrackEvent) => any) | null;
    addTrack(track: MediaStreamTrack): void;
    clone(): MediaStream;
    getAudioTracks(): MediaStreamTrack[];
    getTrackById(trackId: string): MediaStreamTrack | null;
    getTracks(): MediaStreamTrack[];
    getVideoTracks(): MediaStreamTrack[];
    removeTrack(track: MediaStreamTrack): void;
    addEventListener<K extends keyof MediaStreamEventMap>(type: K, listener: (this: MediaStream, ev: MediaStreamEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof MediaStreamEventMap>(type: K, listener: (this: MediaStream, ev: MediaStreamEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}
TKTK

それっぽいgetTracks() を叩いてみると、MediaStreamTrack[] 型が取得できた。
MediaStreamTrack 型のI/Fはこちら。

/** A single media track within a stream; typically, these are audio or video tracks, but other track types may exist as well. */
interface MediaStreamTrack extends EventTarget {
    enabled: boolean;
    readonly id: string;
    readonly isolated: boolean;
    readonly kind: string;
    readonly label: string;
    readonly muted: boolean;
    onended: ((this: MediaStreamTrack, ev: Event) => any) | null;
    onisolationchange: ((this: MediaStreamTrack, ev: Event) => any) | null;
    onmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
    onunmute: ((this: MediaStreamTrack, ev: Event) => any) | null;
    readonly readyState: MediaStreamTrackState;
    applyConstraints(constraints?: MediaTrackConstraints): Promise<void>;
    clone(): MediaStreamTrack;
    getCapabilities(): MediaTrackCapabilities;
    getConstraints(): MediaTrackConstraints;
    getSettings(): MediaTrackSettings;
    stop(): void;
    addEventListener<K extends keyof MediaStreamTrackEventMap>(type: K, listener: (this: MediaStreamTrack, ev: MediaStreamTrackEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void;
    addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void;
    removeEventListener<K extends keyof MediaStreamTrackEventMap>(type: K, listener: (this: MediaStreamTrack, ev: MediaStreamTrackEventMap[K]) => any, options?: boolean | EventListenerOptions): void;
    removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void;
}

kind"audio""video" が入る様子。

TKTK

実際に使う際は、audio, video それぞれで配列が欲しいケースが多いと思うので、その場合は、getTracks() ではなく、getAudioTracks()getVideoTracks() を使うことで、フィルタリングした値が取得出来るっぽい。

TKTK

適当に叩いてみる。

const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
const audioTracks = stream.getAudioTracks();
const audioLabelList = audioTracks.map(track => track.label);
console.log(audioLabelList);
const videoTracks = stream.getVideoTracks();
const videoLabelList = videoTracks.map(track => track.label);
console.log(videoLabelList);

デバイスのラベル(名前)が取れた。

["既定 - MacBook Airのマイク (Built-in)"]
["FaceTime HD Camera"]

TKTK

実際にデバイスを使って、ストリームデータの取得と、画面の描画を試みる。
Videoのほうが簡単そうなので、まずはそっちから。

画面に表示する際には、HTMLの<video /> タグを使う。
https://developer.mozilla.org/ja/docs/Web/HTML/Element/video

import { useEffect, useRef } from "react"

const IndexPage = () => {
  const videoRef = useRef<HTMLVideoElement>(null)
  
  useEffect(() => {
    const setVideoStream = async () => {
      const stream = await navigator.mediaDevices.getUserMedia({ video: true });
      if (videoRef.current) {
        videoRef.current.srcObject = stream;
      }
    }

    setVideoStream();
  }, [])
  
  return (
    <>
      <div>
        <video
          style={{ width: '300px', height: '300px', maxWidth: '100%' }}
          ref={videoRef}
          autoPlay
          playsInline
        />
      </div>
    </>
  )
}

export default IndexPage

style は適当、ref に取得したstream 情報を流す?ことによって描画できるっぽい。
その際に、autoPlay を指定しないとビデオが始まらないので指定。
playsInline は、インラインで再生するかどうかみたいだけど、よくわからない。とりあえず指定。

Functional Component なので、useRef を使ってvideoRef を作成。
videoRef.current.srcObjectstream を突っ込んだらこれだけでビデオが映った。すごい!

参考: https://webrtc.github.io/samples/src/content/getusermedia/gum/

TKTK

デバイス間で通信してビデオするのは置いといて、ひとまずAudio側のキャッチアップに移る。

まずは、AudioContext を理解する必要がありそう。
https://developer.mozilla.org/ja/docs/Web/API/AudioContext

といいつつ、習うより慣れろタイプなので、ひとまずこちらを読んでみる。

https://webrtc.github.io/samples/src/content/getusermedia/audio/
https://github.com/webrtc/samples/tree/gh-pages/src/content/getusermedia/audio

ふむふむ...なるほど、AudioContext を使っていないではないか!
先ほど出てきたgetAudioTracks() でなんとかなりそうなので、やってみる。

TKTK

上記は、HTMLの<audio /> タグを使っている模様。

https://developer.mozilla.org/ja/docs/Web/HTML/Element/audio
controls をつけると、再生ボタンなどが描画される様子。
autoPlay で自動再生はビデオと同じだけど、非推奨っぽい。

音声だけでよければこれでOKで、ビデオと同期する場合は、AudioContext 経由で扱う必要性が出てくる感じかな。

あれ?これもしやgetAudioTrakcs() もいらない...?
Video同様、audioRef を作って差し込んだらいけた...。

とりあえずコード。

import { useEffect, useRef } from "react"

const IndexPage = () => {
  const videoRef = useRef<HTMLVideoElement>(null)
  const audioRef = useRef<HTMLAudioElement>(null)
  
  useEffect(() => {
    const setVideoStream = async () => {
      const stream = await navigator.mediaDevices.getUserMedia({ video: true });
      if (videoRef.current) {
        videoRef.current.srcObject = stream;
      }
    }

    const setAudioStream = async () => {
      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      if (audioRef.current) {
        audioRef.current.srcObject = stream;
      }
    }

    setVideoStream();
    setAudioStream();
  }, [])
  

  return (
    <>
      <div>
        <video
          style={{ width: '300px', height: '300px', maxWidth: '100%' }}
          ref={videoRef}
          autoPlay
          playsInline
        />
        <audio
          ref={audioRef}
          controls
          autoPlay
        />
      </div>
    </>
  )
}

export default IndexPage

音声取れました。イヤホンしてないとハウリングします。

TKTK

API経由でローカルデバイスの情報が取れることがわかったところで、ここからが本題感。

RTCPeerConnection API を使って、WebRTC接続をする模様。
https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection

参考はこちら。
https://webrtc.github.io/samples/src/content/peerconnection/pc1/
https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/pc1/js/main.js

色々すっ飛ばしていきなりハードル上がった感があるけど、一旦気にしない...。
わからなくなったら戻る。

TKTK

通信方式が3種類あるみたい。

  • P2P(Peer-to-peer)
  • MCU(Multipoint Control Unit)
  • SFU(Selective Forwarding Unit)

クライアント同士でやりとりする場合はP2P、サーバーを経由してやりとりする場合はSFUを採用するのが良いのかな?
基本的には、本番運用するならSFUになりそうな雰囲気。
WebRTCサーバーを用意する必要があるので、ローカルだけで試すのはちょっと大掛かりになりそう。
Dockerを使ってやれなくはなさそうだけど、今回はP2Pで通信する前提で調べようかな。

TKTK

なんだかんだ1画面で2つのvideoタグをおいて、一つのビデオをもう一つのほうに流し込むところまでは出来たので、メモ。

1画面にlocalとremoteを仮定したvideoタグを置いて、localからremoteへ映像と音声を送ることを想定する。

はじめに、getUserMedia() API を使って、stream 情報を取得し、videoRef に設定する。

localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
if (localVideoRef.current) {
  localVideoRef.current.srcObject = localStream;
}

その後、各videoタグ用のPeerConnection を生成する。

localPeerConnection = new RTCPeerConnection(configuration);
remotePeerConnection = new RTCPeerConnection(configuration);

次に、local側のPeerConectiononicecandidate イベントを設定する。
これはiceCandidate が生成されたときに発火するイベント?っぽい。
イベント内でremoteのPeerConnectionaddIceCandidate() メソッドを使って、iceCandidate を追加する。

localPeerConnection.onicecandidate = (ev: RTCPeerConnectionIceEvent) => {
const iceCandidate = ev.candidate;
if (iceCandidate) remotePeerConnection.addIceCandidate(iceCandidate)

remote側も同じように設定。

remotePeerConnection.onicecandidate = (ev: RTCPeerConnectionIceEvent) => {
const iceCandidate = ev.candidate;
if (iceCandidate) localPeerConnection.addIceCandidate(iceCandidate)

加えて、remote側では、ontrack イベントも設定する。
少し前まではonstream として、stream 単位で設定していたみたいだが、track 単位で設定できるようになったっぽい。
親子関係は、

  • stream
    • track
    • track
    • ...

という感じ。
track にはkind があり、"video", "audio" それぞれで分岐して、remoteVideoRef, remoteAudioRef をそれぞれ設定する。
これによって、remote用のvideoタグに映像が出力され、remote用のaudioタグに音声が出力される。

remotePeerConnection.ontrack = (ev: RTCTrackEvent) => {
  remoteStream = new MediaStream()
  remoteStream.addTrack(ev.track)
  if (ev.track.kind === 'video') {
    if (remoteVideoRef.current) {
      remoteVideoRef.current.srcObject = remoteStream
    }
  }
  if (ev.track.kind === 'audio') {
    if (remoteAudioRef.current) {
      remoteAudioRef.current.srcObject = remoteStream
    }
  }
}

全貌はこんな感じになりました。
https://github.com/k-takahashi23/react-webrtc-study/commit/bd857f79ac252a59c0a2bb7b35b194678754cd17#diff-aa98fd0757d0e1741503c50cfafb7726939d19819638bbe8e030a27adfec34a3

TKTK

↑今後は、1画面でやっていたこれをHTTP経由で二つのデバイス間で行えるところを目指す。
WebRTCサーバーをWebSocketで実装する必要が出てきそうなので、その辺を調べる。

TKTK

socket.io に苦戦していた...。
ReactのFunctionalComponentを使っていると、インスタンスの生成位置によってイベントが複数回フックするみたいだったので、潔くClassComponentに変更して実装中。

やりとりのシーケンスはこの記事が参考になる。
https://qiita.com/massie_g/items/f5baf316652bbc6fcef1