Realtime APIを使ってFlutterでリアルタイム音声会話AI機能を作ってみる(WebRTC接続)
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
マイクのアクセス許可
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というものが必要となります。
シグナリングサーバと通信する際に通常のOpenAIのAPIキーをそのまま利用してしまうと、APIキーが盗まれる可能性が高まります(例えば、ブラウザ環境で通常のAPIキーをそのまま利用すると、開発者ツールなどで簡単にAPIキーを取得できてしまいます)。
そのため、まずはRealtime APIのセッション管理用のエンドポイント(https://api.openai.com/v1/realtime/sessions
)にリクエストを送り、Ephemeral Keyを取得します。
また、リクエスト時にセッション情報(利用するモデル、音声、指示など)をbodyに含めておきます。
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は音声ストリームだけでなく、イベント情報がテキストデータとして返却されます。**それを受け取るためには、データチャネルを作成しておく必要があります。
データチャネルは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が発話中のユーザの音声入力を検知して割り込むことができます。
Discussion