はじめに
研究開発室の岡田です。音声AIとその周辺技術についての活用研究をしています。
LLMの出現により自然言語でコンピュータを操れるようになり、音声AIはこのI/Fとしての活用が期待されています。 最近ではOpenAIを筆頭に、音声でリアルタイムにLLMとやり取りできるサービスが現れ始めています。 また、OpenAIはそのようなサービスを構築するためのRealtime APIをβ版ではありますが公開しており、 今後正式リリースされれば音声AIをI/Fに用いたサービスが増えていくことが十分に考えられます。
今回は、TypeScriptを用いてAIエージェントを構築するためのオープンソースフレームワークMastraで、このOpenAIのAPIを使う方法をご紹介します。 Mastraについては多くの解説記事がありますので今回の記事では詳細な説明は割愛させていただきますが、 特にWebアプリとの親和性が高く、簡単にwebアプリにAIエージェントを組み込めるということや、ツールやMCPサーバなどの機能追加が容易であることで評価が高いフレームワークです。
MastraでローカルPCの音声入出力を用いてRealtime APIを扱う例はMastraの公式ドキュメントをはじめいくつか紹介されているので、 今回は特に、Mastraのメリットを生かしやすいWebアプリ(ブラウザ)から使用するケースについてご紹介します。
OpenAI Realtime API と ブラウザからの利用方法、構成
OpenAIのRealtime APIは、AI(LLM)とのSpeech-to-Speechの会話を低遅延で実現するためのAPIです。また、リアルタイムの文字起こしも提供しています。
このRealtime APIへのアクセスは、WebRTCを用いる方法と、WebSocketを用いる方法の二つの方法があります。
OpenAIの公式ドキュメントではブラウザを用いる場合にはWebRTCを使用することが推奨されています。一方でサーバ間でのやり取りにはWebSocketを使用すると良いとされています。
今回はブラウザから使用するケースのご紹介ということなのでWebRTCを用いる方法を採用することも考えたのですが、 MastraのメリットであるツールやMCPサーバの呼び出しなどを活用することを考えると、ブラウザ上でエージェントを動かすのはかなり大変そうです1。
Mastraの特徴をいかしつつ、よりシンプルに実装するのであれば、サーバを介してWebSocketを用いた実装も考慮に入れるべきだと思います。 サーバを介すので多少遅延が増えるかもしれませんが、現在のLLMの応答性能から見ると誤差だと思います。
このような理由から今回はサーバからWebSocketでOpenAIのAPIに接続する方法を採用します。ブラウザからサーバへのアクセスもリアルタイム性の確保のためWebSocketで接続する構成にします。
図1 ブラウザからMastraを介してRealtime APIを使用する構成
ブラウザからMastraのエージェントを介したOpenAI Realtime APIへの接続
では、上記の構成に従って実際に動くデモを作っていきます。デモの中身は、簡単なプレゼンテーションを対話式で作成できるようなものになります。 今回作成したデモは、githubのリポジトリにありますので、重要そうなところだけ抜き出して説明します。詳細はリポジトリをご覧ください。
Mastraのエージェントを介してOpenAI Realtime APIを使用するには、まずは公式のドキュメントをベースに検討していくのが良いでしょう。
まず、MastraのエージェントからOpenAI Realtime APIへの接続は、公式のドキュメントに記載されているようにエージェントのvoiceプロパティにOpenAIRealtimeVoiceのインスタンスを指定してあげればよいです。
modelは最近提供され始めたgpt-4o-realtime-preview-2025-06-03でも良いですが、結構お高いのでgpt-4o-mini-realtime-previewでも良いと思います。ザックリ使っただけですが、個人的にはコストに見合う大きな性能差を感じられませんでした。実験レベルならminiで十分でしょう。
const voice = new OpenAIRealtimeVoice({ model: "gpt-4o-mini-realtime-preview", // model: "gpt-4o-realtime-preview-2025-06-03", speaker: "alloy", }); voice.updateConfig({ turn_detection: { type: "server_vad", threshold: 0.6, silence_duration_ms: 1200, }, }); const agent = new Agent({ name: "Agent", instructions: instruction_simple, model: openai("gpt-4o"), tools: { ...(await mcp.getTools()) }, voice, });
一方で、音声の入力方法については、公式ドキュメントではmastraが提供するgetMicrophoneStreamでローカルのマイクデバイスから、NodejsのReadableStreamオブジェクトを取得し、sendメソッドの渡すことになっています。
しかし、今回はローカルのマイクデバイスではなくブラウザから使用するのでこの方法は使えません。sendメソッドを詳しく見ると、ReadableStreamだけでなくInt16Arrayも入力として受け付けてくれそうです。
/** * Streams audio data in real-time to the OpenAI service. * Useful for continuous audio streaming scenarios like live microphone input. * Must be in 'open' state before calling this method. * * @param audioData - Readable stream of audio data to relay * @throws {Error} If audio format is not supported * * @example * ```typescript * // First connect * await voice.open(); * * // Then relay audio * const micStream = getMicrophoneStream(); * await voice.relay(micStream); * ``` */ send(audioData: NodeJS.ReadableStream | Int16Array, eventId?: string): Promise<void>;
ブラウザ上でWebAudioを使用することで音声データ(PCM)を取得することはできますので、これを送信してsendに食わせればいいということになります。ここではWebAudioの説明は省きますが、Chromeの場合はFloat型のサンプリングデータが取得できます。OpenAIの Realtime APIはInt16型の24000Hzのデータを受け付けるので型変換をする必要があります。この方法は、OpenAIのRealtime APIのサンプルなどにもありますのでご参照ください。
ということで、ブラウザからの音声データをMastraのウェブソケットで受け付けて、sendに流し込む形にします。さて、ここで実は一つ問題があります。現時点でのMastraのOpenAIRealtimeVoiceのsendはInt16Arrayの処理にバグがあり動きません。Issueとプルリクは投げてある2ので、最終的にはこのInt16Arrayを流し込むことで動くようになると思いますが、現時点では迂回策が必要です。今回は、受け取るデータをReadableStreamとして扱えるように簡易版のラッパクラスを作りました。
import { Readable } from "stream"; export class Int16QueueStream extends Readable { private queue: Int16Array[] = []; private waiting = false; constructor() { super(); } pushInt16(int16: Int16Array) { this.queue.push(int16); if (this.waiting) { this.waiting = false; this._read(); } } _read() { if (this.queue.length === 0) { this.waiting = true; return; } const int16 = this.queue.shift()!; const buffer = Buffer.from(int16.buffer, int16.byteOffset, int16.byteLength); this.push(buffer); } }
このラッパクラスをsendに渡してあげます。
const int16Stream = new Int16QueueStream(); voice.send(int16Stream);
これで、ラッパクラスにブラウザから受け付けた音声データを流し込めば、OpenAIRealtimeVoiceが音声データをRealtime APIに送信してくれます。
Realtime APIから受け取る音声データの出力方法は、入力の時と同じようにWebAudioの機能を用いて流し込めばよいです。この時は音声入力の時と逆にデータ型を変換させます。
以上で、動くようになります。
動作確認
では、動作確認をしていきます。
せっかくMastraで動かせるようにしたので、その特徴を生かしてMCPサーバを使って機能を実現したいと思います。
今回は、前述したとおり簡単なプレゼンテーションを対話式で作成できるようにしてみます3。
動きとしては、指示されたテーマについて、Marp用のMarkdown形式でファイル出力し、MarpでHTMLに変換して、ブラウザで表示、のような感じを想定しています。
準備として、次のMCPサーバを登録します。
server-filesystem: Markdwonファイル出力用のMCPサーバ
playwright: ブラウザでプレゼンのHTMLを表示するサーバ
インストラクションは、次のような感じにしてみました。
export const instruction_simple = [ "あなたは調査とプレゼンテーション資料作成に特化したAIアシスタントです。", "ユーザー指定のテーマで、Marp(Marpit)形式のMarkdownプレゼン資料を作成し、HTMLへ変換してブラウザ表示までサポートしてください。", "", "【使用言語ルール】", "- ユーザーが英語で指示した場合は英語で応答・資料作成", "- ユーザーが日本語で指示した場合は日本語で応答・資料作成", "", "【タスク手順】", "1. 指定テーマでMarp用のMarkdown資料を作成する(記述方法:https://marpit.marp.app/markdown)。ファイル名はpresentation.mdとする。", "2. 作成したMarkdownがMarp記法に合致しているか確認し、不備があれば1度だけ修正する", "3. Markdownをmarp-convertでHTMLに変換する。ファイル名はpresentation.htmlとする。", "4. ユーザーの変更指示があればファイル作成・修正手順を繰り返す", "5. marp-convertを実行したら、必ずplaywriteのbrowser_press_keyで'r'キーイベントを発行してリロードしてください。", "6. 次のスライドに移動する指示があったら、必ずplaywriteのbrowser_press_keyで'ArrowRight'キーイベントを発行して移動してください。", "7. 前のスライドに移動する指示があったら、必ずplaywriteのbrowser_press_keyで'ArrowLeft'キーイベントを発行して移動してください。", "", "【出力ルール】", `- すべてのファイルは指定のフォルダ「${outputDir}」内で作成`, "- 新規フォルダ作成は禁止", "- 全てのファイル名はアルファベットのみで命名", "- ファイル例: presentation.md / presentation.html / custom.css など", "", "【音声応答ルール】", "- 音声応答は指示内容や操作サポートに必要な最小限の内容のみ", "- プレゼン内容やファイル情報・リンク・パスは口頭説明しない", "", "【プレゼン表示時の注意】", "- プレゼンの画面の操作方法を説明してはならない。", "", "", "【その他注意】", "- わかりやすさ・視覚的な見やすさに注意する", "- テーマや指示が不明確な場合は必ずユーザーに質問で確認する", "- Markdown内のstyle指定は不要(CSSは別ファイルで作成)", "", "【キャラクター性】", "- あなたは知的でフレンドリー、ややウィットに富んだ温かい性格", "- 自分がAIであり現実世界で人間のような行動は不可であることを忘れない", ].join("\n");
安定動作させるためにかなり試行錯誤が必要で、十分調整しきれたとは思いませんが、デモ程度には使える感じになりました。
図2 デモの構成
下の映像が実際に動かしてみたデモです。会話に割り込みながら指示を出せていると思います。
今回はプレゼン作成というタスクをやらせてみましたが、MCPサーバやインストラクションを入れ替えることで他のタスクもこなせるようになると思います。(ただし、APIコストには気を付けて。)
考慮が必要な事項
Mastraを介してブラウザからOpenAIのRealtime APIを使えることがわかりました。 しかし、Realtime API自体に制御の難しさがあるなというのが感想です。
インストラクションがなかなか反映されにくいというのもありますが、英語以外だと頻繁にcontent filterに引っかかりやすく途中で止まってしまうという問題があります。これはOpenAIの開発者コミュニティでも話題に上がっている問題で、何が引っ掛かっているかもわかないので回避も対策もできない状況です。link
そのほか、日本語の読み上げ精度がいまいちであったり、コストが高かったりします。
これらのことは、β版なのである程度致し方ないという感じかもしれません。 インストラクションなどは工夫である程度対策できる可能性はありますが、いずれにせよ精度を上げるための試行錯誤は正式リリースを待ってからの方が良いと思いました。
まとめ
今回は、Mastraを介してブラウザからOpenAIのRealtime APIを使う方法をご紹介しました。 これによりMastraによって構築された様々なAIエージェントをブラウザからリアルタイムに音声で対話しながら扱えるようになります。 Realtime API自体がまだβ版であるため実用に耐えるには難しい段階ではありますが、将来必要となる技術だと思いますので正式版のリリースに期待したいと思います。
最後に
FLECT では音声AIとその周辺技術の研究開発を行っています。特に、今回ご紹介したような音声AIとAIエージェントの統合は今後大きく発展していく技術であると注目しています。これらの技術に興味を持たれましたら是非FLECTにご相談ください。
- WebRTCを用いる方法でもFunction Callingはできます。なので、ツールのラッパーをTypescriptで作成するなどしてやろうと思えばできると思います。サーバ側で動いているMastraのツール呼び出しがまさにこれをしているのだと思うので、同じようなものをブラウザに乗せるということになるのだと思う。ただし、これに関してのプルリクも出ているので、もしかしたら今後はWebRTCで使用する場合でもそれほど手間がかからなくなるかもしれません。↩
- マージされました。(が、なぜか後から別の人が投げた同内容のプルリクがマージされている。解せぬが、まぁいいか。)↩
- 作り始めたら凝り始めてしまい、Brave SearchもMPCに追加して、、、などでAPIのコストが跳ね上がってしまった。本稿の本質ではないので、今回は断念してシンプルなカタチで。↩