OpenAI「Agents SDK」⑧音声エージェント
OpenAIのAgents SDKは以前一通り試した。
で、少し応用的なところも含めて、試してなかった音声を使うものもやっておこうかと。ドキュメントには以下の2つがある。
ASR・LLM・TTSのパイプラインを使う「音声エージェント」
Realtime APIを使う「リアルタイムエージェント」
まずは「音声エージェント」から。
クイックスタート
ローカルのMacでやってみる。
uvで仮想環境作成
uv init -p 3.12 openai-voice-agents-work && cd $_
Agents SDKのパッケージ追加。この時、extrasにvoice
を指定する。
uv add 'openai-agents[voice]'
(snip)
+ openai-agents==0.3.0
(snip)
概念
基本的には、STT(Speech-to-text or Automatic Speech Recognition)・エージェント(LLM)・TTS(Text-to-speech)を組み合わせた「パイプライン」で処理をするもの。このためのモジュールとして VoicePipeline
が用意されている。
referred from https://openai.github.io/openai-agents-python/ja/voice/quickstart/ and translated into Japanese by kun432
エージェント
サンプルコード
import asyncio
import random
import numpy as np
import sounddevice as sd
from agents import (
Agent,
function_tool,
set_tracing_disabled,
)
from agents.voice import (
AudioInput,
SingleAgentVoiceWorkflow,
VoicePipeline,
)
from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
# ツールの定義
@function_tool
def get_weather(city: str) -> str:
"""指定された都市の天気を取得する"""
print(f"[debug] get_weather が読み出されました。指定された都市名: {city}")
choices = ["晴れ", "曇り", "雨", "雪"]
return f"{city} の天気は {random.choice(choices)} です。"
# ハンドオフされるエージェント
spanish_agent = Agent(
name="Spanish",
handoff_description="スペイン語を話すエージェント",
instructions=prompt_with_handoff_instructions(
"あなたは人間と話しているので、丁寧で簡潔に話してください。スペイン語で話してください。",
),
model="gpt-4o-mini",
)
# メインのエージェント
agent = Agent(
name="Assistant",
instructions=prompt_with_handoff_instructions(
(
"あなたは人間と話しているので、丁寧で簡潔に話してください。"
"もしユーザがスペイン語を話す場合は、スペイン語を話すエージェントにハンドオフしてください。"
)
),
model="gpt-4o-mini",
handoffs=[spanish_agent], # ハンドオフするエージェントを追加
tools=[get_weather], # ツールを追加
)
async def main():
# `VoicePipeline` でパイプラインを作成
pipeline = VoicePipeline(workflow=SingleAgentVoiceWorkflow(agent))
# シンプルな例として、3秒間の静音を生成する
# 実際にはマイクから音声データを取得する
buffer = np.zeros(24000 * 3, dtype=np.int16)
audio_input = AudioInput(buffer=buffer)
# パイプラインに音声データを渡して実行
result = await pipeline.run(audio_input)
# 音声の再生に `sounddevice` を使う
player = sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16)
player.start()
# 音声ストリームを再生
async for event in result.stream():
if event.type == "voice_stream_event_audio":
player.write(event.data)
if __name__ == "__main__":
asyncio.run(main())
基本的には過去試したAgents SDKでのエージェントやツール、ハンドオフの定義に加えて、エージェントの処理をラップした「ワークフロー」と、それをさらにラップする「音声パイプライン」を使うことで、音声の入出力が追加される、って感じかな?
実行してみる
export OPENAI_API_KEY=XXXXXXXXXX
uv run sample.py
エラーになる。
ModuleNotFoundError: No module named 'sounddevice'
そういえば sounddevice
を使っているのに、パッケージ追加時は見かけなかったな。追加する。
uv add sounddevice
再度実行すると、スペイン語で発話されるのがわかる、何言ってるかさっぱりわからないけども。
上の例では、無音を入力に使っていたけど、実際に自分が発話した音声で処理するサンプルが以下にある。
こちらも試してみる。サンプルはユーティリティ関数などが別ファイルに分かれているけど、1ファイルにまとめた。
import curses
import time
import numpy as np
import numpy.typing as npt
import sounddevice as sd
import asyncio
import random
import numpy as np
from agents import Agent, function_tool
from agents.extensions.handoff_prompt import prompt_with_handoff_instructions
from agents.voice import (
AudioInput,
SingleAgentVoiceWorkflow,
SingleAgentWorkflowCallbacks,
VoicePipeline,
)
"""
録音されたオーディオバッファを使用するシンプルな例。
1. You can record an audio clip in the terminal.
2. The pipeline automatically transcribes the audio.
3. The agent workflow is a simple one that starts at the Assistant agent.
4. The output of the agent is streamed to the audio player.
以下のような例を試すことができます:
- なにかジョークを話して (ジョークで応答する)
- 東京の天気は? (`get_weather`ツールを呼び出して応答する)
- Hola, como estas? (スペイン語を話すエージェントにハンドオフされる)
"""
def _record_audio(screen: curses.window) -> npt.NDArray[np.float32]:
"""
マイクを使って録音を行うヘルパー関数
"""
screen.nodelay(True) # ノンブロッキング入力
screen.clear()
screen.addstr(
"<スペースキー>を押して録音を開始してください。再度<スペースキー>を押すと録音が終了します。\n"
)
screen.refresh()
recording = False
audio_buffer: list[npt.NDArray[np.float32]] = []
def _audio_callback(indata, frames, time_info, status):
if status:
screen.addstr(f"Status: {status}\n")
screen.refresh()
if recording:
audio_buffer.append(indata.copy())
# コールバックで音声ストリームを開く
with sd.InputStream(samplerate=24000, channels=1, dtype=np.float32, callback=_audio_callback):
while True:
key = screen.getch()
if key == ord(" "):
recording = not recording
if recording:
screen.addstr("録音を開始しました...\n")
else:
screen.addstr("録音を終了しました...\n")
break
screen.refresh()
time.sleep(0.01)
# 録音した音声チャンクを結合する
if audio_buffer:
audio_data = np.concatenate(audio_buffer, axis=0)
else:
audio_data = np.empty((0,), dtype=np.float32)
return audio_data
def record_audio():
# cursesを使用することで、以下を実現しつつ録音を行う
# - macOSでアクセシビリティ権限を必要としない
# - ターミナルをブロックしない
audio_data = curses.wrapper(_record_audio)
return audio_data
class AudioPlayer:
"""
音声を再生するヘルパークラス
"""
def __enter__(self):
self.stream = sd.OutputStream(samplerate=24000, channels=1, dtype=np.int16)
self.stream.start()
return self
def __exit__(self, exc_type, exc_value, traceback):
self.stream.stop() # ストリームが完了するのを待つ
self.stream.close()
def add_audio(self, audio_data: npt.NDArray[np.int16]):
self.stream.write(audio_data)
# ツールの定義
@function_tool
def get_weather(city: str) -> str:
"""Get the weather for a given city."""
print(f"[debug] get_weather called with city: {city}")
choices = ["sunny", "cloudy", "rainy", "snowy"]
return f"The weather in {city} is {random.choice(choices)}."
# ハンドオフされるエージェント
spanish_agent = Agent(
name="Spanish",
handoff_description="スペイン語を話すエージェント",
instructions=prompt_with_handoff_instructions(
"あなたは人間と話しているので、丁寧で簡潔に話してください。スペイン語で話してください。",
),
model="gpt-5-mini",
)
# メインのエージェント
agent = Agent(
name="Assistant",
instructions=prompt_with_handoff_instructions(
(
"あなたは人間と話しているので、丁寧で簡潔に話してください。"
"もしユーザがスペイン語を話す場合は、スペイン語を話すエージェントにハンドオフしてください。"
)
),
model="gpt-5-mini",
handoffs=[spanish_agent],
tools=[get_weather],
)
# ワークフローのコールバッククラスの定義
class WorkflowCallbacks(SingleAgentWorkflowCallbacks):
def on_run(self, workflow: SingleAgentVoiceWorkflow, transcription: str) -> None:
print(f"[debug] on_run called with transcription: {transcription}")
async def main():
# パイプラインを定義
pipeline = VoicePipeline(
workflow=SingleAgentVoiceWorkflow(agent, callbacks=WorkflowCallbacks())
)
# 録音した音声バッファを生成
audio_input = AudioInput(buffer=record_audio())
# 音声をパイプラインに渡して実行
result = await pipeline.run(audio_input)
with AudioPlayer() as player:
async for event in result.stream():
if event.type == "voice_stream_event_audio":
player.add_audio(event.data)
print("オーディオを受信しました")
elif event.type == "voice_stream_event_lifecycle":
print(f"ライフサイクルイベントを受信しました: {event.event}")
# 音声ストリームの最後に1秒間の静音を追加して、音声の最後が途切れないようにする
player.add_audio(np.zeros(24000 * 1, dtype=np.int16))
if __name__ == "__main__":
asyncio.run(main())
実行
uv run sample2.py
こんな感じで表示されたら、スペースバーを押して発話、終わったら再度スペースバーを押す。
「おはようございます」 と発話してみた。
[debug] on_run called with transcription: おはようございます。
ライフサイクルイベントを受信しました: turn_started
オーディオを受信しました
オーディオを受信しました
オーディオを受信しました
ライフサイクルイベントを受信しました: turn_ended
ライフサイクルイベントを受信しました: session_ended
以下が音声で返ってきた。
おはようございます。ご機嫌いかがですか?今日はどのようにお手伝いいたしましょうか?
「東京の天気を教えて」 と発話してみた。
[debug] on_run called with transcription: 東京の天気を教えて。
[debug] get_weather called with city: 東京
ライフサイクルイベントを受信しました: turn_started
オーディオを受信しました
オーディオを受信しました
オーディオを受信しました
ライフサイクルイベントを受信しました: turn_ended
ライフサイクルイベントを受信しました: session_ended
ツールを使った上で以下の音声で返ってきた。
現在東京は雨です。傘が必要になる可能性が高いです。他に知りたいことはありますか?
「Hola, como estas?」 と発話してみた。
[debug] on_run called with transcription: Hola, ¿cómo estás?
ライフサイクルイベントを受信しました: turn_started
オーディオを受信しました
オーディオを受信しました
オーディオを受信しました
オーディオを受信しました
ライフサイクルイベントを受信しました: turn_ended
ライフサイクルイベントを受信しました: session_ended
音声がスペイン語で返ってきた。何を言っているのかはわからないけど、ハンドオフも機能していることがわかる。
パイプラインとワークフロー
VoicePipeline
は、エージェント主導のワークフローを音声アプリに簡単に変換できるクラスです。実行するワークフローを渡すと、パイプラインが入力音声の文字起こし、音声の終了検出、適切なタイミングでのワークフロー呼び出し、そしてワークフロー出力の音声化までを処理します。
この「ワークフロー」、エージェントの処理をラップしたものだと思うのだが、明確には書かれていない。APIリファレンスを見てみる。
基底クラスである VoiceWorkflowBase
には以下と書かれている(DeepL翻訳)
「ワークフロー」とは、文字起こしデータを受け取り、テキスト読み上げモデルによって音声に変換されるテキストを生成する任意のコードを指します。ほとんどの場合、エージェントを作成し、
Runner.run_streamed()
を使用してそれらを実行し、ストリームからテキストイベントの一部またはすべてを返します。
まあそういうことだね。で、用意されているのは SingleAgentVoiceWorkflow
だけで、起点となるメインのエージェントを渡すだけ。より複雑なものを作りたい場合には VoiceWorkflowBase
のサブクラスを自分で作れ、ということみたい。
パイプラインの設定
パイプライン作成時に、以下を設定できます:
- 各音声が文字起こしされるたびに実行されるコードである
workflow
- 使用する
speech-to-text
とtext-to-speech
のモデル- 次のような項目を設定できる
config
- モデル名をモデルにマッピングできるモデルプロバイダー
- トレーシング(トレーシングの無効化可否、音声ファイルのアップロード可否、ワークフロー名、トレース ID など)
- プロンプト、言語、使用するデータ型など、TTS と STT モデルの設定
config
から VoicePipelineConfig
、その中で STTModelSettings
/ TTSModelSettings
という感じで設定する。
ただ、ざっと見た限り、STT・TTSで選択できるのはOpenAIのものだけしか用意されていないように思える。他プロバイダに対応するにはがんばって自分で拡張するしかなさそう。
ちなみにOpenAIのダッシュボードを見ていると、デフォルトは以下のような感じだった。
- STT:
gpt-4o-transcribe
- TTS:
gpt-4o-mini-tts
パイプラインの実行
パイプラインは
run()
メソッドで実行でき、音声入力を次の 2 つの形式で渡せます:
AudioInput
は、完全な音声の文字起こしがある場合に、その結果だけを生成したいときに使用します。話者が話し終えたタイミングの検出が不要なケース、たとえば事前録音の音声や、ユーザーが話し終えるタイミングが明確なプッシュ・トゥ・トークのアプリで有用です。StreamedAudioInput
は、ユーザーが話し終えたタイミングを検出する必要がある場合に使用します。検出された音声チャンクを逐次プッシュでき、音声パイプラインは「activity detection(音声アクティビティ検出)」と呼ばれるプロセスを通じて、適切なタイミングでエージェントのワークフローを自動実行します。
なるほど、VADもあると・・・
あれれ?自分の認識だと、VoicePipeline
は Realtime APIを使わずに、Audio Transcriptions APIを使うものだと思ってて、 (サーバサイド)VAD は Realtime APIだけ、と思ってたけど、そうじゃないのかな?
リアルタイムモデルとRealtime APIの区別が理解できてないような気がするな。
ここはあとで確認。
結果
音声パイプライン実行の結果は
StreamedAudioResult
です。これは、発生したイベントを順次ストリーミングできるオブジェクトです。VoiceStreamEvent
にはいくつかの種類があり、たとえば次のものがあります:
- 音声チャンクを含む
VoiceStreamEventAudio
- ターンの開始・終了などライフサイクルイベントを通知する
VoiceStreamEventLifecycle
- エラーイベントである
VoiceStreamEventError
一つ上のサンプルコードでもこんな感じで処理していた。
(snip)
# 音声をパイプラインに渡して実行
result = await pipeline.run(audio_input)
with AudioPlayer() as player:
async for event in result.stream():
if event.type == "voice_stream_event_audio":
player.add_audio(event.data)
print("オーディオを受信しました")
elif event.type == "voice_stream_event_lifecycle":
print(f"ライフサイクルイベントを受信しました: {event.event}")
(snip)
ベストプラクティス
割り込み
Agents SDK は現在、
StreamedAudioInput
に対する組み込みの割り込み機能をサポートしていません。代わりに、検出された各ターンごとにワークフローの個別の実行がトリガーされます。アプリケーション内で割り込みに対応したい場合は、VoiceStreamEventLifecycle
イベントを監視してください。turn_started は新しいターンが文字起こしされ処理が開始されたことを示します。
ここでの「割り込み」ってのはユーザの発話中への割り込みってことかな?ターンの開始・終了は検知できるが、その「途中」で何かしらの条件に合致したとかイベントを検出したとか、で割り込みができないということだと認識した。
一応ターン開始(turn_started
)・終了(turn_ended
)は検出できるので、それを使った例としてマイクとの連動などが記載されている。
入力途中の割り込みはわからんではないけど、それほど強いユースケースとしてあるのかな?と個人的には思った。出力途中の割り込みは大いにあると思うけども。
で、上のストリーミングのサンプルが以下にある。
のだが、動かしてみたらエラーになる・・・エラーの内容とIssueはこちら。
2025/09/17追記
どうやらRealtime APIがGAしたタイミングで仕様が変わっていた模様。mainブランチにPRがマージされてIssueがクローズされていた。次のバージョンあたりで動くようになると思われる。
トレーシング
こちらもトレーシングはデフォルトで有効になっている。パラメータについてはドキュメント参照。
OpenAIのダッシュボードでトレースを見てみるとこんな感じ。
基本的にはエージェントの前後にSTT・TTSを被せるだけなので、ドキュメントもそれほど内容があるわけではない感じ。LLMモデルは(LiteLLMがあるおかげで)いろいろ選択肢があるけど、こちらは前提としてOpenAIを使う感じ。ここを変えようと思うとそこそこ手間になりそう。
次はこちら。