T-CREATOR

WebRTC で遠隔支援:画面注釈・ポインタ共有・低遅延音声の実装事例

WebRTC で遠隔支援:画面注釈・ポインタ共有・低遅延音声の実装事例

リモートワークの普及に伴い、遠隔での技術支援やカスタマーサポートのニーズが急速に高まっています。従来の画面共有だけでは「ここをクリックしてください」と口頭で説明するしかなく、相手が理解できているか不安になることも多いのではないでしょうか。

本記事では、WebRTC を活用した遠隔支援システムの実装事例を紹介します。画面共有に加えて、リアルタイムで画面上に注釈を描画したり、マウスポインタの位置を共有したり、低遅延で音声通話を行う方法を、実装コード付きで解説します。

背景

遠隔支援システムに求められる要件

遠隔支援を行う際、サポート担当者とユーザー間でスムーズなコミュニケーションが不可欠です。単なる画面共有だけでは以下のような課題があります。

  • 操作箇所を言葉だけで伝えるのが難しい
  • ユーザーが正しく操作できているか確認しづらい
  • 音声の遅延により会話がかみ合わない

これらを解決するため、画面共有・注釈・ポインタ共有・音声通話を統合したシステムが求められています。

WebRTC が遠隔支援に適している理由

WebRTC(Web Real-Time Communication)は、ブラウザ間でリアルタイム通信を実現する技術です。遠隔支援システムに適している理由は以下の通りです。

#特徴遠隔支援での利点
1プラグイン不要ユーザーに追加ソフトのインストールを求めない
2低遅延通信音声・映像のリアルタイム性が高い
3P2P 通信サーバー負荷を抑えられる
4暗号化標準装備セキュアな通信を実現

以下の図は、WebRTC を使った遠隔支援システムの基本構成を示しています。

mermaidflowchart TB
  support["サポート担当者<br/>ブラウザ"]
  user["ユーザー<br/>ブラウザ"]
  signaling["シグナリング<br/>サーバー"]

  support <-->|"1. 接続確立<br/>(Offer/Answer)"| signaling
  user <-->|"1. 接続確立<br/>(Offer/Answer)"| signaling
  support <-.->|"2. P2P通信<br/>(映像・音声・データ)"| user

  style support fill:#e1f5ff
  style user fill:#fff4e1
  style signaling fill:#f0f0f0

図で理解できる要点:

  • シグナリングサーバーは接続確立時のみ使用
  • 確立後はブラウザ間で直接 P2P 通信
  • 映像・音声・データチャネルをすべて WebRTC で実現

課題

遠隔支援システム実装の技術的課題

WebRTC を使った遠隔支援システムを実装する際、以下の技術的課題があります。

1. 画面共有と注釈の同期

画面共有された映像の上に、サポート担当者がリアルタイムで線や図形を描画する必要があります。しかし、以下の問題があります。

  • 画面共有の映像フレームと注釈データの同期タイミング
  • 注釈データの効率的な送信方法
  • 描画パフォーマンスの最適化

2. ポインタ位置の正確な共有

サポート担当者のマウスポインタ位置をユーザー側で表示する際、座標系の違いが問題になります。

  • 画面解像度の差異
  • ブラウザウィンドウサイズの違い
  • 拡大縮小による座標のずれ

3. 低遅延音声通信の実現

遠隔支援では、リアルタイムな会話が重要です。音声遅延が発生すると以下の問題が生じます。

  • 会話のタイミングがずれて意思疎通が困難
  • エコーやハウリングが発生
  • ユーザー体験の著しい低下

以下の図は、これらの課題がどのように関連しているかを示しています。

mermaidflowchart TD
  screen["画面共有ストリーム"]
  annotation["注釈データ"]
  pointer["ポインタ座標"]
  audio["音声ストリーム"]

  sync["同期処理"]
  transform["座標変換"]
  optimize["遅延最適化"]

  screen --> sync
  annotation --> sync
  pointer --> transform
  audio --> optimize

  sync --> issue1["課題1:<br/>フレームと注釈の<br/>タイミングずれ"]
  transform --> issue2["課題2:<br/>解像度差異による<br/>座標ずれ"]
  optimize --> issue3["課題3:<br/>音声遅延による<br/>会話のずれ"]

  style issue1 fill:#ffe1e1
  style issue2 fill:#ffe1e1
  style issue3 fill:#ffe1e1

図で理解できる要点:

  • 各データストリームに固有の技術的課題が存在
  • 同期・変換・最適化がそれぞれ必要
  • これらを統合的に解決する必要がある

解決策

アーキテクチャ設計

遠隔支援システムは以下の 3 つの主要コンポーネントで構成します。

#コンポーネント役割使用技術
1シグナリングサーバーWebRTC 接続確立の仲介Node.js + Socket.IO
2サポート側クライアント画面閲覧・注釈・ポインタ操作TypeScript + React
3ユーザー側クライアント画面共有・注釈受信・ポインタ表示TypeScript + React

システム全体のデータフローを以下に示します。

mermaidsequenceDiagram
  participant S as サポート担当者
  participant SS as シグナリングサーバー
  participant U as ユーザー

  S->>SS: 接続要求
  U->>SS: 接続要求
  SS->>S: Offer送信
  SS->>U: Offer転送
  U->>SS: Answer送信
  SS->>S: Answer転送

  Note over S,U: WebRTC P2P接続確立

  U->>S: 画面共有ストリーム
  S->>U: 注釈データ (DataChannel)
  S->>U: ポインタ座標 (DataChannel)
  S->>U: 音声ストリーム
  U->>S: 音声ストリーム

図で理解できる要点:

  • Offer/Answer 交換でピア接続を確立
  • 画面共有は MediaStream、注釈・ポインタは DataChannel
  • 音声は双方向の MediaStream

1. WebRTC 接続の確立

まず、WebRTC のピア接続を確立するための基本コードを実装します。

RTCPeerConnection の初期化

以下のコードは、WebRTC のピア接続を初期化し、ICE サーバーを設定します。

typescript// WebRTC接続の設定
const configuration: RTCConfiguration = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' }, // Google提供のSTUNサーバー
    { urls: 'stun:stun1.l.google.com:19302' },
  ],
};

// ピア接続の作成
const peerConnection = new RTCPeerConnection(configuration);

ICE Candidate の交換処理

ICE Candidate は、ピア間の最適な通信経路を見つけるための情報です。

typescript// ICE Candidateが生成されたときの処理
peerConnection.onicecandidate = (
  event: RTCPeerConnectionIceEvent
) => {
  if (event.candidate) {
    // シグナリングサーバー経由で相手に送信
    signalingSocket.emit('ice-candidate', {
      candidate: event.candidate,
      targetUserId: remoteUserId,
    });
  }
};

// 相手からのICE Candidateを受信したときの処理
signalingSocket.on(
  'ice-candidate',
  async (data: { candidate: RTCIceCandidate }) => {
    try {
      await peerConnection.addIceCandidate(
        new RTCIceCandidate(data.candidate)
      );
    } catch (error) {
      console.error('ICE Candidate追加エラー:', error);
    }
  }
);

Offer/Answer の交換

サポート側が Offer を作成し、ユーザー側が Answer を返すことで接続を確立します。

typescript// サポート側: Offerの作成と送信
async function createOffer(): Promise<void> {
  try {
    const offer = await peerConnection.createOffer({
      offerToReceiveVideo: true, // 映像を受信
      offerToReceiveAudio: true, // 音声を受信
    });

    await peerConnection.setLocalDescription(offer);

    // シグナリングサーバー経由で送信
    signalingSocket.emit('offer', {
      offer: offer,
      targetUserId: remoteUserId,
    });
  } catch (error) {
    console.error('Offer作成エラー:', error);
  }
}
typescript// ユーザー側: Offerの受信とAnswerの返信
signalingSocket.on(
  'offer',
  async (data: { offer: RTCSessionDescriptionInit }) => {
    try {
      await peerConnection.setRemoteDescription(
        new RTCSessionDescription(data.offer)
      );

      const answer = await peerConnection.createAnswer();
      await peerConnection.setLocalDescription(answer);

      // Answerを返信
      signalingSocket.emit('answer', {
        answer: answer,
        targetUserId: data.fromUserId,
      });
    } catch (error) {
      console.error('Answer作成エラー:', error);
    }
  }
);

2. 画面共有と注釈機能の実装

ユーザー側:画面共有の開始

ユーザー側で画面共有を開始し、ストリームをピア接続に追加します。

typescript// 画面共有の開始
async function startScreenShare(): Promise<void> {
  try {
    const stream =
      await navigator.mediaDevices.getDisplayMedia({
        video: {
          cursor: 'always', // カーソルも含める
          width: { ideal: 1920 },
          height: { ideal: 1080 },
        },
        audio: false, // 画面の音声は含めない
      });

    // ビデオトラックを取得
    const videoTrack = stream.getVideoTracks()[0];

    // ピア接続にトラックを追加
    peerConnection.addTrack(videoTrack, stream);

    // 共有停止時の処理
    videoTrack.onended = () => {
      console.log('画面共有が停止されました');
      handleScreenShareStopped();
    };
  } catch (error) {
    console.error('画面共有開始エラー:', error);
    throw error;
  }
}

サポート側:画面共有の受信と表示

サポート側で受信した画面共有ストリームを video 要素に表示します。

typescript// リモートストリームの受信
peerConnection.ontrack = (event: RTCTrackEvent) => {
  const [remoteStream] = event.streams;

  // video要素に表示
  const videoElement = document.getElementById(
    'remote-screen'
  ) as HTMLVideoElement;
  if (videoElement) {
    videoElement.srcObject = remoteStream;
  }
};

DataChannel の作成

注釈データとポインタ座標を送信するための DataChannel を作成します。

typescript// DataChannelの作成(サポート側で作成)
const annotationChannel = peerConnection.createDataChannel(
  'annotation',
  {
    ordered: false, // 順序保証なし(低遅延優先)
    maxRetransmits: 0, // 再送なし(リアルタイム性優先)
  }
);

const pointerChannel = peerConnection.createDataChannel(
  'pointer',
  {
    ordered: false,
    maxRetransmits: 0,
  }
);

// チャネルオープン時の処理
annotationChannel.onopen = () => {
  console.log('注釈チャネルがオープンしました');
};

pointerChannel.onopen = () => {
  console.log('ポインタチャネルがオープンしました');
};
typescript// ユーザー側: DataChannelの受信
peerConnection.ondatachannel = (
  event: RTCDataChannelEvent
) => {
  const channel = event.channel;

  if (channel.label === 'annotation') {
    setupAnnotationChannel(channel);
  } else if (channel.label === 'pointer') {
    setupPointerChannel(channel);
  }
};

注釈描画機能の実装

サポート側で Canvas 上に描画し、描画データをユーザー側に送信します。

typescript// 注釈データの型定義
interface AnnotationData {
  type: 'start' | 'draw' | 'end' | 'clear';
  x: number;
  y: number;
  color: string;
  lineWidth: number;
  timestamp: number;
}

// サポート側: 注釈描画とデータ送信
class AnnotationDrawer {
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private isDrawing = false;
  private currentColor = '#FF0000';
  private currentLineWidth = 3;

  constructor(
    canvas: HTMLCanvasElement,
    private channel: RTCDataChannel
  ) {
    this.canvas = canvas;
    this.ctx = canvas.getContext('2d')!;
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    this.canvas.addEventListener(
      'mousedown',
      this.startDrawing.bind(this)
    );
    this.canvas.addEventListener(
      'mousemove',
      this.draw.bind(this)
    );
    this.canvas.addEventListener(
      'mouseup',
      this.stopDrawing.bind(this)
    );
  }

  private startDrawing(event: MouseEvent): void {
    this.isDrawing = true;
    const pos = this.getRelativePosition(event);

    // 描画開始データを送信
    this.sendAnnotation({
      type: 'start',
      x: pos.x,
      y: pos.y,
      color: this.currentColor,
      lineWidth: this.currentLineWidth,
      timestamp: Date.now(),
    });
  }

  private draw(event: MouseEvent): void {
    if (!this.isDrawing) return;

    const pos = this.getRelativePosition(event);

    // ローカルで描画
    this.ctx.strokeStyle = this.currentColor;
    this.ctx.lineWidth = this.currentLineWidth;
    this.ctx.lineTo(pos.x, pos.y);
    this.ctx.stroke();

    // 描画データを送信
    this.sendAnnotation({
      type: 'draw',
      x: pos.x,
      y: pos.y,
      color: this.currentColor,
      lineWidth: this.currentLineWidth,
      timestamp: Date.now(),
    });
  }

  private stopDrawing(): void {
    this.isDrawing = false;
    this.ctx.beginPath(); // パスをリセット

    // 描画終了データを送信
    this.sendAnnotation({
      type: 'end',
      x: 0,
      y: 0,
      color: this.currentColor,
      lineWidth: this.currentLineWidth,
      timestamp: Date.now(),
    });
  }

  private getRelativePosition(event: MouseEvent): {
    x: number;
    y: number;
  } {
    const rect = this.canvas.getBoundingClientRect();
    return {
      x: (event.clientX - rect.left) / rect.width, // 0-1の相対座標
      y: (event.clientY - rect.top) / rect.height,
    };
  }

  private sendAnnotation(data: AnnotationData): void {
    if (this.channel.readyState === 'open') {
      this.channel.send(JSON.stringify(data));
    }
  }

  // 注釈クリア機能
  public clearAnnotations(): void {
    this.ctx.clearRect(
      0,
      0,
      this.canvas.width,
      this.canvas.height
    );
    this.sendAnnotation({
      type: 'clear',
      x: 0,
      y: 0,
      color: '',
      lineWidth: 0,
      timestamp: Date.now(),
    });
  }
}

ユーザー側:注釈の受信と表示

ユーザー側で受信した注釈データを Canvas に描画します。

typescript// ユーザー側: 注釈の受信と描画
function setupAnnotationChannel(
  channel: RTCDataChannel
): void {
  const canvas = document.getElementById(
    'annotation-overlay'
  ) as HTMLCanvasElement;
  const ctx = canvas.getContext('2d')!;

  channel.onmessage = (event: MessageEvent) => {
    const data: AnnotationData = JSON.parse(event.data);

    // 相対座標を絶対座標に変換
    const x = data.x * canvas.width;
    const y = data.y * canvas.height;

    switch (data.type) {
      case 'start':
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.strokeStyle = data.color;
        ctx.lineWidth = data.lineWidth;
        ctx.lineCap = 'round';
        ctx.lineJoin = 'round';
        break;

      case 'draw':
        ctx.lineTo(x, y);
        ctx.stroke();
        break;

      case 'end':
        ctx.closePath();
        break;

      case 'clear':
        ctx.clearRect(0, 0, canvas.width, canvas.height);
        break;
    }
  };
}

3. ポインタ共有機能の実装

サポート側のマウスポインタ位置をユーザー側に表示します。

サポート側:ポインタ座標の送信

マウスの移動を検知して、相対座標をユーザー側に送信します。

typescript// ポインタデータの型定義
interface PointerData {
  x: number;
  y: number;
  visible: boolean;
  timestamp: number;
}

// サポート側: ポインタ位置の送信
class PointerTracker {
  private lastSendTime = 0;
  private readonly throttleMs = 16; // 約60FPS

  constructor(
    private canvas: HTMLCanvasElement,
    private channel: RTCDataChannel
  ) {
    this.setupEventListeners();
  }

  private setupEventListeners(): void {
    this.canvas.addEventListener(
      'mousemove',
      this.onMouseMove.bind(this)
    );
    this.canvas.addEventListener(
      'mouseenter',
      this.onMouseEnter.bind(this)
    );
    this.canvas.addEventListener(
      'mouseleave',
      this.onMouseLeave.bind(this)
    );
  }

  private onMouseMove(event: MouseEvent): void {
    const now = Date.now();

    // スロットリング: 送信頻度を制限
    if (now - this.lastSendTime < this.throttleMs) {
      return;
    }

    const pos = this.getRelativePosition(event);
    this.sendPointer(pos.x, pos.y, true);
    this.lastSendTime = now;
  }

  private onMouseEnter(event: MouseEvent): void {
    const pos = this.getRelativePosition(event);
    this.sendPointer(pos.x, pos.y, true);
  }

  private onMouseLeave(): void {
    this.sendPointer(0, 0, false);
  }

  private getRelativePosition(event: MouseEvent): {
    x: number;
    y: number;
  } {
    const rect = this.canvas.getBoundingClientRect();
    return {
      x: (event.clientX - rect.left) / rect.width,
      y: (event.clientY - rect.top) / rect.height,
    };
  }

  private sendPointer(
    x: number,
    y: number,
    visible: boolean
  ): void {
    if (this.channel.readyState === 'open') {
      const data: PointerData = {
        x,
        y,
        visible,
        timestamp: Date.now(),
      };
      this.channel.send(JSON.stringify(data));
    }
  }
}

ユーザー側:ポインタの受信と表示

受信したポインタ座標を元に、カスタムポインタを表示します。

typescript// ユーザー側: ポインタの受信と表示
function setupPointerChannel(
  channel: RTCDataChannel
): void {
  const pointerElement = createPointerElement();
  document.body.appendChild(pointerElement);

  channel.onmessage = (event: MessageEvent) => {
    const data: PointerData = JSON.parse(event.data);

    if (data.visible) {
      // 画面上の絶対座標を計算
      const screenRect = getScreenShareRect();
      const x = screenRect.left + data.x * screenRect.width;
      const y = screenRect.top + data.y * screenRect.height;

      // ポインタを移動
      pointerElement.style.left = `${x}px`;
      pointerElement.style.top = `${y}px`;
      pointerElement.style.display = 'block';
    } else {
      pointerElement.style.display = 'none';
    }
  };
}

// カスタムポインタ要素の作成
function createPointerElement(): HTMLElement {
  const pointer = document.createElement('div');
  pointer.id = 'remote-pointer';
  pointer.style.cssText = `
    position: fixed;
    width: 24px;
    height: 24px;
    background: radial-gradient(circle, #FF0000 0%, #FF000080 70%, transparent 100%);
    border: 2px solid white;
    border-radius: 50%;
    pointer-events: none;
    z-index: 9999;
    transition: left 0.05s linear, top 0.05s linear;
    box-shadow: 0 2px 8px rgba(0,0,0,0.3);
  `;
  return pointer;
}

// 画面共有領域の矩形を取得
function getScreenShareRect(): DOMRect {
  const videoElement = document.getElementById(
    'remote-screen'
  ) as HTMLVideoElement;
  return videoElement.getBoundingClientRect();
}

4. 低遅延音声通信の実装

双方向の音声通話を低遅延で実現します。

音声ストリームの取得と追加

マイクからの音声ストリームを取得し、ピア接続に追加します。

typescript// 音声ストリームの取得
async function setupAudioStream(): Promise<void> {
  try {
    const stream =
      await navigator.mediaDevices.getUserMedia({
        audio: {
          echoCancellation: true, // エコーキャンセル有効
          noiseSuppression: true, // ノイズ抑制有効
          autoGainControl: true, // 自動ゲイン調整有効
          sampleRate: 48000, // 高音質サンプリングレート
          channelCount: 1, // モノラル
        },
        video: false,
      });

    // 音声トラックを取得
    const audioTrack = stream.getAudioTracks()[0];

    // ピア接続にトラックを追加
    peerConnection.addTrack(audioTrack, stream);

    // ローカルストリームを保存
    localAudioStream = stream;
  } catch (error) {
    console.error('音声ストリーム取得エラー:', error);
    throw error;
  }
}

リモート音声の再生

相手からの音声ストリームを受信して再生します。

typescript// リモート音声の受信と再生
peerConnection.ontrack = (event: RTCTrackEvent) => {
  const [remoteStream] = event.streams;
  const track = event.track;

  if (track.kind === 'audio') {
    // audio要素で再生
    const audioElement = document.getElementById(
      'remote-audio'
    ) as HTMLAudioElement;
    if (audioElement) {
      audioElement.srcObject = remoteStream;
      audioElement.play().catch((error) => {
        console.error('音声再生エラー:', error);
      });
    }
  } else if (track.kind === 'video') {
    // video要素で画面共有を表示
    const videoElement = document.getElementById(
      'remote-screen'
    ) as HTMLVideoElement;
    if (videoElement) {
      videoElement.srcObject = remoteStream;
    }
  }
};

音声品質の最適化

音声の遅延を最小化するため、コーデックやビットレートを調整します。

typescript// SDP操作による音声品質の最適化
function optimizeAudioSDP(sdp: string): string {
  // Opusコーデックを優先
  sdp = sdp.replace(
    /(m=audio \d+ [^\r\n]+)/,
    '$1\r\na=fmtp:111 minptime=10;useinbandfec=1'
  );

  // 低遅延モードを有効化
  sdp = sdp.replace(
    /(a=rtpmap:\d+ opus\/)/,
    'a=fmtp:111 maxaveragebitrate=128000;stereo=0;useinbandfec=1\r\n$1'
  );

  return sdp;
}

// Offer作成時にSDPを最適化
async function createOptimizedOffer(): Promise<void> {
  const offer = await peerConnection.createOffer();

  // SDPを最適化
  const optimizedSDP = optimizeAudioSDP(offer.sdp!);
  const optimizedOffer = new RTCSessionDescription({
    type: offer.type,
    sdp: optimizedSDP,
  });

  await peerConnection.setLocalDescription(optimizedOffer);

  signalingSocket.emit('offer', {
    offer: optimizedOffer,
    targetUserId: remoteUserId,
  });
}

ミュート機能の実装

音声のミュート/アンミュート機能を実装します。

typescript// ミュート制御クラス
class AudioController {
  private isMuted = false;

  constructor(private stream: MediaStream) {}

  // ミュート切り替え
  public toggleMute(): boolean {
    this.isMuted = !this.isMuted;
    const audioTrack = this.stream.getAudioTracks()[0];

    if (audioTrack) {
      audioTrack.enabled = !this.isMuted;
    }

    return this.isMuted;
  }

  // ミュート状態の取得
  public getMuteState(): boolean {
    return this.isMuted;
  }

  // 音量レベルの取得(視覚的フィードバック用)
  public async getAudioLevel(): Promise<number> {
    const audioContext = new AudioContext();
    const source = audioContext.createMediaStreamSource(
      this.stream
    );
    const analyser = audioContext.createAnalyser();

    source.connect(analyser);
    analyser.fftSize = 256;

    const bufferLength = analyser.frequencyBinCount;
    const dataArray = new Uint8Array(bufferLength);

    analyser.getByteFrequencyData(dataArray);

    // 平均音量を計算
    const average =
      dataArray.reduce((a, b) => a + b) / bufferLength;

    return average / 255; // 0-1の範囲に正規化
  }
}

具体例

実際の遠隔支援フローの実装

ここでは、実際の遠隔支援セッションの開始から終了までの完全な実装例を示します。

シグナリングサーバーの実装

Node.js と Socket.IO を使ったシグナリングサーバーです。

typescript// server.ts - シグナリングサーバーの実装
import express from 'express';
import { createServer } from 'http';
import { Server, Socket } from 'socket.io';

const app = express();
const httpServer = createServer(app);
const io = new Server(httpServer, {
  cors: {
    origin: '*', // 本番環境では適切に制限
    methods: ['GET', 'POST'],
  },
});

// 接続中のユーザー管理
interface User {
  id: string;
  role: 'support' | 'user';
  socketId: string;
}

const users = new Map<string, User>();

io.on('connection', (socket: Socket) => {
  console.log(`クライアント接続: ${socket.id}`);

  // ユーザー登録
  socket.on(
    'register',
    (data: {
      userId: string;
      role: 'support' | 'user';
    }) => {
      const user: User = {
        id: data.userId,
        role: data.role,
        socketId: socket.id,
      };

      users.set(data.userId, user);
      console.log(
        `ユーザー登録: ${data.userId} (${data.role})`
      );

      // 登録完了を通知
      socket.emit('registered', { userId: data.userId });
    }
  );

  // Offerの中継
  socket.on(
    'offer',
    (data: {
      offer: RTCSessionDescriptionInit;
      targetUserId: string;
    }) => {
      const targetUser = users.get(data.targetUserId);
      if (targetUser) {
        io.to(targetUser.socketId).emit('offer', {
          offer: data.offer,
          fromUserId: getUserIdBySocketId(socket.id),
        });
      }
    }
  );

  // Answerの中継
  socket.on(
    'answer',
    (data: {
      answer: RTCSessionDescriptionInit;
      targetUserId: string;
    }) => {
      const targetUser = users.get(data.targetUserId);
      if (targetUser) {
        io.to(targetUser.socketId).emit('answer', {
          answer: data.answer,
          fromUserId: getUserIdBySocketId(socket.id),
        });
      }
    }
  );

  // ICE Candidateの中継
  socket.on(
    'ice-candidate',
    (data: {
      candidate: RTCIceCandidate;
      targetUserId: string;
    }) => {
      const targetUser = users.get(data.targetUserId);
      if (targetUser) {
        io.to(targetUser.socketId).emit('ice-candidate', {
          candidate: data.candidate,
          fromUserId: getUserIdBySocketId(socket.id),
        });
      }
    }
  );

  // 切断処理
  socket.on('disconnect', () => {
    const userId = getUserIdBySocketId(socket.id);
    if (userId) {
      users.delete(userId);
      console.log(`ユーザー切断: ${userId}`);
    }
  });
});

// Socket IDからユーザーIDを取得
function getUserIdBySocketId(
  socketId: string
): string | undefined {
  for (const [userId, user] of users.entries()) {
    if (user.socketId === socketId) {
      return userId;
    }
  }
  return undefined;
}

const PORT = process.env.PORT || 3001;
httpServer.listen(PORT, () => {
  console.log(`シグナリングサーバー起動: ポート ${PORT}`);
});
typescript// package.json の依存関係
{
  "dependencies": {
    "express": "^4.18.2",
    "socket.io": "^4.6.1"
  },
  "devDependencies": {
    "@types/express": "^4.17.17",
    "@types/node": "^20.0.0",
    "typescript": "^5.0.0"
  }
}

React コンポーネントの実装

サポート側の React コンポーネントで全機能を統合します。

typescript// SupportView.tsx - サポート担当者側の画面
import React, { useRef, useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';

interface SupportViewProps {
  userId: string;
  targetUserId: string;
}

export const SupportView: React.FC<SupportViewProps> = ({
  userId,
  targetUserId,
}) => {
  const videoRef = useRef<HTMLVideoElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [peerConnection, setPeerConnection] =
    useState<RTCPeerConnection | null>(null);
  const [signalingSocket, setSignalingSocket] =
    useState<Socket | null>(null);
  const [annotationChannel, setAnnotationChannel] =
    useState<RTCDataChannel | null>(null);
  const [pointerChannel, setPointerChannel] =
    useState<RTCDataChannel | null>(null);
  const [isMuted, setIsMuted] = useState(false);
  const [isConnected, setIsConnected] = useState(false);

  // 初期化処理
  useEffect(() => {
    initializeConnection();

    return () => {
      cleanup();
    };
  }, []);

  const initializeConnection = async () => {
    // シグナリングサーバーに接続
    const socket = io('http://localhost:3001');
    setSignalingSocket(socket);

    socket.on('connect', () => {
      console.log('シグナリングサーバーに接続しました');
      socket.emit('register', { userId, role: 'support' });
    });

    socket.on('registered', () => {
      console.log('登録完了');
      setupWebRTC(socket);
    });
  };

  const setupWebRTC = async (socket: Socket) => {
    // ピア接続の作成
    const pc = new RTCPeerConnection({
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
      ],
    });

    setPeerConnection(pc);

    // ICE Candidate処理
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        socket.emit('ice-candidate', {
          candidate: event.candidate,
          targetUserId,
        });
      }
    };

    // リモートトラック受信
    pc.ontrack = (event) => {
      if (videoRef.current) {
        videoRef.current.srcObject = event.streams[0];
      }
    };

    // DataChannelの作成
    const annotationCh = pc.createDataChannel(
      'annotation',
      {
        ordered: false,
        maxRetransmits: 0,
      }
    );
    setAnnotationChannel(annotationCh);

    const pointerCh = pc.createDataChannel('pointer', {
      ordered: false,
      maxRetransmits: 0,
    });
    setPointerChannel(pointerCh);

    // 音声ストリームの追加
    await addAudioStream(pc);

    // シグナリング処理
    setupSignaling(socket, pc);

    // 接続確立
    await createAndSendOffer(pc, socket);
  };

  const addAudioStream = async (pc: RTCPeerConnection) => {
    try {
      const stream =
        await navigator.mediaDevices.getUserMedia({
          audio: {
            echoCancellation: true,
            noiseSuppression: true,
            autoGainControl: true,
          },
        });

      stream.getAudioTracks().forEach((track) => {
        pc.addTrack(track, stream);
      });
    } catch (error) {
      console.error('音声ストリーム取得エラー:', error);
    }
  };

  const setupSignaling = (
    socket: Socket,
    pc: RTCPeerConnection
  ) => {
    socket.on('answer', async (data) => {
      await pc.setRemoteDescription(
        new RTCSessionDescription(data.answer)
      );
      setIsConnected(true);
    });

    socket.on('ice-candidate', async (data) => {
      try {
        await pc.addIceCandidate(
          new RTCIceCandidate(data.candidate)
        );
      } catch (error) {
        console.error('ICE Candidate追加エラー:', error);
      }
    });
  };

  const createAndSendOffer = async (
    pc: RTCPeerConnection,
    socket: Socket
  ) => {
    const offer = await pc.createOffer({
      offerToReceiveVideo: true,
      offerToReceiveAudio: true,
    });

    await pc.setLocalDescription(offer);

    socket.emit('offer', {
      offer,
      targetUserId,
    });
  };

  const cleanup = () => {
    if (peerConnection) {
      peerConnection.close();
    }
    if (signalingSocket) {
      signalingSocket.disconnect();
    }
  };

  // ミュート切り替え
  const toggleMute = () => {
    if (peerConnection) {
      const senders = peerConnection.getSenders();
      const audioSender = senders.find(
        (s) => s.track?.kind === 'audio'
      );

      if (audioSender && audioSender.track) {
        audioSender.track.enabled =
          !audioSender.track.enabled;
        setIsMuted(!audioSender.track.enabled);
      }
    }
  };

  return (
    <div className='support-view'>
      <div className='video-container'>
        <video
          ref={videoRef}
          autoPlay
          playsInline
          style={{ width: '100%', maxWidth: '1280px' }}
        />
        <canvas
          ref={canvasRef}
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            pointerEvents: 'none',
          }}
        />
      </div>

      <div className='controls'>
        <button onClick={toggleMute}>
          {isMuted ? 'ミュート解除' : 'ミュート'}
        </button>
        <div className='status'>
          {isConnected ? '接続中' : '接続待機中'}
        </div>
      </div>
    </div>
  );
};

ユーザー側の実装

ユーザー側で画面共有と注釈・ポインタを表示します。

typescript// UserView.tsx - ユーザー側の画面
import React, { useRef, useEffect, useState } from 'react';
import { io, Socket } from 'socket.io-client';

interface UserViewProps {
  userId: string;
}

export const UserView: React.FC<UserViewProps> = ({
  userId,
}) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const pointerRef = useRef<HTMLDivElement>(null);
  const [peerConnection, setPeerConnection] =
    useState<RTCPeerConnection | null>(null);
  const [signalingSocket, setSignalingSocket] =
    useState<Socket | null>(null);
  const [isSharing, setIsSharing] = useState(false);

  useEffect(() => {
    initializeConnection();

    return () => {
      cleanup();
    };
  }, []);

  const initializeConnection = () => {
    const socket = io('http://localhost:3001');
    setSignalingSocket(socket);

    socket.on('connect', () => {
      console.log('シグナリングサーバーに接続しました');
      socket.emit('register', { userId, role: 'user' });
    });

    socket.on('registered', () => {
      setupWebRTC(socket);
    });
  };

  const setupWebRTC = async (socket: Socket) => {
    const pc = new RTCPeerConnection({
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
      ],
    });

    setPeerConnection(pc);

    // ICE Candidate処理
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        socket.emit('ice-candidate', {
          candidate: event.candidate,
          targetUserId: 'support-user-id', // サポート担当者のID
        });
      }
    };

    // DataChannel受信
    pc.ondatachannel = (event) => {
      const channel = event.channel;

      if (channel.label === 'annotation') {
        setupAnnotationReceiver(channel);
      } else if (channel.label === 'pointer') {
        setupPointerReceiver(channel);
      }
    };

    // 音声ストリームの追加
    await addAudioStream(pc);

    // シグナリング処理
    setupSignaling(socket, pc);
  };

  const addAudioStream = async (pc: RTCPeerConnection) => {
    try {
      const stream =
        await navigator.mediaDevices.getUserMedia({
          audio: {
            echoCancellation: true,
            noiseSuppression: true,
          },
        });

      stream.getAudioTracks().forEach((track) => {
        pc.addTrack(track, stream);
      });
    } catch (error) {
      console.error('音声ストリーム取得エラー:', error);
    }
  };

  const setupSignaling = (
    socket: Socket,
    pc: RTCPeerConnection
  ) => {
    socket.on('offer', async (data) => {
      await pc.setRemoteDescription(
        new RTCSessionDescription(data.offer)
      );

      const answer = await pc.createAnswer();
      await pc.setLocalDescription(answer);

      socket.emit('answer', {
        answer,
        targetUserId: data.fromUserId,
      });
    });

    socket.on('ice-candidate', async (data) => {
      try {
        await pc.addIceCandidate(
          new RTCIceCandidate(data.candidate)
        );
      } catch (error) {
        console.error('ICE Candidate追加エラー:', error);
      }
    });
  };

  const setupAnnotationReceiver = (
    channel: RTCDataChannel
  ) => {
    const canvas = canvasRef.current;
    if (!canvas) return;

    const ctx = canvas.getContext('2d')!;

    channel.onmessage = (event) => {
      const data = JSON.parse(event.data);
      const x = data.x * canvas.width;
      const y = data.y * canvas.height;

      switch (data.type) {
        case 'start':
          ctx.beginPath();
          ctx.moveTo(x, y);
          ctx.strokeStyle = data.color;
          ctx.lineWidth = data.lineWidth;
          ctx.lineCap = 'round';
          break;
        case 'draw':
          ctx.lineTo(x, y);
          ctx.stroke();
          break;
        case 'clear':
          ctx.clearRect(0, 0, canvas.width, canvas.height);
          break;
      }
    };
  };

  const setupPointerReceiver = (
    channel: RTCDataChannel
  ) => {
    const pointer = pointerRef.current;
    if (!pointer) return;

    channel.onmessage = (event) => {
      const data = JSON.parse(event.data);

      if (data.visible) {
        const x = data.x * window.innerWidth;
        const y = data.y * window.innerHeight;

        pointer.style.left = `${x}px`;
        pointer.style.top = `${y}px`;
        pointer.style.display = 'block';
      } else {
        pointer.style.display = 'none';
      }
    };
  };

  const startScreenShare = async () => {
    try {
      const stream =
        await navigator.mediaDevices.getDisplayMedia({
          video: {
            cursor: 'always',
            width: { ideal: 1920 },
            height: { ideal: 1080 },
          },
        });

      if (peerConnection) {
        stream.getVideoTracks().forEach((track) => {
          peerConnection.addTrack(track, stream);
        });

        setIsSharing(true);

        stream.getVideoTracks()[0].onended = () => {
          setIsSharing(false);
        };
      }
    } catch (error) {
      console.error('画面共有エラー:', error);
    }
  };

  const cleanup = () => {
    if (peerConnection) {
      peerConnection.close();
    }
    if (signalingSocket) {
      signalingSocket.disconnect();
    }
  };

  return (
    <div className='user-view'>
      <canvas
        ref={canvasRef}
        width={1920}
        height={1080}
        style={{
          position: 'fixed',
          top: 0,
          left: 0,
          width: '100%',
          height: '100%',
          pointerEvents: 'none',
          zIndex: 9998,
        }}
      />

      <div
        ref={pointerRef}
        style={{
          position: 'fixed',
          width: '24px',
          height: '24px',
          background:
            'radial-gradient(circle, #FF0000 0%, #FF000080 70%, transparent 100%)',
          border: '2px solid white',
          borderRadius: '50%',
          pointerEvents: 'none',
          zIndex: 9999,
          display: 'none',
        }}
      />

      <div className='controls'>
        <button
          onClick={startScreenShare}
          disabled={isSharing}
        >
          {isSharing ? '画面共有中' : '画面共有開始'}
        </button>
      </div>
    </div>
  );
};

エラーハンドリングと品質監視

本番環境では、接続品質の監視とエラー処理が重要です。

接続品質の監視

WebRTC の統計情報を取得して、接続品質を監視します。

typescript// ConnectionMonitor.ts - 接続品質監視
class ConnectionMonitor {
  private intervalId: number | null = null;

  constructor(private peerConnection: RTCPeerConnection) {}

  // 監視開始
  public startMonitoring(intervalMs: number = 1000): void {
    this.intervalId = window.setInterval(async () => {
      const stats = await this.getConnectionStats();
      this.analyzeStats(stats);
    }, intervalMs);
  }

  // 監視停止
  public stopMonitoring(): void {
    if (this.intervalId) {
      clearInterval(this.intervalId);
      this.intervalId = null;
    }
  }

  // 統計情報の取得
  private async getConnectionStats(): Promise<RTCStatsReport> {
    return await this.peerConnection.getStats();
  }

  // 統計情報の分析
  private analyzeStats(stats: RTCStatsReport): void {
    let videoStats = {
      packetsLost: 0,
      packetsReceived: 0,
      bytesReceived: 0,
      jitter: 0,
      frameRate: 0,
    };

    let audioStats = {
      packetsLost: 0,
      packetsReceived: 0,
      jitter: 0,
    };

    stats.forEach((report) => {
      // ビデオ統計
      if (
        report.type === 'inbound-rtp' &&
        report.kind === 'video'
      ) {
        videoStats.packetsLost = report.packetsLost || 0;
        videoStats.packetsReceived =
          report.packetsReceived || 0;
        videoStats.bytesReceived =
          report.bytesReceived || 0;
        videoStats.jitter = report.jitter || 0;
        videoStats.frameRate = report.framesPerSecond || 0;
      }

      // オーディオ統計
      if (
        report.type === 'inbound-rtp' &&
        report.kind === 'audio'
      ) {
        audioStats.packetsLost = report.packetsLost || 0;
        audioStats.packetsReceived =
          report.packetsReceived || 0;
        audioStats.jitter = report.jitter || 0;
      }
    });

    // パケットロス率の計算
    const videoLossRate = this.calculateLossRate(
      videoStats.packetsLost,
      videoStats.packetsReceived
    );

    const audioLossRate = this.calculateLossRate(
      audioStats.packetsLost,
      audioStats.packetsReceived
    );

    // 品質警告
    if (videoLossRate > 0.05) {
      console.warn(
        `ビデオパケットロス率が高い: ${(
          videoLossRate * 100
        ).toFixed(2)}%`
      );
    }

    if (audioLossRate > 0.03) {
      console.warn(
        `音声パケットロス率が高い: ${(
          audioLossRate * 100
        ).toFixed(2)}%`
      );
    }

    if (audioStats.jitter > 30) {
      console.warn(
        `音声ジッターが高い: ${audioStats.jitter.toFixed(
          2
        )}ms`
      );
    }

    // 統計情報をログ出力
    console.log('接続品質:', {
      video: {
        lossRate: `${(videoLossRate * 100).toFixed(2)}%`,
        frameRate: videoStats.frameRate,
        bandwidth: `${(
          (videoStats.bytesReceived * 8) /
          1024
        ).toFixed(2)} kbps`,
      },
      audio: {
        lossRate: `${(audioLossRate * 100).toFixed(2)}%`,
        jitter: `${audioStats.jitter.toFixed(2)}ms`,
      },
    });
  }

  // パケットロス率の計算
  private calculateLossRate(
    lost: number,
    received: number
  ): number {
    const total = lost + received;
    return total > 0 ? lost / total : 0;
  }
}

エラーハンドリング

よくあるエラーケースとその対処法を実装します。

typescript// ErrorHandler.ts - エラーハンドリング
export class WebRTCErrorHandler {
  // エラーコード定義
  public static readonly ERROR_CODES = {
    PERMISSION_DENIED: 'NotAllowedError',
    DEVICE_NOT_FOUND: 'NotFoundError',
    CONSTRAINT_NOT_SATISFIED: 'OverconstrainedError',
    ICE_CONNECTION_FAILED: 'ICEConnectionFailed',
    SIGNALING_ERROR: 'SignalingError',
  } as const;

  // 画面共有エラーのハンドリング
  public static handleScreenShareError(
    error: Error
  ): string {
    if (error.name === this.ERROR_CODES.PERMISSION_DENIED) {
      return '画面共有の権限が拒否されました。ブラウザの設定を確認してください。';
    }

    if (error.name === this.ERROR_CODES.DEVICE_NOT_FOUND) {
      return '画面共有に使用できるディスプレイが見つかりません。';
    }

    return `画面共有エラー: ${error.message}`;
  }

  // 音声デバイスエラーのハンドリング
  public static handleAudioError(error: Error): string {
    if (error.name === this.ERROR_CODES.PERMISSION_DENIED) {
      return 'マイクの使用許可が拒否されました。ブラウザの設定を確認してください。';
    }

    if (error.name === this.ERROR_CODES.DEVICE_NOT_FOUND) {
      return 'マイクが見つかりません。デバイスが接続されているか確認してください。';
    }

    if (
      error.name ===
      this.ERROR_CODES.CONSTRAINT_NOT_SATISFIED
    ) {
      return '指定された音声設定がサポートされていません。';
    }

    return `音声デバイスエラー: ${error.message}`;
  }

  // ICE接続エラーのハンドリング
  public static handleICEConnectionError(
    state: RTCIceConnectionState
  ): string {
    switch (state) {
      case 'failed':
        return 'ピア接続に失敗しました。ネットワーク設定を確認してください。';
      case 'disconnected':
        return 'ピア接続が切断されました。再接続を試みています...';
      case 'closed':
        return 'ピア接続が閉じられました。';
      default:
        return `予期しない接続状態: ${state}`;
    }
  }

  // エラーログの記録
  public static logError(
    context: string,
    error: Error
  ): void {
    console.error(`[${context}] エラー発生:`, {
      name: error.name,
      message: error.message,
      stack: error.stack,
      timestamp: new Date().toISOString(),
    });

    // 本番環境ではエラートラッキングサービスに送信
    // 例: Sentry.captureException(error)
  }
}

まとめ

本記事では、WebRTC を活用した遠隔支援システムの実装方法を詳しく解説しました。

画面共有に加えて、リアルタイムの注釈描画、マウスポインタ共有、低遅延音声通話を統合することで、従来の画面共有だけでは実現できなかった高度なサポート体験を提供できます。WebRTC の P2P 通信により、サーバー負荷を抑えながら低遅延のリアルタイムコミュニケーションが可能になります。

実装のポイントは以下の通りです。

#ポイント内容
1DataChannel 活用注釈・ポインタデータは DataChannel で送信し、順序保証なし・再送なしで低遅延を実現
2相対座標の使用解像度差異を吸収するため、0-1 の相対座標でデータを送受信
3音声最適化Opus コーデック、エコーキャンセル、ノイズ抑制を活用して高品質な音声通話を実現
4接続品質監視RTCStatsReport で接続品質を監視し、問題を早期検出
5エラーハンドリングデバイス権限、接続失敗など、各種エラーに適切に対処

今回紹介した実装をベースに、録画機能やチャット機能、複数人での同時サポートなど、さらなる機能拡張も可能です。ぜひ、あなたのプロジェクトに WebRTC を活用した遠隔支援機能を取り入れてみてください。

関連リンク