OpenAI Realtime APIのWebRTCでロボットを操作する

| 17 min read
Author: masayuki-kono masayuki-konoの画像

OpenAIのRealtime APIがWebRTCに対応し、2024年12月18日より価格も大幅に引き下げられました。この機会に、音声による自律走行ロボットの操作機能を開発しましたので、Realtime APIの組み込み方法についてご紹介いたします。

Realtime APIのWebRTC対応については以下の記事でもご紹介していますので併せて参照下さい。

システム構成

#

本ロボットは産業用の清掃ロボットです。前方に回転ブラシを搭載しており、走行しながら床の塵やゴミを掻き出す機能を備えています。

ロボットにはPico-ITX仕様の小型のSBCが搭載されており、このSBC上でロボットを制御するアプリケーション(図中のCleanRobotController)が動作します。SBC周辺のシステム構成を簡略化したものを以下に示します。

システム構成

SBCには制御アプリケーション以外にWebサーバーも動作し、遠隔操作用のWebUI(図中のRemoteOperationUI)を提供します。このWebUIを操作することで、自律走行の開始や停止、手動操作が行えます。

今回はこのRemoteOperationUIに音声操作機能を組み込みました。

アプリケーション構成

#

制御アプリケーションは複数のROS2ノードで構成され、これらのノードがROS2の通信(トピック、サービス、アクション)を介して連携し、各機能を実現します。

以下の図は主要ノードの構成イメージです。各種ハードウェアの通信ドライバのほか、robot_navigatorというノードが自律走行などの制御を統括します。
RemoteOperationUIはrosbridge_websocketノードを中継してroslibjsでROS2の各ノードと通信するWebアプリケーションであり、ROS2には直接依存していません。

実際には他にも多くのトピックやノードが存在し、またノード間の通信は多対多の形で行われます。

ノード構成

RemoteOperationUIは各ROS2ノードが提供するトピックやサービスにアクセス可能であり、Realtime APIからこれらのトピックやサービスを利用できるようにしました。

デモ動画

#

以下は音声入力でロボットを操作した動画です(再生すると音声が出ます。周囲の環境にご注意ください)。

動画では音声入力をトリガに以下のような機能を呼び出しています。

  • 各ノードからサブスクライブしたトピックのメッセージの値を確認する
    • バッテリーの電圧値
    • I/O
    • 運転状態
  • トピックのパブリッシュやサービスを呼び出す
    • 清掃の初期位置へ移動する
    • 清掃を開始する

コード上はトピックのメッセージの値を文字列化してLLMへ入力しているのみですが、適切な言い回しで回答してくれています。
動画には含まれていませんが、例えば、カメラLEDのI/Oの値を教えてのようにメッセージに含まれる一部の情報を回答させることも可能でした。

Information

バッテリーの交換が必要な電圧値を指示しておき、電圧の計測値と減少率をLLMへ入力した場合に、「あと何分でバッテリーを交換すれば良い?」を質問してみましたが、期待通りの回答は得られませんでした。単純な四則演算でも、現時点のモデル(gpt-4o-realtime-preview-2024-12-17gpt-4o-mini-realtime-preview-2024-12-17)では正確な計算を委ねるのは難しいようです。今後のモデルの進化に期待したいところです。

走行機能には旋回方向に関するオプションがあり、これも音声入力で指示できます。また、どのような選択肢があるかをLLMに質問することも可能です。

以降でRealtime APIをどのようにアプリケーションに組み込んだかを解説します。

WebRTCセッションの確立

#

WebRTCセッションの確立は以下の2ステップで行います。

  1. OpenAI APIキーを使用してhttps://api.openai.com/v1/realtime/sessionsへPOSTし、WebRTCのSFUサーバーから一時認証キーを取得
  2. 取得した一時認証キーを使用してhttps://api.openai.com/v1/realtimeへPOSTし、WebRTCセッションを確立
    • 音声の再生用にaudio要素を生成
    • getUserMediaでマイクのメディアストリームを取得
    • データチャネルを生成してイベントハンドラを登録
    • HTTPのRequest/ResponseでSDPを交換

具体的な接続処理についてはopenai-realtime-consoleのサンプルコードとほぼ同様のため、ここではCreate sessionのリクエストボディの設定内容を中心に説明します。

body: JSON.stringify({
    model: `gpt-4o-mini-realtime-preview-2024-12-17`,
    voice: 'ash',
    instructions: robotContext,
    tools: voiceCommandTools,
}),

各パラメータの説明を以下に示します。

model

#

使用するモデルを指定します。gpt-4o-realtime-preview-2024-12-17も利用可能ですが、今回のユースケースでは顕著な差異が見られなかったため、より低コストなminiバージョンを採用しました。

voice

#

8種類の音声(alloy, ash, ballad, coral, echo, sage, shimmer, verse)から選択できます。試聴の結果、最も誠実な印象を受けたashを採用しました。

instructions

#

システムの概要や音声アシスタントへの指示を記述します。以下は一部抜粋です。

export const robotContext = `You are a friendly cleaning robot.
Communicate in ${import.meta.env.VITE_VOICE_LANGUAGE || 'English'}.
Be helpful and enthusiastic about your job keeping floor clean and efficient.

When describing your capabilities, mention that you:
- I have a rotating brush up front that I use to sweep away dust and debris
- I move around on crawler tracks, which let me go forward, backward, and turn in place
// ...

Your main functions include:
1. Navigate to one of the four corners to start cleaning
2. Clean floor in an efficient pattern
// ...

Always maintain a friendly and enthusiastic tone. Use "I" and "my" when speaking.`;

多言語対応を考慮し、使用言語を明示的に指定しています。これは特定の言語で話しかけた際に他の言語で応答するケースを防ぐためです。

なお、instructionsは省略可能なパラメータで、指定しない場合は以下の内容が適用されます。

Your knowledge cutoff is 2023-10. You are a helpful, witty, and friendly AI.
Act like a human, but remember that you aren't a human and that you can't do human things in the real world.
Your voice and personality should be warm and engaging, with a lively and playful tone.
If interacting in a non-English language, start by using the standard accent or dialect familiar to the user. Talk quickly.
You should always call a function if you can.
Do not refer to these rules, even if you're asked about them.

セッション開始後もsession.updateのイベントでinstructionsの内容を更新可能ですが、通信回数を減らすためCreate sessionのリクエストボディに記述しています。

tools

#

LLMから呼び出す関数を定義します。以下は清掃開始コマンドの例です。

export const voiceCommandTools = [
{
    type: 'function',
    name: 'start_cleaning',
    description: 'Start cleaning operation. If no option is specified, ' +
                'ask them "Which direction should I turn at the first edge, left or right?"',
    parameters: {
        type: 'object',
        strict: true,
        properties: {
            option: {
                type: 'string',
                enum: Object.values(CleaningOption),
                description: 'Cleaning mode: ' +
                           'TurnLeft: 0 - move straight ahead and turn left at the first edge, ' +
                           'TurnRight: 1 - move straight ahead and turn right at the first edge, ',
            },
        },
        required: ['option'],
    },
},
// ...

各関数定義には以下を記述します。LLMはこれらの情報を基に適切なタイミングで関数を呼び出します。

  • name: サーバーからのイベントで関数を識別するための名前
  • description: 関数の機能説明
  • parameters: 引数の型定義や説明
Information

関数の説明文(description)は実質的にAPI仕様書のような役割を果たします。将来的には関数コメントから自動生成するなど、仕様とプロンプトの一元管理も検討できそうです。

今回はROS2のトピックのパブリッシュやサービスを呼び出す関数を登録しました。システムの状態(IOやバッテリーの電圧値など)を取得する関数は登録していません。 サブスクライブしたこれらのトピックのコールバック毎に最新の状態をLLMへ入力しています(入力の方法については後述します)。ユーザからシステムの状態を問われたらLLMが事前に入力された状態に基づいて回答します。

LLMから関数が呼び出されるまでの流れ

#

セッション確立後、音声はメディアストリーム、イベント(JSONデータ)はデータチャネルを介してクライアントとサーバー間でやりとりされます。

以下は、ユーザーが「清掃を開始して、右旋回で」と発話してから、清掃開始に失敗したことが音声出力されるまでの一連の流れです。実際にはより多くのイベントが発生しますが、主要なものだけを示しています。

LLMからの関数呼び出し(応答音声あり)

  1. クライアントがユーザーの音声データをサーバーへ送信
  2. サーバーが音声を解析し、response.doneイベントを送信
    • このイベントには清掃開始関数の呼び出し情報が含まれる
  3. クライアントがresponse.doneの関数情報に基づいて清掃開始関数を実行
  4. クライアントが関数の実行結果をconversation.item.createイベントでサーバーへ送信
    • この例では清掃開始の失敗を通知
  5. クライアントがresponse.createイベントをサーバへ送信
  6. サーバーが応答を生成し、response.doneイベントを送信
    • 音声出力用のテキストを含む
  7. サーバーが生成した音声データをクライアントへ送信
  8. クライアントが受信した音声データをスピーカーから出力
    • 清掃開始の失敗理由と対処方法が説明される

このフローの特徴は、会話アイテムが2つの方法で生成される点です。

  • ユーザーの音声入力
  • クライアントコードからのconversation.item.createイベント

どちらの場合も、最終的にサーバーからのresponse.doneイベントとして応答が返されます。

Information

関数の呼び出しが失敗した場合、クライアントから通知した失敗理由がLLMによって解釈され、音声で出力されるだけでなく、状況に応じて別の関数呼び出しが自動的に行われることもあります。

例えば、セッション生成時にtoolsで定義した各関数に対して、

  • 実行のための事前条件
  • 事前条件を満たすために必要な関数の呼び出し順序

をinstructionsに記述しておくことで、LLMが自動的に、

  1. ユーザの指示を解釈
  2. 必要な事前条件を確認
  3. 条件を満たすために必要な関数を順次呼び出し

という一連の制御を行うことが可能になるかもしれません。これにより、ユーザは細かい実行手順を意識することなく、必要最低限の指示でロボットを操作できるようになります。

サーバーからのイベント受信

#

会話中にサーバーから様々なイベントが通知されますが、今回は以下のtypeのイベントをハンドリングしました。

セッションの確立後に通知されるイベントです。UIでセッションの確立状態を表示するために使用しています。

レスポンスのストリーミングが完了したときに通知されます。生のオーディオデータを除くすべての出力項目が含まれます。

セッションの生成時にtoolsで記述した関数の識別子がこのイベントに付帯されるため、識別子が付帯されていたら対応する関数を呼び出します。

清掃を開始して、右旋回でという音声入力を開始してからresponse.doneイベントまでに通知されるイベントのtypeを時系列に並べてみました。それぞれの説明の詳細はAPIのリファレンスマニュアルを参照して下さい。

  1. input_audio_buffer.speech_started
    • 音声入力が開始されたことの通知です
    • ユーザがしゃべり始めたタイミングでUIに何かを表示したり、ロボットを動作させたりする場合のトリガとして使えます
  2. input_audio_buffer.speech_stopped
    • 音声入力が終了したことの通知です
  3. input_audio_buffer.committed
    • 入力した音声バッファがコミットされたことの通知です
  4. conversation.item.created
    • 入力した音声が会話に追加されたことの通知です
  5. response.created
    • レスポンスの生成が開始されたことの通知です
  6. rate_limits.updated
    • レスポンスの生成毎に出力トークン数が増加するため、更新後のレートリミットが通知されます
    • この時点では通知された内容は予約値であり、最終的にはresponse.doneのタイミングで確定するようです
  7. response.output_item.added
    • レスポンスの生成中に新しい会話アイテムが生成されたことの通知です
    • ここでは清掃を開始して・・・を受けて関数呼び出しが発生するため、これが新たな会話アイテムとして追加されています
  8. conversation.item.created
    • 新しい会話アイテムが生成されたことの通知です
    • このイベントにも関数の識別子は付帯されていますが、関数の引数は含まれません
  9. response.function_call_arguments.delta
  10. response.function_call_arguments.done
    • このイベントから関数の引数が含まれます
    • これをトリガに関数を呼び出すことは可能ですが、APIリファレンスによると以下の記述があり、その場合の本イベントの扱いが不明のため、最後に通知されるresponse.doneのイベントを使用しています
      • Also emitted when a Response is interrupted, incomplete, or cancelled.
  11. response.output_item.done
  12. response.done

response.doneの内容の例を以下へ示します。

{
  "type": "response.done",
  "event_id": "event_AicGD5PyKifYkTtIGKL12",
  "response": {
    "object": "realtime.response",
    "id": "resp_AicGChEXNeESepESoW0yh",
    "status": "completed",
    "status_details": null,
    "output": [
      {
        "id": "item_AicGCctid4UaN8AS0r7q1",
        "object": "realtime.item",
        "type": "function_call",
        "status": "completed",
        "name": "start_cleaning",                 // 関数の識別子
        "call_id": "call_BaRhg5LjLJ2HnmAo",       // サーバー側で発番したfunction_callの識別子
        "arguments": "{\"option\":\"TurnRight\"}" // 関数の引数
      }
    ],
    "usage": {
      "total_tokens": 1485,
      "input_tokens": 1468,
      "output_tokens": 17,
      "input_token_details": {
        "text_tokens": 1040,
        "audio_tokens": 428,
        "cached_tokens": 1408,
        "cached_tokens_details": {
          "text_tokens": 1024,
          "audio_tokens": 384
        }
      },
      "output_token_details": {
        "text_tokens": 17,
        "audio_tokens": 0
      }
    },
    "metadata": null
  }
}

response.outputの配列にtypeがfunction_callの要素が存在したら、以下のフィールドを参照して対応する関数を呼び出します。

  • name
    • セッションの生成時に指定したtoolsのname(関数の識別子)に対応します
  • arguments
    • セッションの生成時に指定したtoolsのparameters.propertiesで定義した引数に対応します

call_idはサーバー側で発番したfunction_callの識別子です。関数呼び出しの結果を返してさらに応答させる場合に使用します(後述します)。

前述した通り、レスポンスの生成時に通知される更新後のレートリミットです。

レスポンスの生成毎にRPD(Request per day)のカウントが1つ増加しますが、現時点ではRealtime APIのRPDは100と少ないため、すぐにリミットに達してしまいます。14m24s(100 Requests / 24h)経過すると新たに1つリクエストが可能となりますが、使用時は何度か会話するため、基本的にはリミットに達したら翌日まで待つ、といった運用となります。

デモ中にリミットまでの残数がいくつあるかを確認できることは重要なので、rate_limits.updatedのイベントに含まれるRPDのカウントをUIで表示するようにしています。

rate_limits.updatedの内容の例を以下へ示します。

{
  "type": "rate_limits.updated",
  "event_id": "event_AicGDRYkh88SGw1PRybuE",
  "rate_limits": [
    {
      "name": "requests",
      "limit": 100,
      "remaining": 40, // RPDの残数
      "reset_seconds": 51030.103
    },
    {
      "name": "tokens",
      "limit": 20000,
      "remaining": 14080,
      "reset_seconds": 17.76
    }
  ]
}

APIリファレンスのSession lifecycle eventsに記載されている通り、WebRTCのセッションが確立して30分経過するとセッションが強制的に切断されます。

The maximum duration of a Realtime session is 30 minutes.

セッションが切断されると、以下のエラーが通知されるため、これをトリガにセッションを自動で再開するようにしています。

{
  "type": "error",
  "event_id": "event_AhbxcTqlUCcm4XLtyZOrl",
  "error": {
    "type": "invalid_request_error",
    "code": "session_expired",
    "message": "Your session hit the maximum duration of 30 minutes.",
    "param": null,
    "event_id": null
  }
}
Information

Realtime APIのセッションはステートフルですが、セッションを再生成するとそれまでの会話の履歴は失われます。
今回のアプリケーションではロボットへの指示が主な用途で会話の履歴がそれほど重要ではないため、特に対処はしていません。
会話の文脈を引き継ぎたい場合は、会話の履歴(テキストデータ)をクライアント側で保持しておき、セッションの生成時にconversation.item.createでLLMへ再入力する必要がありそうです。

サーバーへのイベント送信

#

ユーザからの音声入力はメディアストリームを介してサーバーへ送信されますが、以下のtypeのイベントをクライアントコードからサーバーへ送信するケースがあります。

会話へ新しいアイテムを追加するイベントです。
LLMとの会話はユーザとの音声の入出力のみで構成される訳ではなく、本イベントでシステムからテキスト形式で会話のアイテムを追加できます。
用途としては以下の通りです。

  • response.doneイベントで指定された関数呼び出し(function_call)の結果応答
    • 関数呼び出しが失敗した場合にはその理由や対処方法を入力する
  • システムからの能動的な状態通知
    • サブスクライブしたトピック(IOやバッテリーの電圧値、運転状態など)のコールバック毎に最新の状態を入力する

関数呼び出しの結果応答の例を以下へ示します。call_idはresponse.doneイベントに含まれるfunction_callのcall_idと同値を設定します。

{
  "event_id": "client_7528f99a-9367-4df1-8039-f727949a2863",
  "type": "conversation.item.create",
  "item": {
    "type": "function_call_output",
    "call_id": "call_BaRhg5LjLJ2HnmAo", // サーバーからresponse.doneで通知されたfunction_callのcall_idを設定する
    "output": "The command has failed. \"I failed to start cleaning. Please make sure the vacuum pads are raised. If the vacuum pads are down, please use the 'release vacuum' command first.\""
  }
}

関数呼び出しの結果応答ではなく、システムからの状態通知である場合は、itemのtypeはmessageとします。

{
  "event_id": "client_45bdca38-42d3-421e-8ef1-15edc1be637c",
  "type": "conversation.item.create",
  "item": {
    "type": "message",
    "role": "system",
    "content": [
      {
        "type": "input_text",
        "text": "Battery status: Left: 17.7V (6 minutes until charge threshold), Right: 17.7V (6 minutes until charge threshold) (Low battery threshold: 11.0V, Charge recommended threshold: 14.0V)"
      }
    ]
  }
}

conversation.item.createで会話へアイテムを追加した後に、応答の音声を生成させる場合に使用します(会話にアイテムを追加しただけでは、音声は生成されません)。

以下の例はfunction_call_outputの応答の音声を生成するときのイベントの内容です。response.instructionsで応答の対象を明示していますが、指定しなくても直前のconversation.item.createの内容に対する応答の音声が生成されるため、省略は可能です。

{
  "event_id": "client_323e1463-3261-42c7-b274-461c16d0abc5",
  "type": "response.create",
  "response": {
    "instructions": "Respond to the function call output."
  }
}

システムからの状態通知では基本的には応答の音声は生成せず、異常発生時にのみ状態通知後に応答の音声を生成するようにしています。
この場合、注意喚起として強めな口調で話しかけるようにresponse.instructionsへ指定しています。

{
  "event_id": "client_7496b5ec-9e65-46c2-9918-1c4278673982",
  "type": "response.create",
  "response": {
    "instructions": "CRITICAL WARNING! Respond with maximum urgency and severity. Use a stern, authoritative tone that emphasizes the immediate danger or critical nature of this situation. Strongly emphasize the need for immediate action and potential consequences if not addressed. This warning must be treated as a top priority for user safety and system integrity."
  }
}

直前にconversation.item.createで入力した状態通知の内容に対して応答の音声を生成する指示となります。

以下はバッテリー低下時に生成された応答の音声です(再生すると音声が出ます。周囲の環境にご注意ください)。

instructionsの指示が強すぎて、緊急地震速報みたいな感じになってしまいました(😅)。

各種シーケンス

#

最後に、各種シーケンスをまとめてみました。

システムからの状態通知(応答音声なし)

#

システムからの状態通知(音声応答なし)

  • クライアントコードでCleanRobotControllerを構成する各ROS2ノードがパブリッシュするトピックをサブスクライブ
  • サブスクライブしたトピックのコールバック毎にconversation.item.createイベントをサーバーへ送信

システムの状態の変化毎にLLMへ入力してゆきますが、ユーザへの音声応答は生成しません。ユーザから音声で状態取得の指示があった場合は、LLMが事前に入力されたシステムの状態に基づいて回答します。

Information

センサやモータ状態のトピックは100msec程度の比較的短い周期でパブリッシュされることが一般的です。このような周期的にパブリッシュされるトピックをLLMへ入力する場合はトークン数が多くならないように注意して下さい。
今回ご紹介したバッテリー状態も周期的にパブリッシュされるトピックなので、一定値以上だけ変化した場合にのみLLMへ入力するよう制限しています。

システムからの状態通知(応答音声あり)

#

システムからの状態通知(音声応答あり)

システムからの状態通知(応答音声なし)のシーケンスの後に、response.createイベントを送信して応答の音声を生成しています。
conversation.item.createイベントで送信した内容に基づいて応答の音声が生成されます。

ユーザからの状態取得指示

#

ユーザからの状態取得指示

システムからの状態通知(音声応答なし)で事前に入力されたシステムの状態に基づいて、LLMが音声応答を生成します。

Information

最新のシステムの状態を取得する関数をtoolsに登録する方法も考えられますが、以下の理由から状態変化毎にLLMへ入力する方式を採用しています。

  • 各状態の取得関数を個別に定義する実装コストを削減できる
  • 過去の状態変化の履歴を踏まえた、より文脈に即した応答が期待できる

LLMからの関数呼び出し(応答音声なし)

#

関数呼び出しで関数の実行に成功した場合のシーケンスです。

LLMからの関数呼び出し(応答音声なし)

クライアントコードはresponse.doneイベントのfunction_callに対応する関数を実行します。関数の実行に成功した旨をconversation.item.createイベントでサーバーへ送信します。

LLMからの関数呼び出し(応答音声あり)

#

関数呼び出しで関数の実行に失敗した場合のシーケンスです。

LLMからの関数呼び出し(応答音声あり)

クライアントコードはresponse.doneイベントのfunction_callに対応する関数を実行します。関数の実行に失敗した旨をconversation.item.createイベントでサーバーへ送信し、response.createイベントで応答の音声を生成します。

まとめ

#

開発を通して

#

音声操作機能の開発を通じて、以下のようなシンプルな設計がロボットシステムにおいても現実的になってきたことを実感しました。

  • システムの状態を逐次LLMへ入力
  • システムが提供するAPIをLLMへツールとして登録
  • ユーザーとの対話や情報提供、システム機能の呼び出しはAIアシスタントに委ねる

特にROS2のようなアーキテクチャではサービスが分散化されており、システムの状態取得や操作が細かな粒度で行えるため、LLMとの統合がしやすいように考えています。

今後の展望

#

現時点ではRPDが100と制限が厳しく、実運用での活用にはまだ課題が残りますが、これは時間の経過とともに解決されていくと考えています。

自律走行ロボットのUIについて、工場や倉庫などの使用環境では、手袋を着用していたり、両手がふさがっていたりするため、タッチパネルなどのGUIでの操作は困難なケースがあります。
音声操作を組み込むことで、このような環境でも直感的な操作が可能となり、使用性を大きく向上できると期待しています。
従来、音声操作機能を実用レベルで実装するには技術的なハードルが高く、開発コストも大きな課題でしたが、LLMの活用によってこれらの障壁が大幅に下がりつつあります。

近い将来、様々なロボットプロジェクトにおいて、音声操作機能の実装が当たり前となる時代が来るのではないでしょうか。

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。