技術 約3分で読めます

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,
  }
});

詳細は音声ハウリング対策の記事を参照。

実際の接続フロー

  1. 発信者: マイク開始 → Offer生成 → QR 1/4, 2/4, 3/4, 4/4 を順番に表示
  2. 応答者: マイク開始 → カメラで4枚のQRを順番に読み取り → 自動でAnswer生成 → QR 1/4〜4/4 を表示
  3. 発信者: カメラで4枚のQRを読み取り
  4. 接続完了: P2P通話開始

動作デモ

実際に動くデモを作った。

P2P 音声通話(WebRTC)

PCとスマホで試してみると、ちゃんとP2P通話ができる。

結論

技術的には可能。完全にサーバレスで、QRコード交換だけでP2P音声通話が実現できた。

ただし実用性は微妙

  • QRコード4枚を順番に読み取るのは手間
  • 映像を追加するとQRがさらに増える
  • 普通にLINE通話使えばいい

とはいえ、WebRTCのシグナリングの仕組みを理解するには良い実験だった。「サーバなしでP2P通信」という制約で何ができるかを考えるのは面白い。

参考