Loading
更新日:20250519

通話、ビデオチャット等のAPI連携方法

はじめに

ウェブアプリケーションに通話やビデオチャット機能を実装することで、ユーザー間のコミュニケーションを大幅に向上させることができます。特にリモートワークが普及した現代では、この機能はビジネスアプリケーションにとって重要な要素となっています。本記事では、Next.jsアプリケーションにTwilioを使ったビデオチャット機能を実装する方法を解説します。

ビデオチャットの実装方法

ウェブアプリケーションにビデオチャット機能を実装する主な方法は以下の3つです:

1. WebRTC APIを直接使用

ブラウザの標準APIであるWebRTCを直接使用する方法です。最も自由度が高いですが、シグナリングサーバーの構築など複雑な実装が必要になります。

2. 専用のビデオ通話API

TwilioやAgora.ioなどの外部サービスが提供するAPIを使用する方法です。実装が比較的簡単でスケーラビリティにも優れています。

3. オープンソースのフレームワーク

Jitsi MeetやLiveKitなどのオープンソースフレームワークを使用する方法です。カスタマイズ性と経済性のバランスが取れています。

今回は2番目の方法として、Twilioのビデオ通話APIを使用した実装例を紹介します。

Twilioのセットアップ

アカウント作成と認証情報の取得

  1. Twilio Videoの公式サイトでアカウントを作成します
  2. ダッシュボードから「Video」サービスを有効にします
  3. 以下の認証情報を取得します:
    1. Account SID(ACで始まる文字列)
    2. API Key SID(SKで始まる文字列)
    3. API Key Secret

環境変数の設定

Next.jsプロジェクトのルートディレクトリに.env.localファイルを作成し、以下の環境変数を設定します:

plain text
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_KEY=SKxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
TWILIO_API_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

必要なパッケージのインストール

shell
npm install twilio twilio-video
# または
yarn add twilio twilio-video

Next.jsでの実装例

バックエンド(API Routes)の実装

Twilioと通信するためには、サーバーサイドの処理が必要です。Next.jsのAPI Routesを使って以下のエンドポイントを実装します。

ルーム作成・取得エンドポイント

typescript
// src/app/api/twilio/get-or-create-room/route.ts
import { NextRequest, NextResponse } from 'next/server';
import twilio from 'twilio';

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const { roomId, identity = 'user-' + Date.now() } = body;

    // 環境変数から認証情報を取得
    const twilioAccountSid = process.env.TWILIO_ACCOUNT_SID;
    const twilioApiKey = process.env.TWILIO_API_KEY;
    const twilioApiSecret = process.env.TWILIO_API_SECRET;

    // Twilioクライアントの初期化
    const client = twilio(twilioApiKey, twilioApiSecret, { accountSid: twilioAccountSid });

    // ルームが存在するか確認、なければ作成
    let room;
    try {
      room = await client.video.v1.rooms(roomId).fetch();
    } catch (error) {
      room = await client.video.v1.rooms.create({
        uniqueName: roomId,
        type: 'group',
        maxParticipants: 50,
      });
    }

    // アクセストークンの生成
    const AccessToken = twilio.jwt.AccessToken;
    const VideoGrant = AccessToken.VideoGrant;
    const videoGrant = new VideoGrant({ room: roomId });
    const token = new AccessToken(
      twilioAccountSid,
      twilioApiKey,
      twilioApiSecret,
      { identity }
    );
    token.addGrant(videoGrant);

    // トークンとルーム情報を返す
    return NextResponse.json({
      roomId,
      token: token.toJwt(),
    });
  } catch (error) {
    return NextResponse.json(
      { error: 'Internal server error while creating room' },
      { status: 500 }
    );
  }
}

フロントエンドの実装

次に、フロントエンドのコンポーネントを実装します。

ヘルパー関数

typescript
// src/lib/twilio.ts
import { nanoid } from 'nanoid';

// ルームを取得または作成する関数
export async function getOrCreateRoom(roomId: string): Promise<{ roomId: string, token: string }> {
  try {
    const response = await fetch('/api/twilio/get-or-create-room', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ roomId }),
    });

    if (!response.ok) {
      throw new Error('Failed to get or create room');
    }

    return await response.json();
  } catch (error) {
    console.error('Error in getOrCreateRoom:', error);
    throw error;
  }
}

// ユニークなルームIDを生成する関数
export function generateRoomId(baseName: string): string {
  const randomId = nanoid(5).toLowerCase();
  return `${baseName.toLowerCase().replace(/[^a-z0-9-]/g, '')}-${randomId}`;
}

ビデオルームコンポーネント

typescript
// src/components/TwilioRoom.tsx
import React, { useEffect, useState, useRef } from 'react';
import { getOrCreateRoom } from '../lib/twilio';
import { connect, createLocalVideoTrack, createLocalAudioTrack } from 'twilio-video';

const TwilioRoom = ({ roomId, username = `user-${Date.now()}` }) => {
  const [room, setRoom] = useState(null);
  const [participants, setParticipants] = useState([]);
  const [localVideoTrack, setLocalVideoTrack] = useState(null);
  const [localAudioTrack, setLocalAudioTrack] = useState(null);
  const [isAudioEnabled, setIsAudioEnabled] = useState(true);
  const [isVideoEnabled, setIsVideoEnabled] = useState(true);
  const [isConnecting, setIsConnecting] = useState(true);
  const [error, setError] = useState(null);

  const localVideoRef = useRef(null);
  const participantRefs = useRef({});

  // ローカルビデオを確実に接続するための関数
  const ensureLocalVideoConnection = async () => {
    // ブラウザのメディアAPIを直接使用
    const stream = await navigator.mediaDevices.getUserMedia({
      video: { width: 640, height: 480 }
    });

    // ビデオ要素に直接接続
    if (localVideoRef.current) {
      localVideoRef.current.srcObject = stream;
      localVideoRef.current.play().catch(err => {
        console.warn('Direct video play error:', err);
      });
    }

    // Twilioのビデオトラックを作成
    const videoTrack = await createLocalVideoTrack({
      width: 640, height: 480
    });

    return videoTrack;
  };

  useEffect(() => {
    // Twilioルームに接続する処理
    const connectToRoom = async () => {
      try {
        // ルームを取得または作成してトークンを取得
        const { token } = await getOrCreateRoom(roomId);

        // カメラとマイクの接続を確保
        const videoTrack = await ensureLocalVideoConnection();
        const audioTrack = await createLocalAudioTrack();

        setLocalVideoTrack(videoTrack);
        setLocalAudioTrack(audioTrack);

        // トークンでTwilioルームに接続
        const twilioRoom = await connect(token, {
          name: roomId,
          tracks: [videoTrack, audioTrack],
          video: true,
          audio: true
        });

        setRoom(twilioRoom);
        setParticipants(Array.from(twilioRoom.participants.values()));

        // 参加者接続イベントの設定
        twilioRoom.on('participantConnected', participant => {
          setParticipants(prev => [...prev, participant]);
        });

        // 参加者切断イベントの設定
        twilioRoom.on('participantDisconnected', participant => {
          setParticipants(prev => prev.filter(p => p.identity !== participant.identity));
        });

        setIsConnecting(false);
      } catch (err) {
        setError(`接続に失敗しました: ${err.message}`);
        setIsConnecting(false);
      }
    };

    connectToRoom();

    // クリーンアップ関数
    return () => {
      if (localVideoTrack) {
        localVideoTrack.stop();
      }
      if (localAudioTrack) {
        localAudioTrack.stop();
      }
      if (room) {
        room.disconnect();
      }
    };
  }, [roomId]);

  // UI表示部分
  return (
    <div className="video-room-container">
      <h2>ルーム: {roomId}</h2>
      <p>ユーザー名: {username}</p>

      <div className="video-grid">
        {/* ローカルビデオ */}
        <div className="participant-container local-participant">
          <div className="video-container">
            <video ref={localVideoRef} autoPlay playsInline muted />
          </div>
          <div className="participant-name">あなた ({username})</div>
        </div>

        {/* リモート参加者 */}
        {participants.map(participant => (
          <div key={participant.identity} className="participant-container">
            <div className="video-container">
              <video ref={el => { participantRefs.current[participant.identity] = el; }}
                     autoPlay playsInline />
            </div>
            <div className="participant-name">{participant.identity}</div>
          </div>
        ))}
      </div>

      {/* コントロールボタン */}
      <div className="video-controls">
        <button onClick={() => {
          if (localVideoTrack) {
            isVideoEnabled ? localVideoTrack.disable() : localVideoTrack.enable();
            setIsVideoEnabled(!isVideoEnabled);
          }
        }}>
          {isVideoEnabled ? 'ビデオをオフにする' : 'ビデオをオンにする'}
        </button>

        <button onClick={() => {
          if (localAudioTrack) {
            isAudioEnabled ? localAudioTrack.disable() : localAudioTrack.enable();
            setIsAudioEnabled(!isAudioEnabled);
          }
        }}>
          {isAudioEnabled ? 'ミュート' : 'ミュート解除'}
        </button>

        <button onClick={() => {
          if (room) {
            room.disconnect();
            window.location.href = '/';
          }
        }}>
          退室する
        </button>
      </div>
    </div>
  );
};

export default TwilioRoom;

よくある問題と解決策

1. 自分のカメラが表示されない問題

症状: ルームに接続したとき、相手のビデオは表示されるが自分のビデオが表示されない。

解決策:

  1. ブラウザのカメラ権限を確認: アドレスバーの左側のアイコンからカメラ権限が許可されているか確認します。
  2. 二重接続アプローチを使用:
    1. ブラウザのネイティブMediaAPIで直接接続
    2. Twilio SDKでも接続
    3. これにより、どちらかが失敗しても他方がバックアップとなります
  3. カメラ再接続ボタンを追加: ユーザーが手動で再接続できるようにします。
typescript
const retryCamera = async () => {
  if (localVideoTrack) {
    localVideoTrack.stop();
  }
  const newVideoTrack = await ensureLocalVideoConnection();
  setLocalVideoTrack(newVideoTrack);
  if (room) {
    room.localParticipant.publishTrack(newVideoTrack);
  }
};

2. 音声が聞こえない問題

症状: ビデオは表示されるが、相手の音声が聞こえない。

解決策:

  1. オーディオトラックの明示的な作成:
typescript
const audioTrack = await createLocalAudioTrack();
  1. オーディオトラックの明示的なアタッチ:
typescript
participant.audioTracks.forEach(publication => {
  if (publication.isSubscribed && publication.track) {
    publication.track.attach(videoElement);
  }
});
  1. ミュート状態の確認: ローカルとリモートの両方がミュートになっていないか確認します。

代替サービスの比較

Twilioの他にも、ビデオチャット機能を実装するためのサービスはいくつかあります。代表的なものを比較してみましょう。

1. Agora.io

特徴:

  • 低レイテンシーに強み(特にアジア太平洋地域)
  • 高ボリュームのアプリケーションに適した価格設定
  • モバイルサポートが充実

2. Vonage Video API (旧OpenTok)

特徴:

  • エンタープライズグレードの信頼性
  • 放送機能が強力
  • セッションアーカイブ/録画機能が充実

3. LiveKit (オープンソース)

特徴:

  • オープンソースで自己ホスト可能
  • WebRTCベースで高パフォーマンス
  • コスト効率が高い

比較表

機能TwilioAgora.ioVonage VideoLiveKit
料金体系トラック分単位参加者分単位トラック分単位自己ホスト/従量制
地域対応グローバルアジア太平洋に強みグローバル自己ホスト
実装の容易さ高い中程度高いやや低い
スケーラビリティ良好非常に良い良好非常に良い
カスタマイズ性中程度中程度中程度高い
ドキュメント充実普通良好普通

外部連携サービスの利用料について

ビデオチャット機能に関する外部連携サービスの利用料について、ビデオチャットAPIの料金を円換算(1ドル=155円)でまとめると以下の通りでした。

通話料金の目安(1分あたり・1ユーザー)

■Agora

HD:0.618円

フルHD:1.393円

■VideoSDK.live

HD(720p):0.463円

FHD(1080p):1.083円

■SignalWire

HD:0.93円

FHD:1.86円

■Sendbird

P2P接続:0.217円

サーバーリレー:0.542円

クラウド録画:0.914円

このように、FHDビデオ通話は約1〜2円/分/ユーザー、P2P型やHD解像度では0.2〜0.6円程度となります。実際のコストは「同時接続数」や「通話時間」により大きく変動します。これに想定の利用者数と時間をかけて、ご予算を計画しておく必要があると考えます。

Daily.coのAPIを使用したサンプルビデオ通話アプリのテスト結果

テスト概要:

・実施日時: 2025年5月16日(金曜日) 21:45頃

・テスト環境: PC側(当方)とモバイル端末(テスター)での接続

・テスト項目: 接続性能、画質、音質、安定性

【良好だった点】

映像品質:画像の表示は問題なく、PC・モバイル間で相互に映像を確認できました。

【課題が残った点】

エビデンスでは音声をキャップした状態でを取ってしまい大変恐縮ですが、音声品質:当方(PC側)からの音声はテスター(モバイル側)に届いていましたが、モバイル側からの音声が当方に届かないという事象が発生しました。

モバイル適応性:テスターより「電波状況により時々LINE通話でも同様の問題が発生する」との指摘をいただきましたが、その後LINE通話に切り替えた際は正常に音声が聞こえたため、Daily.coのAPIのモバイル環境への最適化に課題があると考えられます。

まとめ

ビデオチャット機能の実装には、様々な方法とサービスがあります。Twilioは実装の容易さと機能性のバランスが良く、特に初めてビデオチャット機能を実装する場合に適しています。

本記事で紹介した実装方法を応用することで、基本的なビデオチャット機能を持つNext.jsアプリケーションを構築することができます。また、よくある問題への対処法も知っておくことで、スムーズな実装が可能になります。

実際の実装では、セキュリティや認証、UIのカスタマイズなど、さらに考慮すべき点がありますが、それらは別の機会に解説したいと思います。