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

リモートワークの普及に伴い、遠隔での技術支援やカスタマーサポートのニーズが急速に高まっています。従来の画面共有だけでは「ここをクリックしてください」と口頭で説明するしかなく、相手が理解できているか不安になることも多いのではないでしょうか。
本記事では、WebRTC を活用した遠隔支援システムの実装事例を紹介します。画面共有に加えて、リアルタイムで画面上に注釈を描画したり、マウスポインタの位置を共有したり、低遅延で音声通話を行う方法を、実装コード付きで解説します。
背景
遠隔支援システムに求められる要件
遠隔支援を行う際、サポート担当者とユーザー間でスムーズなコミュニケーションが不可欠です。単なる画面共有だけでは以下のような課題があります。
- 操作箇所を言葉だけで伝えるのが難しい
- ユーザーが正しく操作できているか確認しづらい
- 音声の遅延により会話がかみ合わない
これらを解決するため、画面共有・注釈・ポインタ共有・音声通話を統合したシステムが求められています。
WebRTC が遠隔支援に適している理由
WebRTC(Web Real-Time Communication)は、ブラウザ間でリアルタイム通信を実現する技術です。遠隔支援システムに適している理由は以下の通りです。
# | 特徴 | 遠隔支援での利点 |
---|---|---|
1 | プラグイン不要 | ユーザーに追加ソフトのインストールを求めない |
2 | 低遅延通信 | 音声・映像のリアルタイム性が高い |
3 | P2P 通信 | サーバー負荷を抑えられる |
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 通信により、サーバー負荷を抑えながら低遅延のリアルタイムコミュニケーションが可能になります。
実装のポイントは以下の通りです。
# | ポイント | 内容 |
---|---|---|
1 | DataChannel 活用 | 注釈・ポインタデータは DataChannel で送信し、順序保証なし・再送なしで低遅延を実現 |
2 | 相対座標の使用 | 解像度差異を吸収するため、0-1 の相対座標でデータを送受信 |
3 | 音声最適化 | Opus コーデック、エコーキャンセル、ノイズ抑制を活用して高品質な音声通話を実現 |
4 | 接続品質監視 | RTCStatsReport で接続品質を監視し、問題を早期検出 |
5 | エラーハンドリング | デバイス権限、接続失敗など、各種エラーに適切に対処 |
今回紹介した実装をベースに、録画機能やチャット機能、複数人での同時サポートなど、さらなる機能拡張も可能です。ぜひ、あなたのプロジェクトに WebRTC を活用した遠隔支援機能を取り入れてみてください。
関連リンク
- article
WebRTC で遠隔支援:画面注釈・ポインタ共有・低遅延音声の実装事例
- article
WebRTC で E2EE ビデオ会議:Insertable Streams と鍵交換を実装する手順
- article
WebRTC 完全メッシュ vs SFU 設計比較:同時接続数と帯域コストを数式で見積もる
- article
WebRTC 開発環境セットアップ完全版:ローカル NAT/HTTPS/証明書を最短で通す手順
- article
WebRTC の内部プロトコル徹底解剖:DTLS-SRTP・RTP/RTCP・ICE-Trickle を一気に理解
- article
WebRTC 技術設計:SFU vs MCU vs P2P の選定基準と費用対効果
- article
NestJS クリーンアーキテクチャ:UseCase/Domain/Adapter を疎結合に保つ設計術
- article
WebSocket プロトコル設計:バージョン交渉・機能フラグ・後方互換のパターン
- article
MySQL 読み書き分離設計:ProxySQL で一貫性とスループットを両立
- article
Motion(旧 Framer Motion)アニメオーケストレーション設計:timeline・遅延・相互依存の整理術
- article
WebRTC で遠隔支援:画面注釈・ポインタ共有・低遅延音声の実装事例
- article
JavaScript パフォーマンス最適化大全:レイアウトスラッシングを潰す実践テク
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来