はじめに
こんにちは。
ABEJAのシステム開発部でエンジニアをしている中島です。
こちらはABEJAアドベントカレンダー2024 8日目の記事です。
本記事では、英語のスピーチが苦手な中島がAIの力で英語を話すことに挑戦 そして挫折 する話をします。
今回の記事の対象者はソースコードをある程度読むことができる方を想定しています。
大枠として下記の構成で進行します。
先に結論
OpenAIのRealtime APIのリファレンス実装を見ながら、リアルタイム翻訳機能を実装しました。
リファレンスのUtilityを使えば、簡単に実装することができます。
出来たものは下記のようなアプリケーションです。
OpenAI Realtime API とは
まず最初にOpenAI Realtime APIについて簡単に説明します。
Realtime APIは、今年の10月に発表されたOpenAIの新しいAPIです。
こちらは低遅延でマルチモーダルな会話体験を構築可能なAPIで、
個人の主観を交えて紹介すると下記のような特徴があります。
- 音声/テキストの両方を入出力としてサポート
- 例として、音声to音声の入出力を行うことでAIアシスタントと会話しているかのような体験が可能となります。
- 音声入力から下記が返却されるため統合した処理を行うことが容易です。
- 入力音声認識テキスト
- LLM回答テキスト
- LLM回答音声
- 音声から直接入力するので、従来の
音声認識でテキスト化してLLMに入力する
方式よりも下記の面で優位性があるように感じます。- 処理が少なくなる分レスポンス速度が向上する。
- 音声の抑揚、感情を保持した入力となるため回答の質の向上が期待できる。
- WebSocketでの双方向通信が可能
- 従来のREST APIでは困難な割り込みによる会話も可能です。
- 低遅延
- 会話の遅延が少ないため、リアルタイムでの会話が可能
- Function callingを使用可能
- 会話の途中で特定の関数を呼び出すことが可能です。
- コストについては少し高価
- GPT-4oの倍程度の価格設定となっています。
- 音声入力については1分あたり約0.06 ドル、出力で約0.24ドルの課金となります。
アプリケーション方針
Realtime APIの特徴を踏まえてアプリケーションの方針を決めていきます。
今回のアプリケーションでは英語のスピーチが苦手な想定ユーザー中島を対象とします。
想定ユーザー中島は日本語/英語のリーディングはある程度できますが、英語のスピーチが苦手です。
そのため、Realtime APIで音声だけを返却しても理解が追いつかない可能性があります。
上記から下記機能の実装を検討します。
- 日本語音声入力から英語音声出力
- 今回のメイン機能
- 日本語音声入力から日本語テキスト化
- 日本語が正しく認識されているか確認するため
- 日本語音声入力から英語テキスト化
- 英語に正しく変換できているか確認するため
リファレンス読解
さて、それでは早速Realtime APIを使ってアプリケーションを作成していきましょう。
WebSocketで1から実装してもいいのですが、
締め切りが残り 1 日しかないため公式のリファレンスをベースにアプリケーションを作成していきます。
それではリファレンスの読解を進めましょう。
Utility Library
Utility Library の使い方は こちら にまとまっています。
まずはこちらを読んで使用感を確認しましょう。
今回使用しそうな部分を抜粋します。
- 音声データの送信
- こちらでは音声データの送信方法が記載されています。
- 音声データは Int16Array もしくは ArrayBuffer で送信する必要があります。
- また、音声の切れ目を自動で検知する設定 (
turn_detection
) を有効にしていない場合は、音声終了時に明示的にclient.createResponse();
で終了を通知する必要があります。
- 回答の受信
- こちらでは回答の受信方法が記載されています。
conversation.updated
イベントで回答を受信することができます。- Realtime API のイベント種別とは異なっているので、こちらの Library の中でイベントをラップしているようです。
リファレンス実装
リファレンス実装はコードが多いのですが、個人的に見るべきコードは下記部分です。
-
- メイン部分のコンポーネントです。
- 内部でRealtime APIとの接続等を行っているため、こちらを見ればどのように実装すればいいのかがわかります。
- 初期化処理 L83-L92
- Realtime APIの初期化処理です。
- コンポーネントの初期化時に
client = new RealtimeClient();
で初期化しています。
- Realtime API との接続 L165-L195
connectConversation
ではRealtime APIとの接続を行っています。- 今回の実装で必要となりそうな部分は下記です。
client.connect()
: Realtime APIとの接続を行うawait wavRecorder.record((data) => client.appendInputAudio(data.mono))
: 音声データ取得時にRealtime APIに送信する
- Realtime API との切断 L200-L219
disconnectConversation
ではRealtime APIとの接続を行っています。- 今回の実装で必要となりそうな部分は下記です。
client.disconnect()
: Realtime APIとの接続を切断する
- マウント時処理 L374-L501
- 今回の実装で必要となりそうな部分は下記です。
client.updateSession({ instructions: instructions });
: インストラクションを更新するclient.updateSession({ input_audio_transcription: { model: 'whisper-1' } })
: 音声認識モデルを指定するclient.on('conversation.updated', async ({ item, delta }: any) => {}
: Realtime APIからの回答(音声/テキスト)を受信する
- 今回の実装で必要となりそうな部分は下記です。
- 下記については今回音声の自動検出をする関係で必要ないと判断しました。
-
- こちらは音声データの取得/再生を行うライブラリです。
- 音声処理のAudioWorklet等を記述しても良いですが、折角OpenAIが提供しているのでこちらを使用します。
- (余談) relay-server
- こちらは
npm run relay
をしたときに起動する WebSocket サーバの実装です。 - 実装を見るとわかるのですが、こちらの WebSocket サーバはRealtime APIとの通信を中継するだけのものです。
- APIキーを公開したくない場合はこちらを使用することで、APIキーをブラウザで保持せずに Realtime API と通信することが可能です。
- 社内での利用やセキュリティを考慮する場合はこちらを使用することを検討してください。
- こちらは
アプリケーションの実装
上記でリファレンスを読解したので、実際にアプリケーションを作成していきます。
リファレンスから今回のアプリでは下記3点が要点となりそうです。
- Realtime APIの初期化時に下記を行う
client = new RealtimeClient()
で初期化client.updateSession()
で必要な情報を設定client.on('conversation.updated', async ({ item, delta }: any) => {})
で回答の受信方法を設定
- Realtime APIとの接続時に下記を行う
client.connect()
で Realtime APIと接続await wavRecorder.record((data) => client.appendInputAudio(data.mono))
で音声データ送信の設定
- Realtime APIとの切断
client.disconnect()
で Realtime APIとの接続を切断
実装
今回はNext.jsのアプリケーションを作成します。
使い勝手を考えた結果、 useRealtime API
というカスタムフックでRealtime APIの処理を行うことにしました。
異常系の処理等は省き、コア部分を抜粋します。
下記がその実装です。
/* eslint-disable @typescript-eslint/no-explicit-any */ "use client"; import { useState, useCallback, useRef } from "react"; import { RealtimeClient } from "@openai/realtime-api-beta"; import { WavRecorder, WavStreamPlayer } from "@/lib/wavtools/index"; // インストラクション // 日本語を英語に翻訳するように指示しています。 // 暫定的なものですので、実際のアプリケーションでは改良が必要です。 const instructions = `System settings: Instructions: - あなたは優れた翻訳者です - これからユーザーが話す日本語を英語に翻訳してください - 日本語は適宜送信されるので意見を交えず忠実に翻訳してください `; export function useRealtimeApi() { const wavRecorderRef = useRef<WavRecorder>( new WavRecorder({ sampleRate: 24000 }), ); const wavStreamPlayerRef = useRef<WavStreamPlayer>( new WavStreamPlayer({ sampleRate: 24000 }), ); const clientRef = useRef<RealtimeClient>(); const [isListening, setIsListening] = useState(false); // 音声認識開始 const startRecognizing = useCallback(async () => { if (isListening || !clientRef.current) return; const wavRecorder = wavRecorderRef.current; const wavStreamPlayer = wavStreamPlayerRef.current; const client = clientRef.current; await clientRef.current.connect(); await wavRecorder.begin(); await wavStreamPlayer.connect(); await wavRecorder.record((data) => client.appendInputAudio(data.mono)); setIsListening(true); }, [isListening]); // 音声認識終了 const stopRecognizing = useCallback(async () => { if (!isListening || !clientRef.current) return; const wavRecorder = wavRecorderRef.current; const wavStreamPlayer = wavStreamPlayerRef.current; const client = clientRef.current; await wavRecorder.pause(); await wavRecorder.record(() => {}); await wavRecorder.end(); await wavStreamPlayer.interrupt(); client.disconnect(); setIsListening(false); }, [isListening]); // Realtime API の初期化 const initializeRealtimeApi = useCallback( async ( apiKey: string, onConversationUpdated: (item: any, delta: any) => void, ) => { if (!clientRef.current) { const wavStreamPlayer = wavStreamPlayerRef.current; const client = apiKey === "" ? new RealtimeClient({ url: "ws://localhost:8081" }) : new RealtimeClient({ apiKey: apiKey, dangerouslyAllowAPIKeyInBrowser: true, }); // インストラクションを更新 client.updateSession({ instructions: instructions }); // 音声認識モデルを指定 client.updateSession({ input_audio_transcription: { model: "whisper-1" }, }); client.updateSession({ turn_detection: { type: "server_vad" }, }); client.on("error", (event: any) => console.error("error: ", event)); client.on("conversation.interrupted", async () => { const trackSampleOffset = await wavStreamPlayer.interrupt(); if (trackSampleOffset?.trackId) { const { trackId, offset } = trackSampleOffset; await client.cancelResponse(trackId, offset); } }); // Realtime API からの回答を受信 // ここで音声を再生する処理を追加 // 回答をonConversationUpdatedに渡すことで、アプリケーション側での処理を可能とする client.on( "conversation.updated", async ({ item, delta }: { item: any; delta: any }) => { if (delta?.audio) { wavStreamPlayer.add16BitPCM(delta.audio, item.id); } onConversationUpdated(item, delta); }, ); clientRef.current = client; } }, [], ); return { isListening, startRecognizing, stopRecognizing, initializeRealtimeApi, }; }
上記のカスタムフックでは下記を提供しています。
isListening
: 音声認識中かどうかのフラグstartRecognizing
: 音声認識を開始する関数stopRecognizing
: 音声認識を終了する関数initializeRealtime API
: Realtime APIの初期化を行う関数 ※ 異常系の処理等は省略しているため、実際のアプリケーションでは追加が必要です。
あとはNext.jsのページでこのカスタムフックを使用してアプリケーションを作成します。
詳細は割愛しますが、下記のようなコードで実装しました。
"use client"; import { useState, useEffect } from "react"; import AudioVisualizer from "@/components/AudioVisualizer"; import TextDisplay from "@/components/TextDisplay"; import { Mic, Pause } from "lucide-react"; import { ApiKeyInput } from "@/components/ApiKeyInput"; import { useRealtimeApi } from "@/hooks/useRealtimeApi"; export default function Home() { const { isListening, startRecognizing, stopRecognizing, initializeRealtimeApi, } = useRealtimeApi(); const [delta, setDelta] = useState<any>(null); const [item, setItem] = useState<any>(null); useEffect(() => { initializeRealtimeApi( process.env.NEXT_PUBLIC_OPENAI_API_KEY, (item, delta) => { setDelta(delta); setItem(item); }, ); }, [initializeRealtimeApi]); return ( <div className="p-4"> <button onClick={startRecognizing} disabled={isListening} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 m-2 rounded" > Start Recognizing </button> <button onClick={stopRecognizing} disabled={!isListening} className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 m-2 rounded" > Stop Recognizing </button> <div className="m-2"> {delta && ( <div> <h2>Conversation</h2> <pre>{JSON.stringify(delta, null, 2)}</pre> </div> )} </div> <div className="m-2"> {item && ( <div> <h2>Item</h2> <pre>{JSON.stringify(item, null, 2)}</pre> </div> )} </div> </div> ); }
実際にできたもの
上記を改良して、実際にできたものは 冒頭の動画 のアプリケーションです。
こちらでは下記のような動作が可能です。
録音
ボタンを押すと音声認識を開始します。停止
ボタンを押すと音声認識を終了します。- 音声認識中にはリアルタイムで英語に翻訳した音声を再生します。
- 音声認識中には音声認識結果(日本語)を左側のテキストエリアに表示します。
- 音声認識中には音声認識結果を英語に翻訳したテキストを右側のテキストエリアに表示します。
今回のアプリケーションでは省きましたが、下記のような改良ができるかと思います。
- 翻訳結果を再度日本語に翻訳して表示することで、翻訳の精度を確認する
- 結果が安定しない部分があるので、インストラクションを改良して安定した結果を得る
動作に興味がある方は下記で試すことが可能です。
- https://openai-realtime-fiddle.vercel.app/
- APIキーを入力する必要があります。 Realtime APIを使用するためコストには気をつけてください。
- 現在は異常系のテスト等をしていないため、動作確認は自己責任で行っていただけますと幸いです。
まとめ
Realtime APIでは音声/テキストに対してリアルタイムなやりとりが可能です。
ブラウザベースのJavaScriptアプリケーションであれば公式のUtilityを活用することで高速な実装が可能でした。
入力音声から出力音声/テキストをまとめて処理できるので処理も簡便です。
上記からLLMで何かしらのアクションを行うアプリケーションであれば有用な部分が大きいです。
一方で現時点では導入にあたっては下記を加味して検討が必要かと存じます。
- ベータ版であること
- 少し高価であること
とは言ったものの、将来的には正式版となりコストが下がる可能性もあるかと思っています。
音声/テキストをリアルタイムで処理するアプリケーションを作成したい方は是非検討してみてください!