🗣️

Realtime APIを使ってFlutterでリアルタイム音声会話AI機能を作ってみる(WebRTC接続)

2025/01/29に公開

OpenAIのRealtime APIがWebRTCに対応

2024年12月17日(12 Days of OpenAI の 9日目)に発表されたアップデートにより、Realtime APIのWebRTCサポートが追加されました。それ以前はWebSocketのみの対応であったため、音声処理を自前で実装しなければならず、実装が複雑になってしまうデメリットがありました。

一方、WebRTCはP2P通信によって直接相手と通信できるため、より低遅延で音声ストリームに最適化された仕組みを備えており、音声品質が向上します。さらに、実装の面でもWebSocketより容易になります。

Realtime APIは現時点で音声のみの対応ですが、WebRTC自体は映像のリアルタイム通信も実現する技術であり、Google MeetやZoomなどのビデオ通話サービスにも利用されています。

OpenAI DevDay 2024でも、WebRTCを使ったアプリケーションのデモが行われていました。

WebRTCが採用するP2P通信とは

一般的なWebSocket通信はクライアント・サーバー方式であり、すべてのデータをサーバーが仲介します。これに対して、WebRTCが採用しているP2P(Peer-to-Peer)通信は、サーバーを介さずに端末同士が直接通信を行う方式です。

P2P通信にはいくつかメリットがありますが、今回のRealtime APIが採用している大きなメリットは、サーバーを介さずにデータをやり取りするため、音声ストリームの送受信が高速かつ遅延が少なくなることにあります。

ただし一方で、P2P通信の接続を確立する仕組みはやや複雑であり、仕組みを理解しなければ実装方法を把握するのが難しくなります。

WebRTCの登場人物

WebRTCで通信を行う時に登場する人物(要素)について解説します。

自分と相手

  • これから音声などのデータを送り合う自分の端末と相手の端末です。

シグナリングサーバ

  • WebRTCの接続を確立するためには、**「どの端末と通信するか」「どのような通信条件で行うか」**といった情報を事前に交換する必要があります。
  • これらの情報交換を仲介する役割がシグナリングサーバです。
  • WebRTCはP2P通信でサーバーを介さずにデータをやり取りしますが、接続確立前の情報交換(シグナリング)だけはサーバーを介して行う必要があります。

STUNサーバ

  • STUNサーバには大きく分けて2つの役割があります。
    1. STUNサーバに問い合わせてきた端末に対して、その端末の利用しているグローバルIPアドレスを教えること
    2. WebRTCの接続を確立するための通信経路候補(ICE Candidate)を探索すること
  • STUNサーバ自体は、シグナリングサーバのように「自分と相手の情報交換を行う」機能は持ちません。あくまで「自分の端末が外部からどう見えるか」を教えてくれる仕組みです。

SDP

  • WebRTC通信のための基本情報をまとめるプロトコルです。
  • WebRTC通信を確立するには、自分と相手がそれぞれSDPを作成し、交換を行います。
  • 送信側(自分)のSDPをOffer、受信側(相手)から返ってくるSDPをAnswerと呼びます。
  • SDPには、下記のような情報が含まれます。
    • 自身のグローバルIPアドレス
    • 取り扱うメディア情報(音声や映像のコーデックなど)
    • 通信経路(ICE Candidate)の候補

ICE

  • WebRTC接続で利用する**通信経路についての情報(プロトコル)**を指します。
  • STUNサーバの役割にも記載している通り、ICEはSTUNサーバによって作成され、通信可能な経路の候補をICE Candidateと呼びます。
  • ICEの情報は基本的にSDPの中に入れて相手に送信します。
  • ただし、STUNサーバからICE Candidateを取得するのに時間がかかる場合があるため、最初にICE情報を空にしたSDPを送り、後からICE Candidateだけ個別に送信することも可能です(この仕組みをTrickle ICEと呼ばれ、この後の実装でも利用します)。

WebRTCの接続フロー

ここからは、FlutterでRealtime APIをWebRTCで利用する際のおおまかな流れについて説明します。

1. 各端末でSDPを作成

  • この段階ではローカルで取得できるメディア情報(音声のコーデック)をSDPに格納します。
  • STUNサーバから取得が必要なグローバルIPやICEの情報はまだ格納されません。

2. SDP(Offer)を相手に送信

  • Realtime APIを利用する場合、OpenAIが提供するAPIのエンドポイントがシグナリングサーバとして機能します。
  • 手順1で作成したSDP(Offer)をシグナリングサーバ経由で相手に送信します。
  • 繰り返しますが、この時点ではグローバルIPやICEの情報が未設定のままですが、Trickle ICEではそれで問題ありません。

3. 相手からSDP(Answer)を受信

  • 相手も同様にSDPを作成し、Offerに対するAnswerを返してきます。
  • これもシグナリングサーバを介して受信します。

4. ICEとグローバルIPを取得

  • STUNサーバに問い合わせ、グローバルIPアドレスとICE Candidateの情報を取得します。

5. ICEを相手に送信

  • SDPでまだ含めていないグローバルIPアドレスやICE Candidateの情報を、シグナリングサーバ経由で相手に送信します。

6. 相手からICEを受信

  • 相手もSTUNサーバから取得したICE Candidateを、同じくシグナリングサーバ経由で送信してきます。

7. ICEを決定しWebRTC接続が確立

  • 双方のICE Candidateが出揃ったら、最適な経路を選択します。
  • 経路が確定して接続が確立すると、P2Pで音声ストリームを送受信できるようになります。

Flutterでの実装

ここからは、実際にFlutterでRealtime APIをWebRTCで利用する方法を紹介します。

パッケージのインストール

FlutterでWebRTCを実装するには、flutter_webrtcというパッケージを利用すると実装が容易になります。また、シグナリングサーバへの通信のためhttpパッケージも利用します。

flutter pub add flutter_webrtc
flutter pub add http

https://pub.dev/packages/flutter_webrtc
https://pub.dev/packages/http

マイクのアクセス許可

iOSの設定

ios/Runner/Info.plist に以下を追加して、マイクへのアクセス権限を明記します。

<key>NSMicrophoneUsageDescription</key>
<string>音声チャットのためにマイクを使用します。</string>

Androidの設定

android/app/src/main/AndroidManifest.xml にマイク使用許可を追加します。

<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>

WebRTC接続のためのサービスクラスの作成

import 'dart:convert';
import 'dart:io';

import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:http/http.dart' as http;

class WebRTCVoiceChatService {
  WebRTCVoiceChatService();

  late final RTCPeerConnection peerConnection;
  late final RTCDataChannel dataChannel;
  late final MediaStream localStream;

  Future<void> connect() async {
    // OpenAIのシグナリングサーバを使用するには、事前にクライアントシークレット(Ephemeral Key)を取得する必要がある
    // https://platform.openai.com/docs/guides/realtime-webrtc
    final key = await _fetchEphemeralKey();
    if (key == null) {
      print('Failed to fetch Ephemeral Key');
      return;
    }

    // WebRTC用のPeerConnectionを作成
    peerConnection = await createPeerConnection({
      'iceServers': [
        // Googleが提供しているオープンなSTUNサーバを利用
        // https://dev.to/alakkadshaw/google-stun-server-list-21n4
        {'urls': 'stun:stun.l.google.com:19302'},
        // TURNサーバを追加する際は以下に追加(今回は省略)
        // {'urls': 'turn:your-turn-server'},
      ],
    });
    print('Created PeerConnection successfully: $peerConnection');

    // SDPに含めるメディア情報の取得(今回は音声のみ)
    localStream = await navigator.mediaDevices.getUserMedia({
      'audio': true,
      'video': false,
      'mandatory': {
        'googNoiseSuppression': true,
        'googEchoCancellation': true,
        'googAutoGainControl': true,
        'minSampleRate': 16000,
        'maxSampleRate': 48000,
        'minBitrate': 32000,
        'maxBitrate': 128000,
      },
      'optional': [
        {'googHighpassFilter': true},
      ],
    });

    // PeerConnectionにメディア情報を追加
    localStream.getTracks().forEach((track) {
      peerConnection.addTrack(track, localStream);
    });

    // テキストなどのデータも取得するためデータチャネルを作成
    dataChannel = await peerConnection.createDataChannel(
      'oai-events',
      RTCDataChannelInit(),
    );

    // ICE Candidateが生成された時のコールバック
    // STUNサーバからICE Candidateを受けとるたびに呼ばれる
    peerConnection.onIceCandidate = (RTCIceCandidate candidate) {
      print('Generated ICE Candidate: ${candidate.candidate}');
    };

    // ICEを通じて接続の状態が変化した時のコールバック
    peerConnection.onIceConnectionState = (RTCIceConnectionState state) {
      print('ICE connection state changed: $state');

      // ICEの手続きが完了したということはWebRTCの接続が確立されたと同義
      if (state == RTCIceConnectionState.RTCIceConnectionStateCompleted) {
        print('WebRTC connection established successfully!');
      }
    };

    // データチャネルでメッセージ(バイナリ、文字列)を受け取った時のコールバック
    // Realtime APIからはバイナリデータは送信されないため、文字列のみを受け取る
    dataChannel.onMessage = (RTCDataChannelMessage message) {
      print('Received text message: ${message.text}');
    };

    // 音声ストリームを受け取った時のコールバック
    peerConnection.onAddStream = (MediaStream stream) {
      final audioTracks = stream.getAudioTracks();
      if (audioTracks.isNotEmpty) {
        // スピーカーをオンにして読み上げ
        Helper.setSpeakerphoneOn(true);
      }
    };

    // 自分のSDPを作成し、PeerConnectionセット
    final offer = await peerConnection.createOffer();
    if (offer.sdp == null) {
      print('Failed to create offer');
      return;
    }
    await peerConnection.setLocalDescription(offer);
    print('Set local description: ${offer.sdp}');

    // シグナリングサーバを経由して自分のSDP(Offer)をを相手に送信する
    // 同時に相手のSDP(Answer)をレスポンスとして受け取る
    final response =
        await _sendSDPToSignalingServer(sdp: offer.sdp!, ephemeralKey: key);
    if (response == null) {
      return;
    }
    final answer = RTCSessionDescription(response, 'answer');

    // 受け取った相手のSDPをPeerConnectionにセット
    await peerConnection.setRemoteDescription(answer);
    print('Set remote description: $answer.sdp');

    // ICEの送受信および経路選択は自動で行われるため、ここでの処理は以上
    // 自分のICEの作成は、setLocalDescriptionを呼んだ後に処理が行われる
    // 相手のICEの受信は、setRemoteDescriptionを呼んだ後に処理が行われる
  }

  Future<String?> _fetchEphemeralKey() async {
    final url = Uri.parse('https://api.openai.com/v1/realtime/sessions');
    const apiKey = 'your_openai_api_key_here';

    // これから行う会話のセッション情報を作成
    // https://platform.openai.com/docs/api-reference/realtime-sessions/create
    final body = jsonEncode({
      'model': 'gpt-4o-realtime-preview-2024-12-17',
      'voice': 'sage',
      'instructions': 'あなたは親切なアシスタントです。回答は日本語で行ってください。',
      'input_audio_transcription': {
        'model': 'whisper-1',
      },
    });

    // 通常のAPIキーを使ってリクエストを送信すると、Ephemeral Keyが返ってくる
    final response = await http.post(
      url,
      headers: {
        'Authorization': 'Bearer $apiKey',
        'Content-Type': 'application/json',
      },
      body: body,
    );

    // レスポンスからEphemeral Keyを取得
    if (response.statusCode == 200) {
      final jsonResponse = jsonDecode(response.body) as Map<String, dynamic>;
      final clientSecret = jsonResponse['client_secret']['value'] ?? '';
      if (clientSecret.isEmpty) {
        print('Ephemeral key not found in response');
        return null;
      }
      print('Ephemeral key fetched successfully: $clientSecret');
      return clientSecret as String;
    } else {
      print(
        'Network request failed, status code: ${response.statusCode}, response body: ${response.body}',
      );
      return null;
    }
  }

  // SDPをシグナリングサーバに送信する
  // https://platform.openai.com/docs/guides/realtime-webrtc#connection-details
  Future<String?> _sendSDPToSignalingServer({
    required String sdp,
    required String ephemeralKey,
  }) async {
    final url = Uri.parse('https://api.openai.com/v1/realtime');
    final client = HttpClient();
    final request = await client.postUrl(url);

    // OpenAIのシグナリングサーバにはEphemeral KeyをAuthorizationヘッダにセットする
    request.headers.set('Authorization', 'Bearer $ephemeralKey');
    request.headers.set('Content-Type', 'application/sdp');
    request.write(sdp);

    final response = await request.close();
    final decodedResponse = await response.transform(utf8.decoder).join();

    if (decodedResponse.isNotEmpty) {
      print('Received SDP(Answer) response: $decodedResponse');
      return decodedResponse;
    } else {
      print('Failed to receive SDP response');
      return null;
    }
  }
}

ここからは、各パートに分けてコードの内容を解説します。

1. クライアントシークレット(Ephemeral Key)の取得

Future<String?> _fetchEphemeralKey() async {
    final url = Uri.parse('https://api.openai.com/v1/realtime/sessions');
    const apiKey = 'your_openai_api_key_here';

    // これから行う会話のセッション情報を作成
    // https://platform.openai.com/docs/api-reference/realtime-sessions/create
    final body = jsonEncode({
      'model': 'gpt-4o-realtime-preview-2024-12-17',
      'voice': 'sage',
      'instructions': 'あなたは親切なアシスタントです。回答は日本語で行ってください。',
      'input_audio_transcription': {
        'model': 'whisper-1',
      },
    });

    // 通常のAPIキーを使ってリクエストを送信すると、Ephemeral Keyが返ってくる
    final response = await http.post(
      url,
      headers: {
        'Authorization': 'Bearer $apiKey',
        'Content-Type': 'application/json',
      },
      body: body,
    );
    
    -- 省略 --

いきなりWebRTCとは関係ない部分ですが、Realtime APIでOpenAIのシグナリングサーバを利用するには、Ephemeral Keyというものが必要となります。
https://platform.openai.com/docs/guides/realtime-webrtc#creating-an-ephemeral-token

シグナリングサーバと通信する際に通常のOpenAIのAPIキーをそのまま利用してしまうと、APIキーが盗まれる可能性が高まります(例えば、ブラウザ環境で通常のAPIキーをそのまま利用すると、開発者ツールなどで簡単にAPIキーを取得できてしまいます)。

そのため、まずはRealtime APIのセッション管理用のエンドポイント(https://api.openai.com/v1/realtime/sessions)にリクエストを送り、Ephemeral Keyを取得します。

また、リクエスト時にセッション情報(利用するモデル、音声、指示など)をbodyに含めておきます。
https://platform.openai.com/docs/api-reference/realtime-sessions/create

2. PeerConnectionの作成

// WebRTC用のPeerConnectionを作成
    peerConnection = await createPeerConnection({
      'iceServers': [
        // Googleが提供しているオープンなSTUNサーバを利用
        // https://dev.to/alakkadshaw/google-stun-server-list-21n4
        {'urls': 'stun:stun.l.google.com:19302'},
        // TURNサーバを追加する際は以下に追加(今回は省略)
        // {'urls': 'turn:your-turn-server'},
      ],
    });
    print('Created PeerConnection successfully: $peerConnection');

WebRTCの説明では登場しませんでしたが、PeerConnectionはFlutterのweb_rtcパッケージにおいて、通信セッションを管理するためのオブジェクトです。SDPやICEの管理から、通信経路の決定までも一元的に管理してくれます。

PeerConnectionはcreatePeerConnectionを呼び出すことで作成でき、この時点でSTUNサーバの設定を行います(必要であればTURNサーバも)。今回STUNサーバはGoogleが提供しているオープンなサーバ(stun:stun.l.google.com:19302)を利用しています。

3. メディア情報の作成&セット

// SDPに含めるメディア情報の取得(今回は音声のみ)
    localStream = await navigator.mediaDevices.getUserMedia({
      'audio': true,
      'video': false,
      'mandatory': {
        'googNoiseSuppression': true,
        'googEchoCancellation': true,
        'googAutoGainControl': true,
        'minSampleRate': 16000,
        'maxSampleRate': 48000,
        'minBitrate': 32000,
        'maxBitrate': 128000,
      },
      'optional': [
        {'googHighpassFilter': true},
      ],
    });

    // PeerConnectionにメディア情報を追加
    localStream.getTracks().forEach((track) {
      peerConnection.addTrack(track, localStream);
    });

この後で作成するSDPに含めるメディア情報をPeerConnectionに設定します。

メディア情報は、web_rtcパッケージのgetUserMediaで取得することができます。取得の際には音声や映像どれを含めるのか、ノイズ抑制やサンプリングレートなどの設定を行います。

その後、addTrackでPeerConnectionにメディア情報を設定します。

4. データチャネルの作成

// テキストなどのデータも取得するためデータチャネルを作成
    dataChannel = await peerConnection.createDataChannel(
      'oai-events',
      RTCDataChannelInit(),
    );

WebRTCは音声・映像だけでなく、テキストやバイナリデータも送り合うことができます。

**Realtime APIは音声ストリームだけでなく、イベント情報がテキストデータとして返却されます。**それを受け取るためには、データチャネルを作成しておく必要があります。
https://platform.openai.com/docs/api-reference/realtime-server-events

データチャネルはPeerConnectionのcreateDataChannelで作成することができ、第一引数にはデータチャネルの名前(識別子)、第二引数にデータチャネルの設定を与えます。データチャネルの名前は任意の名前で問題ありません。データチャネルの設定も特にこだわりが無ければ初期設定(RTCDataChannelInite())で問題ないです。

5. 各種コールバックの設定

    // ICE Candidateが生成された時のコールバック
    // STUNサーバからICE Candidateを受けとるたびに呼ばれる
    peerConnection.onIceCandidate = (RTCIceCandidate candidate) {
      print('Generated ICE Candidate: ${candidate.candidate}');
    };

    // ICEを通じて接続の状態が変化した時のコールバック
    peerConnection.onIceConnectionState = (RTCIceConnectionState state) {
      print('ICE connection state changed: $state');

      // ICEの手続きが完了したということはWebRTCの接続が確立されたと同義
      if (state == RTCIceConnectionState.RTCIceConnectionStateCompleted) {
        print('WebRTC connection established successfully!');
      }
    };

    // データチャネルでメッセージ(バイナリ、文字列)を受け取った時のコールバック
    // Realtime APIからはバイナリデータは送信されないため、文字列のみを受け取る
    dataChannel.onMessage = (RTCDataChannelMessage message) {
      print('Received text message: ${message.text}');
    };

    // 音声ストリームを受け取った時のコールバック
    peerConnection.onAddStream = (MediaStream stream) {
      final audioTracks = stream.getAudioTracks();
      if (audioTracks.isNotEmpty) {
        // スピーカーをオンにして読み上げ
        Helper.setSpeakerphoneOn(true);
      }
    };

「データを受け取った時」「状態が変わった時」などに呼ばれるコールバックを設定します。

onIceCandidate
STUNサーバからICE Candidateを受け取るたびに呼ばれます。受け取ったICE Candidateは自動的に相手に送信されます。

onIceConnectionState
ICEを通じて接続の状態が変化した時に呼ばれます。具体的には、相手と接続を試みている状態(checking)、通信経路が見つかった状態(connected)、接続が確立された状態(completed)などのような状態ががあります。

onMessage
データチャ熱を通してメッセージ(バイナリ、文字列)を受け取った時に呼ばれます。Realtime APIからはバイナリデータは送信されてこないので、文字列データのみ表示するようにしています。

onAddStream
音声や映像ストリームを受け取った時に呼ばれます。Realtime APIからは動画ストリームは送信されてこないので、音声データのみ処理して、受け取った時のスピーカをオンにして読み上げるようにしています。

その他にも、PeerConnectionにはその他に様々なコールバックが用意されていますので、必要に応じて設定してください。

6. シグナリングサーバへの送信ロジック

  // SDPをシグナリングサーバに送信する
  // https://platform.openai.com/docs/guides/realtime-webrtc#connection-details
  Future<String?> _sendSDPToSignalingServer({
    required String sdp,
    required String ephemeralKey,
  }) async {
    final url = Uri.parse('https://api.openai.com/v1/realtime');
    final client = HttpClient();
    final request = await client.postUrl(url);

    // OpenAIのシグナリングサーバにはEphemeral KeyをAuthorizationヘッダにセットする
    request.headers.set('Authorization', 'Bearer $ephemeralKey');
    request.headers.set('Content-Type', 'application/sdp');
    request.write(sdp);

    final response = await request.close();
    final decodedResponse = await response.transform(utf8.decoder).join();

    if (decodedResponse.isNotEmpty) {
      print('Received SDP(Answer) response: $decodedResponse');
      return decodedResponse;
    } else {
      print('Failed to receive SDP response');
      return null;
    }
  }

先に_sendSDPToSignalingServerメソッドの説明をします。こちらのメソッドでは、名前の通りシグナリングサーバにSDPを送信します。

前述しましたが、シグナリングサーバはRealtime APIのAPIエンドポイント(https://api.openai.com/v1/realtime)が役割を担います。 また、シグナリングサーバにリクエストを送る際はEphemeral Keyを含める必要があります。

bodyにはSDP(Offer)を直接書き込み送信することで相手側のSDP(Answer)をレスポンスとして取得することができます。

7. SDPの作成・受け渡し・セット

    // 自分のSDPを作成し、PeerConnectionセット
    final offer = await peerConnection.createOffer();
    if (offer.sdp == null) {
      print('Failed to create offer');
      return;
    }
    await peerConnection.setLocalDescription(offer);
    print('Set local description: ${offer.sdp}');

    // シグナリングサーバを経由して自分のSDP(Offer)をを相手に送信する
    // 同時に相手のSDP(Answer)をレスポンスとして受け取る
    final response =
        await _sendSDPToSignalingServer(sdp: offer.sdp!, ephemeralKey: key);
    if (response == null) {
      return;
    }
    final answer = RTCSessionDescription(response, 'answer');

    // 受け取った相手のSDPをPeerConnectionにセット
    await peerConnection.setRemoteDescription(answer);
    print('Set remote description: $answer.sdp');

自分のSDPの作成して相手に送信、さらに相手のSDPを受け取ってPeerConnectionに設定します。

メディア情報を設定したPeerConnectionから、createOfferを呼び出すことで自分のSDP(Offer)を作成することができます。また、setLocalDescriptionで自分のSDPをPeerConnectionにセットします。

次に自分のSDP(Offer)を相手に送信し、相手のSDP(Answer)を受け取ります。同様に、受け取ったSDP(Answer)はsetRemoteDescriptionでPeerConnectionにセットします。

これらの手続きが完了すると、PeerConnectionは最初に設定したSTUNサーバからICE Candidateの取得を開始し、相手と交換、通信経路の確立までを自動的に行います。(5で設定したICEのコールバックが呼ばれるので、ある程度動きが見えると思います。)

うまく通信経路が確立すれば、WebRTC通信の準備が完了です🎉

UI側のコード

UI側のコードは最低限のものだけ用意しました。

import 'package:flutter/material.dart';
import 'package:flutter_webrtc_voice_chat/web_rtc_voice_chat_service.dart';

class VoiceChatPage extends StatefulWidget {
  const VoiceChatPage({super.key});

  
  State<VoiceChatPage> createState() => _VoiceChatPageState();
}

class _VoiceChatPageState extends State<VoiceChatPage> {
  late WebRTCVoiceChatService _voiceChatService;

  
  void initState() {
    super.initState();
    _voiceChatService = WebRTCVoiceChatService();
  }

  Future<void> _startVoiceChat() async {
    await _voiceChatService.connect();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Voice Chat Test'),
      ),
      body: Center(
        child: ElevatedButton(
          onPressed: _startVoiceChat,
          child: const Text('Connect to Realtime'),
        ),
      ),
    );
  }
}

動作確認

Realtime APIはVAD(voice activity detection)に対応しているので、AIが発話中のユーザの音声入力を検知して割り込むことができます。

https://youtu.be/wdaSE0oh_Bo

Discussion