【自分向け】WebRTCの勉強
WebRTCについて調べつつ、Reactで実装してみる。
既にまとめている人がいるようなので、主にこれを参考にしていく。
とりあえずリポジトリを用意。
create-next-app
を使って環境構築してます。
$ npx create-next-app --example with-typescript react-webrtc-study
ちょっと古いけど参考になる。
WebRTCは、Web Real Time Communicationの略で、オープンソースで仕様が決められている様子。
現状、ブラウザ経由でカメラやオーディオデバイスを操作し、P2Pなどでビデオ通話が実現できる、くらいのイメージ。
getUserMedia()
APIを使って、
- カメラ
- マイク
- スピーカー
の情報を取得できる感じかな。
Navigator.getUserMedia()
がDeprecated になっていて、代わりにMediaDevices.getUserMedia()
が推奨されている様子。
一旦適当に叩いてみると、値が取れた。
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;
}
それっぽい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"
が入る様子。
実際に使う際は、audio, video それぞれで配列が欲しいケースが多いと思うので、その場合は、getTracks()
ではなく、getAudioTracks()
とgetVideoTracks()
を使うことで、フィルタリングした値が取得出来るっぽい。
適当に叩いてみる。
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"]
実際にデバイスを使って、ストリームデータの取得と、画面の描画を試みる。
Videoのほうが簡単そうなので、まずはそっちから。
画面に表示する際には、HTMLの<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.srcObject
にstream
を突っ込んだらこれだけでビデオが映った。すごい!
参考: https://webrtc.github.io/samples/src/content/getusermedia/gum/
デバイス間で通信してビデオするのは置いといて、ひとまずAudio側のキャッチアップに移る。
まずは、AudioContext
を理解する必要がありそう。
といいつつ、習うより慣れろタイプなので、ひとまずこちらを読んでみる。
ふむふむ...なるほど、AudioContext
を使っていないではないか!
先ほど出てきたgetAudioTracks()
でなんとかなりそうなので、やってみる。
上記は、HTMLの<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
音声取れました。イヤホンしてないとハウリングします。
API経由でローカルデバイスの情報が取れることがわかったところで、ここからが本題感。
RTCPeerConnection
API を使って、WebRTC接続をする模様。
参考はこちら。
色々すっ飛ばしていきなりハードル上がった感があるけど、一旦気にしない...。
わからなくなったら戻る。
やはりちゃんと内容理解してからじゃないと実装難しそうなので、読む。
通信方式が3種類あるみたい。
- P2P(Peer-to-peer)
- MCU(Multipoint Control Unit)
- SFU(Selective Forwarding Unit)
クライアント同士でやりとりする場合はP2P、サーバーを経由してやりとりする場合はSFUを採用するのが良いのかな?
基本的には、本番運用するならSFUになりそうな雰囲気。
WebRTCサーバーを用意する必要があるので、ローカルだけで試すのはちょっと大掛かりになりそう。
Dockerを使ってやれなくはなさそうだけど、今回はP2Pで通信する前提で調べようかな。
この辺も参考に。
続きはまた今度。
なんだかんだ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側のPeerConection
にonicecandidate
イベントを設定する。
これはiceCandidate
が生成されたときに発火するイベント?っぽい。
イベント内でremoteのPeerConnection
のaddIceCandidate()
メソッドを使って、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
}
}
}
全貌はこんな感じになりました。
↑今後は、1画面でやっていたこれをHTTP経由で二つのデバイス間で行えるところを目指す。
WebRTCサーバーをWebSocketで実装する必要が出てきそうなので、その辺を調べる。
socket.io
に苦戦していた...。
ReactのFunctionalComponentを使っていると、インスタンスの生成位置によってイベントが複数回フックするみたいだったので、潔くClassComponentに変更して実装中。
やりとりのシーケンスはこの記事が参考になる。
ローカルで複数タブを開いて、タブ間で通信が出来たっぽい。
後ほどデプロイして複数デバイスで動作確認をする。
Firebaseとかでいいかな...。