【完全保存版】WebRTCで作る！ブラウザだけで動く電話アプリの実装方法

# はじめに
実務でブラウザ上での電話機能を実装する機会があり、自分用の備忘録として残しておきます。少しでもお役に立てれれば幸いです。間違っている箇所などありましたらご教授いただけると大変助かります。

# 前提条件
- Node.js v14以上
- SSL証明書（Let's Encryptなど）
- 基本的なJavaScript/HTML/CSSの知識
-  基本的なネットワークの知識
- WebSocketの基本的な理解


# WebRTCとは？
WebRTCとは**ブラウザ同士を直接つないで、音声や動画をやりとりできる仕組み**のことを指します。もっと簡単に言うと、「webブラウザだけでリアルタイム通信ができる」ということになります。つまり、WebRTCを使用するとzoomのようなコミュニケーションツールを開発することができます。

:::note info
特徴とポイント
* **通信データはサーバーを経由せず**ブラウザ同士で直接やりとりできる
* マイク・カメラ・画面共有もできる
* P2P通信を使用
:::

# P2Pとは？
WebRTCを理解するにあたって、P2P通信の理解はとても重要になります。P2P通信とは、**サーバーを介さずに端末同士の通信を可能にする通信方式**になります。

#### クライアント・サーバー型通信(従来の通信)
普段使用している一般的なチャットアプリや、webサーバーなどはクライアント・サーバー型通信といい、サーバーを介して通信が行われます。

![サーバー (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935088/27ba327f-273b-4ebb-b4db-59a8ea7dca40.png)


#### P2P通信
P2P通信は従来の通信とは違い、サーバーを介さずに直接ブラウザ同士が繋がれる通信になります。
![サーバー (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935088/dcc4acaf-2593-4a02-a1ee-196558308d0d.png)

このようにP2P通信の技術を用いることにより、ブラウザ間でのリアルタイムでのデータの受け渡しを可能にさせています。

# WebRTCを使用して電話機能を作る
ここまででWebRTCとはどんなものなのか、どういった通信を利用してこの仕組みを作っているのかざっくりと理解できたと思います。ただ、問題となるのは、同じネットワーク内だったら簡単にブラウザ同士が繋がれますが、インターネットを超えての通信というのがやっかいになります。そちらも踏まえて、各ステップわかりやすく説明していきます。


## step1. 基本的なフロントエンドの実装(HTML)
まず初めに基本的な通話アプリと画面と機能を作ります。


```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <title>WebRTC Phone</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #f5f5f5;
            min-height: 100vh;
            padding: 20px;
        }

        .phone-app {
            max-width: 400px;
            margin: 0 auto;
            background: white;
            border-radius: 20px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
            overflow: hidden;
        }

        .header {
            background: #007AFF;
            color: white;
            padding: 20px;
            text-align: center;
        }

        .header h1 {
            font-size: 24px;
            font-weight: 600;
        }

        .status {
            font-size: 14px;
            opacity: 0.9;
            margin-top: 5px;
        }

        .call-screen {
            padding: 30px;
            text-align: center;
            min-height: 200px;
            display: flex;
            flex-direction: column;
            justify-content: center;
            display: none;
        }

        .call-screen.active {
            display: flex;
        }

        .caller-info {
            margin-bottom: 30px;
        }

        .caller-name {
            font-size: 28px;
            font-weight: 600;
            color: #333;
            margin-bottom: 10px;
        }

        .call-status {
            font-size: 16px;
            color: #666;
            margin-bottom: 15px;
        }

        .call-timer {
            font-size: 20px;
            color: #007AFF;
            font-weight: 500;
        }

        .contact-list {
            padding: 0;
        }

        .contact-item {
            display: flex;
            align-items: center;
            padding: 15px 20px;
            border-bottom: 1px solid #eee;
            cursor: pointer;
            transition: background-color 0.2s;
        }

        .contact-item:hover {
            background-color: #f8f8f8;
        }

        .contact-item:last-child {
            border-bottom: none;
        }

        .contact-avatar {
            width: 50px;
            height: 50px;
            background: #007AFF;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-size: 20px;
            margin-right: 15px;
        }

        .contact-info {
            flex: 1;
        }

        .contact-name {
            font-size: 18px;
            font-weight: 600;
            color: #333;
            margin-bottom: 3px;
        }

        .contact-status {
            font-size: 14px;
            color: #666;
        }

        .call-btn {
            background: #34C759;
            border: none;
            border-radius: 50%;
            width: 60px;
            height: 60px;
            color: white;
            font-size: 24px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .call-btn:hover {
            background: #30B14C;
            transform: scale(1.05);
        }

        .call-controls {
            display: flex;
            justify-content: center;
            gap: 40px;
            margin-top: 30px;
        }

        .control-button {
            border: none;
            border-radius: 50%;
            width: 70px;
            height: 70px;
            color: white;
            font-size: 28px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .end-call-btn {
            background: #FF3B30;
        }

        .end-call-btn:hover {
            background: #E6342A;
            transform: scale(1.05);
        }

        .mute-btn {
            background: #666;
        }

        .mute-btn:hover {
            background: #555;
            transform: scale(1.05);
        }

        .mute-btn.active {
            background: #FF3B30;
        }

        .back-btn {
            position: absolute;
            top: 20px;
            left: 20px;
            background: none;
            border: none;
            color: white;
            font-size: 20px;
            cursor: pointer;
        }

        /* 着信画面のスタイル */
        .incoming-call-screen {
            padding: 40px 30px;
            text-align: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            display: none;
            flex-direction: column;
            justify-content: center;
            min-height: 400px;
        }

        .incoming-call-screen.active {
            display: flex;
        }

        .incoming-avatar {
            width: 120px;
            height: 120px;
            background: rgba(255, 255, 255, 0.2);
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 48px;
            margin: 0 auto 20px;
            animation: ring-pulse 2s infinite;
        }

        @keyframes ring-pulse {
            0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4); }
            50% { transform: scale(1.05); box-shadow: 0 0 0 20px rgba(255, 255, 255, 0); }
            100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
        }

        .incoming-caller-name {
            font-size: 32px;
            font-weight: 600;
            margin-bottom: 10px;
        }

        .incoming-status {
            font-size: 18px;
            opacity: 0.9;
            margin-bottom: 40px;
        }

        .incoming-call-controls {
            display: flex;
            justify-content: center;
            gap: 60px;
        }

        .answer-btn {
            background: #34C759;
            width: 80px;
            height: 80px;
            font-size: 32px;
        }

        .decline-btn {
            background: #FF3B30;
            width: 80px;
            height: 80px;
            font-size: 32px;
        }

        /* アニメーション */
        .calling-animation {
            animation: pulse 1.5s infinite;
        }

        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.05); }
            100% { transform: scale(1); }
        }

        /* レスポンシブ */
        @media (max-width: 480px) {
            .phone-app {
                margin: 0;
                border-radius: 0;
                min-height: 100vh;
            }
            
            body {
                padding: 0;
            }
        }
    </style>
</head>
<body>
    <div class="phone-app">
        <!-- ヘッダー -->
        <div class="header">
            <button class="back-btn" id="backBtn" style="display: none;">
                <i class="fa-solid fa-arrow-left"></i>
            </button>
            <h1>電話帳</h1>
            <div class="status" id="status">連絡先を選択してください</div>
        </div>

        <!-- 連絡先リスト -->
        <div class="contact-list" id="contactList">
            <div class="contact-item" data-user-id="user_test1" data-name="test1">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test1</div>
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
            <div class="contact-item" data-user-id="user_test2" data-name="test2">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test2</div>
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
            <div class="contact-item" data-user-id="user_test3" data-name="test3">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test3</div>
                    
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
            <div class="contact-item" data-user-id="user_test4" data-name="test4">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test4</div>
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
        </div>

        <!-- 着信画面 -->
        <div class="incoming-call-screen" id="incomingCallScreen">
            <div class="incoming-avatar">
                <i class="fa-solid fa-user"></i>
            </div>
            <div class="incoming-caller-name" id="incomingCallerName">Unknown</div>
            <div class="incoming-status">着信中...</div>
            
            <div class="incoming-call-controls">
                <button class="control-button decline-btn" id="declineBtn">
                    <i class="fa-solid fa-phone-slash"></i>
                </button>
                <button class="control-button answer-btn" id="answerBtn">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
        </div>

        <!-- 通話画面 -->
        <div class="call-screen" id="callScreen">
            <div class="caller-info">
                <div class="caller-name" id="callerName">Unknown</div>
                <div class="call-status" id="callStatus">通話中</div>
                <div class="call-timer" id="callTimer" style="display: none;">00:00</div>
            </div>

            <div class="call-controls">
                <button class="control-button mute-btn" id="muteBtn">
                    <i class="fa-solid fa-microphone"></i>
                </button>
                <button class="control-button end-call-btn" id="endCallBtn">
                    <i class="fa-solid fa-phone-slash"></i>
                </button>
            </div>
        </div>
    </div>

    <!-- 音声要素 -->
    <audio id="remoteAudio" autoplay></audio>
    <script src="./index.js" type="module"></script>
</body>
</html>
```

webブラウザで確認すると、下記のような表示になってるかと思います。

![スクリーンショット 2025-05-29 180428.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935088/aa61580d-b49d-4cfe-9b83-d640d7596a1c.png)



## step2. 基本的なフロントエンドの実装(Javascript)

## 1. websocket接続の確立
相手とリアルタイムで通信をやりとりするために、シグナリングサーバーとwebsocket接続を行います。


WebSocket接続にはwssプロトコルを使用します。今回は、ポート3000番で後ほどシグナリングサーバーを作成するので、3000としておきます。適宜変更してください。


```javascript
// 開発環境では通常ws://を使用
const ws = new WebSocket('ws://localhost:3000');
// 本番環境ではwss://を使用
const ws = new WebSocket('wss://yourdomain.com:3000');
```

:::note info
シグナリングサーバーは後ほど説明します。
:::

## 2. マイクの取得
→ getUserMedia APIで許可を取って、マイクの起動をする

```main.js
let localStream = null;
let peer = null

// マイクの音を取得
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
    localStream = stream;
});
```

## 3. PeerConnection作成
#### PeerConnectionとは？
>ブラウザ上でPeer to Peer(P2P)通信を可能にする為のAPI

簡単に説明すると、**自分のパソコンと相手のパソコンをつなぐ橋（はし）みたいなもの**になります。このPeerConnectionを作らないと、P2P通信（直接やりとりする通信）ができません。

WebRTCでPeerConnectionを作るときに必要なものが3つあるので順に説明していきます。

### <INS>1. STUNサーバーの設定</INS>
外部のインターネット同士が繋がるために外から見えるIPアドレスを知る必要があります(グローバルIPアドレス)
STUNサーバーとは**外部から見た自分のIPアドレスを取得してくれる**ものになります。つまり、「あなたのインターネット上の住所(自分の外向きのグローバルIPアドレスとポート」)はここですよ！」と教えてくれるサーバーになります。

今回はすでに用意されているSTUNサーバーを使用します。

```main.js
 const peer = new RTCPeerConnection({
        iceServers: [
            // Google STUN サーバー（複数追加）
            { urls: 'stun:stun.l.google.com:19302' },
            { urls: 'stun:stun1.l.google.com:19302' },
            { urls: 'stun:stun2.l.google.com:19302' },
            { urls: 'stun:stun3.l.google.com:19302' },
            { urls: 'stun:stun4.l.google.com:19302' },
            
            // 追加のSTUNサーバー
            { urls: 'stun:stun.stunprotocol.org:3478' },
            { urls: 'stun:stun.voip.eutelia.it:3478' },
            { urls: 'stun:stun.voipbuster.com:3478' },
        ]
    });
```


### <INS>2. ICE候補の送信設定</INS>
相手と通信するためには、いくつかの通信経路の候補を試す必要があります。
WebRTCは、パソコンが使える通信方法（IPアドレスやポート）をいくつも探して、その中から実際に通信できる最適な経路を1つ選びます。

:::note info
ICEとは
**通信できるかもしれない経路（IPアドレスやポート）の候補**のこと
この中から相手とつながる道を選びます。
:::

```main.js
 peer.onicecandidate = event => {
        if (event.candidate) {
             // シグナリング経由でICE候補を相手に送信
            ws.send(JSON.stringify({ type: 'candidate', candidate: event.candidate }));
        }
    };
```
**コードの動作**
1. 新しい通信ルート（ICE候補）が見つかった時に実行される
2. 見つかったルート情報が `event.candidate` に入っている
3. WebSocket 経由で相手に送信する


### <INS>3. 相手の音声を受信する設定</INS>
相手から送られてくる音声を再生するための設定を行います。
```main.js
peer.ontrack = event => {
        document.getElementById('remoteAudio').srcObject = event.streams[0];
    };
```

**コードの動作**
1. 相手から音声データが届いた時に実行される(ontrack)
2. 相手の音声データを受信(events.steams[0])
3. HTML上の音声再生エリア(remoteAudio)に設定
4. 相手の声がスピーカーから聞こえる

### <INS>PeerConnection全体コード</INS>
```main.js
function createPeer() {
    // PeerConnection作成
    const peer = new RTCPeerConnection({
        iceServers: [
            // Google STUN サーバー（複数追加）
            { urls: 'stun:stun.l.google.com:19302' },
            { urls: 'stun:stun1.l.google.com:19302' },
            { urls: 'stun:stun2.l.google.com:19302' },
            { urls: 'stun:stun3.l.google.com:19302' },
            { urls: 'stun:stun4.l.google.com:19302' },
            
            // 追加のSTUNサーバー
            { urls: 'stun:stun.stunprotocol.org:3478' },
            { urls: 'stun:stun.voip.eutelia.it:3478' },
            { urls: 'stun:stun.voipbuster.com:3478' },
        ]
    });

    // ICE候補の送信設定
    peer.onicecandidate = event => {
        if (event.candidate) {
            ws.send(JSON.stringify({ type: 'candidate', candidate: event.candidate }));
        }
    };

    // 相手の音声を受信する設定
    peer.ontrack = event => {
        document.getElementById('remoteAudio').srcObject = event.streams[0];
    };
    
    return peer;
}
```

以上でPeerConnectionの作成ができました。


## 4. 自分の音声をPeerConnectionに追加
peerConnectionを作成したあと、
**この音声を相手に送信しますよ**とPeerConnectionに登録する
```main.js
//上記で作成したPeerConnection作成関数を呼び出す
peer = createPeer();

//自分の音声をPeerConnectionに追加
localStream.getTracks().forEach(track => peer.addTrack(track, localStream));
```

## 5. Offer(提案)の作成
通話するにあたっての条件(どんな音声形式を使うか、どのポートで通信するかなど)の作成を行います。

```main.js
const offer = await peer.createOffer()
```

## 6. 自分の設定として保存
作成したoffer(提案)を自分の設定として保存します。この処理は、「この条件で私は準備OKですよ」という意味になります。

```main.js
await peer.setLocalDescription(offer);
```

:::note info
`peer.onicecandidate`は　`setLocalDescription(offer)` を呼んだ直後から発火し始めます。
setLocalDescription()を呼んだあとの流れ
* offerをローカルに保存する
* この段階でICE候補の探索を始める
* ICE候補が見つかるたびに`peer.onicecandidate` が発火
:::

## 7. 相手にoffer(提案)を送信
自分の設定として保存できたら、そのofferを今後は相手に送信をします。
送信する際は、シグナリングサーバー経由で相手に送信します。

```main.js
ws.send(JSON.stringify({ type: 'offer', offer }));
```


## 通話ボタンが押された後の全体コード

```main.js
// 通話開始ボタンが押されたら
document.querySelectorAll('.callBtns').forEach((btn)=>{
    btn.onclick = async (event) => {
        if (!localStream) {
            console.error('マイクの準備ができていません');
            return;
        }

        // 電話相手のIDと名前を取得する
        const userInformation = getUserInformation(event)
        const userId = userInformation["userId"] //電話相手のIDを取得
        const name = userInformation["name"] //電話相手の名前を取得

        //発信開始UI処理
        startOutgoingCall(name)
        currentTargetUserId = userId; //通話相手のIDをグローバル変数に格納
        
        // ################ WebRTCの確立 #################
        // 1. peerConnectionを作成
        peer = createPeer();
    
        // 2. peerConnectionに自分の音声を追加する
        localStream.getTracks().forEach(track => peer.addTrack(track, localStream));
    
        // 3. 通話するにあたっての条件の作成をする
        const offer = await peer.createOffer();
    
        // 4. 作成した条件をローカルに保存
        await peer.setLocalDescription(offer);

        // 5. 作成した条件を相手に送信
        ws.send(JSON.stringify({ 
            type: 'offer', 
            offer,
            targetUserId: currentTargetUserId //通話したい相手のIDも送信する
        }));
    };
});

```


## 8. Websocketでメッセージを受信する処理
offerを送信したら、相手からそのOfferに対するAnswerを受信する必要があります。
以下がメッセージを受信する処理になります。

```main.js
// バイナリデータが来た時にどの形式で受け取りたいか設定
//バイナリデータが来た場合は、blob形式で受け取る
ws.binaryType = 'blob'; // または 'arraybuffer'

ws.onmessage = async (event) => {
    try {
        let data;
        
        // データタイプを確認
        if (event.data instanceof Blob) {
            const text = await event.data.text();
            data = JSON.parse(text);
        } else if (typeof event.data === 'string') {
            data = JSON.parse(event.data);
        } else {
            console.error('未対応のデータ形式:', event.data);
            return;
        };

        if(data.type === "hangup"){
            UpdateUIForEndCall()
        }

        // メッセージタイプに応じた処理
        // 通話提案が来た場合の処理
        if (data.type === 'offer') {
            pendingOfferData = data;
            pendingCandidates = []; 
            
            // 着信画面を表示
            showIncomingCall(data.fromUserId, data.fromUserName || data.fromUserId, data)
        }

        // 通話受け入れが来た時の処理
        if (data.type === 'answer') {
            if (peer) {
                try {
                    await peer.setRemoteDescription(new RTCSessionDescription(data.answer));
                    // remoteDescriptionが設定された後に保留中のcandidateを処理
                    if (pendingCandidates.length > 0) {
                        console.log(`保留中のCandidate数: ${pendingCandidates.length}`);
                        for (const candidate of pendingCandidates) {
                            try {
                                await peer.addIceCandidate(new RTCIceCandidate(candidate));
                                console.log('保留Candidate追加完了');
                            } catch (error) {
                                console.error('保留Candidate追加エラー:', error);
                            }
                        }
                        pendingCandidates = []; // クリア
                    }
                    
                    // UI更新：通話開始
                    updateCallState("connected")
                } catch (error) {
                    console.error('Answer処理エラー:', error);
                }
            }
        }

        // 通話経路の候補を受信したときの処理
        if (data.type === 'candidate') {
            console.log("Candidate受信:", data);
            
            // peerが存在し、かつremoteDescriptionが設定されているか確認
            if (peer && peer.remoteDescription) {
                try {
                    await peer.addIceCandidate(new RTCIceCandidate(data.candidate));
                    console.log('Candidate追加完了');
                } catch (error) {
                    console.error('Candidate追加エラー:', error);
                    // エラーが発生しても保留リストに追加
                    pendingCandidates.push(data.candidate);
                }
            } else {
                // peerが存在しないか、remoteDescriptionが未設定の場合は保留
                console.log('Candidate保留中（peer未作成またはremoteDescription未設定）');
                pendingCandidates.push(data.candidate);
            }
        }
        
    } catch (error) {
        console.error('メッセージ処理エラー:', error);
    }
};
```

こちら一つずつ解説していきます。
### <INS>1. データ形式の確認と変換</INS>
Websocketに送信されてくるデータは**2つの形式**があります。
* 文字列
* バイナリーデータ

文字列の場合は、そのままJSON解析ができますが、バイナリーデータで送信されてきた場合は、テキストに変換してJSON解析する必要があります。

```main.js
let data;
        
// データタイプを確認
if (event.data instanceof Blob) {
    // Blobの場合、テキストに変換
    const text = await event.data.text();
    data = JSON.parse(text);
} else if (typeof event.data === 'string') {
    // 文字列の場合、直接パース
    data = JSON.parse(event.data);
} else {
    console.error('未対応のデータ形式:', event.data);
    return;
}
```

### <INS>2. メッセージ対応に応じた処理</INS>

* Offer(通話提案)を受けた場合の処理
Offerを受けた場合、返答送信をする必要があります。
ここがかなり複雑なので、順番に説明していきます。

(処理順番)
1. Offerを受信する
2. 着信画面を表示する(UI更新)　`showIncomingCall`
3. 通話受け入れボタンを押す　`answerBtn.addEventListener`
4. 通話中の画面を表示する(UI更新)　`answerCall`
5. PeerConnectionを作成
6. マイクの音声を追加
7. 相手のOffer情報を設定
8. 自分のAnswerを作成＆送信

```main.js
if (data.type === 'offer') {
    pendingOfferData = data;
    pendingCandidates = []; // candidateをリセット ← 追加
    
    // 着信画面を表示
    showIncomingCall(data.fromUserId, data.fromUserName || data.fromUserId, data)
}

answerBtn.addEventListener('click', () => {
    answerCall();
    acceptCallRequest()
});


const acceptCallRequest = async () => {
    try {
        if (!pendingOfferData) {
            console.error('保留中のofferがありません');
            return;
        }
        
        console.log('通話受諾処理開始');
        
        currentTargetUserId = pendingOfferData.fromUserId;
        
        // WebRTC処理開始
        peer = createPeer();
        
        // remoteDescriptionを設定
        await peer.setRemoteDescription(new RTCSessionDescription(pendingOfferData.offer));
        console.log('RemoteDescription設定完了');
        
        // ローカルストリームを追加
        localStream.getTracks().forEach(track => peer.addTrack(track, localStream));
        
        // answerを作成
        const answer = await peer.createAnswer();
        await peer.setLocalDescription(answer);
        console.log('LocalDescription設定完了');
        
        // remoteDescriptionが設定された後に保留中のcandidateを処理
        console.log(`保留中のCandidate数: ${pendingCandidates.length}`);
        for (const candidate of pendingCandidates) {
            try {
                await peer.addIceCandidate(new RTCIceCandidate(candidate));
                console.log('保留Candidate追加完了');
            } catch (error) {
                console.error('保留Candidate追加エラー:', error);
            }
        }
        pendingCandidates = []; // クリア
        
        // answer送信
        ws.send(JSON.stringify({ 
            type: 'answer', 
            answer, 
            targetUserId: currentTargetUserId
        }));
        
        console.log('Answer送信完了');
        
        // offerデータをクリア
        pendingOfferData = null;
        
    } catch (error) {
        console.error('通話受諾エラー:', error);
    }
};
```

```uiController.js
// 着信来た時のUI更新処理
export const showIncomingCall = (fromUserId, fromUserName, offer) =>{
      console.log('着信表示:', fromUserId, fromUserName);
      // 着信音再生
      ringtone.currentTime = 0;
      const playPromise = ringtone.play();

      if (playPromise !== undefined) {
            playPromise
            .then(() => {
                  console.log(' 着信音再生開始（ループ）');
            })
            .catch(error => {
                  console.log('着信音再生エラー:', error);
                  // ユーザーに通知
                  alert('着信音を再生するには、画面をクリックしてください');
            });
      }
      
      // pending offerを保存
      pendingOffer = offer;
      // UI表示
      incomingCallerName.textContent = fromUserName || fromUserId;
      
      // 着信画面表示
      contactList.style.display = 'none';
      incomingCallScreen.classList.add('active');
      status.textContent = '着信中';
}

export const answerCall = () =>{
      console.log('通話を受諾');
                  
      // 着信音停止
      ringtone.pause();
      ringtone.currentTime = 0;
      
      // UI切り替え
      incomingCallScreen.classList.remove('active');
      callScreen.classList.add('active');
      backBtn.style.display = 'block';
      
      callerName.textContent = incomingCallerName.textContent;
      callStatus.textContent = '通話中';
      
      isInCall = true;
      callConnected();
}

export const callConnected = () =>{
      callStatus.textContent = '通話中';
      callTimer.style.display = 'block';
      callScreen.classList.remove('calling-animation');
      startTimer();
}
```

* Answer(通話受け入れ)を受けた場合の処理
通話受け入れを受けた場合は、相手の「通話OK」の返事を受け取って、通話を開始します。

```main.js
        if (data.type === 'answer') {
            if (peer) {
                try {
                    await peer.setRemoteDescription(new RTCSessionDescription(data.answer));
                    // remoteDescriptionが設定された後に保留中のcandidateを処理
                    if (pendingCandidates.length > 0) {
                        console.log(`保留中のCandidate数: ${pendingCandidates.length}`);
                        for (const candidate of pendingCandidates) {
                            try {
                                await peer.addIceCandidate(new RTCIceCandidate(candidate));
                                console.log('保留Candidate追加完了');
                            } catch (error) {
                                console.error('保留Candidate追加エラー:', error);
                            }
                        }
                        pendingCandidates = []; // クリア
                    }
                    
                    // UI更新：通話開始
                    updateCallState("connected")
                } catch (error) {
                    console.error('Answer処理エラー:', error);
                }
            }
        }
```

```uiController.js
export const updateCallState = (state) => {
      switch(state) {
            case 'connected':
                  callConnected();
                  break;
            case 'ended':
                  endCall();
                  break;
      }
};
```

* Candidate(通信経路候補)を受けた場合の処理
通信経路候補を受信した場合は、その候補を自分のリストに追加します

```main.js
        // 通話経路の候補を受信したときの処理
        if (data.type === 'candidate') {
            console.log("Candidate受信:", data);
            
            // peerが存在し、かつremoteDescriptionが設定されているか確認
            if (peer && peer.remoteDescription) {
                try {
                    await peer.addIceCandidate(new RTCIceCandidate(data.candidate));
                    console.log('Candidate追加完了');
                } catch (error) {
                    console.error('Candidate追加エラー:', error);
                    // エラーが発生しても保留リストに追加
                    pendingCandidates.push(data.candidate);
                }
            } else {
                // peerが存在しないか、remoteDescriptionが未設定の場合は保留
                console.log('Candidate保留中（peer未作成またはremoteDescription未設定）');
                pendingCandidates.push(data.candidate);
            }
        }
```


## step3. シグナリングサーバーの準備
フロント側の実装が完了したので、次に**相手とつなぐ仕組み**を作る必要があります。
直接ブラウザ同士が通信するために、**お互いの場所（IPアドレスなど）を事前に交換する必要**があります。このやり取りをする仕組みを「シグナリング」といいます。

![サーバー (3).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935088/c1c0c99e-0779-4b75-85ee-907752420cd8.png)

シグナリングサーバーを実装するのに、今回はNode.jsで実装していきます。


まず、必要なパッケージのインストールを行います。

```node
# 必要なパッケージをインストール
npm init -y
npm install ws
```
```server.js
// server.js
const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');

const connectedUsers = new Map(); // userId -> WebSocket

// SSL証明書の読み込み
const server = https.createServer({
      key: fs.readFileSync("privkey.pemのパス"),
      cert: fs.readFileSync("fullchain.pemのパス"),
});

// WebSocketサーバーをHTTPSサーバーに接続
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
      ws.on('message', (data) => {
            try {
                  const message = JSON.parse(data); 

                  // 登録処理（WebSocket接続とユーザーIDを紐付けてMapに保存）
                  if (message.type === 'register') {
                        // ユーザーIDをキーに、WebSocket接続オブジェクトを値としてMapに保存
                        connectedUsers.set(message.userId, ws);
                        return; // 登録メッセージは転送しない
                  }

                    // typeがcandidate, answer, offerの場合
                    // 指定されたユーザーがWebsocket接続しているかチェック
                  const targetWs = connectedUsers.get(message.targetUserId);
                  if (targetWs && targetWs.readyState === WebSocket.OPEN) {
                        const messageWithSender = {
                              ...message ,
                              fromUserId: getCurrentUserId(ws) // 送信者のIDを取得
                        };
                        targetWs.send(JSON.stringify(messageWithSender));
                  }
                  
                  
            } catch (error) {
                  console.error('メッセージ解析エラー:', error);
            }
      });
});

//WebSocket接続オブジェクトから対応するユーザーIDを取得する
const getCurrentUserId =(ws)= {
      for (let [userId, connection] of connectedUsers) {
            if (connection === ws) {
                  return userId;
            }
      }
      return null;
}

server.listen(3000, () => {
      console.log('WSSサーバーがポート3000で起動しました');
});
```

そして起動します。
正常に起動すると、「SSサーバーがポート3000で起動しました'」と表示されます。
```node
node server.js
```

# 全体コード
```index.html
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
    <title>WebRTC Phone</title>
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #f5f5f5;
            min-height: 100vh;
            padding: 20px;
        }

        .phone-app {
            max-width: 400px;
            margin: 0 auto;
            background: white;
            border-radius: 20px;
            box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
            overflow: hidden;
        }

        .header {
            background: #007AFF;
            color: white;
            padding: 20px;
            text-align: center;
        }

        .header h1 {
            font-size: 24px;
            font-weight: 600;
        }

        .status {
            font-size: 14px;
            opacity: 0.9;
            margin-top: 5px;
        }

        .call-screen {
            padding: 30px;
            text-align: center;
            min-height: 200px;
            display: flex;
            flex-direction: column;
            justify-content: center;
            display: none;
        }

        .call-screen.active {
            display: flex;
        }

        .caller-info {
            margin-bottom: 30px;
        }

        .caller-name {
            font-size: 28px;
            font-weight: 600;
            color: #333;
            margin-bottom: 10px;
        }

        .call-status {
            font-size: 16px;
            color: #666;
            margin-bottom: 15px;
        }

        .call-timer {
            font-size: 20px;
            color: #007AFF;
            font-weight: 500;
        }

        .contact-list {
            padding: 0;
        }

        .contact-item {
            display: flex;
            align-items: center;
            padding: 15px 20px;
            border-bottom: 1px solid #eee;
            cursor: pointer;
            transition: background-color 0.2s;
        }

        .contact-item:hover {
            background-color: #f8f8f8;
        }

        .contact-item:last-child {
            border-bottom: none;
        }

        .contact-avatar {
            width: 50px;
            height: 50px;
            background: #007AFF;
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            color: white;
            font-size: 20px;
            margin-right: 15px;
        }

        .contact-info {
            flex: 1;
        }

        .contact-name {
            font-size: 18px;
            font-weight: 600;
            color: #333;
            margin-bottom: 3px;
        }

        .contact-status {
            font-size: 14px;
            color: #666;
        }

        .call-btn {
            background: #34C759;
            border: none;
            border-radius: 50%;
            width: 60px;
            height: 60px;
            color: white;
            font-size: 24px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .call-btn:hover {
            background: #30B14C;
            transform: scale(1.05);
        }

        .call-controls {
            display: flex;
            justify-content: center;
            gap: 40px;
            margin-top: 30px;
        }

        .control-button {
            border: none;
            border-radius: 50%;
            width: 70px;
            height: 70px;
            color: white;
            font-size: 28px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .end-call-btn {
            background: #FF3B30;
        }

        .end-call-btn:hover {
            background: #E6342A;
            transform: scale(1.05);
        }

        .mute-btn {
            background: #666;
        }

        .mute-btn:hover {
            background: #555;
            transform: scale(1.05);
        }

        .mute-btn.active {
            background: #FF3B30;
        }

        .back-btn {
            position: absolute;
            top: 20px;
            left: 20px;
            background: none;
            border: none;
            color: white;
            font-size: 20px;
            cursor: pointer;
        }

        /* 着信画面のスタイル */
        .incoming-call-screen {
            padding: 40px 30px;
            text-align: center;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            display: none;
            flex-direction: column;
            justify-content: center;
            min-height: 400px;
        }

        .incoming-call-screen.active {
            display: flex;
        }

        .incoming-avatar {
            width: 120px;
            height: 120px;
            background: rgba(255, 255, 255, 0.2);
            border-radius: 50%;
            display: flex;
            align-items: center;
            justify-content: center;
            font-size: 48px;
            margin: 0 auto 20px;
            animation: ring-pulse 2s infinite;
        }

        @keyframes ring-pulse {
            0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.4); }
            50% { transform: scale(1.05); box-shadow: 0 0 0 20px rgba(255, 255, 255, 0); }
            100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); }
        }

        .incoming-caller-name {
            font-size: 32px;
            font-weight: 600;
            margin-bottom: 10px;
        }

        .incoming-status {
            font-size: 18px;
            opacity: 0.9;
            margin-bottom: 40px;
        }

        .incoming-call-controls {
            display: flex;
            justify-content: center;
            gap: 60px;
        }

        .answer-btn {
            background: #34C759;
            width: 80px;
            height: 80px;
            font-size: 32px;
        }

        .decline-btn {
            background: #FF3B30;
            width: 80px;
            height: 80px;
            font-size: 32px;
        }

        /* アニメーション */
        .calling-animation {
            animation: pulse 1.5s infinite;
        }

        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.05); }
            100% { transform: scale(1); }
        }

        /* レスポンシブ */
        @media (max-width: 480px) {
            .phone-app {
                margin: 0;
                border-radius: 0;
                min-height: 100vh;
            }
            
            body {
                padding: 0;
            }
        }
    </style>
</head>
<body>
    <div class="phone-app">
        <!-- ヘッダー -->
        <div class="header">
            <button class="back-btn" id="backBtn" style="display: none;">
                <i class="fa-solid fa-arrow-left"></i>
            </button>
            <h1>電話帳</h1>
            <div class="status" id="status">連絡先を選択してください</div>
        </div>

        <!-- 連絡先リスト -->
        <div class="contact-list" id="contactList">
            <div class="contact-item" data-user-id="user_test1" data-name="test1">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test1</div>
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
            <div class="contact-item" data-user-id="user_test2" data-name="test2">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test2</div>
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
            <div class="contact-item" data-user-id="user_test3" data-name="test3">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test3</div>
                    
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
            <div class="contact-item" data-user-id="user_test4" data-name="test4">
                <div class="contact-avatar">
                    <i class="fa-solid fa-user"></i>
                </div>
                <div class="contact-info">
                    <div class="contact-name">test4</div>
                </div>
                <button class="call-btn callBtns">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
        </div>

        <!-- 着信画面 -->
        <div class="incoming-call-screen" id="incomingCallScreen">
            <div class="incoming-avatar">
                <i class="fa-solid fa-user"></i>
            </div>
            <div class="incoming-caller-name" id="incomingCallerName">Unknown</div>
            <div class="incoming-status">着信中...</div>
            
            <div class="incoming-call-controls">
                <button class="control-button decline-btn" id="declineBtn">
                    <i class="fa-solid fa-phone-slash"></i>
                </button>
                <button class="control-button answer-btn" id="answerBtn">
                    <i class="fa-solid fa-phone"></i>
                </button>
            </div>
        </div>

        <!-- 通話画面 -->
        <div class="call-screen" id="callScreen">
            <div class="caller-info">
                <div class="caller-name" id="callerName">Unknown</div>
                <div class="call-status" id="callStatus">通話中</div>
                <div class="call-timer" id="callTimer" style="display: none;">00:00</div>
            </div>

            <div class="call-controls">
                <button class="control-button mute-btn" id="muteBtn">
                    <i class="fa-solid fa-microphone"></i>
                </button>
                <button class="control-button end-call-btn" id="endCallBtn">
                    <i class="fa-solid fa-phone-slash"></i>
                </button>
            </div>
        </div>
    </div>

    <!-- 音声要素 -->
    <audio id="remoteAudio" autoplay></audio>
    <script src="./index.js" type="module"></script>
</body>
</html>
```
```main.js
import { debugCandidateDetails, getIceCandidateType, setupConnectionMonitoring, } from './debugger.js';
import { getUserInformation, startOutgoingCall, showIncomingCall, answerCall, UpdateUIForEndCall, updateCallState } from './update.js';
const answerBtn = document.getElementById('answerBtn');
const endCallBtn = document.getElementById('endCallBtn');
// 現在のURLのパラメーターを取得
const params = new URLSearchParams(window.location.search);
const userId = params.get('userId'); 
// const ringtone = new Audio('sound.mp3');


// main.js - Blob対応版
const ws = new WebSocket('wss://phone-test.tokyo:3000');
let peer = null;
let localStream = null;
let currentTargetUserId = null; // 現在通話中の相手を記録
let pendingOfferData = null; // 保留中のofferデータ
let pendingCandidates = []; // 保留中のcandidate ← 追加

// WebSocketのバイナリタイプを設定（オプション）
ws.binaryType = 'blob';

// 1. マイクの音を取得
navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
    localStream = stream;
});

// 接続時に自分のIDを登録
ws.onopen = () => {
    ws.send(JSON.stringify({
        type: 'register',
        userId: userId
    }));
};

// 2. 相手からのデータを受信（Blob対応）
ws.onmessage = async (event) => {
    try {
        let data;
        
        // データタイプを確認
        if (event.data instanceof Blob) {
            const text = await event.data.text();
            data = JSON.parse(text);
        } else if (typeof event.data === 'string') {
            data = JSON.parse(event.data);
        } else {
            console.error('未対応のデータ形式:', event.data);
            return;
        };

        if(data.type === "hangup"){
            UpdateUIForEndCall()
        }

        // メッセージタイプに応じた処理
        // 通話提案が来た場合の処理
        if (data.type === 'offer') {
            pendingOfferData = data;
            pendingCandidates = []; // candidateをリセット ← 追加
            
            // 着信画面を表示
            showIncomingCall(data.fromUserId, data.fromUserName || data.fromUserId, data)
        }

        // 通話受け入れが来た時の処理
        if (data.type === 'answer') {
            if (peer) {
                try {
                    await peer.setRemoteDescription(new RTCSessionDescription(data.answer));
                    // remoteDescriptionが設定された後に保留中のcandidateを処理
                    if (pendingCandidates.length > 0) {
                        console.log(`保留中のCandidate数: ${pendingCandidates.length}`);
                        for (const candidate of pendingCandidates) {
                            try {
                                await peer.addIceCandidate(new RTCIceCandidate(candidate));
                                console.log('保留Candidate追加完了');
                            } catch (error) {
                                console.error('保留Candidate追加エラー:', error);
                            }
                        }
                        pendingCandidates = []; // クリア
                    }
                    
                    // UI更新：通話開始
                    updateCallState("connected")
                } catch (error) {
                    console.error('Answer処理エラー:', error);
                }
            }
        }

        // 通話経路の候補を受信したときの処理
        if (data.type === 'candidate') {
            console.log("Candidate受信:", data);
            
            // peerが存在し、かつremoteDescriptionが設定されているか確認
            if (peer && peer.remoteDescription) {
                try {
                    await peer.addIceCandidate(new RTCIceCandidate(data.candidate));
                    console.log('Candidate追加完了');
                } catch (error) {
                    console.error('Candidate追加エラー:', error);
                    // エラーが発生しても保留リストに追加
                    pendingCandidates.push(data.candidate);
                }
            } else {
                // peerが存在しないか、remoteDescriptionが未設定の場合は保留
                console.log('Candidate保留中（peer未作成またはremoteDescription未設定）');
                pendingCandidates.push(data.candidate);
            }
        }
        
    } catch (error) {
        console.error('メッセージ処理エラー:', error);
    }
};


// #################################################################
//  ################# 通話開始ボタンが押されたら #######################
// #################################################################

document.querySelectorAll('.callBtns').forEach((btn)=>{
    btn.onclick = async (event) => {
        if (!localStream) {
            console.error('マイクの準備ができていません');
            return;
        }

        const userInformation = getUserInformation(event)
        const userId = userInformation["userId"] //電話相手のIDを取得
        const name = userInformation["name"] //電話相手の名前を取得

        //発信開始UI処理
        startOutgoingCall(name)
        currentTargetUserId = userId; //通話相手のIDをグローバル変数に格納
        
        // ################ WebRTCの確立 #################
        // 1. peerConnectionを作成
        peer = createPeer();
    
        // 2. peerConnectionに自分の音声を追加する
        localStream.getTracks().forEach(track => peer.addTrack(track, localStream));
    
        // 3. 通話するにあたっての条件の作成をする
        const offer = await peer.createOffer();
    
        // 4. 作成した条件をローカルに保存
        await peer.setLocalDescription(offer);

        // 5. 作成した条件を相手に送信
        ws.send(JSON.stringify({ 
            type: 'offer', 
            offer,
            targetUserId: currentTargetUserId //通話したい相手のIDも送信する
        }));
    };
});

// #################################################################
//  ################# 通話終了ボタンが押されたら #####################
// #################################################################

endCallBtn.addEventListener('click', () => {
    endCall();
});

// #################################################################
//  ################# 通話受諾ボタンを押したときの処理 ################
// #################################################################

answerBtn.addEventListener('click', () => {
    // ringtone.pause();
    // ringtone.currentTime = 0;
    answerCall();
    acceptCallRequest()
});



// 4. WebRTC接続オブジェクトの作成
const createPeer = () => {
    const pc = new RTCPeerConnection({
        iceServers: [
            // Google STUN サーバー（複数追加）
            { urls: 'stun:stun.l.google.com:19302' },
            { urls: 'stun:stun1.l.google.com:19302' },
            { urls: 'stun:stun2.l.google.com:19302' },
            { urls: 'stun:stun3.l.google.com:19302' },
            { urls: 'stun:stun4.l.google.com:19302' },
            
            // 追加のSTUNサーバー
            { urls: 'stun:stun.stunprotocol.org:3478' },
            { urls: 'stun:stun.voip.eutelia.it:3478' },
            { urls: 'stun:stun.voipbuster.com:3478' },
            
            // OpenRelay TURN サーバー（既存のものに加えて）
            {
                urls: 'turn:openrelay.metered.ca:80',
                username: 'openrelayproject',
                credential: 'openrelayproject'
            },
            {
                urls: 'turn:openrelay.metered.ca:443',
                username: 'openrelayproject', 
                credential: 'openrelayproject'
            },
            {
                urls: 'turn:openrelay.metered.ca:443?transport=tcp',
                username: 'openrelayproject',
                credential: 'openrelayproject'
            },
            
            // 追加の無料TURNサーバー
            {
                urls: 'turn:numb.viagenie.ca',
                username: 'webrtc@live.com',
                credential: 'muazkh'
            },
            {
                urls: 'turn:192.158.29.39:3478?transport=udp',
                username: '28224511:1379330808',
                credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA='
            },
            {
                urls: 'turn:192.158.29.39:3478?transport=tcp',
                username: '28224511:1379330808',
                credential: 'JZEOEt2V3Qb0y27GRntt2u2PAYA='
            }
        ],
        iceCandidatePoolSize: 10, // ICE候補のプールサイズを増やす
        bundlePolicy: 'max-bundle',
        rtcpMuxPolicy: 'require'
    });

    // ICE候補の送信設定（詳細ログ付き）
    // setLocalDescriptionを呼んだ後に非同期で発生
    pc.onicecandidate = event => {
        if (event.candidate && currentTargetUserId) {
            const candidate = event.candidate;
            const candidateType = getIceCandidateType(candidate);
            
            ws.send(JSON.stringify({ 
                type: 'candidate', 
                candidate: event.candidate, 
                targetUserId: currentTargetUserId
            }));
        } else if (!event.candidate) {
            console.log(' ICE候補の収集が完了しました');
        }
    };
    
    // 相手の音声を受信する設定
    pc.ontrack = event => {
        console.log('音声ストリーム受信:', event.streams[0]);
        const remoteAudio = document.getElementById('remoteAudio');
        if (remoteAudio) {
            remoteAudio.srcObject = event.streams[0];
            
            // 音声の再生を明示的に開始
            remoteAudio.play().then(() => {
                console.log('音声再生開始');
            }).catch(e => {
                console.error('音声再生エラー:', e);
                // ユーザーインタラクションが必要な場合の処理
                alert('音声を再生するには画面をタップしてください');
            });
            
            // 音量確認
            remoteAudio.volume = 1.0;
            console.log('音量設定:', remoteAudio.volume);
        }
    };
    
    return pc;
};


// 通話終了ボタンが押されたら
document.querySelectorAll('.end-call-btn').forEach((btn)=>{
    btn.onclick = async () => {
        endCall();
    };
});

// 通話終了処理
const endCall = () => {
    console.log('通話終了処理開始');
    
    // 1. 相手に通話終了を通知
    if (ws && ws.readyState === WebSocket.OPEN && currentTargetUserId) {
        ws.send(JSON.stringify({ 
            type: 'hangup',
            targetUserId: currentTargetUserId 
        }));
    }
    
    // 2. PeerConnectionをクリーンアップ
    if (peer) {
        // イベントハンドラをクリア（重要！）
        peer.onicecandidate = null;
        peer.ontrack = null;
        peer.oniceconnectionstatechange = null;
        peer.onconnectionstatechange = null;
        peer.onicegatheringstatechange = null;
        peer.onsignalingstatechange = null;
        
        // 接続を閉じる
        peer.close();
        peer = null;
    }
    
    // 3. リモート音声をクリア（localStreamは触らない！）
    const remoteAudio = document.getElementById('remoteAudio');
    if (remoteAudio && remoteAudio.srcObject) {
        // リモートのトラックのみ停止
        remoteAudio.srcObject.getTracks().forEach(track => track.stop());
        remoteAudio.srcObject = null;
    }
    
    // 4. 変数リセット
    currentTargetUserId = null;
    pendingOfferData = null;
    pendingCandidates = [];
    
    // 5. UI更新
    UpdateUIForEndCall();
    
    console.log('通話終了完了');
    
    // デバッグ：localStreamの状態確認
    if (localStream) {
        const tracks = localStream.getAudioTracks();
        console.log('🎤 LocalStream状態:', {
            active: localStream.active,
            trackCount: tracks.length,
            trackState: tracks[0]?.readyState
        });
    }
};


// 通話受諾処理用の関数
const acceptCallRequest = async () => {
    try {
        if (!pendingOfferData) {
            console.error('保留中のofferがありません');
            return;
        }
        
        currentTargetUserId = pendingOfferData.fromUserId;
        
        // WebRTC処理開始
        peer = createPeer();
        
        // remoteDescriptionを設定
        await peer.setRemoteDescription(new RTCSessionDescription(pendingOfferData.offer));

        // ローカルストリームを追加
        localStream.getTracks().forEach(track => peer.addTrack(track, localStream));
        
        // answerを作成
        const answer = await peer.createAnswer();
        await peer.setLocalDescription(answer);
        
        // remoteDescriptionが設定された後に保留中のcandidateを処理
        for (const candidate of pendingCandidates) {
            try {
                await peer.addIceCandidate(new RTCIceCandidate(candidate));
                console.log('保留Candidate追加完了');
            } catch (error) {
                console.error('保留Candidate追加エラー:', error);
            }
        }
        pendingCandidates = []; // クリア
        
        // answer送信
        ws.send(JSON.stringify({ 
            type: 'answer', 
            answer, 
            targetUserId: currentTargetUserId
        }));
        
        // offerデータをクリア
        pendingOfferData = null;
        
    } catch (error) {
        console.error('通話受諾エラー:', error);
    }
};


// 通話拒否処理用のグローバル関数
window.declineIncomingCall = () => {
    console.log('通話を拒否しました');
    pendingOfferData = null;
    pendingCandidates = []; // クリア ← 追加
    currentTargetUserId = null;
};

```

```uiController.js
// ##############################################################################
// ############################# UI更新の処理 ####################################
// ##############################################################################

// UI要素
const contactList = document.getElementById('contactList');
const callScreen = document.getElementById('callScreen');
const incomingCallScreen = document.getElementById('incomingCallScreen');
const backBtn = document.getElementById('backBtn');
const status = document.getElementById('status');
const callerName = document.getElementById('callerName');
const callStatus = document.getElementById('callStatus');
const callTimer = document.getElementById('callTimer');
const muteBtn = document.getElementById('muteBtn');
const incomingCallerName = document.getElementById('incomingCallerName');
const ringtone = new Audio('sound.mp3');
ringtone.loop = true;  // ループ再生を有効化
// 音声再生の許可状態を管理
let audioPermissionGranted = false;

// 音声再生の許可を取得（修正版）
const initAudioPermission = async () => {
      if (audioPermissionGranted) return;
      
      try {
            // 音量を0にして再生を試みる
            ringtone.volume = 0;
            await ringtone.play();
            ringtone.pause();
            ringtone.currentTime = 0;
            ringtone.volume = 0.7;  // 元の音量に戻す
            audioPermissionGranted = true;
            console.log('音声再生許可を取得しました');
      } catch (error) {
            console.log('音声再生許可待ち...');
      }
};

// ページ読み込み時とクリック時の両方で試行
document.addEventListener('DOMContentLoaded', initAudioPermission);
document.addEventListener('click', initAudioPermission, { once: true });



// 通話状態
let isInCall = false;
let callStartTime = null;
let timerInterval = null;
let pendingOffer = null; // 保留中のoffer
let isMuted = false



// 発信開始UI処理
export const startOutgoingCall = (name) =>{
      callerName.textContent = name
      callStatus.textContent = '呼び出し中...';

      // UI切り替え
      contactList.style.display = 'none';
      callScreen.classList.add('active');
      backBtn.style.display = 'block';
      status.textContent = '通話中';
      callScreen.classList.add('calling-animation');

      isInCall = true;
}

// 着信来た時のUI更新処理
export const showIncomingCall = (fromUserId, fromUserName, offer) =>{
      console.log('着信表示:', fromUserId, fromUserName);
      // 着信音再生
      ringtone.currentTime = 0;
      const playPromise = ringtone.play();

      if (playPromise !== undefined) {
            playPromise
            .then(() => {
                  console.log(' 着信音再生開始（ループ）');
            })
            .catch(error => {
                  console.log('着信音再生エラー:', error);
                  // ユーザーに通知
                  alert('着信音を再生するには、画面をクリックしてください');
            });
      }
      
      // pending offerを保存
      pendingOffer = offer;
      // UI表示
      incomingCallerName.textContent = fromUserName || fromUserId;
      
      // 着信画面表示
      contactList.style.display = 'none';
      incomingCallScreen.classList.add('active');
      status.textContent = '着信中';
}
export const updateCallState = (state) => {
      switch(state) {
            case 'connected':
                  callConnected();
                  break;
            case 'ended':
                  endCall();
                  break;
      }
};


// 通話承諾
export const answerCall = () =>{
      console.log('通話を受諾');
                  
      // 着信音停止
      ringtone.pause();
      ringtone.currentTime = 0;
      
      // UI切り替え
      incomingCallScreen.classList.remove('active');
      callScreen.classList.add('active');
      backBtn.style.display = 'block';
      
      callerName.textContent = incomingCallerName.textContent;
      callStatus.textContent = '通話中';
      
      isInCall = true;
      callConnected();
}

// 通話接続完了
export const callConnected = () =>{
      callStatus.textContent = '通話中';
      callTimer.style.display = 'block';
      callScreen.classList.remove('calling-animation');
      startTimer();
}


export const getUserInformation = (event) =>{
      const contactItem = event.target.closest('.contact-item');
      const userId = contactItem.dataset.userId;
      const name = contactItem.dataset.name;

      return {"userId" : userId, "name": name}
}


// 通話終了
export const UpdateUIForEndCall = () => {

      console.log("endCalllll");

      isInCall = false;
      // 着信音停止
      if (ringtone) {
            ringtone.pause();
            ringtone.currentTime = 0;
      }

      // UI初期化
      contactList.style.display = 'block';
      callScreen.classList.remove('active');
      incomingCallScreen.classList.remove('active');
      backBtn.style.display = 'none';
      status.textContent = '連絡先を選択してください';
      callTimer.style.display = 'none';
      callScreen.classList.remove('calling-animation');

      stopTimer();
      resetMute(isMuted);
      pendingOffer = null;
}

// 通話拒否ボタンが押された時の処理
export const declineCall = () => {
      console.log('通話を拒否');
      
      // 着信音停止
      if (ringtone) {
            ringtone.pause();
            ringtone.currentTime = 0;
      }
      
      // UI初期化
      incomingCallScreen.classList.remove('active');
      contactList.style.display = 'block';
      status.textContent = '連絡先を選択してください';
      
      pendingOffer = null;
};

// ミュートリセット
const resetMute =(isMuted) =>{
      isMuted = false;
      muteBtn.classList.remove('active');
      muteBtn.querySelector('i').className = 'fa-solid fa-microphone';
}

// タイマー機能
export const startTimer = ()=> {
      callStartTime = Date.now();
      timerInterval = setInterval(updateTimer, 1000);
}

export const stopTimer = ()=> {
      if (timerInterval) {
            clearInterval(timerInterval);
            timerInterval = null;
      }
      callTimer.textContent = '00:00';
}

export const updateTimer= () => {
      const elapsed = Math.floor((Date.now() - callStartTime) / 1000);
      const minutes = Math.floor(elapsed / 60).toString().padStart(2, '0');
      const seconds = (elapsed % 60).toString().padStart(2, '0');
      callTimer.textContent = `${minutes}:${seconds}`;
}


 // 音声再生の許可を取得（ブラウザポリシー対応）
document.addEventListener('click', () => {
      ringtone.play().then(() => {
            ringtone.pause();
            ringtone.currentTime = 0;
      }).catch(() => {});
}, { once: true });


```


```server.js
// server.js
const fs = require('fs');
const https = require('https');
const WebSocket = require('ws');

const connectedUsers = new Map(); // userId -> WebSocket

// SSL証明書の読み込み
const server = https.createServer({
      key: fs.readFileSync("privkey.pemのパス"),
      cert: fs.readFileSync("fullchain.pemのパス"),
});

// WebSocketサーバーをHTTPSサーバーに接続
const wss = new WebSocket.Server({ server });

wss.on('connection', (ws) => {
      ws.on('message', (data) => {
            try {
                  const message = JSON.parse(data); 

                  // 登録処理（WebSocket接続とユーザーIDを紐付けてMapに保存）
                  if (message.type === 'register') {
                        // ユーザーIDをキーに、WebSocket接続オブジェクトを値としてMapに保存
                        connectedUsers.set(message.userId, ws);
                        return; // 登録メッセージは転送しない
                  }

                    // typeがcandidate, answer, offerの場合
                    // 指定されたユーザーがWebsocket接続しているかチェック
                  const targetWs = connectedUsers.get(message.targetUserId);
                  if (targetWs && targetWs.readyState === WebSocket.OPEN) {
                        const messageWithSender = {
                              ...message ,
                              fromUserId: getCurrentUserId(ws) // 送信者のIDを取得
                        };
                        targetWs.send(JSON.stringify(messageWithSender));
                  }
                  
                  
            } catch (error) {
                  console.error('メッセージ解析エラー:', error);
            }
      });
});

//WebSocket接続オブジェクトから対応するユーザーIDを取得する
const getCurrentUserId =(ws)= {
      for (let [userId, connection] of connectedUsers) {
            if (connection === ws) {
                  return userId;
            }
      }
      return null;
}

server.listen(3000, () => {
      console.log('WSSサーバーがポート3000で起動しました');
});
```


# step4. 起動する
実際に二つのブラウザで開いて確認してみます。
電話帳で選択した相手に電話をかけると通話ができます。

(URL)
https://phone-test.tokyo/?userId=user_test1
https://phone-test.tokyo/?userId=user_test2

<table>
<tr>
<td width="50%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935088/f2422a51-e69b-4068-9974-de65d27db732.gif" alt="発信側">
</td>
<td width="50%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3935088/931a1a4f-315a-431d-a2c4-7c5d25826da9.gif" alt="着信側">
</td>
</tr>
<tr>
<td align="center">発信側</td>
<td align="center">着信側</td>
</tr>
</table>

# 最後に
WebRTC初めて勉強しましたが本当に難しいです。TURNサーバーなども自前で構築できるようになれればと思います。グループ通話などもできるように引き続き勉強していきたいと思います！
最後までご覧いただきありがとうございました！