3
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+Javascriptでの通話機能の実装【ActionCable + WebRTC】

Last updated at Posted at 2024-12-10

外部APIを使用せずRailsとJavascriptだけで通話機能を実現する手助け

まず前提として、通話機能を自前のコードだけで実現することは推奨しません。
これは、私が意地でも通話APIを使用せずにRailsだけで通話機能を作成したくて、生み出したものです。
とんでもなく難しいので、初学者の方は手を出さないことをお勧めします。(こいつのためだけに3ヶ月かかりました)
※1番難しい部分を超細かく解説したので、これを見れば初学者の方でも実装できるかもしれません
↓ signaling_server.jsのすべての機能と役割を解説しています。
https://qiita.com/Reo-lab/items/c88520c3f0d715a8976c

通話機能を実装するために知らなくてはいけないこと

根本の技術 : WebRTC(WebRealtimeComminucation)
Railsでの技術 : ActionCable
Javascript : 基本的なJavascriptの仕様

まず、ActionCableの知識が無い人は、リアルタイムのチャット機能や通知機能の作成から始めましょう。ActionCableは本来これらを作成するための技術です。これらを作成して、ActionCableの動かし方を学びましょう。
Javascriptについては、そこまで知らなくても進めることは可能ですが、かなり学習する必要があります。
上記2つの技術が、問題ない方はWebRTCについて学びましょう。

WebRTCとは

WebRTCとは、ブラウザ間で音声や映像、データなどをリアルタイムにやり取りできる通信技術です。
たくさん有用なサイトやドキュメントがあるのでそこから学びましょう。

下記のドキュメントは難しいですが大変読む価値があります
https://zenn.dev/voluntas/scraps/82b9e111f43ab3

今回の実装に使用する技術は
・P2P通信
・SDP,ICE交換
・シグナリングサーバー
・STUNサーバー

どのように実装するか

WebRTCとactioncableとjavascriptを組み合わせる

WebRTCを使用してブラウザ間のP2P接続を確立するためにSDPとICEの交換が必要

SDPとICE交換のためにシグナリングサーバーが必要
これをActionCable+Javascriptで作成する

ユーザーが接続を開始すると、シグナリングサーバー経由でSDPやICE情報を交換する。
これにより、ブラウザ間でのピアツーピア接続が確立される。

接続が確立されると、ブラウザ間での音声ストリームの送受信が可能になり、リアルタイムの音声通話が実現される。

図で説明すると
WEBアプリ (1).png

これのシグナリングサーバー部分(③SDP,ICE交換④接続確立)を、ActionCableとJavascriptを使用して作成しようということです。

具体的な役割

  • Javascript (signaling_server.js) :
      WebRTCピア(ブラウザ)間の接続に必要な情報(SDP、ICE候補)を生成し、ActionCableを通じて送受信します。

  • ActionCable: 
      クライアント間のシグナリングメッセージ(SDPやICE候補)を仲介します。

シグナリングサーバーの処理フロー

(1) クライアントAが通話を開始

  ・クライアントAはRTCPeerConnectionオブジェクトを作成し、SDP(オファー)を生成。
  ・このオファーをActionCable経由でクライアントBに送信。

(2) クライアントBがオファーを受け入れる

  ・クライアントBがSDP(オファー)を受信し、応答(アンサー)を生成。
  ・このアンサーをActionCable経由でクライアントAに送信。

(3) ICE候補の交換

  ・双方のクライアントでICE候補を生成し、ActionCable経由で交換。

(4) WebRTC接続の確立

  ・SDPとICE候補が交換されると、ブラウザ間で直接通信が確立。

図で説明すると
Javascript.png
これ全体が、シグナリングサーバーの動きです。
※厳密には、⑦~⑪を複数回繰り返して接続を確立します。

先ほどの図と組み合わせると
1.png
大体これで全体像のイメージがつかめたと思います。

通話機能を作成していこう!

まずActioncableとjavascriptのsignaling_server.jsでの環境構築は
MITが公開しているgithubのREADMEを見て学びましょう
https://github.com/jeanpaulsio/action-cable-signaling-server?tab=readme-ov-file

実践的な機能に改修しよう

上記のMITのgithubを熟読して、実際に動かしてみて機能を理解していきましょう。
そうしていくと、このソースは少し古くてうまく動かない所が出てきます。
それを解説しましょうと言いたいところですが、正直コードを追加しすぎて、どこが最低限必要な改修かが分からなくなってしまいました。
なので、私の実際のコードを下記に貼るので参考に各所修正していってみてください。

ー※追記※ー
signaling_server.jsの解説記事を追加で書きました。
全ての機能と役割を解説しているのでこれを見れば多分動くようになります。
https://qiita.com/Reo-lab/items/c88520c3f0d715a8976c
ーーーーー

それでも動かない場合は、そもそもActionCableが上手く動いていない、Javascriptが機能していない可能性があります。そこら編をつぶしていきながら改修しましょう。
それでも無理な場合は、記事の最後をご覧ください。

私のコード

私はチャット部屋を作成し、そこにいるユーザー同士で通話をできるように実装しています。
MITのソースは最低限の機能なので、特定のユーザーを識別して通信を行うことができません。
それをチャットルームを作成してその中にユーザーを入れ込むことで、特定の通信をできるようにしています。
つまり、チャット機能と同じ概念です。
具体的には、broadcastDataにroom-idを入れて、データ交換時に同じidかを識別しています。

ActionCable

案外ActionCable関連で設定することは少ないです。

  • app/channels/session_channel.rb
    channels/session_channel.rb
    # frozen_string_literal: true
    
    # SessionChannelは通話機能のサブスクライブを提供しています
    class SessionChannel < ApplicationCable::Channel
      def subscribed
        stream_from "session_channel_#{params[:chatroom_id]}"
      end
    
      def unsubscribed
        # Any cleanup needed when channel is unsubscribed
      end
    end
    
  • controllers/session_controller.rb
    controllers/session_controller.rb
    # frozen_string_literal: true
    
    # SessionsController
    class SessionsController < ApplicationController
      def create
        chatroom_id = params[:session][:chatroomId]
        head :no_content
        ActionCable.server.broadcast "session_channel_#{chatroom_id}", session_params
      end
    
      private
    
      def session_params
        params.require(:session).permit(:type, :from, :to, :sdp, :candidate, :chatroomId)
      end
    end
    

View

  • chatrooms/show.html.erb
    chatrooms/show.html.erb
    <div class = "chatroom-videochat-box">
      <h1>ビデオ/ボイスチャットルーム</h1>
      <input type="hidden" id="room-id" value="<%= @chatroom.id %>" />
    
      <div>Random User ID:
        <span id="current-user"><%= current_user.id%></span>
      </div>
    
      <div id="remote-video-container">
      <div id="remote-user-icon-container" style="display: none;">
      </div>
    
      </div>
      <div id="local-video-container">
        <video id="local-video" autoplay></video>
        <div id="user-icon-container" style="display: none;">
          <% if current_user.icon_image.attached? %>
            <img id="user-icon" src="<%= url_for(current_user.icon_image) %>" alt="User Icon" class="user-icon-voice-chat" />
          <% else %>
            <img id="user-icon" src="<%= asset_path('default_user_icon.png') %>" alt="Default User Icon" class="user-icon-voice-chat" />
          <% end %>
        </div>
      </div>
      <div class = "video-audio-select">
        <label for="video-select">Select Video Device:</label>
        <select id="video-select"></select>
      </div>
      <div class = "video-audio-select">
        <label for="audio-select">Select Audio Device:</label>
        <select class = "audio-select" id="audio-select"></select>
      </div>
      <hr />
    
      <button id="join-button">
        ボイスルームに参加する
      </button>
    
      <button id="leave-button">
        ボイスルームから退出する
      </button>
    </div>
    

signaling_server.js

  • javascript/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);

開発環境と本番環境の違いの注意点

開発環境でjavascriptが動いているのに本番環境で動かない時には以下を確認しましょう。

config/importmap.rb
pin_all_from 'app/javascript/controllers', under: 'controllers'
pin_all_from 'app/javascript/channels', under: 'channels'
pin 'signaling_server', to: 'signaling_server.js'

実際の本番環境でjavascriptを動かす場合、javascriptファイルをインポートマップで正しく登録する必要があります。
ここがちゃんと設定されているか。
また以下のように

javascript/signaling_server.js
import consumer from "channels/consumer";

設定したmapからjavascriptファイルを読み込むので、すべてのjavascriptファイルのimport部分の表記を上記のような、絶対パス指定にしてください。

javascript/signaling_server.js
// NG: 相対パス指定
import consumer from "./channels/consumer";

このように相対パス指定にしていると、開発環境だと動きますが本番環境で動かなくなります。

それでも動かない場合

通話機能を本番環境で実装した私のアプリのコードを参考にしながら、自分と違うところを探して改修していってみてください。

本来ならすべてを解説したいのですが、あまりにコードが長すぎて解説が困難なため、機会があったら少しずつ解説していきたいです。

それでも成功例を提示することで、すこしでも実装の手助けになればと思いこの記事を書かせていただきました。
非常に難しいですが、不可能ではないので、挑戦する猛者はぜひ頑張ってみてください!

(早々に諦めることも大事です、この記事はこの実装方法を推奨しているわけではないです)

3
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
3
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?