0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Railsだけで通話機能は実装できるのか

Posted at

今回の記事

前回の記事でRailsだけでの通話機能作成手順を説明しました!
https://qiita.com/Reo-lab/items/90a037f5c18c027342d1

その中で最も難しい部分、Javascriptでのsignaling_server.jsを今回は解説していこうと思います!

最初にフローを軽く説明したあと
コードの行っていることを上から順にすべて解説しています。

signaling_server.jsで行っていること

リアルタイム通信: WebRTCを使用してユーザー間でビデオ・音声ストリームを共有。
シグナリング: RailsのActionCableを利用して、ユーザー同士の接続情報をやり取り。
UI操作: チャットルーム内でユーザーがデバイスを選択、セッションの参加・退出、ビデオ/オーディオの表示を管理。

通信確立までのフローの概要

(1) Aが参加ボタンを押す

   ・handleJoinSessionが発動
   ・ActionCableに参加して、データをJOIN_ROOMというタイプにして通知します

(2) Bが参加ボタンを押す

   ・handleJoinSessionが発動
   ・BがActionCableに参加するとAの通知を受け取り、joinRoomが発動します
   ・Bが受け取ったAのデータをもとに、Aに対してのSDPオファーを作成して送信。

(3) AがSDPオファーを受け取りアンサー作成

   ・Aが受け取ったBのSDPを保存し、BへのSDPアンサーを作成して送信。

(4) Bがアンサーを受け取る

   ・Bが受け取ったSDPアンサーを保存します。これで、お互いのSDP交換が完了します。

(5) ICE候補の交換

   ・SDP交換が完了すると、WEBRTCが自動的にICE候補を生成し始めます。
   ・そのICE候補を検知し、お互いが相手に送信します。
   ・これを複数回繰り返し、双方が十分な候補を交換し終わると、WebRTCが最適な接続パスを選択します。

(6) P2Pの接続が確立

signaling_server.jsのコード

あまりに長いので折り畳んでいます
// app/javascript/signaling_server.js

import consumer from "channels/consumer";

// Broadcast Types
const JOIN_ROOM = "JOIN_ROOM";
const EXCHANGE = "EXCHANGE";
const REMOVE_USER = "REMOVE_USER";

// DOM Elements
let currentUser;
let localVideo;
let remoteVideoContainer;

// Objects
let pcPeers = {};
let localstream;
let videoDevices = [];
let audioDevices = [];

window.onload = () => {
  const chatroomElement = document.getElementById("room-id"); // チャットルームを特定するための要素を取得
  if (chatroomElement) {
  currentUser = document.getElementById("current-user").innerText;
  localVideo = document.getElementById("local-video");
  remoteVideoContainer = document.getElementById("remote-video-container");
 }
};

// Ice Credentials
const ice = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };

// Add event listener's to buttons
document.addEventListener("turbo:load", async () => {
  const chatroomElement = document.getElementById("room-id"); // チャットルームを特定するための要素を取得
  if (chatroomElement) {
  currentUser = document.getElementById("current-user").innerHTML;
  localVideo = document.getElementById("local-video");
  remoteVideoContainer = document.getElementById("remote-video-container");

  const joinButton = document.getElementById("join-button");
  const leaveButton = document.getElementById("leave-button");

  joinButton.onclick = handleJoinSession;
  leaveButton.onclick = handleLeaveSession;

  // デバイスを取得して選択肢を作成
  await getMediaDevices();
  populateDeviceSelects();
 }
});

const getMediaDevices = async () => {
  const devices = await navigator.mediaDevices.enumerateDevices();
  videoDevices = devices.filter(device => device.kind === "videoinput");
  audioDevices = devices.filter(device => device.kind === "audioinput");
};

const populateDeviceSelects = () => {
  const videoSelect = document.getElementById("video-select");
  const audioSelect = document.getElementById("audio-select");
  
  // ビデオなしのオプションを追加
  const noVideoOption = document.createElement("option");
  noVideoOption.value = ""; // ビデオなしの場合の値
  noVideoOption.text = "ビデオなし"; // 表示名
  videoSelect.appendChild(noVideoOption);

  videoDevices.forEach(device => {
    const option = document.createElement("option");
    option.value = device.deviceId;
    option.text = device.label || `Camera ${videoSelect.length + 1}`;
    videoSelect.appendChild(option);
  });

  audioDevices.forEach(device => {
    const option = document.createElement("option");
    option.value = device.deviceId;
    option.text = device.label || `Microphone ${audioSelect.length + 1}`;
    audioSelect.appendChild(option);
  });
};

const handleJoinSession = async () => {
  const videoSelect = document.getElementById("video-select");
  const audioSelect = document.getElementById("audio-select");
  const userIconContainer = document.getElementById("user-icon-container"); // アイコンコンテナを取得
  const localVideo = document.getElementById("local-video");

  if (videoSelect.value === "") {
    // ビデオなしの場合、アイコンを表示
    userIconContainer.style.display = "block";
    localVideo.style.display = "none";
  } else {
    // ビデオがある場合、アイコンを非表示
    userIconContainer.style.display = "none";
  }

  const constraints = {
    video: videoSelect.value ? { deviceId: { exact: videoSelect.value } } : false, // ビデオなしの場合はfalseに
    audio: {
      deviceId: audioSelect.value ?  { deviceId: { exact: videoSelect.value } } : undefined,
      echoCancellation: false,
      noiseSuppression: true,
      sampleRate: 44100, // サンプルレートを指定
    },
  };

  try {
    // メディアデバイスの取得
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    localstream = stream;
    localVideo.srcObject = stream;
    localVideo.muted = true;
    console.log("Local video stream obtained");
  
    const chatroomIdElement = document.getElementById("room-id");
    const roomId = chatroomIdElement.value; 
    console.log("Attempting to join the session...");
    consumer.subscriptions.create(
      { channel: "SessionChannel", chatroom_id: roomId }, // chatroom_idを追加
      {
       connected: () => {
         console.log("Successfully connected to the session channel.");
         const dataToSend = {
           type: JOIN_ROOM,
           from: currentUser,
           chatroomId: roomId,
         };
         console.log("Broadcasting data:", dataToSend); // 送信するデータのログ
         console.log("Before broadcasting data");
         broadcastData(dataToSend);
         console.log(`${currentUser} has joined the room.`);
      },
      received: (data) => {
        console.log("received", data);
        if (data.from === currentUser) return;
        switch (data.type) {
        case JOIN_ROOM:
          return joinRoom(data);
        case EXCHANGE:
          if (data.to !== currentUser) return;
          return exchange(data);
        case REMOVE_USER:
          return removeUser(data);
        default:
          return;
        }
      },      disconnected: () => {
        console.error("Disconnected from the session channel."); // 切断時のエラーログ
      },
      rejected: () => {
      console.error("Failed to connect to the session channel."); // 接続失敗時のエラーログ
      }
    }
  );
  } catch (error) {
  console.error("Error accessing media devices.", error);
  alert("Error accessing local video and audio stream: " + error.message);
 }
};

const handleLeaveSession = () => {
  for (let user in pcPeers) {
    pcPeers[user].close();
  }
  pcPeers = {};

  remoteVideoContainer.innerHTML = "";

  broadcastData({
    type: REMOVE_USER,
    from: currentUser,
  });
};

const joinRoom = (data) => {
  createPC(data.from, true);
};

const removeUser = (data) => {
  console.log("removing user", data.from);
  let video = document.getElementById(`remoteVideoContainer+${data.from}`);
  video && video.remove();
  delete pcPeers[data.from];
};

const createPC = (userId, isOffer) => {
  let pc = new RTCPeerConnection(ice);
  pcPeers[userId] = { pc, userId }; // PCとユーザーIDを保存

  for (const track of localstream.getTracks()) {
    pc.addTrack(track, localstream);
  }

  isOffer &&
    pc
      .createOffer()
      .then((offer) => {
        return pc.setLocalDescription(offer);
      })
      .then(() => {
        broadcastData({
          type: EXCHANGE,
          from: currentUser,
          to: userId,
          sdp: JSON.stringify(pc.localDescription),
        });
      })
      .catch(logError);

  pc.onicecandidate = (event) => {
    event.candidate &&
      broadcastData({
        type: EXCHANGE,
        from: currentUser,
        to: userId,
        candidate: JSON.stringify(event.candidate),
      });
  };

  pc.ontrack = async (event) => {
    const stream = event.streams[0]; // 受信したストリームを取得
    const videoTracks = stream.getVideoTracks(); // ビデオトラックを取得
    const audioTracks = stream.getAudioTracks(); // オーディオトラックを取得
    console.log("videoTracks:", videoTracks); 
    let existingVideo = document.getElementById(`remoteVideoContainer+${userId}`);
    let remoteUserIconContainer = document.getElementById("remote-user-icon-container");

    const remoteUserId = pcPeers[userId].userId;
    console.log("Remote User ID:", remoteUserId); 
    // アイコンを表示する関数
    const showremoteUserIcon = async () => {
      
      try {
        const remoteUser = await fetch(`/users/${remoteUserId}/icon`) // ユーザーアイコン情報を取得
          .then(response => response.json());
      
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "block"; // アイコンを表示
          const remoteUserIcon = document.createElement("img");
          remoteUserIcon.src = remoteUser.url; // APIから返されるURLを使ってアイコンを設定
          remoteUserIcon.alt = "User Icon";
          remoteUserIcon.classList.add("user-icon-voice-chat");
          remoteUserIconContainer.appendChild(remoteUserIcon); // アイコンをコンテナに追加
        }
      } catch (error) {
        console.error("Error fetching user icon:", error);
      }
    };
  
    if (videoTracks.length === 0 || !videoTracks[0].enabled) {
      // ビデオトラックがない場合はアイコンを表示
        showremoteUserIcon();
        if (existingVideo) {
          existingVideo.style.display = "none"; // 既存のビデオ要素を非表示
        }
        if (remoteUserIconContainer) {
            remoteUserIconContainer.style.display = "block"; // アイコンを表示
        }
    } else {
      // ビデオトラックがある場合
      if (!existingVideo) {
        const element = document.createElement("video");
        element.id = `remoteVideoContainer+${userId}`;
        element.autoplay = true;
        element.srcObject = stream;
        element.classList.add('remote-video');
        remoteVideoContainer.appendChild(element);
        console.log("Remote video element created and added.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      } else {
        // 既存の要素がある場合はストリームを再設定
        existingVideo.srcObject = stream;
        existingVideo.style.display = "block"; // ビデオを表示
        console.log("Existing video element updated.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      }
    }
    if (audioTracks.length > 0) {
      const audioElement = document.createElement("audio");
      audioElement.srcObject = stream;
      audioElement.autoplay = true; // 自動再生
      document.body.appendChild(audioElement); // DOMに追加
      console.log("Audio element created and added.");
    }
  };

  pc.oniceconnectionstatechange = () => {
    if (pc.iceConnectionState == "disconnected") {
      console.log("Disconnected:", userId);
      broadcastData({
        type: REMOVE_USER,
        from: userId,
      });
    }
  };

  return pc;
};

const exchange = (data) => {
  let pc;

  if (!pcPeers[data.from]) {
    pc = createPC(data.from, false);
  } else {
    pc = pcPeers[data.from].pc;// pcを取得
  }

  if (data.candidate) {
    pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data.candidate)))
      .then(() => console.log("Ice candidate added"))
      .catch(logError);
  }

  if (data.sdp) {
    const sdp = JSON.parse(data.sdp);
    pc.setRemoteDescription(new RTCSessionDescription(sdp))
      .then(() => {
        if (sdp.type === "offer") {
          pc.createAnswer()
            .then((answer) => {
              return pc.setLocalDescription(answer);
            })
            .then(() => {
              broadcastData({
                type: EXCHANGE,
                from: currentUser,
                to: data.from,
                sdp: JSON.stringify(pc.localDescription),
              });
            });
        }
      })
      .catch(logError);
  }
};

const broadcastData = (data) => {
  /**
   * Add CSRF protection: https://stackoverflow.com/questions/8503447/rails-how-to-add-csrf-protection-to-forms-created-in-javascript
   */
  const csrfToken = document.querySelector("[name=csrf-token]").content;
  const headers = new Headers({
    "content-type": "application/json",
    "X-CSRF-TOKEN": csrfToken,
  });
  // roomIdをデータに含める
  data.chatroomId = document.getElementById("room-id").value;

  fetch("/sessions", {
    method: "POST",
    body: JSON.stringify(data),
    headers,
  })
  .then(response => {
    console.log("Response status:", response.status); // ステータスコードをログ出力
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`);
    }
    return response.text(); // サーバーからのレスポンスをJSONとして解析
  })
  .then(text => {
    console.log("Raw response text:", text); // レスポンスをテキストとしてログに出力
    if (text.trim() === "") { // 空のレスポンスをチェック
      throw new Error("相手が参加すると通話が開始されます");
    }
    try {
        const jsonResponse = JSON.parse(text); // 必要に応じてパース
        console.log("Parsed JSON:", jsonResponse);
    } catch (parseError) {
        console.error("Error parsing JSON:", parseError);
        console.log("Received raw data during parsing:", text);
    }
  })
  .catch(error => {
    console.error("Error broadcasting data:", error);
  });
};

const logError = (error) => console.warn("Whoops! Error:", error);

コードの詳細解説

定義部分

signaling_server.js
const JOIN_ROOM = "JOIN_ROOM";
const EXCHANGE = "EXCHANGE";
const REMOVE_USER = "REMOVE_USER";

これらは、通信時に使用されるイベントタイプを定義しています。

  • JOIN_ROOM: 新しいユーザーがチャットルームに参加する際のイベント。
  • EXCHANGE: WebRTCのシグナリングプロセス(SDPやICE候補の交換)で使用。
  • REMOVE_USER: ユーザーがルームを退出したときのイベント。

DOM要素の初期化

signaling_server.js
let currentUser;
let localVideo;
let remoteVideoContainer;
let pcPeers = {};
let localstream;
let videoDevices = [];
let audioDevices = [];
  • currentUser: 現在のログイン中のユーザー。
  • localVideo: 自分の映像を表示するHTML 要素。
  • remoteVideoContainer: 他のユーザーの映像を格納するコンテナ。
  • pcPeers: WebRTCのピア接続(RTCPeerConnection)を管理するオブジェクト。
  • localstream: 自分のメディアストリーム(映像・音声)。
  • videoDevices/audioDevices: 利用可能なビデオ・オーディオデバイスの情報

ページロード時の初期化

signaling_server.js
window.onload = () => {
  const chatroomElement = document.getElementById("room-id");
  if (chatroomElement) {
    currentUser = document.getElementById("current-user").innerText;
    localVideo = document.getElementById("local-video");
    remoteVideoContainer = document.getElementById("remote-video-container");
  }
};
  • ページロード時に、現在のユーザーやチャットルーム、ビデオエレメントを特定。
  • room-id は現在のチャットルームを識別するための要素。

ICEサーバーの設定

signaling_server.js
// Ice Credentials
const ice = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] };
  • iceServers は、WebRTCでピア間(ユーザー間)の接続を確立する際に、候補となる通信経路を探索するためのサーバーリストを指定します。
  • stun:stun.l.google.com:19302 は、Googleが提供するSTUN(Session Traversal Utilities for NAT)サーバーを指定しています。このサーバーを利用して、NAT越え(ルーターやファイアウォールを超えた通信)を行います。

ボタンの操作設定

signaling_server.js
// Add event listener's to buttons
document.addEventListener("turbo:load", async () => {
  const chatroomElement = document.getElementById("room-id"); // チャットルームを特定するための要素を取得
  if (chatroomElement) {
  currentUser = document.getElementById("current-user").innerHTML;
  localVideo = document.getElementById("local-video");
  remoteVideoContainer = document.getElementById("remote-video-container");

  const joinButton = document.getElementById("join-button");
  const leaveButton = document.getElementById("leave-button");

  joinButton.onclick = handleJoinSession;
  leaveButton.onclick = handleLeaveSession;

  // デバイスを取得して選択肢を作成
  await getMediaDevices();
  populateDeviceSelects();
 }
});
  • 上部分は先ほどのwindow.onloadと同じ処理を書いています。(うまく動かないことがあったため、消しても動くかも)
  • joinButton/leaveButton で参加/退出ボタンを定義して、クリックしたときにhandleJoinSession/handleLeaveSessionが呼び出されるように設定しています。
  • await getMediaDevices(); ユーザーの利用可能なデバイス(カメラやマイク)の情報を取得します。非同期処理で getMediaDevices 関数を呼び出し、結果を待ちます。
  • populateDeviceSelects(); 取得したデバイス情報を使って、HTML内のデバイス選択ドロップダウンを更新します。これにより、ユーザーは利用するカメラやマイクを選択できるようになります。

利用可能なデバイスの取得

signaling_server.js
const getMediaDevices = async () => {
  const devices = await navigator.mediaDevices.enumerateDevices();
  videoDevices = devices.filter(device => device.kind === "videoinput");
  audioDevices = devices.filter(device => device.kind === "audioinput");
};
  • navigator.mediaDevices.enumerateDevices(): デバイス情報を取得。
  • videoDevices/audioDevices にそれぞれビデオ入力デバイスとオーディオ入力デバイスを格納。

デバイス選択UIの作成

signaling_server.js
const populateDeviceSelects = () => {
  const videoSelect = document.getElementById("video-select");
  const audioSelect = document.getElementById("audio-select");
  // ビデオなしオプション
  const noVideoOption = document.createElement("option");
  noVideoOption.value = "";
  noVideoOption.text = "ビデオなし";
  videoSelect.appendChild(noVideoOption);
  // デバイスを選択肢として追加
  videoDevices.forEach(device => {
    const option = document.createElement("option");
    option.value = device.deviceId;
    option.text = device.label || `Camera ${videoSelect.length + 1}`;
    videoSelect.appendChild(option);
  });
  audioDevices.forEach(device => {
    const option = document.createElement("option");
    option.value = device.deviceId;
    option.text = device.label || `Microphone ${audioSelect.length + 1}`;
    audioSelect.appendChild(option);
  });
};
  • 利用可能なビデオ・オーディオデバイスをUIで選択可能にします。
  • noVideoOption:「ビデオなし」のオプションを追加。
  • ここら辺は自前で作成したものなので改善の余地がまだまだあると思います。

セッション参加処理

signaling_server.js
const handleJoinSession = async () => {
  const videoSelect = document.getElementById("video-select");
  const audioSelect = document.getElementById("audio-select");
  const userIconContainer = document.getElementById("user-icon-container"); // アイコンコンテナを取得
  const localVideo = document.getElementById("local-video");

  if (videoSelect.value === "") {
    // ビデオなしの場合、アイコンを表示
    userIconContainer.style.display = "block";
    localVideo.style.display = "none";
  } else {
    // ビデオがある場合、アイコンを非表示
    userIconContainer.style.display = "none";
  }

  const constraints = {
    video: videoSelect.value ? { deviceId: { exact: videoSelect.value } } : false, // ビデオなしの場合はfalseに
    audio: {
      deviceId: audioSelect.value ?  { deviceId: { exact: videoSelect.value } } : undefined,
      echoCancellation: false,
      noiseSuppression: true,
      sampleRate: 44100, // サンプルレートを指定
    },
  };

  try {
    // メディアデバイスの取得
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    localstream = stream;
    localVideo.srcObject = stream;
    localVideo.muted = true;
    console.log("Local video stream obtained");
  
    const chatroomIdElement = document.getElementById("room-id");
    const roomId = chatroomIdElement.value; 
    console.log("Attempting to join the session...");
    consumer.subscriptions.create(
      { channel: "SessionChannel", chatroom_id: roomId }, // chatroom_idを追加
      {
       connected: () => {
         console.log("Successfully connected to the session channel.");
         const dataToSend = {
           type: JOIN_ROOM,
           from: currentUser,
           chatroomId: roomId,
         };
         console.log("Broadcasting data:", dataToSend); // 送信するデータのログ
         console.log("Before broadcasting data");
         broadcastData(dataToSend);
         console.log(`${currentUser} has joined the room.`);
      },
      received: (data) => {
        console.log("received", data);
        if (data.from === currentUser) return;
        switch (data.type) {
        case JOIN_ROOM:
          return joinRoom(data);
        case EXCHANGE:
          if (data.to !== currentUser) return;
          return exchange(data);
        case REMOVE_USER:
          return removeUser(data);
        default:
          return;
        }
      },      disconnected: () => {
        console.error("Disconnected from the session channel."); // 切断時のエラーログ
      },
      rejected: () => {
      console.error("Failed to connect to the session channel."); // 接続失敗時のエラーログ
      }
    }
  );
  } catch (error) {
  console.error("Error accessing media devices.", error);
  alert("Error accessing local video and audio stream: " + error.message);
 }
};
  • 参加ボタンが押された時に発動します。長いので細かく分けて説明します。
    const videoSelect = document.getElementById("video-select");
    const audioSelect = document.getElementById("audio-select");
    const userIconContainer = document.getElementById("user-icon-container"); // アイコンコンテナを取得
    const localVideo = document.getElementById("local-video");
    
    • videoSelect と audioSelect: ユーザーが選択したカメラとマイクを取得する要素。
    • userIconContainer: ビデオがオフの場合に表示するアイコンコンテナ。
    • localVideo: ローカルで表示するビデオ要素。
    if (videoSelect.value === "") {
    // ビデオなしの場合、アイコンを表示
    userIconContainer.style.display = "block";
    localVideo.style.display = "none";
    } else {
    // ビデオがある場合、アイコンを非表示
    userIconContainer.style.display = "none";
    }
    
    • if (videoSelect.value === "") この部分でビデオなしの場合のアイコン表示を追加しています。
     const constraints = {
      video: videoSelect.value ? { deviceId: { exact: videoSelect.value } } : false, 
      audio: {
        deviceId: audioSelect.value ? { exact: audioSelect.value } : undefined,
        echoCancellation: false, // エコーキャンセリングを無効化
        noiseSuppression: true,  // ノイズ抑制を有効化
        sampleRate: 44100,       // オーディオのサンプルレートを指定
      },
    };
    
    • const constraints ビデオと音声データの品質設定をしています。
    • echoCancellation: false,noiseSuppression: true,にしたら音質が良くなります。
    const stream = await navigator.mediaDevices.getUserMedia(constraints);
    localstream = stream;
    localVideo.srcObject = stream; 
    localVideo.muted = true;
    console.log("Local video stream obtained");
    
    • .getUserMedia は、WebAPIで、ユーザーのカメラやマイクなどのメディアデバイスにアクセスするためのメソッドです。このメソッドを使うことで、ビデオや音声のストリームを取得できます。
    • (constraints) で先ほど設定した制約に基づいて取得されます。
    • localVideo.srcObject = stream; 上で定義したストリームを localVideo にセットしてローカル画面で表示します。
    • localVideo.muted = true; なぜか映像に音声があってうるさかったのでミュートしました。
    consumer.subscriptions.create(
    { channel: "SessionChannel", chatroom_id: roomId },  // chatroom_idを追加
    {
    connected: () => {
      console.log("Successfully connected to the session channel.");
      const dataToSend = {
        type: JOIN_ROOM,
        from: currentUser,
        chatroomId: roomId,
      };
      broadcastData(dataToSend); // 他の参加者に参加情報を通知
      console.log(`${currentUser} has joined the room.`);
    },
    received: (data) => { /* 受信処理 */ },
    disconnected: () => { /* 切断時の処理 */ },
    rejected: () => { /* 接続失敗時の処理 */ }
    }
    );
    
    • ここでActionCableが出てきます。
    • ActionCableを使ってSessionChannel(前記事で作成したやつ)に接続します
    • const dataToSend = {type: JOIN_ROOM,from: currentUser,chatroomId: roomId,};
      接続時に他の参加者に送信されるデータを定義しています。roomIdを追加しています。
    • broadcastData(dataToSend); そしてここで、先ほど定義したデータを相手ユーザーにブロードキャストします。JOIN_ROOMイベントをブロードキャストしています。
    received: (data) => {
    console.log("received", data);
    if (data.from === currentUser) return; // 自分のデータは無視
    
    switch (data.type) {
    case JOIN_ROOM:
      return joinRoom(data); // 他のユーザーがルームに参加したときの処理
    case EXCHANGE:
      if (data.to !== currentUser) return;
      return exchange(data); // WebRTCシグナリングのデータ交換
    case REMOVE_USER:
      return removeUser(data); // ユーザーが退出した場合の処理
    default:
      return;
    }
    }
    
    • 上で省略したデータを受信した時の処理部分です。
    • 先ほどの設定したdataのtype部分JOIN_ROOM, EXCHANGE, REMOVE_USER などを判断して対応する処理に飛ばします。

JOIN_ROOMを受け取った時の処理

signalin_server.js
const joinRoom = (data) => {
  createPC(data.from, true);
};
  • JOIN_ROOMが受け取られた時に呼び出されます。つまり他のユーザーがルームに参加した際に、そのユーザーとのピアツーピア接続(RTCPeerConnection)を作成するための物です。
  • dataに相手の情報が入っており、fromは送信者を特定します。
  • createPC(data.from, true):
    createPC関数を呼び出して、指定された相手ユーザー(data.from)とのピアツーピア接続を初期化します。
  • 第二引数の true は、このクライアント(dataを受け取った側)がオファー(Offer)を作成する側であることを意味します。
  • WebRTC では通信を確立する際、必ず片方が「オファー(Offer)」を作成する側、もう片方が「アンサー(Answer)」を作成する側になります。
  • 現在のコードの動作フロー
    • ① AがjoinRoomをクリック:
      • AはJOIN_ROOMをbroadcastDataで送信。
      • ActionCableを通じて、Aを除くすべてのサブスクライバ(例えばB)に通知される。
    • ② Bが通知を受け取る:
      • ActionCableのreceivedイベントがトリガーされる。
      • JOIN_ROOMのデータを受け取り、joinRoom(data)を実行。
    • ③ joinRoomが実行される:
      const joinRoom = (data) => {
       createPC(data.from, true); // 第二引数`true`に注目
      };
      
      • data.fromに基づいて新しいPeerConnectionを作成。
      • 第二引数がtrueなので、このクライアント(B)がオファーを作成する。
    • ④ オファー作成の流れ:
      • BはcreateOfferを呼び出してSDPを生成。
      • そのSDPをbroadcastDataでAに送信。

ピア接続の作成

signaling_server.js
const createPC = (userId, isOffer) => {
  let pc = new RTCPeerConnection(ice);
  pcPeers[userId] = { pc, userId }; // PCとユーザーIDを保存

  for (const track of localstream.getTracks()) {
    pc.addTrack(track, localstream);
  }

  isOffer &&
    pc
      .createOffer()
      .then((offer) => {
        return pc.setLocalDescription(offer);
      })
      .then(() => {
        broadcastData({
          type: EXCHANGE,
          from: currentUser,
          to: userId,
          sdp: JSON.stringify(pc.localDescription),
        });
      })
      .catch(logError);

  pc.onicecandidate = (event) => {
    event.candidate &&
      broadcastData({
        type: EXCHANGE,
        from: currentUser,
        to: userId,
        candidate: JSON.stringify(event.candidate),
      });
  };

  pc.ontrack = async (event) => {
    const stream = event.streams[0]; // 受信したストリームを取得
    const videoTracks = stream.getVideoTracks(); // ビデオトラックを取得
    const audioTracks = stream.getAudioTracks(); // オーディオトラックを取得
    console.log("videoTracks:", videoTracks); 
    let existingVideo = document.getElementById(`remoteVideoContainer+${userId}`);
    let remoteUserIconContainer = document.getElementById("remote-user-icon-container");

    const remoteUserId = pcPeers[userId].userId;
    console.log("Remote User ID:", remoteUserId); 
    // アイコンを表示する関数
    const showremoteUserIcon = async () => {
      
      try {
        const remoteUser = await fetch(`/users/${remoteUserId}/icon`) // ユーザーアイコン情報を取得
          .then(response => response.json());
      
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "block"; // アイコンを表示
          const remoteUserIcon = document.createElement("img");
          remoteUserIcon.src = remoteUser.url; // APIから返されるURLを使ってアイコンを設定
          remoteUserIcon.alt = "User Icon";
          remoteUserIcon.classList.add("user-icon-voice-chat");
          remoteUserIconContainer.appendChild(remoteUserIcon); // アイコンをコンテナに追加
        }
      } catch (error) {
        console.error("Error fetching user icon:", error);
      }
    };
  
    if (videoTracks.length === 0 || !videoTracks[0].enabled) {
      // ビデオトラックがない場合はアイコンを表示
        showremoteUserIcon();
        if (existingVideo) {
          existingVideo.style.display = "none"; // 既存のビデオ要素を非表示
        }
        if (remoteUserIconContainer) {
            remoteUserIconContainer.style.display = "block"; // アイコンを表示
        }
    } else {
      // ビデオトラックがある場合
      if (!existingVideo) {
        const element = document.createElement("video");
        element.id = `remoteVideoContainer+${userId}`;
        element.autoplay = true;
        element.srcObject = stream;
        element.classList.add('remote-video');
        remoteVideoContainer.appendChild(element);
        console.log("Remote video element created and added.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      } else {
        // 既存の要素がある場合はストリームを再設定
        existingVideo.srcObject = stream;
        existingVideo.style.display = "block"; // ビデオを表示
        console.log("Existing video element updated.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      }
    }
    if (audioTracks.length > 0) {
      const audioElement = document.createElement("audio");
      audioElement.srcObject = stream;
      audioElement.autoplay = true; // 自動再生
      document.body.appendChild(audioElement); // DOMに追加
      console.log("Audio element created and added.");
    }
  };
  • 先ほどのJOIN_ROOMを受け取ったユーザーがオファーを作成するための物です。

     const createPC = (userId, isOffer) => { 
         let pc = new RTCPeerConnection(ice);
         pcPeers[userId] = { pc, userId }; // PCとユーザーIDを保存
    
    • RTCPeerConnection: WebRTCのAPIを使用して、ピア間での接続を管理するオブジェクトを作成。
      • 引数 ice: ICE サーバーの設定を指定(最初に設定した、GoogleのSTUN サーバーを使用)。
    • pcPeers: グローバルなオブジェクトで、各ユーザーに対応するRTCPeerConnectionを保存します。
      • userId をキーとして、RTCPeerConnectionインスタンスと関連するユーザー情報を格納。
      for (const track of localstream.getTracks()) {
        pc.addTrack(track, localstream);
      }
    
    • localstream.getTracks():
      • ローカルのビデオやオーディオストリームから各メディアトラックを取得。
    • pc.addTrack(track, localstream):
      • 取得したメディアトラックを現在の RTCPeerConnection に追加。
      • これにより、ローカルのストリームがリモートのユーザーに共有される準備が整います。
    isOffer &&
      pc
        .createOffer()
        .then((offer) => {
          return pc.setLocalDescription(offer);
        })
        .then(() => {
          broadcastData({
            type: EXCHANGE,
            from: currentUser,
            to: userId,
            sdp: JSON.stringify(pc.localDescription),
          });
        })
        .catch(logError);
    
    • isOffer:
      • この引数がtrueの場合、接続のオファーを作成。先ほどjoinRoomでtrueにしたところです。
    • createOffer:
      • SDP(SessionDescriptionProtocol)を生成し、リモートユーザーに送信するオファーを作成。
    • setLocalDescription:
      • 作成したオファーをローカルのセッション記述として設定。
      • WebRTCの仕様で、RTCPeerConnection は自分自身のセッション記述を追跡する必要があります。
      • つまりローカルに上で作成したオファーを自分のものと設定しないといけない仕様です。
      • この記述がないと動かないので注意
    • broadcastData:
      • 他のユーザーにSDPオファーを送信。
        • 送信するデータ構造
          • EXCHANGE:
            ここでEXCHANGEが出てきます。
            EXCHANGEを呼び出してSDPの交換を始めようということです。
          • currentUser:この接続を作成したローカルユーザー。
          • userId:接続相手のユーザー。
          • sdp:ローカルSDP情報を文字列として送信。

    Viewの処理

    signaling_server.js
    pc.ontrack = async (event) => {
    const stream = event.streams[0]; // 受信したストリームを取得
    const videoTracks = stream.getVideoTracks(); // ビデオトラックを取得
    const audioTracks = stream.getAudioTracks(); // オーディオトラックを取得
    console.log("videoTracks:", videoTracks); 
    let existingVideo = document.getElementById(`remoteVideoContainer+${userId}`);
    let remoteUserIconContainer = document.getElementById("remote-user-icon-container");
    
    const remoteUserId = pcPeers[userId].userId;
    console.log("Remote User ID:", remoteUserId); 
    // アイコンを表示する関数
    const showremoteUserIcon = async () => {
      
      try {
        const remoteUser = await fetch(`/users/${remoteUserId}/icon`) // ユーザーアイコン情報を取得
          .then(response => response.json());
      
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "block"; // アイコンを表示
          const remoteUserIcon = document.createElement("img");
          remoteUserIcon.src = remoteUser.url; // APIから返されるURLを使ってアイコンを設定
          remoteUserIcon.alt = "User Icon";
          remoteUserIcon.classList.add("user-icon-voice-chat");
          remoteUserIconContainer.appendChild(remoteUserIcon); // アイコンをコンテナに追加
        }
      } catch (error) {
        console.error("Error fetching user icon:", error);
      }
    };
    
    if (videoTracks.length === 0 || !videoTracks[0].enabled) {
      // ビデオトラックがない場合はアイコンを表示
        showremoteUserIcon();
        if (existingVideo) {
          existingVideo.style.display = "none"; // 既存のビデオ要素を非表示
        }
        if (remoteUserIconContainer) {
            remoteUserIconContainer.style.display = "block"; // アイコンを表示
        }
    } else {
      // ビデオトラックがある場合
      if (!existingVideo) {
        const element = document.createElement("video");
        element.id = `remoteVideoContainer+${userId}`;
        element.autoplay = true;
        element.srcObject = stream;
        element.classList.add('remote-video');
        remoteVideoContainer.appendChild(element);
        console.log("Remote video element created and added.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      } else {
        // 既存の要素がある場合はストリームを再設定
        existingVideo.srcObject = stream;
        existingVideo.style.display = "block"; // ビデオを表示
        console.log("Existing video element updated.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      }
    }
    if (audioTracks.length > 0) {
      const audioElement = document.createElement("audio");
      audioElement.srcObject = stream;
      audioElement.autoplay = true; // 自動再生
      document.body.appendChild(audioElement); // DOMに追加
      console.log("Audio element created and added.");
    }
      };
    
      pc.oniceconnectionstatechange = () => {
    if (pc.iceConnectionState == "disconnected") {
      console.log("Disconnected:", userId);
      broadcastData({
        type: REMOVE_USER,
        from: userId,
      });
    }
      };
    
      return pc;
    };
    
  • 通信がつながった時のビデオの表示、アイコンの表示、音声の追加をしています。

    pc.ontrack = async (event) => {
    const stream = event.streams[0]; // 受信したストリームを取得
    const videoTracks = stream.getVideoTracks(); // ビデオトラックを取得
    const audioTracks = stream.getAudioTracks(); // オーディオトラックを取得
    console.log("videoTracks:", videoTracks); 
    
    • (event);
      RTCPeerConnectionが新しいメディアストリーム(音声トラックやビデオトラック)を送信してきたときに、このイベントがトリガーされます。
    • const stream = event.streams[0];
      複数のストリームが含まれる可能性がありますが、ここでは最初のストリームを取得しています
    • videoTracks/audioTracks;
      ストリームからビデオトラック(映像データ)とオーディオトラック(音声データ)をそれぞれ取得。
    let existingVideo = document.getElementById(`remoteVideoContainer+${userId}`);
    let remoteUserIconContainer = document.getElementById("remote-user-icon-container");
    
    const remoteUserId = pcPeers[userId].userId;
    console.log("Remote User ID:", remoteUserId); 
    
    • リモートユーザーのアイコン表示を追加するための物です
    • existingVideo: 既に生成されたリモートのビデオ要素があるか確認。
    • remoteUserIconContainer: ユーザーアイコンを表示するための DOM コンテナ。
    • remoteUserId:
      pcPeers(WebRTC のピア接続情報)からリモートユーザーの ID を取得しています。このIDから相手のアイコンを特定して表示しようということです
    // アイコンを表示する関数
    const showremoteUserIcon = async () => {
      
      try {
        const remoteUser = await fetch(`/users/${remoteUserId}/icon`) // ユーザーアイコン情報を取得
          .then(response => response.json());
      
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "block"; // アイコンを表示
          const remoteUserIcon = document.createElement("img");
          remoteUserIcon.src = remoteUser.url; // APIから返されるURLを使ってアイコンを設定
          remoteUserIcon.alt = "User Icon";
          remoteUserIcon.classList.add("user-icon-voice-chat");
          remoteUserIconContainer.appendChild(remoteUserIcon); // アイコンをコンテナに追加
        }
      } catch (error) {
        console.error("Error fetching user icon:", error);
      }
    };
    
    • ここは普通のjavascriptなので、詳細を省きます。
    • remoteUserIdを使ってユーザーからアイコン情報を取得して、それをimgにしてurlにしてViewコンテナに追加しています。
    if (videoTracks.length === 0 || !videoTracks[0].enabled) {
      // ビデオトラックがない場合はアイコンを表示
        showremoteUserIcon();
        if (existingVideo) {
          existingVideo.style.display = "none"; // 既存のビデオ要素を非表示
        }
        if (remoteUserIconContainer) {
            remoteUserIconContainer.style.display = "block"; // アイコンを表示
        }
    } else {
      // ビデオトラックがある場合
      if (!existingVideo) {
        const element = document.createElement("video");
        element.id = `remoteVideoContainer+${userId}`;
        element.autoplay = true;
        element.srcObject = stream;
        element.classList.add('remote-video');
        remoteVideoContainer.appendChild(element);
        console.log("Remote video element created and added.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      } else {
        // 既存の要素がある場合はストリームを再設定
        existingVideo.srcObject = stream;
        existingVideo.style.display = "block"; // ビデオを表示
        console.log("Existing video element updated.");
        
        // アイコンを非表示に
        if (remoteUserIconContainer) {
          remoteUserIconContainer.style.display = "none";
        }
      }
    }
    
    • ビデオトラックの有無での分岐処理です
    • ビデオがあればそれを表示し、ない場合はアイコンを表示させます。
    • ここも普通のJavascrptなので詳細は省きます。
    if (audioTracks.length > 0) {
    const audioElement = document.createElement("audio");
    audioElement.srcObject = stream;
    audioElement.autoplay = true;
    document.body.appendChild(audioElement);
    }
    
    • オーディオトラックの処理です
    • 音声トラックがある場合、音声を再生できる 要素を生成します。
    • 要素は画面に表示されないので、document.body に追加するだけです。
    pc.oniceconnectionstatechange = () => {
    if (pc.iceConnectionState == "disconnected") {
        console.log("Disconnected:", userId);
        broadcastData({
            type: REMOVE_USER,
            from: userId,
        });
    }
    };
    
    • ICE接続状態の変更ハンドリングです。
    • disconnected状態になった場合、リモートユーザーの削除をブロードキャストします。

    EXCHANGE処理

    signaling_server.js
    const exchange = (data) => {
      let pc;
    
      if (!pcPeers[data.from]) {
        pc = createPC(data.from, false);
      } else {
        pc = pcPeers[data.from].pc;// pcを取得
      }
    
      if (data.candidate) {
        pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data.candidate)))
          .then(() => console.log("Ice candidate added"))
          .catch(logError);
      }
    
      if (data.sdp) {
        const sdp = JSON.parse(data.sdp);
        pc.setRemoteDescription(new RTCSessionDescription(sdp))
          .then(() => {
            if (sdp.type === "offer") {
              pc.createAnswer()
                .then((answer) => {
                return pc.setLocalDescription(answer);
                })
                .then(() => {
                  broadcastData({
                  type: EXCHANGE,
                  from: currentUser,
                  to: data.from,
                  sdp: JSON.stringify(pc.localDescription),
                });
              });
           }
         })
         .catch(logError);
        }
      };
    
    • ついにきた重要な部分です。

    • ここでは、主に2つの処理を行っています。

      • SDPの処理
      • ICE候補の追加処理
    • 流れに沿って説明します。
      Bがオファーを作成して、SDPを送信してきた時

      const sdp = JSON.parse(data.sdp);
      pc.setRemoteDescription(new RTCSessionDescription(sdp))
      

      ここで、AがBのSDPを保存します。

      .then(() => {
      if (sdp.type === "offer") {
        pc.createAnswer()
          .then((answer) => {
          return pc.setLocalDescription(answer);
          })
          .then(() => {
            broadcastData({
            type: EXCHANGE,
            from: currentUser,
            to: data.from,
            sdp: JSON.stringify(pc.localDescription),
          });
        });
      }
      

      そしてAがBに対してアンサーを作成します。
      そのアンサーをBが受け取り、またEXCHANGEが発動し

      const sdp = JSON.parse(data.sdp);
      pc.setRemoteDescription(new RTCSessionDescription(sdp))
      

      先ほどと同じ部分でBがAのSDPを保存します。
      Bが受け取ったのはアンサーなのでif (sdp.type === "offer") 以降は発動しません。

      これで、お互いのSDP交換が完了します。
      そうすると、WEBRTCが自動的にICE候補を生成し始めます。

      const createPC = (userId, isOffer) => {
      ~省略~
          pc.onicecandidate = (event) => {
              event.candidate &&
                broadcastData({
                  type: EXCHANGE,
                  from: currentUser,
                  to: userId,
                  candidate: JSON.stringify(event.candidate),
              });
          };
      ~省略~
      };
      

      自動生成されるICE候補を、
      コードの結構前に出てきた、createPCのpc.onicecandidateが検知して新しいICE候補かを判断してEXCHANGEに送ります。
      ここで下のコードが働きます

       if (data.candidate) {
           pc.addIceCandidate(new RTCIceCandidate(JSON.parse(data.candidate)))
             .then(() => console.log("Ice candidate added"))
             .catch(logError);
       }
      
      • pc.addIceCandidateを呼び出して、PeerConnectionに受信したICE候補を追加します。
      • これを複数回繰り返し、双方が十分な候補を交換し終わると、WebRTCが最適な接続パスを選択します。
      • これでP2Pの接続が確立が完了します!

    最後!毎回送ってるbroadcastDataの設定部分

    signaling_server.js
    const broadcastData = (data) => {
      /**
       * Add CSRF protection: https://stackoverflow.com/questions/8503447/rails-how-to-add-csrf-protection-to-forms-created-in-javascript
       */
      const csrfToken = document.querySelector("[name=csrf-token]").content;
      const headers = new Headers({
        "content-type": "application/json",
        "X-CSRF-TOKEN": csrfToken,
      });
      // roomIdをデータに含める
      data.chatroomId = document.getElementById("room-id").value;
    
      fetch("/sessions", {
        method: "POST",
        body: JSON.stringify(data),
        headers,
      })
      .then(response => {
        console.log("Response status:", response.status); // ステータスコードをログ出力
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        return response.text(); // サーバーからのレスポンスをJSONとして解析
      })
      .then(text => {
        console.log("Raw response text:", text); // レスポンスをテキストとしてログに出力
        if (text.trim() === "") { // 空のレスポンスをチェック
          throw new Error("相手が参加すると通話が開始されます");
        }
        try {
            const jsonResponse = JSON.parse(text); // 必要に応じてパース
            console.log("Parsed JSON:", jsonResponse);
        } catch (parseError) {
            console.error("Error parsing JSON:", parseError);
            console.log("Received raw data during parsing:", text);
        }
      })
      .catch(error => {
        console.error("Error broadcasting data:", error);
      });
    };
    
  • 長いですがエラー処理が多いだけなので必要な部分は下だけです

    const csrfToken = document.querySelector("[name=csrf-token]").content;
      const headers = new Headers({
        "content-type": "application/json",
        "X-CSRF-TOKEN": csrfToken,
      });
      // roomIdをデータに含める
      data.chatroomId = document.getElementById("room-id").value;
    
      fetch("/sessions", {
        method: "POST",
        body: JSON.stringify(data),
        headers,
      })
    
    • data.chatroomId = document.getElementById("room-id").value;
      ここでdataにchatroomのidを含めています
    • 他部分はMITのソース通りなので詳しくは、そちらでご覧ください。

お疲れさまでした!

途中何度か心が折れかけましたが何とかすべて解説できました。
これを見れば恐らく通話機能を問題なく作成できる記事になったと思います!
通話機能の作成頑張ってください!!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?