RealtimeAPIをPythonで書き直してみた(WebRTCや仕様を理解)
openAIの公式にあるこちらのサンプル(以下サンプルアプリ)
これのバックエンドをpythonに書き直しました。
またフロントエンドもdaisyUIを用いる等で、シンプルに書き直しています。
各関数や型について、書き直す中で理解したのでメモしておきます。
構成の書き直し
サンプルアプリでは、サーバーサイドをミドルウェアとして構成しています。
(このおかげで、npm run dev
のコマンドを打つだけで、フロントもサーバーも立ち上がります)
サーバーをpythonで書く場合、無理にミドルウェアにして統合するより、フロントとサーバーを別々に立ち上げたほうが良いです。
今回サーバーはpoetryで環境を作り、fastAPIで組みました
WebRTC
今回キモとなるのがWebRTCです。
フロントで音声を受け取って、音声データをサーバーに送るという目的ならwebsocketでも実現できるのですが、音声コミュニケーションで一番大事なリアルタイム性においてWebRTCのほうが遅延を抑えられるというメリットがあります。
WebRTCはwebsocketに比べて実装の手間はかかりますが、pythonでWebRTCを扱うライブラリaiortcを使えば比較的簡単に実装できます。
用語や概念を理解するのに少し時間は必要でしたが、動かすだけなら以下のコードを見ればとりあえず動くものは作れるでしょう。
サーバーサイド
get_tokenで設定を渡す
この部分のjsonで、モデルを何にするかとか、応答音声を誰にするかなどの設定を投げます。
async with httpx.AsyncClient() as client:
response = await client.post(
"https://api.openai.com/v1/realtime/sessions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json={
"model": model,
"voice": voice,
"instructions": instructions if instructions else "",
"input_audio_transcription": {
"model": "whisper-1"
},
"modalities": ["text","audio"],
}
)
ちなみにリクエストがPOSTだと実行できませんでした。
パラメーターについては以下のとおり
- model(必須):2025年2月現在、gpt-4o-realtime-preview-2024-12-17のみです。
- voice(必須):"alloy", "echo", "fable", "onyx", "nova", "shimmer","verse"
- instructions:"あなたは面白い芸人です。関西弁で返してください"みたいなプロンプトを入れます
- input_audio_transcription:ユーザーがしゃべった音声を文字起こしするには、
{"model": "whisper-1","language":"ja"}
等を設定する必要があります。
これを設定しないとconversation.item.input_audio_transcription.completed
イベントが発火しません - modalities:音声のみを受け取るか、テキストのみを受け取るか
その他の設定項目については以下参照
WebRTC接続のセットアップ
# WebRTC接続のセットアップ
@app.post("/session/start")
この部分で接続をセットアップしています。
やっていることは、以下の4つです
- シグナリング
- 接続経路の確保
- P2P接続の確立
- データ送受信
中身についてはコードにコメントを入れている通りです。
アプリを作る際にこの部分を触ることはほとんどないと思いますので、詳しい説明は割愛します
フロント側
重要な処理はcomponents/App.tsxに書いてあります
セッションの開始(startSession())
WebRTCにおいてキモの部分はほとんどここにあると考えて良いでしょう。
この部分でWebRTCの接続を確立しています。
大事な部分は、
setDataChannel(dc); // この時、イベントリスナーがアタッチされる(下のuseEffect参照)
の部分だと思います
WebRTCを理解するまでは、接続を確立するプロセスと、(しゃべっている最中に)データをやりとりする部分は別々だろう、と考えていました。
WebRTCでは、接続を確立した時点で「データを送信した時に何をするか、受信した時に何をするか」というイベントとアクションも一緒に設定します。
今回のアプリでは、音声データを送った時やrealtimeAPIから音声やデータが返された時のアクションを以下の部分で設定しています。
// データチャンネルが作成されたときにイベントリスナーをアタッチ
useEffect(() => {
if (dataChannel) {
// 新しいサーバーイベントをリストに追加
dataChannel.addEventListener("message", (e) => {
setEvents((prev: any[]) => [{...JSON.parse(e.data), timestamp: new Date()}, ...prev]);
});
// データチャンネルが開いたときにセッションをアクティブにする
dataChannel.addEventListener("open", () => {
setIsSessionActive(true);
setEvents([]);
});
}
}, [dataChannel]);
AIの応答を受け取る部分
startSession()
の中の以下の部分がAIの応答を受け取っている部分になります。
'langchain-core/messages'ライブラリのAIMessageクラスを設けるのは必須ではありませんが、型がわかりやすくなるので私は積極的に導入しています。
if (data.type === "response.done") {
const {event_id, response, type } = data;
if (response.output) {
for (const item of response.output) {
const { content, id, object, role, status,type} = item;
for (const contentItem of content) {
if (contentItem.type === "audio") {
// realtime apiの設定で、"modalities": ["audio"]とした場合は、contentItem.transcriptが返ってくる
const aiMessage = new AIMessage(contentItem.transcript);
setMessages((prev: BaseMessage[]) => [...prev, aiMessage])
} else if (contentItem.type === "text") {
// realtime apiの設定で、"modalities": ["text"]とした場合は、contentItem.textが返ってくる
console.log("text", contentItem.text);
const aiMessage = new AIMessage(contentItem.text);
setMessages((prev: BaseMessage[]) => [...prev, aiMessage])
}
}
}
}
ユーザーの発話を記録する部分
同じくstartSession()
の中で、AIの応答を受け取る部分の下に書いてある以下の部分が、ユーザーの発話を記録する部分になります。
else if (data.type === "conversation.item.input_audio_transcription.completed") {
const humanMessage = new HumanMessage(data.transcript);
setMessages((prev: BaseMessage[]) => [...prev, humanMessage])
}
繰り返しますが、conversation.item.input_audio_transcription.completed
イベントを受け取るためには、サーバーサイドのget_tokenで設定を渡す部分でinput_audio_transcription
を設定しておく必要があります。これを設定しないとconversation.item.input_audio_transcription.completed
イベント自体が返ってこないので注意
Session.updateで返されるデータ
「ユーザーが話し始めた」とか「AIが返答を返している」といったイベントごとに、セッションがupdateされ、データが返されます。
どういったイベントがあるかや、そのイベントどのようなデータが渡されるかについては、以下の公式ドキュメントを参照
今回のアプリでは、受け取ったイベント及びデータはcomponents/EventLog.tsxで表示させるようにしています。
(daisyuiのaccordionを使うことで、公式のサンプルアプリよりシンプルに記述することができました)
上記で説明した通り、今回のアプリのように「AIの応答を表示する」と「ユーザーの話した内容をテキストで表示する」だけならresponse.done
とconversation.item.input_audio_transcription.completed
だけで事足ります。
Discussion