📹

WebRTC完全入門|実装しながら学ぶシグナリング・SDP・ICEの仕組み(サンプルコード付き)

に公開

WebRTC完全入門|実装しながら学ぶシグナリング・SDP・ICEの仕組み(サンプルコード付き)

はじめに

私がこれまで経験したIoTデバイスの映像配信実装では、Amazon Kinesis Video Streams WebRTC SDKなどのマネージドサービスを利用して開発を行ってきました。

実運用ではSDKを利用することで比較的容易に映像配信を実現できますが、その一方で、内部で行われているシグナリングやSDP交換、ICE Candidate収集といったWebRTCのプロトコルレベルの理解を深く掘り下げる機会は多くありませんでした。

そこで今回は、WebRTCの仕組みをより深く理解することを目的に、外部サービスへ依存せずローカル環境のみで完結する構成でWebRTC映像配信を実装してみました。

また、本記事では処理をフェーズごとに区切り、各ステップで実際にやり取りされるメッセージやログを確認しながら進めます。完成コードを追うだけでなく、WebRTC内部で何が起きているのかを理解することを目指します。

実装環境

役割 環境
Master(送信側) Linux(NVIDIA Jetson / Ubuntu想定)/ Go 1.23
Signaling Server Go 1.23 / WebSocket
Viewer(受信側) ブラウザ / React 18 / TypeScript 5.x

WebRTCとは何か

WebRTC(Web Real-Time Communication)は、ブラウザやアプリケーション間で低遅延なリアルタイム通信を実現する技術です。映像・音声・データを直接やり取りできるため、ビデオ通話だけでなく、IoTデバイスやロボットの映像配信など幅広い用途で利用されています。

接続確立は3つのフェーズに分かれます。

P2P通信が始まるのはPhase 3以降です。


Phase 1: シグナリング

理論

シグナリングとは、WebRTC通信を開始するために必要な接続情報を通信相手と交換するプロセスです。

SDP(Session Description Protocol)には以下が含まれます。

  • 使用するコーデック(H.264, VP8など)
  • 暗号化フィンガープリント
  • ICE認証情報(ufrag / pwd)
  • 映像パラメータ

Offer/Answerモデル

OfferはViewer(受信側)が生成します。Viewerが「自分はH264で受け取れる」という受信能力を提示し、MasterがAnswerで「じゃあH264で送る」と確定させます。

コード:シグナリングサーバー

go.mod

module signaling

go 1.23

require github.com/gorilla/websocket v1.5.1

hub.go

package main

import (
	"log"
	"sync"

	"github.com/gorilla/websocket"
)

type Hub struct {
	mu      sync.RWMutex
	master  *Client
	viewers map[*Client]bool
}

func NewHub() *Hub {
	return &Hub{viewers: make(map[*Client]bool)}
}

func (h *Hub) RegisterMaster(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.master = c
	log.Println("[Hub] Master registered:", c.conn.RemoteAddr())
}

func (h *Hub) RegisterViewer(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.viewers[c] = true
	log.Printf("[Hub] Viewer registered: %s (total: %d)", c.conn.RemoteAddr(), len(h.viewers))
}

func (h *Hub) Unregister(c *Client) {
	h.mu.Lock()
	defer h.mu.Unlock()
	if c == h.master {
		h.master = nil
		log.Println("[Hub] Master disconnected")
	} else {
		delete(h.viewers, c)
		log.Printf("[Hub] Viewer disconnected (total: %d)", len(h.viewers))
	}
}

func (h *Hub) ForwardToViewers(msg []byte) {
	h.mu.RLock()
	defer h.mu.RUnlock()
	for viewer := range h.viewers {
		if err := viewer.Send(msg); err != nil {
			log.Printf("[Hub] Send to viewer error: %v", err)
		}
	}
}

func (h *Hub) ForwardToMaster(msg []byte) {
	h.mu.RLock()
	defer h.mu.RUnlock()
	if h.master == nil {
		log.Println("[Hub] No master, dropping message")
		return
	}
	if err := h.master.Send(msg); err != nil {
		log.Printf("[Hub] Send to master error: %v", err)
	}
}

func (h *Hub) IsMaster(c *Client) bool {
	h.mu.RLock()
	defer h.mu.RUnlock()
	return c == h.master
}

client.go

package main

import (
	"encoding/json"
	"log"
	"sync"

	"github.com/gorilla/websocket"
)

const (
	MsgTypeJoin      = "join"
	MsgTypeOffer     = "offer"
	MsgTypeAnswer    = "answer"
	MsgTypeCandidate = "candidate"
)

type SignalingMessage struct {
	Type    string          `json:"type"`
	Role    string          `json:"role,omitempty"`
	Payload json.RawMessage `json:"payload,omitempty"`
}

type Client struct {
	hub  *Hub
	conn *websocket.Conn
	mu   sync.Mutex
}

func NewClient(hub *Hub, conn *websocket.Conn) *Client {
	return &Client{hub: hub, conn: conn}
}

func (c *Client) Send(msg []byte) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.conn.WriteMessage(websocket.TextMessage, msg)
}

func (c *Client) ReadLoop() {
	defer func() {
		c.hub.Unregister(c)
		c.conn.Close()
	}()

	for {
		_, raw, err := c.conn.ReadMessage()
		if err != nil {
			if websocket.IsUnexpectedCloseError(err,
				websocket.CloseGoingAway,
				websocket.CloseAbnormalClosure,
			) {
				log.Printf("[Client] Read error: %v", err)
			}
			return
		}

		var msg SignalingMessage
		if err := json.Unmarshal(raw, &msg); err != nil {
			log.Printf("[Client] JSON parse error: %v", err)
			continue
		}

		log.Printf("[Client] Received type=%s from %s", msg.Type, c.conn.RemoteAddr())
		c.handleMessage(msg, raw)
	}
}

func (c *Client) handleMessage(msg SignalingMessage, raw []byte) {
	switch msg.Type {
	case MsgTypeJoin:
		if msg.Role == "master" {
			c.hub.RegisterMaster(c)
		} else {
			c.hub.RegisterViewer(c)
		}
	case MsgTypeOffer:
		// ViewerからのOfferをMasterへ転送
		log.Println("[Client] Forwarding Offer -> Master")
		c.hub.ForwardToMaster(raw)
	case MsgTypeAnswer:
		// MasterからのAnswerをViewerへ転送
		log.Println("[Client] Forwarding Answer -> Viewers")
		c.hub.ForwardToViewers(raw)
	case MsgTypeCandidate:
		if c.hub.IsMaster(c) {
			log.Println("[Client] Forwarding ICE Candidate: Master -> Viewers")
			c.hub.ForwardToViewers(raw)
		} else {
			log.Println("[Client] Forwarding ICE Candidate: Viewer -> Master")
			c.hub.ForwardToMaster(raw)
		}
	}
}

main.go(シグナリングサーバー)

package main

import (
	"log"
	"net/http"

	"github.com/gorilla/websocket"
)

var upgrader = websocket.Upgrader{
	CheckOrigin: func(r *http.Request) bool { return true },
}

func main() {
	hub := NewHub()

	http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
		conn, err := upgrader.Upgrade(w, r, nil)
		if err != nil {
			log.Printf("Upgrade error: %v", err)
			return
		}
		client := NewClient(hub, conn)
		go client.ReadLoop()
	})

	log.Println("[Server] Signaling server listening on :8080")
	if err := http.ListenAndServe(":8080", nil); err != nil {
		log.Fatal(err)
	}
}

Phase 1を単体で確認する

まずシグナリングサーバーだけ起動し、wscatで手動シグナリングを試します。

# シグナリングサーバー起動
cd signaling && go mod tidy && go run .
# Terminal A: Masterとして接続(npxでインストール不要)
npx wscat -c ws://127.0.0.1:8080/ws

# 1. joinメッセージ(WebSocketフレーム)を送信
#    サーバー: hub.RegisterMaster(c) が呼ばれ、このWebSocket接続をMasterとして記憶する
> {"type":"join","role":"master"}
# Terminal B: Viewerとして接続
npx wscat -c ws://127.0.0.1:8080/ws

# 2. joinメッセージ(WebSocketフレーム)を送信
#    サーバー: hub.RegisterViewer(c) が呼ばれ、Viewerリストに追加される
> {"type":"join","role":"viewer"}

# 3. offerメッセージを送信(実際のSDPは省略形)
#    サーバー: IsMaster(c)==false なので hub.ForwardToMaster(raw) でTerminal Aに転送される
#    ポイント: サーバーはpayloadの中身を一切見ない。typeフィールドだけで転送先を判断する
> {"type":"offer","payload":{"type":"offer","sdp":"v=0\r\n..."}}
# Terminal A(Master)でAnswerを返信
# 4. answerメッセージを送信
#    サーバー: hub.ForwardToViewers(raw) で全Viewerに転送される
> {"type":"answer","payload":{"type":"answer","sdp":"v=0\r\n..."}}

シグナリングサーバーの出力(Phase 1確認)

[Server] Signaling server listening on :8080

[Hub] Master registered: 127.0.0.1:52341
[Client] Received type=join from 127.0.0.1:52341

[Hub] Viewer registered: 127.0.0.1:52342 (total: 1)
[Client] Received type=join from 127.0.0.1:52342

[Client] Received type=offer from 127.0.0.1:52342
[Client] Forwarding Offer -> Master       <- ViewerのOfferがMasterに転送される

[Client] Received type=answer from 127.0.0.1:52341
[Client] Forwarding Answer -> Viewers     <- MasterのAnswerがViewerに転送される

確認ポイント

  • type=offer はViewer→Masterへ転送される
  • type=answer はMaster→Viewerへ転送される
  • シグナリングサーバーはSDPの中身を解釈せず、ただ転送するだけ

Phase 2: ICE(Interactive Connectivity Establishment)

理論

ICEは P2P通信のための最適な通信経路を探索するプロトコル です。
Candidateの交換はシグナリングサーバー経由ですが、実際の接続チェック(Connectivity Check)はピア同士が直接UDPパケットを送り合います。

ICE Candidateとは

「この経路で繋げられるかもしれない」というアドレス情報の1つです。

candidate:1 1 UDP 2122260223 192.168.1.10 54321 typ host
|                 |          |            |     +- 種類(host/srflx/relay)
|                 |          |            +------- ポート番号
|                 |          +-------------------- IPアドレス
|                 +------------------------------ 優先度
+------------------------------------------------ 固定文字列

Candidateの3種類

ICE Candidate交換の全フロー

Trickle ICEとは

ICE Candidateは複数存在し、収集には時間がかかる場合があります。

従来のICE(Vanilla ICE)では、すべてのCandidateの収集が完了するまで待ってからSDPを相手へ送信していました。そのため、接続開始まで数秒以上待たされることがあります。

一方、Trickle ICEではCandidateが見つかるたびにシグナリングサーバー経由で相手へ送信します。相手側は受信したCandidateから順次接続テストを開始できるため、接続確立までの時間を大幅に短縮できます。

本記事の実装で OnICECandidate コールバックで即送信しているのはこのためです。

コード:Master側のICE処理

// webrtc.go(Master)

func (m *MasterPeer) SetupCallbacks() {

	// ICE Candidateが見つかり次第シグナリングサーバーに送信(Trickle ICE)
	m.pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
		if candidate == nil {
			// nil = ICE収集完了のシグナル
			log.Println("[Master] ICE gathering complete")
			return
		}

		// Candidateの種類とアドレスをログで確認
		log.Printf("[Master] ICE Candidate found: type=%-5s addr=%s:%d",
			candidate.Typ,     // host / srflx / relay
			candidate.Address, // IPアドレス
			candidate.Port,    // ポート番号
		)

		candidateJSON, _ := json.Marshal(candidate.ToJSON())
		msg, _ := json.Marshal(SignalingMessage{
			Type:    MsgTypeCandidate,
			Payload: candidateJSON,
		})
		sendWS(m.ws, msg)
	})

	// ICE接続状態の変化をログ出力
	// checking -> connected の順に遷移すれば成功
	m.pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
		log.Printf("[Master] ICE state: %s", state)
	})

	m.pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
		log.Printf("[Master] Connection state: %s", state)
		switch state {
		case webrtc.PeerConnectionStateConnected:
			log.Println("[Master] P2P connected! Streaming started.")
		case webrtc.PeerConnectionStateFailed:
			log.Println("[Master] Connection failed.")
		}
	})
}

// ViewerからのOfferを受け取りAnswerを生成・送信
func (m *MasterPeer) HandleOffer(payload json.RawMessage) error {
	var offer webrtc.SessionDescription
	if err := json.Unmarshal(payload, &offer); err != nil {
		return err
	}

	// OfferのSDPをログで確認(ice-ufragやコーデック情報が含まれている)
	log.Printf("[Master] Offer SDP received:\n%s", offer.SDP)

	if err := m.pc.SetRemoteDescription(offer); err != nil {
		return err
	}

	answer, err := m.pc.CreateAnswer(nil)
	if err != nil {
		return err
	}
	if err = m.pc.SetLocalDescription(answer); err != nil {
		return err
	}

	// AnswerのSDPをログで確認(Masterが選択したコーデックが含まれている)
	log.Printf("[Master] Answer SDP created:\n%s", answer.SDP)

	answerJSON, _ := json.Marshal(answer)
	msg, _ := json.Marshal(SignalingMessage{
		Type:    MsgTypeAnswer,
		Payload: answerJSON,
	})
	return sendWS(m.ws, msg)
}

func (m *MasterPeer) HandleCandidate(payload json.RawMessage) error {
	var candidate webrtc.ICECandidateInit
	if err := json.Unmarshal(payload, &candidate); err != nil {
		return err
	}
	log.Printf("[Master] ICE Candidate received from viewer: %s", candidate.Candidate)
	return m.pc.AddICECandidate(candidate)
}

コード:Viewer側のICE処理

// hooks/useWebRTC.ts(Viewer)の ICE 関連部分

// ICE Candidateが見つかり次第送信(Trickle ICE)
pc.onicecandidate = (event) => {
  if (event.candidate) {
    console.log(
      `[Viewer] ICE Candidate found: type=${event.candidate.type} addr=${event.candidate.address}:${event.candidate.port}`
    );
    sendMessage({ type: "candidate", payload: event.candidate.toJSON() });
  } else {
    // null = ICE収集完了
    console.log("[Viewer] ICE gathering complete");
  }
};

// ICE接続状態の変化を監視
// new -> checking -> connected の順に変化すれば成功
pc.oniceconnectionstatechange = () => {
  console.log(`[Viewer] ICE state: ${pc.iceConnectionState}`);
};

// MasterからのICE Candidateを受け取る(ws.onmessage内)
case "candidate": {
  const candidate = msg.payload as RTCIceCandidateInit;
  console.log(`[Viewer] ICE Candidate received from master: ${candidate.candidate}`);

  if (remoteDescSet.current) {
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
    console.log("[Viewer] ICE Candidate added");
  } else {
    // Answerがまだの場合はキューに積む(Race Condition対策)
    candidateQueue.current.push(candidate);
    console.log("[Viewer] ICE Candidate queued (waiting for Answer)");
  }
  break;
}

Phase 2のログで確認する

Master側のターミナル出力(Phase 2)

[Main] Waiting for Viewer's Offer...

# Phase 1: Offer/Answer
[Master] Offer SDP received:
v=0
o=- 8802121346989272519 2 IN IP4 127.0.0.1
s=-
t=0 0
a=group:BUNDLE 0
a=ice-ufrag:XXXX          <- ICE認証トークン
a=ice-pwd:YYYYYYYYYYYY    <- ICE認証パスワード
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98
a=rtpmap:96 H264/90000    <- Viewerが対応するコーデック一覧
a=rtpmap:97 VP8/90000
...

[Master] Answer SDP created:
v=0
...
a=rtpmap:96 H264/90000    <- MasterがH264を選択したことがわかる
...

# Phase 2: ICE Candidate収集(Trickle ICE)
[Master] ICE Candidate found: type=host  addr=192.168.1.10:54321
[Master] ICE Candidate found: type=host  addr=192.168.1.10:54322
[Master] ICE Candidate found: type=srflx addr=203.0.113.1:12345
[Master] ICE gathering complete

# ViewerからのCandidateを受信
[Master] ICE Candidate received from viewer: candidate:1 1 udp ... typ host
[Master] ICE Candidate received from viewer: candidate:2 1 udp ... typ srflx

# Connectivity Check(ピア直接通信)の結果
[Master] ICE state: checking    <- 候補を試し始めた
[Master] ICE state: connected   <- 最適な経路が確定した
[Master] Connection state: connected
[Master] P2P connected! Streaming started.

ブラウザのコンソール出力(Phase 2)

[Viewer] WebSocket connected
[Viewer] Offer SDP sent to master

# MasterのAnswerを受信
[Viewer] Remote Description (Answer) set

# ICE Candidate収集(Trickle ICEで逐次送信)
[Viewer] ICE Candidate found: type=host  addr=192.168.1.20:43210
[Viewer] ICE Candidate found: type=srflx addr=203.0.113.2:54321
[Viewer] ICE gathering complete

# MasterからのCandidateを受信
[Viewer] ICE Candidate received from master: candidate:1 1 udp ... typ host
[Viewer] ICE Candidate added
[Viewer] ICE Candidate received from master: candidate:2 1 udp ... typ srflx
[Viewer] ICE Candidate added

# Connectivity Checkの結果
[Viewer] ICE state: checking
[Viewer] ICE state: connected
[Viewer] Connection state: connected

シグナリングサーバーの出力(Phase 2)

# CandidateもWebSocket経由でシグナリングサーバーが転送している
[Client] Received type=candidate from 127.0.0.1:52342
[Client] Forwarding ICE Candidate: Viewer -> Master

[Client] Received type=candidate from 192.168.1.10:52341
[Client] Forwarding ICE Candidate: Master -> Viewers

# Trickle ICEのため複数回繰り返される
[Client] Forwarding ICE Candidate: Viewer -> Master
[Client] Forwarding ICE Candidate: Master -> Viewers
...

確認ポイント

  • type=host のCandidateは複数出る(NICやループバックアドレスごとに生成される)
  • type=srflx が出ていればSTUNサーバーへの到達が確認できている
  • type=relay が出ている場合はTURNサーバーを使っている(P2P不可の証拠)
  • ICE stateが checking -> connected の順に変化すれば成功
  • ICE stateが checking -> failed で止まった場合はSTUN/TURNを確認

Phase 2を段階的に確認する

前半: Candidate収集のみ確認(Masterなし)

シグナリングサーバーを起動した状態でViewerだけ起動し、ブラウザで「接続」をクリックします。Masterが存在しないため接続は完了しませんが、ICE Candidate収集は行われます。

# シグナリングサーバーが起動済みであることを確認してから実行
cd viewer && npm install && npm run dev
# ブラウザで http://localhost:3000 を開き「接続」をクリック
# ブラウザコンソール(Masterなし)
[Viewer] WebSocket connected
[Viewer] Offer SDP sent to master             <- シグナリングサーバーに送信(Masterには届かない)
[Viewer] ICE Candidate found: type=host  addr=192.168.1.20   <- ローカルIPが収集された
[Viewer] ICE Candidate found: type=srflx addr=203.0.113.2   <- STUNサーバーに到達できた
[Viewer] ICE gathering complete

type=srflx が出ればSTUNへの到達を確認できます。出ない場合はUDP 3478番ポートのFirewallを確認してください。

中間: Candidate転送のみ確認(wscatでMaster役を代替)

wscatをMaster役として接続すると、シグナリングサーバーがCandidateを転送していることをログで確認できます。

# Terminal A: wscatでMaster役として接続
npx wscat -c ws://127.0.0.1:8080/ws
> {"type":"join","role":"master"}
# この状態でブラウザの「接続」をクリック
# Terminal A(wscat)に届くメッセージ
{"type":"offer","payload":{...}}       <- ViewerのOfferが届く
{"type":"candidate","payload":{...}}   <- ViewerのICE Candidateが届く(Trickle ICEで複数回)
{"type":"candidate","payload":{...}}
# シグナリングサーバーのログ
[Client] Forwarding Offer -> Master
[Client] Forwarding ICE Candidate: Viewer -> Master
[Client] Forwarding ICE Candidate: Viewer -> Master

後半: Connectivity Check(Masterが必要)

ICE state が checking -> connected に変化するかどうかは、ピア同士が直接UDPパケットを送り合うため、wscatでは確認できません。実際のMasterを起動する必要があります(Phase 3の確認手順を参照)。


Phase 3: DTLS/SRTPと映像配信

ICEで通信経路が確定したら、暗号化通信を確立します。

DTLS(Datagram Transport Layer Security)

TLSのUDP版です。鍵交換を行い、以降の通信を暗号化します。証明書のフィンガープリントはSDPの a=fingerprint に含まれており、中間者攻撃(MITM)を防ぎます。

# SDPに含まれるDTLSフィンガープリント
a=fingerprint:sha-256 AA:BB:CC:DD:...
a=setup:actpass   <- DTLSのどちら側がクライアントになるか

SRTP(Secure Real-time Transport Protocol)

DTLSで交換した鍵を使い、映像データを暗号化して送受信します。ここから先はシグナリングサーバーは通信に一切関与しません。

コード:映像トラックの受信とgetStats

// hooks/useWebRTC.ts(Viewer)

// Phase 3: 映像トラック受信
pc.ontrack = (event) => {
  console.log(`[Viewer] Track received: kind=${event.track.kind} id=${event.track.id}`);
  if (videoRef.current && event.streams[0]) {
    videoRef.current.srcObject = event.streams[0];
    console.log("[Viewer] Video stream attached to <video> element");
  }
};

// Phase 3: 接続確立後に映像の統計情報を2秒ごとに取得
const startStatsCollection = (pc: RTCPeerConnection) => {
  setInterval(async () => {
    if (pc.connectionState !== "connected") return;

    const reports = await pc.getStats();
    reports.forEach((report) => {

      // 映像の受信品質
      if (report.type === "inbound-rtp" && report.kind === "video") {
        console.log("[Viewer] Video stats:", {
          fps:          report.framesPerSecond,        // フレームレート
          width:        report.frameWidth,             // 解像度(幅)
          height:       report.frameHeight,            // 解像度(高さ)
          bytesReceived: report.bytesReceived,         // 受信バイト数(増加し続ける)
          packetsLost:  report.packetsLost,            // パケットロス数
          jitter:       report.jitter,                 // ジッター(遅延揺らぎ)
          decoder:      report.decoderImplementation,  // デコーダ名
        });
      }

      // 使用中のICE経路情報
      if (report.type === "candidate-pair" && report.state === "succeeded") {
        console.log("[Viewer] Active ICE pair:", {
          local:  report.localCandidateId,          // 使用中のローカルCandidate
          remote: report.remoteCandidateId,         // 使用中のリモートCandidate
          rtt:    report.currentRoundTripTime,      // 往復遅延(秒)
        });
      }
    });
  }, 2000);
};

Phase 3のログで確認する

ブラウザのコンソール出力(Phase 3)

# DTLS/SRTP確立
[Viewer] Connection state: connected    <- P2P暗号化通信が確立

# 映像トラック受信(ontrack)
[Viewer] Track received: kind=video id=xxxxx
[Viewer] Video stream attached to <video> element   <- 映像が表示される

# getStatsの出力(2秒ごと)
[Viewer] Video stats: {
  fps: 30,                     <- フレームレート
  width: 1280,                 <- 解像度
  height: 720,
  bytesReceived: 524288,       <- 受信済みバイト数(増加し続ける)
  packetsLost: 0,              <- パケットロスなし
  jitter: 0.003,               <- ジッター(低いほどよい)
  decoder: "H264"              <- H264でデコードされている
}

# 使用中のICE経路
[Viewer] Active ICE pair: {
  local:  "RTCIceCandidate_xxxx",
  remote: "RTCIceCandidate_yyyy",
  rtt: 0.002                   <- 往復遅延2ms(同一LAN内の場合)
}

確認ポイント

  • Connection state: connected でP2P確立を確認
  • Track received: kind=video で映像トラック受信を確認
  • bytesReceived が増加し続けていれば映像が流れている
  • packetsLost が増えている場合はネットワーク品質に問題あり
  • rtt が高い(0.1秒以上)場合はTURNサーバー経由になっている可能性あり

フルコード:Viewer(React + TypeScript)

hooks/useWebRTC.ts

import { useEffect, useRef, useState, useCallback } from "react";

export type ConnectionStatus =
  | "idle"
  | "connecting"
  | "connected"
  | "disconnected"
  | "failed";

interface SignalingMessage {
  type: "join" | "offer" | "answer" | "candidate";
  role?: "master" | "viewer";
  payload?: RTCSessionDescriptionInit | RTCIceCandidateInit;
}

export interface VideoStats {
  fps: number;
  width: number;
  height: number;
  packetsLost: number;
  rtt: number;
}

interface UseWebRTCOptions {
  signalingUrl: string;
  iceServers?: RTCIceServer[];
}

export const useWebRTC = ({
  signalingUrl,
  iceServers = [{ urls: "stun:stun.l.google.com:19302" }],
}: UseWebRTCOptions) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const pcRef = useRef<RTCPeerConnection | null>(null);
  const wsRef = useRef<WebSocket | null>(null);
  const [status, setStatus] = useState<ConnectionStatus>("idle");
  const [stats, setStats] = useState<VideoStats | null>(null);
  const candidateQueue = useRef<RTCIceCandidateInit[]>([]);
  const remoteDescSet = useRef(false);

  const sendMessage = useCallback((msg: SignalingMessage) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify(msg));
    }
  }, []);

  const flushCandidateQueue = useCallback(async (pc: RTCPeerConnection) => {
    for (const candidate of candidateQueue.current) {
      await pc.addIceCandidate(new RTCIceCandidate(candidate));
      console.log("[Viewer] Flushed queued ICE Candidate");
    }
    candidateQueue.current = [];
  }, []);

  const startStatsCollection = useCallback((pc: RTCPeerConnection) => {
    const interval = setInterval(async () => {
      if (pc.connectionState !== "connected") {
        clearInterval(interval);
        return;
      }
      const reports = await pc.getStats();
      let videoStats: Partial<VideoStats> = {};
      reports.forEach((report) => {
        if (report.type === "inbound-rtp" && report.kind === "video") {
          videoStats.fps = Math.round(report.framesPerSecond ?? 0);
          videoStats.width = report.frameWidth ?? 0;
          videoStats.height = report.frameHeight ?? 0;
          videoStats.packetsLost = report.packetsLost ?? 0;
        }
        if (report.type === "candidate-pair" && report.state === "succeeded") {
          videoStats.rtt = report.currentRoundTripTime ?? 0;
        }
      });
      if (videoStats.fps !== undefined) setStats(videoStats as VideoStats);
    }, 2000);
    return () => clearInterval(interval);
  }, []);

  const connect = useCallback(() => {
    if (wsRef.current || pcRef.current) return;
    setStatus("connecting");
    remoteDescSet.current = false;
    candidateQueue.current = [];

    const ws = new WebSocket(signalingUrl);
    wsRef.current = ws;
    const pc = new RTCPeerConnection({ iceServers });
    pcRef.current = pc;

    // Phase 3: 映像トラック受信
    pc.ontrack = (event) => {
      console.log(`[Viewer] Track received: kind=${event.track.kind}`);
      if (videoRef.current && event.streams[0]) {
        videoRef.current.srcObject = event.streams[0];
        console.log("[Viewer] Video stream attached to <video> element");
      }
    };

    // Phase 3: 接続状態の監視
    pc.onconnectionstatechange = () => {
      console.log(`[Viewer] Connection state: ${pc.connectionState}`);
      switch (pc.connectionState) {
        case "connected":    setStatus("connected");    startStatsCollection(pc); break;
        case "disconnected": setStatus("disconnected"); break;
        case "failed":       setStatus("failed");       break;
        case "closed":       setStatus("idle");         break;
      }
    };

    // Phase 2: ICE Candidate送信(Trickle ICE)
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        console.log(`[Viewer] ICE Candidate found: type=${event.candidate.type} addr=${event.candidate.address}`);
        sendMessage({ type: "candidate", payload: event.candidate.toJSON() });
      } else {
        console.log("[Viewer] ICE gathering complete");
      }
    };

    // Phase 2: ICE接続状態の監視
    pc.oniceconnectionstatechange = () => {
      console.log(`[Viewer] ICE state: ${pc.iceConnectionState}`);
    };

    ws.onopen = () => {
      console.log("[Viewer] WebSocket connected");
      sendMessage({ type: "join", role: "viewer" });

      // Phase 1: Offerを生成してMasterに送信
      pc.addTransceiver("video", { direction: "recvonly" });
      pc.createOffer()
        .then((offer) => pc.setLocalDescription(offer).then(() => offer))
        .then((offer) => {
          sendMessage({ type: "offer", payload: offer });
          console.log("[Viewer] Offer SDP sent to master");
        })
        .catch((err) => console.error("[Viewer] CreateOffer error:", err));
    };

    ws.onmessage = async (event) => {
      const msg: SignalingMessage = JSON.parse(event.data);
      switch (msg.type) {
        case "answer": {
          // Phase 1: MasterのAnswerをRemoteDescriptionにセット
          const answer = msg.payload as RTCSessionDescriptionInit;
          await pc.setRemoteDescription(new RTCSessionDescription(answer));
          remoteDescSet.current = true;
          console.log("[Viewer] Remote Description (Answer) set");
          await flushCandidateQueue(pc);
          break;
        }
        case "candidate": {
          // Phase 2: MasterのICE Candidateを追加
          const candidate = msg.payload as RTCIceCandidateInit;
          console.log(`[Viewer] ICE Candidate received from master: ${candidate.candidate}`);
          if (remoteDescSet.current) {
            await pc.addIceCandidate(new RTCIceCandidate(candidate));
            console.log("[Viewer] ICE Candidate added");
          } else {
            candidateQueue.current.push(candidate);
            console.log("[Viewer] ICE Candidate queued");
          }
          break;
        }
      }
    };

    ws.onerror = (e) => { console.error("[Viewer] WebSocket error:", e); setStatus("failed"); };
    ws.onclose = () => console.log("[Viewer] WebSocket closed");
  }, [signalingUrl, iceServers, sendMessage, flushCandidateQueue, startStatsCollection]);

  const disconnect = useCallback(() => {
    pcRef.current?.close();
    wsRef.current?.close();
    pcRef.current = null;
    wsRef.current = null;
    remoteDescSet.current = false;
    candidateQueue.current = [];
    if (videoRef.current) videoRef.current.srcObject = null;
    setStatus("idle");
    setStats(null);
  }, []);

  useEffect(() => () => { disconnect(); }, [disconnect]);

  return { videoRef, status, stats, connect, disconnect };
};

components/ConnectionStatus.tsx

import React from "react";
import type { ConnectionStatus, VideoStats } from "../hooks/useWebRTC";

interface Props { status: ConnectionStatus; stats: VideoStats | null; }

const STATUS_CONFIG: Record<ConnectionStatus, { label: string; color: string; pulse: boolean }> = {
  idle:         { label: "未接続",        color: "#6b7280", pulse: false },
  connecting:   { label: "接続中...",      color: "#f59e0b", pulse: true  },
  connected:    { label: "ストリーミング中", color: "#22c55e", pulse: true  },
  disconnected: { label: "切断",          color: "#ef4444", pulse: false },
  failed:       { label: "接続失敗",       color: "#ef4444", pulse: false },
};

export const ConnectionStatusBadge: React.FC<Props> = ({ status, stats }) => {
  const config = STATUS_CONFIG[status];
  return (
    <div style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
      <div style={{
        display: "inline-flex", alignItems: "center", gap: "6px",
        background: "rgba(0,0,0,0.7)", padding: "4px 12px",
        borderRadius: "99px", width: "fit-content",
      }}>
        <div style={{
          width: "8px", height: "8px", borderRadius: "50%",
          backgroundColor: config.color,
          animation: config.pulse ? "pulse 1.5s infinite" : "none",
        }} />
        <span style={{ color: "#fff", fontSize: "12px", fontWeight: 500 }}>
          {config.label}
        </span>
      </div>
      {stats && status === "connected" && (
        <div style={{
          background: "rgba(0,0,0,0.6)", padding: "4px 8px",
          borderRadius: "4px", fontSize: "10px", color: "#9ca3af",
          fontFamily: "monospace",
        }}>
          {stats.width}x{stats.height} @ {stats.fps}fps
          {" | "}RTT: {(stats.rtt * 1000).toFixed(0)}ms
          {" | "}Loss: {stats.packetsLost}
        </div>
      )}
      <style>{`@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.4}}`}</style>
    </div>
  );
};

components/VideoPlayer.tsx

import React from "react";
import { ConnectionStatusBadge } from "./ConnectionStatus";
import type { ConnectionStatus, VideoStats } from "../hooks/useWebRTC";

interface Props {
  videoRef: React.RefObject<HTMLVideoElement>;
  status: ConnectionStatus;
  stats: VideoStats | null;
}

export const VideoPlayer: React.FC<Props> = ({ videoRef, status, stats }) => (
  <div style={{
    position: "relative", width: "100%", maxWidth: "960px",
    borderRadius: "12px", overflow: "hidden",
    background: "#111", aspectRatio: "16/9",
  }}>
    <video
      ref={videoRef}
      autoPlay playsInline
      muted  // autoplay policyのためmutedは必須
      style={{ width: "100%", height: "100%", objectFit: "contain", display: "block" }}
    />
    {status !== "connected" && (
      <div style={{
        position: "absolute", inset: 0, display: "flex",
        flexDirection: "column", alignItems: "center",
        justifyContent: "center", color: "#6b7280",
      }}>
        <span style={{ fontSize: "14px" }}>
          {status === "idle"         && "接続待機中"}
          {status === "connecting"   && "エッジデバイスに接続中..."}
          {status === "disconnected" && "エッジデバイスが切断されました"}
          {status === "failed"       && "接続に失敗しました"}
        </span>
      </div>
    )}
    <div style={{ position: "absolute", top: "12px", left: "12px" }}>
      <ConnectionStatusBadge status={status} stats={stats} />
    </div>
  </div>
);

App.tsx

import React from "react";
import { useWebRTC } from "./hooks/useWebRTC";
import { VideoPlayer } from "./components/VideoPlayer";

const SIGNALING_URL = import.meta.env.VITE_SIGNALING_URL ?? "ws://localhost:8080/ws";

const App: React.FC = () => {
  const { videoRef, status, stats, connect, disconnect } = useWebRTC({
    signalingUrl: SIGNALING_URL,
    iceServers: [
      { urls: "stun:stun.l.google.com:19302" },
      // TURNが必要な場合(4G/5G回線など)
      // { urls: "turn:your-turn.example.com:3478", username: "user", credential: "pass" },
    ],
  });

  return (
    <div style={{
      minHeight: "100vh", background: "#0a0a0a", display: "flex",
      flexDirection: "column", alignItems: "center", justifyContent: "center",
      gap: "24px", padding: "24px", fontFamily: "system-ui, sans-serif",
    }}>
      <h1 style={{ color: "#f9fafb", margin: 0, fontSize: "20px" }}>
        エッジデバイス 遠隔監視
      </h1>
      <VideoPlayer videoRef={videoRef} status={status} stats={stats} />
      <div style={{ display: "flex", gap: "12px" }}>
        <button onClick={connect}
          disabled={status === "connected" || status === "connecting"}
          style={{
            padding: "10px 28px", borderRadius: "8px", border: "none",
            background: (status === "connected" || status === "connecting") ? "#374151" : "#22c55e",
            color: "#fff", fontSize: "14px", fontWeight: 500,
            cursor: (status === "connected" || status === "connecting") ? "not-allowed" : "pointer",
          }}>接続</button>
        <button onClick={disconnect}
          disabled={status === "idle"}
          style={{
            padding: "10px 28px", borderRadius: "8px", border: "none",
            background: status === "idle" ? "#374151" : "#ef4444",
            color: "#fff", fontSize: "14px", fontWeight: 500,
            cursor: status === "idle" ? "not-allowed" : "pointer",
          }}>切断</button>
      </div>
    </div>
  );
};

export default App;

動作確認:3つのPhaseを順番に確認する

Step 1: シグナリングサーバーのみ(Phase 1の確認)

cd signaling && go mod tidy && go run .

wscatで手動シグナリングを試してOffer/Answerの転送を確認します。

# Terminal A: Master役(npxでインストール不要)
npx wscat -c ws://127.0.0.1:8080/ws
> {"type":"join","role":"master"}

# Terminal B: Viewer役
npx wscat -c ws://127.0.0.1:8080/ws
> {"type":"join","role":"viewer"}
> {"type":"offer","payload":{"type":"offer","sdp":"v=0\r\n..."}}
# -> Terminal AにOfferが届く

# Terminal AからAnswer
> {"type":"answer","payload":{"type":"answer","sdp":"v=0\r\n..."}}
# -> Terminal BにAnswerが届く -> Phase 1 OK

Step 2: Viewerを起動(Phase 2前半の確認)

cd viewer && npm install && npm run dev

ブラウザで「接続」をクリック。Masterはまだ起動しないでおきます。

# ブラウザコンソール(Phase 2 前半)
[Viewer] WebSocket connected
[Viewer] Offer SDP sent to master
[Viewer] ICE Candidate found: type=host  addr=192.168.1.20   <- ローカルIP
[Viewer] ICE Candidate found: type=srflx addr=203.0.113.2   <- STUNが機能している
[Viewer] ICE gathering complete

type=srflx が出ていればSTUNサーバーへの到達が確認できています。出ない場合はUDP 3478番ポートのFirewallを確認してください。

Step 2.5: wscatでICE Candidateの転送を確認(Phase 2単体確認)

Masterを起動せずに、wscatでMaster役を手動で担うことでICE Candidate転送だけを確認できます。

# Terminal C: wscatでMaster役として接続
npx wscat -c ws://127.0.0.1:8080/ws
> {"type":"join","role":"master"}
# この状態でブラウザの「接続」をクリックすると...
# Terminal C(wscat)に届くメッセージ
{"type":"offer","payload":{...}}         <- ViewerのOfferが届く
{"type":"candidate","payload":{...}}     <- ViewerのICE Candidateが届く(複数回)
{"type":"candidate","payload":{...}}
# シグナリングサーバーのログ
[Client] Forwarding Offer -> Master
[Client] Forwarding ICE Candidate: Viewer -> Master   <- Trickle ICEで逐次届く
[Client] Forwarding ICE Candidate: Viewer -> Master
[Client] Forwarding ICE Candidate: Viewer -> Master

確認ポイント

  • wscatにcandidateメッセージが届いている → シグナリングサーバーのCandidate転送が正常
  • candidateのtypeを確認: host(LAN内IP)、srflx(STUNで取得したグローバルIP)
  • Connectivity Check(ICE state: checking → connected)はピア同士のUDP通信のため、wscatでは確認できない。Step 3(Master起動)まで待つ

Step 3: Masterを起動(Phase 2後半〜Phase 3の確認)

Masterの映像エンコードにlibvpxが必要です。事前にインストールしてください。

# macOS
brew install libvpx

# Ubuntu / Jetson
sudo apt-get install libvpx-dev
cd master && go mod tidy

# macOS / カメラなし: テストパターン(カラーバー)を送信
go run .

# Linux / Jetson: 実カメラを使う場合
# CAMERA_DEVICE=/dev/video0 go run .
# Masterのターミナル
[Master] Offer SDP received:          <- Phase 1
[Master] Answer SDP created:          <- Phase 1
[Master] ICE Candidate found: type=host  ...    <- Phase 2
[Master] ICE Candidate found: type=srflx ...   <- Phase 2
[Master] ICE state: checking          <- Phase 2: Connectivity Check開始
[Master] ICE state: connected         <- Phase 2: 経路確定
[Master] Connection state: connected  <- Phase 3: P2P確立
[Master] P2P connected! Streaming started.

# ブラウザコンソール
[Viewer] ICE state: checking
[Viewer] ICE state: connected
[Viewer] Connection state: connected  <- Phase 3
[Viewer] Track received: kind=video   <- Phase 3: 映像受信開始
[Viewer] Video stream attached to <video> element
[Viewer] Video stats: { fps: 30, width: 1280, height: 720, rtt: 0.002 }

ブラウザにカラーバーが表示され、左上に「ストリーミング中」バッジと解像度・RTT・パケットロスが表示されれば成功です。

サンプルコード

本記事のコードはすべて以下のリポジトリで公開しています。

https://github.com/tugu-develop/webrtc-edge-demo

ディレクトリ構成

webrtc-edge-demo/
├── signaling/
│   ├── main.go
│   ├── hub.go
│   ├── client.go
│   └── go.mod
├── master/
│   ├── main.go
│   ├── webrtc.go
│   ├── camera.go
│   └── go.mod
└── viewer/
    ├── src/
    │   ├── App.tsx
    │   ├── hooks/useWebRTC.ts
    │   └── components/
    │       ├── VideoPlayer.tsx
    │       └── ConnectionStatus.tsx
    ├── package.json
    └── tsconfig.json

参考

Discussion