QRコードでWebRTCシグナリング:完全サーバレスP2P通話の実験
WebRTCでP2P通話を実現するには、通常シグナリングサーバが必要になる。でも「本当にサーバなしでできないの?」と思って、QRコードでシグナリング情報を交換する方法を試してみた。
結論から言うと、技術的には可能だがUXは微妙。でも実験としては面白かったので記録しておく。
WebRTCとシグナリング問題
WebRTCは本来P2P通信のための技術だが、最初の接続確立にはシグナリングが必要になる。
sequenceDiagram
participant A as User A
participant B as User B
A->>B: 1. Offer (SDP)
B->>A: 2. Answer (SDP)
A-->>B: 3. P2P通信開始
B-->>A: 3. P2P通信開始
この「Offer」と「Answer」の交換をどうやって行うかが問題。普通はFirebaseやWebSocketサーバを使うが、今回はQRコードで直接交換する。
SDPの問題:でかすぎる
WebRTCのSDP(Session Description Protocol)には、コーデック情報やICE候補などが含まれる。実際に生成してみると…
SDP length: 2847
Base64 encoded: 3796
約3.8KB。QRコードの容量上限は約2.9KB(バイナリモード)なので、1枚には収まらない。
解決策:分割QRコード
SDPを1KBずつ分割して、複数のQRコードで交換する方式にした。
const CHUNK_SIZE = 1000;
function createChunks(data: string): string[] {
const chunks: string[] = [];
const totalChunks = Math.ceil(data.length / CHUNK_SIZE);
for (let i = 0; i < totalChunks; i++) {
const chunk = data.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE);
// フォーマット: インデックス|総数|データ
chunks.push(`${i}|${totalChunks}|${chunk}`);
}
return chunks;
}
読み取り側は、受け取ったチャンクを順番に関係なく蓄積し、全部揃ったら結合する。
const receivedChunks = new Map<number, string>();
function onQrDetected(raw: string) {
const parsed = parseChunk(raw);
if (!parsed) return;
receivedChunks.set(parsed.index, parsed.data);
// 全部揃ったら結合
if (receivedChunks.size === parsed.total) {
const sorted = [...receivedChunks.entries()]
.sort((a, b) => a[0] - b[0]);
const fullData = sorted.map(e => e[1]).join('');
processCompleteSdp(fullData);
}
}
ICE候補の収集
WebRTCではICE(Interactive Connectivity Establishment)候補の収集が必要。これはNAT越えのための経路情報で、STUNサーバに問い合わせて取得する。
const config: RTCConfiguration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
]
};
peerConnection = new RTCPeerConnection(config);
ICE候補の収集完了を待ってからSDPを出力する必要がある。
function waitForIceGathering(timeout = 5000): Promise<void> {
return new Promise((resolve) => {
if (peerConnection.iceGatheringState === 'complete') {
resolve();
return;
}
const timeoutId = setTimeout(() => resolve(), timeout);
peerConnection.onicegatheringstatechange = () => {
if (peerConnection.iceGatheringState === 'complete') {
clearTimeout(timeoutId);
resolve();
}
};
});
}
タイムアウトを設けているのは、環境によってはICE収集が完了しないことがあるため。
音声設定:ハウリング対策
音声取得時にエコーキャンセルなどを有効にしておく。
localStream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
}
});
詳細は音声ハウリング対策の記事を参照。
実際の接続フロー
- 発信者: マイク開始 → Offer生成 → QR 1/4, 2/4, 3/4, 4/4 を順番に表示
- 応答者: マイク開始 → カメラで4枚のQRを順番に読み取り → 自動でAnswer生成 → QR 1/4〜4/4 を表示
- 発信者: カメラで4枚のQRを読み取り
- 接続完了: P2P通話開始
動作デモ
実際に動くデモを作った。
PCとスマホで試してみると、ちゃんとP2P通話ができる。
結論
技術的には可能。完全にサーバレスで、QRコード交換だけでP2P音声通話が実現できた。
ただし実用性は微妙:
- QRコード4枚を順番に読み取るのは手間
- 映像を追加するとQRがさらに増える
- 普通にLINE通話使えばいい
とはいえ、WebRTCのシグナリングの仕組みを理解するには良い実験だった。「サーバなしでP2P通信」という制約で何ができるかを考えるのは面白い。