はじめに
この記事は限界開発鯖 Advent Calendar 2021の14日目の記事です。昨日は @kawaemon の「Rust のコンパイルが妙に遅くなったので原因を調べた話」でした。非常に変態的な記事でしたね…
改めてこんにちは、しゅんです。普段はCPU自作からXRまで色々やっています。一応Webフロントエンドが専門です。
さて、皆さんはWebRTCという技術をご存知でしょうか?WebRTCはWeb Real-Time Communicationsの略で、ブラウザ間でのデータの送受信を可能にする技術です。データが中継サーバを介さない1ため、比較的低遅延での送受信ができることが特徴です。
WebRTCはビデオ会議やボイスチャットなどに使われ、Google Meet、Discordなどが採用しています。
今回は「とりあえず動かす」ことを目標に、WebRTCの説明とWebRTCを用いた1対1のビデオ通話アプリの作成をしていこうと思います。
※筆者自体も「WebRTC完全に理解した」くらいの知識量なので間違っている点があれば是非ご指摘ください。
WebRTCの概要
先程も少し話しましたが、改めてWebRTCについて説明します。
WebRTCはブラウザ間でのデータ通信を可能にする技術です。WebRTC自体が何かのプロトコルというわけではなく、様々なプロトコルを利用して作られたものがWebRTCです。
ブラウザ上ではWebRTC APIというWeb APIとして提供されており、複雑な技術の組み合わせであるWebRTCを非常に簡単に扱えるようになっています。
通信が確立するまで
さて、次はWebRTCを用いた通信が確立するまでを見ていきましょう。
まずは通信で相手に送信するストリームを取得します。このストリームにはカメラ映像や音声が含まれています。ブラウザではMedia Capture and Streams APIというAPIを用いて取得します。もちろんカメラ映像・音声以外を送信することもでき、PCの画面や、Canvasの画像を送信することも可能です。
次にSession Description
を相手と交換します。Session Description
には送信するメディアの種類・コーデックやIPアドレス・ポート番号などが記載されています。特にメディアはブラウザごとに対応コーデックが若干異なるため、Session Description
を交換することによってお互いのブラウザが対応しているコーデックを使用するようにしています。なお、Session Description
を交換する一連の流れはSession Description Protocol(SDP)
と呼ばれています。SDPにはOfferとAnswerがあり、自分と相手のどちらか一方がOfferを作成しもう一方に渡します。Offerを受け取った側はAnswerを作成し、それをOfferを送った方に渡します。
最後にICE Candidate
を相手と交換します。ICE Candidate
には自分と通信ができる可能性のある経路が記載されています。これを互いに交換し、ICE Candidate
に記載の経路を元に順次通信を試みます。通信が確立できれば成功です。
一応シーケンス図を示しておきます。
しかしこのシーケンス図には一つ大きな問題があり、ブラウザ同士が通信できることが前提となっています。そもそもWebRTCはブラウザ同士の通信を可能にする技術なのに、はじめからブラウザ同士が通信できれば苦労しません。残念ながらWebRTCはサーバを完全になくす技術では無いのです。WebRTCで通信を確立するためにはSession Description
とICE Candidate
の交換が必要です。これらの情報交換を「シグナリング」と呼び、シグナリングを行うための中継サーバを「シグナリングサーバ」と呼びます。シグナリングサーバは情報交換さえできればよく、WebRTCではプロトコルが規定されていませんが、大抵はWebSocketを用いて実装されます。
今回はサーバの準備が面倒なのと、解説を簡略化するためにコピー&ペーストを用いた全手動シグナリングサーバ2を用いてSession Description
とICE Candidate
の交換をします。ということでシグナリングサーバを間に挟んだシーケンス図を示します。
またブラウザがNATの内側に居る場合(大抵はそうです)、NATの外側からNATの内側に通信することができません。しかしSTUNと呼ばれるプロトコルを用いることによってNATに穴を開け、通信を可能とします(UDPホールパンチング)。さらに特殊な環境に居るブラウザの場合、STUNを用いても通信をすることができないことがあります。この場合はTURNサーバを用います。ブラウザはTURNと一旦通信し、TURNを通じて他のブラウザと通信するようにします。もちろんこの場合はオーバーヘッドが生じ、WebRTCの良さが失われるため、積極的に使用するものではありません。
実験目的ならばSTUNサーバのみで十分ですし、STUNサーバはGoogleなどが無料で提供しているものがいくつかあるため今回はこれを用います。
ビデオ通話アプリを作ってみる
WebRTCの解説をしたところで、早速WebRTCを用いたビデオ通話アプリを作ってみましょう。今回はTypeScript+Tailwind CSS+Viteを用いた環境で解説しますが、自分の好きな技術を使用してください。
とりあえず先にコードを示します。なお今回使用したソースコードはGitHubにあるのでちゃんと見たい方はそちらをご覧ください。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no" />
<title>WebRTC Sample</title>
</head>
<body>
<script type="module" src="/src/index.ts"></script>
<div class="container mx-auto my-4 flex flex-col gap-8">
<!-- ビデオ映像 -->
<div class="grid grid-cols-2 gap-2">
<div class="flex flex-col items-stretch">
<p class="text-center text-lg">自分の映像</p>
<video class="rounded-lg" id="my-video" autoplay></video>
</div>
<div class="flex flex-col items-stretch">
<p class="text-center text-lg">相手の映像</p>
<video class="rounded-lg" id="other-video" autoplay></video>
</div>
</div>
<div class="mx-10 border-t-2 border-t-grey-300"></div>
<!-- SDP関係 -->
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col items-center gap-2">
<span class="text-lg">SDP Output</span>
<textarea
class="resize-none w-full rounded-lg border-2 border-grey-300 outline-teal-300"
id="sdp-output"
readonly
rows="10"
></textarea>
<button class="px-4 py-2 rounded-lg bg-teal-400 text-white" id="sdp-create-offer-button">
SDP オファー発行
</button>
</label>
<label class="flex flex-col items-center gap-2">
<span class="text-lg">SDP Input</span>
<textarea
class="resize-none w-full rounded-lg border-2 border-grey-300 outline-teal-300"
id="sdp-input"
rows="10"
></textarea>
<button class="px-4 py-2 rounded-lg bg-teal-400 text-white" id="sdp-receive-button">SDP 受け取り</button>
</label>
</div>
<div class="mx-10 border-t-2 border-t-grey-300"></div>
<!-- ICE Candidate関係 -->
<div class="grid grid-cols-2 gap-2">
<label class="flex flex-col items-center gap-2">
<span class="text-lg">ICE Output</span>
<textarea
class="resize-none w-full rounded-lg border-2 border-grey-300 outline-teal-300"
id="ice-output"
readonly
rows="10"
></textarea>
</label>
<label class="flex flex-col items-center gap-2">
<span class="text-lg">ICE Input</span>
<textarea
class="resize-none w-full rounded-lg border-2 border-grey-300 outline-teal-300"
id="ice-input"
rows="10"
></textarea>
<button class="px-4 py-2 rounded-lg bg-teal-400 text-white" id="ice-receive-button">ICE 受け取り</button>
</label>
</div>
</div>
</body>
</html>
// Tailwind CSS用
import "./index.css";
// 各種HTML要素を取得
const myVideo = document.getElementById("my-video") as HTMLVideoElement;
const otherVideo = document.getElementById("other-video") as HTMLVideoElement;
const sdpOutput = document.getElementById("sdp-output") as HTMLTextAreaElement;
const sdpInput = document.getElementById("sdp-input") as HTMLTextAreaElement;
const sdpCreateOfferButton = document.getElementById("sdp-create-offer-button") as HTMLButtonElement;
const sdpReceiveButton = document.getElementById("sdp-receive-button") as HTMLButtonElement;
const iceOutput = document.getElementById("ice-output") as HTMLTextAreaElement;
const iceInput = document.getElementById("ice-input") as HTMLTextAreaElement;
const iceReceiveButton = document.getElementById("ice-receive-button") as HTMLButtonElement;
// WebRTCのコネクションオブジェクトを作成
const peer = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
// 自身のビデオストリームを取得
const myMediaStream = await navigator.mediaDevices.getUserMedia({
video: {
width: 800,
height: 600,
},
});
myVideo.srcObject = myMediaStream;
// 自身のビデオストリームを設定
myMediaStream.getTracks().forEach((track) => peer.addTrack(track, myMediaStream));
// Offer SDPの作成処理
sdpCreateOfferButton.addEventListener("click", async () => {
const sessionDescription = await peer.createOffer();
await peer.setLocalDescription(sessionDescription);
sdpOutput.value = JSON.stringify(sessionDescription, null, 2);
});
// SDPの受け取り処理
sdpReceiveButton.addEventListener("click", async () => {
const sessionDescription: RTCSessionDescriptionInit = JSON.parse(sdpInput.value);
await peer.setRemoteDescription(sessionDescription);
// Offer SDPの場合はAnswer SDPを作成
if (sessionDescription.type === "offer") {
const sessionDescription = await peer.createAnswer();
await peer.setLocalDescription(sessionDescription);
sdpOutput.value = JSON.stringify(sessionDescription, null, 2);
}
});
// 自身のICE Candidateの一覧
const iceCandidates: RTCIceCandidate[] = [];
// ICE Candidateが生成された時の処理
peer.addEventListener("icecandidate", (event) => {
if (event.candidate === null) return;
iceCandidates.push(event.candidate);
iceOutput.value = JSON.stringify(iceCandidates, null, 2);
});
// ICE Candidateの受け取り処理
iceReceiveButton.addEventListener("click", async () => {
const iceCandidates: RTCIceCandidateInit[] = JSON.parse(iceInput.value);
for (const iceCandidate of iceCandidates) {
await peer.addIceCandidate(iceCandidate);
}
});
// Trackを取得した時の処理
peer.addEventListener("track", (event) => {
otherVideo.srcObject = event.streams[0];
});
それでは順番にコードを見ていきましょう。はじめにindex.html
ですが、ここには
- 自分と相手のビデオ映像
- 自分が発行したSDPとSDPを受け取るためのtextarea
- Offer SDP発行ボタン
- SDP受け取りボタン
- 自分が発行したICE CandidateとICE Candidateを受け取るためのtextarea
- ICE Candidate受け取りボタン
があります。これらの要素はJavaScript側で操作するので事前にclassやidを振っておきましょう。サンプルコードの場合はこのように表示されます。
次にindex.ts
を見ていきます。
// 各種HTML要素を取得
const myVideo = document.getElementById("my-video") as HTMLVideoElement;
const otherVideo = document.getElementById("other-video") as HTMLVideoElement;
const sdpOutput = document.getElementById("sdp-output") as HTMLTextAreaElement;
const sdpInput = document.getElementById("sdp-input") as HTMLTextAreaElement;
const sdpCreateOfferButton = document.getElementById("sdp-create-offer-button") as HTMLButtonElement;
const sdpReceiveButton = document.getElementById("sdp-receive-button") as HTMLButtonElement;
const iceOutput = document.getElementById("ice-output") as HTMLTextAreaElement;
const iceInput = document.getElementById("ice-input") as HTMLTextAreaElement;
const iceReceiveButton = document.getElementById("ice-receive-button") as HTMLButtonElement;
ここではindex.html
の各要素を取得しています。
// WebRTCのコネクションオブジェクトを作成
const peer = new RTCPeerConnection({ iceServers: [{ urls: "stun:stun.l.google.com:19302" }] });
RTCPeerConnection
はコネクションを管理するオブジェクトです。基本的に通信を一つ始めるごとに一つ作ります。iceServers
にはSTUN/TURNサーバのアドレスや認証情報を載せることができ、今回はGoogleが無償で提供しているSTUNサーバを利用しています。
// 自身のビデオストリームを取得
const myMediaStream = await navigator.mediaDevices.getUserMedia({
video: {
width: 800,
height: 600,
},
});
myVideo.srcObject = myMediaStream;
// 自身のビデオストリームを設定
myMediaStream.getTracks().forEach((track) => peer.addTrack(track, myMediaStream));
navigator.mediaDevices.getUserMedia()
はデバイスのビデオ映像やマイクのストリームを取得する関数です。実行するとPromise<MediaStream>
が返ってきます。一度もビデオ映像やマイクを利用したことがない場合はブラウザ側がユーザにビデオ/マイク使用の許可を求めます。ユーザが拒否した場合はエラーとなるため通常はエラーハンドリングが必要です。
また、引数としてビデオ映像の解像度やアスペクト比などを決めることもできます。今回はビデオ映像のみですが、audio: true
とすることでマイクのストリームを取得することもできます。
ストリームを取得したあとはそれを<video>
タグやpeer
に設定しています。ストリームは複数のトラックから構成されており、peer
への登録はトラックごとなため、getTracks()
でストリームに含まれるトラックを取得してpeer
に登録しています。
// Offer SDPの作成処理
sdpCreateOfferButton.addEventListener("click", async () => {
const sessionDescription = await peer.createOffer();
await peer.setLocalDescription(sessionDescription);
sdpOutput.value = JSON.stringify(sessionDescription, null, 2);
});
「SDP オファー発行」のボタンを押したときの処理です。
.createOffer()
メソッドを呼び出すことでOffer SDPを発行することができます。この発行されたOffer SDPは自分のSDPなため、.setLocalDescription()
メソッドでpeer
に登録しましょう。
最後にOffer SDPをJSONにしたものをtextareaに出力しています。
// SDPの受け取り処理
sdpReceiveButton.addEventListener("click", async () => {
const sessionDescription: RTCSessionDescriptionInit = JSON.parse(sdpInput.value);
await peer.setRemoteDescription(sessionDescription);
// Offer SDPの場合はAnswer SDPを作成
if (sessionDescription.type === "offer") {
const sessionDescription = await peer.createAnswer();
await peer.setLocalDescription(sessionDescription);
sdpOutput.value = JSON.stringify(sessionDescription, null, 2);
}
});
「SDP 受け取り」のボタンを押したときの処理です。
まずtextareaからJSONをパースしてSDPを作ります。このSDPは相手側のSDPなので.setRemoteDescription()
でpeer
に登録します。
また、このSDPがOffer SDPの場合はAnswer SDPを返さないといけないので、.createAnswer()
でAnswer SDPを生成したあとに.setLocalDescription()
でpeer
に登録し、JSONにしてtextareaに出力しています。
// 自身のICE Candidateの一覧
const iceCandidates: RTCIceCandidate[] = [];
// ICE Candidateが生成された時の処理
peer.addEventListener("icecandidate", (event) => {
if (event.candidate === null) return;
iceCandidates.push(event.candidate);
iceOutput.value = JSON.stringify(iceCandidates, null, 2);
});
ICE CandidateはSDPの発行・受け取り後に非同期で随時生成されるため、icecandidate
というイベントを購読することで生成されたICE Candidateを取得できます。取得したICE Candidateを配列に追加して、JSONにしてtextareaに出力しています。
// ICE Candidateの受け取り処理
iceReceiveButton.addEventListener("click", async () => {
const iceCandidates: RTCIceCandidateInit[] = JSON.parse(iceInput.value);
for (const iceCandidate of iceCandidates) {
await peer.addIceCandidate(iceCandidate);
}
});
「ICE 受け取り」のボタンを押したときの処理です。
まずtextareaからJSONをパースしてICE Candidateの配列を取得します。
これらを全て.addIceCandidate()
で受け取っています。
// Trackを取得した時の処理
peer.addEventListener("track", (event) => {
otherVideo.srcObject = event.streams[0];
});
track
というイベントを購読することで相手側のストリームが取得できます。event.streams
に配列で入っていますが、今回は一つしかストリームを登録していないのでストリームは確実に一つです。このストリームを<video>
タグに設定することで相手側のビデオ映像を見ることができます。
動かしてみる
作成したビデオ通話アプリを早速動かしてみましょう。ブラウザで作成したサイトの2つウィンドウで開きます。実際のシグナリングサーバの気持ちになってSDPとICE Candidateを交換してみます。
まず、どちらか一方のウィンドウの「SDP オファー発行」を押します。すると「SDP Output」にズラーッと文字列が表示されます。これがSDPです(同時に「ICE Output」にも表示されると思いますがとりあえず無視します)。SDPの文字列の中に"type": "offer"
というものがあるのを確認してください。これをすべて選択してコピーします。コピーしたSDPをもう一方のウィンドウの「SDP Input」に貼り付け、「SDP 受け取り」を押します。するとAnswer SDPが発行されて「SDP Output」に表示されます。こちらは"type": "answer"
と書かれています。そしてこれも全て選択&コピーして、もう一方のウィンドウの「SDP Input」に貼り付け「SDP 受け取り」を押してください。ここでは特に何も起きないと思いますが、これでSDPの交換が完了しました。
次にICE Candidateの交換をします。どちらか一方の「ICE Output」の文字列をコピーして、もう一方の「ICE Input」にペーストして「ICE 受け取り」を押します。これをお互いにやれば、「相手の映像」にビデオ映像が映ると思います(場合によっては一方のみで接続が確立することもあります)。
終わりに
今回は全手動でシグナリングを行いましたが、ここにWebSocketなどを用いればちゃんとしたビデオ通話アプリを作ることができます。また、チャットアプリなどを通じてSDPやICE Candidateを交換すれば遠く離れた人とも通信を確立することができます。
また、今回のソースコードを使用したデモサイトも作りましたのでお友達と手動シグナリングをしてみるのも面白いかもしれません。
さて明日は @Saigyo_HBK の「YOLOX+motpyで始めるMultiple Object Tracking」です。どんな記事か楽しみですね!