🦉

RealtimeAPIをPythonで書き直してみた(WebRTCや仕様を理解)

2025/02/24に公開

openAIの公式にあるこちらのサンプル(以下サンプルアプリ)
https://github.com/openai/openai-realtime-console

これのバックエンドをpythonに書き直しました。
またフロントエンドもdaisyUIを用いる等で、シンプルに書き直しています。
https://github.com/manusa3190/realtimeapi_test

各関数や型について、書き直す中で理解したのでメモしておきます。

構成の書き直し

サンプルアプリでは、サーバーサイドをミドルウェアとして構成しています。
(このおかげで、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:音声のみを受け取るか、テキストのみを受け取るか

その他の設定項目については以下参照
https://platform.openai.com/docs/api-reference/realtime-sessions/create

WebRTC接続のセットアップ

# WebRTC接続のセットアップ
@app.post("/session/start")

この部分で接続をセットアップしています。

やっていることは、以下の4つです

  1. シグナリング
  2. 接続経路の確保
  3. P2P接続の確立
  4. データ送受信

中身についてはコードにコメントを入れている通りです。

アプリを作る際にこの部分を触ることはほとんどないと思いますので、詳しい説明は割愛します

フロント側

重要な処理は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され、データが返されます。

どういったイベントがあるかや、そのイベントどのようなデータが渡されるかについては、以下の公式ドキュメントを参照
https://learn.microsoft.com/ja-jp/azure/ai-services/openai/realtime-audio-reference

今回のアプリでは、受け取ったイベント及びデータはcomponents/EventLog.tsxで表示させるようにしています。
(daisyuiのaccordionを使うことで、公式のサンプルアプリよりシンプルに記述することができました)

上記で説明した通り、今回のアプリのように「AIの応答を表示する」と「ユーザーの話した内容をテキストで表示する」だけならresponse.doneconversation.item.input_audio_transcription.completedだけで事足ります。

Discussion