MENU

問い合わせ


    【NuxtとWebRTCで作るビデオチャット 第3回】RTCPeerConnection で1対1通話をつなぐ(offer/answer/ICE)

    いよいよ、2つのブラウザの間で映像と音声がつながります。前回の第2回:Nitro の WebSocket でシグナリングサーバーを作るでは、同じルームの相手にだけメッセージを中継するサーバーを作りました。今回はそれと第1回のカメラ映像を組み合わせ、RTCPeerConnection(=ブラウザ同士の通話を管理するAPI)で1対1のビデオ通話を完成させます。

    「WebRTCのofferとanswerとICEって、結局どの順番で何を送ればいいの?両方が同時に接続しようとしたらどうなるの?」

    結論から言うと、offer/answer の順番や「同時に接続しようとした」ときの衝突は、MDN が紹介している Perfect negotiation(完全なネゴシエーション)というパターンに沿って書けば、両側で同じコードのまま扱えます。今回はこのパターンで usePeerConnection を実装し、同じPCの2つのタブで通話できるところまで確認します。ただし、同じPC・同じLANで動いても、社外の相手とつながるとは限りません。「どこからでもつながる」状態にするには第6回の TURN サーバーまでが必要です。

    目次

    今回作るもの:1対1のビデオ通話ページ

    • /room/ルームID を開き、「カメラとマイクをオンにして入室」を押すとルームに参加する
    • 同じルームに2人目が入ると自動で接続が始まり、相手の映像と音声が大きく、自分の映像が右下に小さく表示される
    • 接続状態(相手を待っています/接続中/接続しました/切断/失敗)を表示する
    • 相手が退出したりタブを閉じたりすると「相手を待っています」に戻り、また入ってくれば再接続する

    追加・変更するファイル

    ファイル役割
    code/app/composables/usePeerConnection.tsRTCPeerConnection の作成、offer/answer/ICE のやりとり、相手の映像の受け取り
    code/app/components/RemoteVideo.vue相手の映像と音声を再生する
    code/app/pages/room/[id].vue通話ページ(第1回の useLocalMedia、第2回の useSignaling と組み合わせる)

    仕組み:offer・answer・ICE 候補の流れ

    WebRTC の接続は、次の3種類の情報をシグナリングサーバー経由で交換して成立します。

    情報内容例え
    offer(SDP)「こういう形式の映像・音声を送受信したい」という提案電話をかける側の自己紹介
    answer(SDP)offer に対する「この形式でいきましょう」という応答受けた側の返事
    ICE 候補自分に届く可能性のある経路(IPアドレスとポートの組み合わせ)の候補「この番号なら届きます」のリスト

    SDP(Session Description Protocol)は、通話の条件を書いたテキストです。ICE(Interactive Connectivity Establishment)は、候補の中から実際に通信できる経路を探す仕組みで、候補が見つかるたびに少しずつ相手に送ります。

    「両方が同時に offer を送る」問題と Perfect negotiation

    1対1の通話でも、「どちらが offer を送るか」は意外と厄介です。今回の実装では、両方のブラウザが自分のカメラ映像を載せた時点で offer を作るため、ほぼ同時に offer を送り合う(衝突する)ことがあります。

    Perfect negotiation は、2人に polite(=譲る側)と impolite(=譲らない側)の役割を割り当てて衝突を解決するパターンです。

    役割衝突したときの動きこのサンプルでの割り当て
    polite自分の offer を取り下げ(rollback)、相手の offer に answer を返す後からルームに入った人
    impolite相手の offer を無視し、自分の offer への answer を待つ先にルームにいた人

    📰 出典:MDN「Establishing a connection: The WebRTC perfect negotiation pattern」

    このパターンの利点は、役割以外は両側がまったく同じコードで動くことです。「入った順番によって別々の処理を書く」必要がなく、後の回で通話中に画面共有を追加するような「接続し直し(再ネゴシエーション)」にも同じコードで対応できます。

    実装1:usePeerConnection で接続を作る

    接続の作成とイベントの登録

    code/app/composables/usePeerConnection.ts(connect 関数の抜粋)

    export function usePeerConnection(
      signaling: SignalingChannel,
      getLocalStream: () => MediaStream | null,
      // STUN/TURN サーバーの設定は第6回で追加する(同じPC内・同じLAN内なら無しでもつながる)
      rtcConfig: RTCConfiguration = {},
    ) {
      const remotePeerId = ref<PeerId | null>(null)
      const remoteStream = shallowRef<MediaStream | null>(null)
      const connectionState = ref<RTCPeerConnectionState>('new')
    
      let pc: RTCPeerConnection | null = null
      // Perfect negotiation 用の状態
      let polite = false
      let makingOffer = false
      let ignoreOffer = false
      let isSettingRemoteAnswerPending = false
    
      function sendDescription(description: RTCSessionDescription | null) {
        if (description) signaling.send({ type: 'description', description: description.toJSON() })
      }
    
      /** 相手1人との接続を作る。polite は「衝突したら自分が譲る側」かどうか */
      function connect(peerId: PeerId, isPolite: boolean) {
        close()
        remotePeerId.value = peerId
        polite = isPolite
        // (状態フラグの初期化は省略)
    
        const conn = new RTCPeerConnection(rtcConfig)
        pc = conn
    
        // 自分のカメラ・マイクのトラックを載せる。これで negotiationneeded が発生する
        const localStream = getLocalStream()
        localStream?.getTracks().forEach(track => conn.addTrack(track, localStream))
    
        conn.addEventListener('negotiationneeded', async () => {
          try {
            makingOffer = true
            await conn.setLocalDescription() // 引数なし=状況に応じて offer か answer を自動生成
            sendDescription(conn.localDescription)
          }
          catch (e) {
            console.error('[peer] negotiationneeded', e)
          }
          finally {
            makingOffer = false
          }
        })
    
        conn.addEventListener('icecandidate', ({ candidate }) => {
          // candidate が null なのは「候補の収集が終わった」合図。相手に送る必要はない
          if (candidate) signaling.send({ type: 'ice-candidate', candidate: candidate.toJSON() })
        })
    
        conn.addEventListener('track', ({ streams }) => {
          if (streams[0]) remoteStream.value = streams[0]
        })
    
        conn.addEventListener('connectionstatechange', () => {
          connectionState.value = conn.connectionState
        })
      }

    ポイントを順に見ていきます。

    • addTrack で映像・音声を載せる:第1回で取得した MediaStream のトラック(映像1本・音声1本)を接続に追加します。第2引数に stream を渡しておくと、相手側の track イベントで同じまとまり(streams[0])として受け取れます。
    • negotiationneeded で offer を作る:トラックを追加すると、ブラウザが「交渉が必要になった」と知らせてくれます。ここで引数なしの setLocalDescription() を呼ぶと、ブラウザが状況に応じて offer を自動生成します。
    • icecandidate で候補を送る:候補が見つかるたびに呼ばれるので、そのままシグナリングで送ります。toJSON() で送れる形に変換しています。
    • connectionstatechange で状態を表示する:connectionState は new → connecting → connected と変わり、切れると disconnected や failed になります。

    STUN/TURN サーバーは今回まだ設定していません(rtcConfig は空)。同じPC内や同じLAN内では、端末自身が持つアドレスの候補(host 候補)だけで接続できるためです。

    受け取った offer・answer・ICE 候補を処理する

    code/app/composables/usePeerConnection.ts(handleSignal 関数)

      async function handleSignal(msg: Extract<ServerMessage, { type: 'description' | 'ice-candidate' }>) {
        const conn = pc
        // 1対1なので、今の相手以外からのメッセージは無視する
        if (!conn || msg.from !== remotePeerId.value) return
        try {
          if (msg.type === 'description') {
            const description = msg.description
            // 自分も offer を作っている最中なら「衝突」
            const readyForOffer = !makingOffer && (conn.signalingState === 'stable' || isSettingRemoteAnswerPending)
            const offerCollision = description.type === 'offer' && !readyForOffer
            // 衝突したら impolite 側は相手の offer を無視し、polite 側は自分の offer を取り下げて相手に合わせる
            ignoreOffer = !polite && offerCollision
            if (ignoreOffer) return
    
            isSettingRemoteAnswerPending = description.type === 'answer'
            await conn.setRemoteDescription(description) // polite 側の取り下げ(rollback)はブラウザが自動で行う
            isSettingRemoteAnswerPending = false
            if (description.type === 'offer') {
              await conn.setLocalDescription()
              sendDescription(conn.localDescription)
            }
          }
          else if (msg.candidate) {
            try {
              await conn.addIceCandidate(msg.candidate)
            }
            catch (e) {
              // 無視した offer に対応する候補の追加は失敗してよい
              if (!ignoreOffer) throw e
            }
          }
        }
        catch (e) {
          console.error('[peer] signal', e)
        }
      }

    MDN の Perfect negotiation のコードを、第2回で作ったメッセージの型に合わせて書き直したものです。流れは次のとおりです。

    1. offer を受け取ったとき、自分も offer を作っている最中(makingOffer)か、すでに offer を送って返事待ちなら「衝突」と判断する
    2. 衝突したとき、impolite 側は受け取った offer を無視する
    3. polite 側は setRemoteDescription で相手の offer を受け入れる。このとき、自分が出していた offer はブラウザが自動で取り下げる
    4. offer を受け入れたら、引数なしの setLocalDescription() で answer を作って返す
    5. ICE 候補は addIceCandidate で追加する。無視した offer に関係する候補はエラーになるが、それは想定内なので握りつぶす

    ルームへの参加・退出に合わせて接続する

    code/app/composables/usePeerConnection.ts(シグナリングの受信と close)

      signaling.onMessage((msg) => {
        switch (msg.type) {
          case 'joined':
            // 先に誰かいたら、その人とつなぐ。後から入った自分は polite(譲る側)
            if (msg.peers[0]) connect(msg.peers[0], true)
            break
          case 'peer-joined':
            // 相手が後から入ってきた。先にいた自分は impolite
            if (!pc) connect(msg.peerId, false)
            break
          case 'peer-left':
            if (msg.peerId === remotePeerId.value) close()
            break
          case 'description':
          case 'ice-candidate':
            void handleSignal(msg)
            break
        }
      })
    
      function close() {
        pc?.close()
        pc = null
        remotePeerId.value = null
        remoteStream.value = null
        connectionState.value = 'new'
      }
    
      onScopeDispose(close)
    
      return { remotePeerId, remoteStream, connectionState, close }
    }

    第2回のサーバーは、ルームに参加した本人には joined(先にいた人の一覧付き)を、先にいた人には peer-joined を送ります。これを使って、後から入った人を polite、先にいた人を impolite にしています。両方が connect した時点でそれぞれ offer を作り始めますが、上の衝突処理によってどちらか一方の offer だけが採用されます。

    追記(第4回):この「毎回必ず offer が衝突する」作りでは、検証を繰り返すと一定の割合で接続が始まらないことが分かったため、第4回で「最初の offer は後から入った側だけが作り、先にいた側は offer を受け取ってからトラックを載せる」形に変更しました。衝突時の処理はそのまま残しています。

    実装2:相手の映像を再生する RemoteVideo.vue

    code/app/components/RemoteVideo.vue(script 部分)

    <script setup lang="ts">
    const props = defineProps<{ stream: MediaStream | null }>()
    
    const videoEl = ref<HTMLVideoElement | null>(null)
    // ブラウザの自動再生ポリシーで音声付き再生がブロックされた場合にボタンを出す
    const needsClick = ref(false)
    
    async function play() {
      const el = videoEl.value
      if (!el || !el.srcObject) return
      try {
        await el.play()
        needsClick.value = false
      }
      catch {
        needsClick.value = true
      }
    }
    
    watch(
      [videoEl, () => props.stream],
      ([el, stream]) => {
        if (!el) return
        el.srcObject = stream
        if (stream) void play()
      },
      { immediate: true },
    )
    </script>

    第1回の LocalPreview と違い、相手の声を聞くために muted は付けません。ブラウザには「音声付きの動画は、利用者の操作なしには自動再生しない」という自動再生ポリシーがあります。今回は入室ボタンを押した後に再生が始まるので通常は問題になりませんが、再生がブロックされた場合に備えて「クリックして再生」ボタンを出すようにしています。

    実装3:通話ページ /room/[id].vue

    code/app/pages/room/[id].vue(script 部分の抜粋)

    <script setup lang="ts">
    import { ROOM_ID_PATTERN } from '#shared/types/signaling'
    
    const route = useRoute()
    const roomId = computed(() => String(route.params.id))
    const isValidRoomId = computed(() => ROOM_ID_PATTERN.test(roomId.value))
    
    const { stream: localStream, error: mediaError, isStarting, start: startMedia, stop: stopMedia } = useLocalMedia()
    const signaling = useSignaling()
    const { remoteStream, connectionState, close: closePeer } = usePeerConnection(signaling, () => localStream.value)
    
    const joined = computed(() => signaling.status.value === 'open' && signaling.roomId.value !== null)
    
    // カメラ・マイクを取得してからルームに参加する(先に参加すると映像なしで offer が作られるため)
    async function enter() {
      await startMedia()
      if (!localStream.value) return
      signaling.join(roomId.value)
    }
    
    function leave() {
      closePeer()
      signaling.disconnect()
      stopMedia()
    }
    </script>

    Nuxt では app/pages/room/[id].vue というファイル名にすると、/room/abc のようなURLの abc 部分を route.params.id で受け取れます。ルームIDはサーバーでも検証していますが、画面でも同じ ROOM_ID_PATTERN(第2回で shared/ に定義)でチェックし、使えない文字なら入室ボタンを出しません。

    入室の順番は「カメラ・マイクを取得 → ルームに参加」です。先にルームに参加すると、映像を載せる前に接続が始まってしまいます(後から載せても再ネゴシエーションで対応はできますが、最初の接続がやり直しになります)。

    テンプレート(省略)では、RemoteVideo を大きく、第1回の LocalPreview を右下に小さく重ね、connectionState を「接続しました」などの日本語に置き換えて表示しています。退出ボタンやページ移動では、接続・シグナリング・カメラのすべてを止めます。

    動作確認の方法

    この回の確認にはブラウザのタブ(またはウィンドウ)が2つ必要です。

    1. npm run dev で起動する
    2. 1つ目のタブで http://localhost:3000/room/test-1on1 を開き、「カメラとマイクをオンにして入室」を押す → 「相手を待っています」と表示される
    3. 2つ目のタブ(別のプロファイルやシークレットウィンドウでも可)で同じURLを開いて入室する → 両方のタブで「接続しました」になり、相手側の映像が映る
    4. 片方で「退出する」を押すかタブを閉じる → もう片方が「相手を待っています」に戻る。再び入室すると再接続される
    5. Chrome なら chrome://webrtc-internals、Firefox なら about:webrtc を開くと、接続の状態や選ばれた経路を確認できる

    同じPCで2つのタブからカメラを使うと、同じカメラ映像が両方に映ります。マイクも同じものを使うため、スピーカーから音を出すとハウリングすることがあるので、音量を下げるかイヤホンを使ってください。

    筆者の環境では、Playwright で Chromium を自動操作し、テスト用の映像・音声(–use-fake-device-for-media-stream)で次の点を確認しました。

    確認したこと結果
    2タブで入室両方「接続しました」。相手の映像(320×240 から開始)と音声トラックを受信
    別々のブラウザコンテキスト(シークレットウィンドウ相当)同士同じく接続
    offer の衝突両方が offer を作り、impolite 側が相手の offer を無視、polite 側が自分の offer を取り下げて answer を返す流れで接続
    退出・再入室・タブを閉じる残った側が「相手を待っています」に戻り、再入室で再接続

    実際のカメラ・マイクでの映像や音質、別々のPCやスマホとの通話、社外ネットワークとの接続はこの環境では確認していません。npm run build と npm run typecheck は通っています。

    つまずきやすい点とセキュリティ上の注意

    • 3人目が入ると接続できない:今回は1対1の前提で、先の2人は3人目からのメッセージを無視するため、3人目は「相手を待っています」のまま映像が出ません(筆者の環境で確認)。第4回で定員(2名)を超えた入室をサーバーで断り、理由を表示するようにします。
    • 同じPC・同じLANでつながっても安心できない:会社のネットワークや携帯回線どうしでは、直接の経路が見つからず failed になることがあります。STUN/TURN を設定する第6回までは「つながる」の最低ラインに届いていないと考えてください。
    • SDP や ICE 候補には IP アドレスが含まれる:デバッグ用にコンソールへ出力したまま公開しないようにします。webrtc-internals の画面をスクリーンショットで共有するときも同様です。
    • setRemoteDescription 前の addIceCandidate:自前で順番を管理すると「リモートの説明がまだ無い」エラーになりがちです。Perfect negotiation のように、届いた順に処理する形にしておくのが無難です。
    • 映像は暗号化されるが、相手の確認はしていない:WebRTC のメディアは DTLS-SRTP で暗号化されますが、「ルームに入ってきた相手が本当にその人か」はアプリ側で確認しない限り分かりません。本番では参加者の認証が必要です。

    発注者向けメモ:「つながりました」の報告を受けたら確認したいこと

    1対1のビデオ通話は、今回のように同じPCの2つのタブであれば比較的早い段階で動きます。ただし、それは「条件の良い環境でつながった」という段階です。利用者の環境(社外、携帯回線、会社のファイアウォールの内側)でつながるかどうかは、TURN サーバーの用意と実環境でのテストが済むまで分かりません。

    • ☐ 動作確認が「同じPCの2タブ」「同じ社内LAN」だけでなく、社外ネットワークや携帯回線どうしでも行われているか
    • ☐ 利用者の端末・ブラウザの組み合わせ(PC同士、PCとスマホ、iPhone と Android など)の確認範囲が決まっているか
    • ☐ 接続中・失敗・相手の退出など、状態ごとの表示が画面の仕様に含まれているか
    • ☐ 通話に参加できる人の確認(ログイン、招待された人だけ等)の方針が決まっているか

    開発会社への質問例:

    • 「社外の相手や携帯回線どうしで接続できるかは、いつ・どの環境で確認する予定ですか?」
    • 「接続に失敗したとき、利用者にはどう表示され、どうすればよいと案内しますか?」
    • 「両方が同時に接続しようとした場合の処理は、どのような方式で実装していますか?」
    • 「TURN サーバーは今回の見積もりに含まれていますか?含まれていない場合、つながらない利用者はどの程度出る想定ですか?」

    「デモで動いた」の次に「どの環境で確かめたか」を聞くだけで、スケジュールやリスクの見え方がかなり変わります。

    まとめと次回予告

    • WebRTC の接続は、offer・answer(SDP)と ICE 候補をシグナリングで交換して成立する
    • Perfect negotiation パターンでは、polite と impolite の役割だけを決め、両側が同じコードで offer の衝突を解決できる
    • 引数なしの setLocalDescription() を使うと、ブラウザが状況に応じて offer と answer を作り分けてくれる
    • 同じPCの2タブでつながっても、社外とつながる保証はない。第6回の STUN/TURN までが「つながる」の最小単位

    次回(第4回)は「ルーム作成と招待リンク、入室前プレビュー」です。トップページからルームを作って招待URLをコピーできるようにし、入室前にカメラを確認する画面と、定員を超えた入室を断る仕組みを追加します。

    この連載の記事一覧

    この記事は連載「NuxtとWebRTCで作るビデオチャット」の1回です。連載のほかの回は次のとおりです(連載の一覧ページ)。

    システム制作・運用・保守のお問い合わせはこちら


      よかったらシェアしてね!
      • URLをコピーしました!
      • URLをコピーしました!

      この記事を書いた人

      株式会社THIRD HERO代表取締役 朝野貴朗
      Webシステム開発を中心に、toC向けサービスサイトの運営、ツール開発などを行ってまいりました。

      コメント

      コメント一覧 (1件)

      目次