Realtime API with WebRTC
WebRTCによるOpenAI Realtime APIの呼び出しは、すべてWebブラウザで動作するJavaScriptによって実装します。上記のOpenAIのドキュメントに記載されているコードを、ほぼそのまま流用します。WebRTCでRealtime APIを呼び出すには、OpenAIのサーバーよりephemeral token(一時トークン)を取得し、それをAPIキーとして使用します。この一時トークンの取得については、Oracle APEXが動作するデータベース・サーバーで実行します。
作成したAPEXアプリケーションは以下のように動作します。GIF動画なので音声が含まれていないため動作が分かりませんが、ボタンStartを押してConnection Stateがopenになってから「OpenAIの創業者について教えてください。」と聞いています。Realtime APIのレスポンスに音声のトランスクリプトが含まれるため、それを取り出してページ・アイテムに設定しています。同時にWebブラウザでは、トランスクリプトの読み上げが行われています。Realtime APIから返された次の文章が読み上げられています。
「OpenAIは、2015年に設立されました。創業者の一人には、イーロン・マスクやサム・アルトマンなどがいます。彼らは、人工知能の安全な発展と普及を目指して活動を始めました。設立当初は、営利目的ではなく、オープンソースでのAI研究を重視していました。」
以下より実装について紹介します。
OpenAIのAPIサーバーより一時キーを取得するに当たって、OpenAIのAPIを呼び出します。このAPI呼び出しの認証には、OpenAIの(一時キーではない)APIキーを指定します。このAPIキーをOracle APEXのWeb資格証明として登録します。
Web資格証明の静的IDはOPENAI_API_KEYとしています。認証タイプはHTTPヘッダー、資格証明名はHTTPヘッダー名であるAuthenticationを設定します。資格証明シークレットはBearerで始めて空白で区切り、skで始まるOpenAIのAPIキーを設定します。URLに対して有効については、https://api.openai.com/を設定します。
空のAPEXアプリケーションを作成し、デフォルトで作成されるホーム・ページにWebRTCによるOpenAI Realtime APIの呼び出しを実装します。
APEXアプリケーションの名前はOpenAI WebRTCとします。
WebRTCの呼び出しを含んだJavaScriptのコードは、すべて静的アプリケーション・ファイルのjs/app.jsに記述します。ページ・プロパティのJavaScriptのファイルURLとして、以下を設定します。
[module,defer]#APP_FILES#js/app#MIN#.js
一時キーの取得は、Ajaxコールバックとして実装します。プロセスの名前をGET_EPHEMERAL_TOKENとして、ソースのPL/SQLコードに以下を記述します。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
declare | |
C_TOKEN_URL constant varchar2(80) := 'https://api.openai.com/v1/realtime/sessions'; | |
l_response clob; | |
l_response_json json_object_t; | |
l_request clob; | |
e_api_call_failed exception; | |
begin | |
l_request := json_object( | |
-- 'model' value 'gpt-4o-realtime-preview-2024-12-17' | |
-- gpt-4o-miniが選択できる。 | |
'model' value 'gpt-4o-mini-realtime-preview-2024-12-17' | |
,'voice' value 'verse' | |
-- ephemeral token取得時にinstructionsを指定できるみたい。 | |
,'instructions' value '日本語で会話をしてください。' | |
); | |
apex_web_service.clear_request_headers(); | |
apex_web_service.set_request_headers('Content-Type', 'application/json'); | |
apex_debug.info('request: %s', l_request); | |
l_response := apex_web_service.make_rest_request( | |
p_url => C_TOKEN_URL | |
,p_http_method => 'POST' | |
,p_body => l_request | |
,p_credential_static_id => 'OPENAI_API_KEY' | |
); | |
if apex_web_service.g_status_code <> 200 then | |
raise e_api_call_failed; | |
end if; | |
apex_debug.info('response', l_response); | |
htp.p(l_response); | |
end; |
一時キーはレスポンスであるJSONドキュメントのclient_secret.valueとして返されます。
ホーム・ページにはAjaxコールバックGET_EPHEMERAL_TOKENの呼び出しとレスポンスを確認するため、ボタンGET_EPHEMERAL_TOKENと、そのボタンをクリックしたときに実行される動的アクションを定義しています。このボタンはあくまでデバッグ用で、WebRTCでのRealtime API呼び出しには使用されません。
WebRTCによる会話を開始するボタンSTARTと、終了するボタンSTOPを作成します。最初に静的コンテンツのリージョンを作成し、静的IDとしてCONTROLSを割り当てます。外観のテンプレートとして、Buttons Containerを選択します。
作成した静的コンテンツのリージョンにボタンSTARTを作成します。動作のアクションとして動的アクションで定義を選択し、カスタム属性としてdata-action="START"を設定します。この設定により、ボタンをクリックするとAPEXアクションとして定義された"START"が呼び出されます。
同様にボタンSTOPを作成します。詳細のカスタム属性としてdata-action="STOP"を設定します。ボタンSTARTの右隣に配置するため、レイアウトの新規行の開始をオフにします。
OpenAIのドキュメントにあるサンプルに対して、以下のコードを追加しています。
- 一時キーを取得するために、apex.server.processを使ってAjaxコールバックGET_EPHEMERAL_TOKENを呼び出しています。
- データ・チャネルの状態をページ・アイテムP1_CONNECTION_STATEに表示しています。
- OpenAI Realtime APIの音声応答のトランスクリプトを取り出して、ページ・アイテムP1_RESPONSEに表示しています。
- WebRTCによる会話の開始と終了を、APEXアクションのSTARTとSTOPとして定義しています。
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// instance of RTCDataChannel https://developer.mozilla.org/ja/docs/Web/API/RTCDataChannel | |
var dc = null; | |
// instaance of RTCPeerConnection https://developer.mozilla.org/ja/docs/Web/API/RTCPeerConnection | |
var pc = null; | |
/* | |
* OpenAIのRealtime APIのモデルと、WebRTCによる会話を開始する。 | |
*/ | |
async function init() { | |
/* | |
* Get an ephemeral key from the database running APEX app. | |
*/ | |
let EPHEMERAL_KEY = null; | |
await apex.server.process("GET_EPHEMERAL_TOKEN", {}, | |
{ | |
success: (data) => { | |
EPHEMERAL_KEY = data.client_secret.value; | |
} | |
} | |
); | |
// apex.debug.info("EPHEMERAL_KEY: ", EPHEMERAL_KEY); | |
// Create a peer connection | |
pc = new RTCPeerConnection(); | |
// Set up to play remote audio from the model | |
const audioEl = document.createElement("audio"); | |
audioEl.autoplay = true; | |
pc.ontrack = e => audioEl.srcObject = e.streams[0]; | |
// Add local audio track for microphone input in the browser | |
const ms = await navigator.mediaDevices.getUserMedia({ | |
audio: true | |
}); | |
pc.addTrack(ms.getTracks()[0]); | |
// Set up data channel for sending and receiving events | |
dc = pc.createDataChannel("oai-events"); | |
dc.addEventListener("message", (e) => { | |
// Realtime server events appear here! | |
console.log(e.data); | |
/* | |
* レスポンス完了時の音声データのトランスクリプトを | |
* APEXアプリケーションのP1_RESPONSEにセットする。 | |
*/ | |
const data = JSON.parse(e.data); | |
if ( data.type === "response.done" ) { | |
if ( data.response.status === "completed" ) { | |
if ( data.response.output.length === 0 ) { | |
return; | |
}; | |
const content = data.response.output[0].content; | |
const audioContent = content.find(item => item.type === 'audio'); | |
if ( audioContent != null ) { | |
apex.item("P1_RESPONSE").setValue(audioContent.transcript); | |
} | |
const textContent = content.find(item => item.type === 'text'); | |
if ( textContent != null ) { | |
apex.item("P1_RESPONSE").setValue(textContent.text); | |
} | |
} else if ( data.response.status === "failed" ) { | |
apex.item("P1_RESPONSE").setValue( data.response.status_details.error.message ); | |
} | |
} | |
}); | |
/* | |
* データチャンネルの状態をページ・アイテムP1_CONNECTION_STATEにセットする。 | |
*/ | |
dc.addEventListener("open", (element) => { | |
apex.item("P1_CONNECTION_STATE").setValue("open"); | |
}); | |
dc.addEventListener("close", (element) => { | |
apex.item("P1_CONNECTION_STATE").setValue("close"); | |
}); | |
// Start the session using the Session Description Protocol (SDP) | |
const offer = await pc.createOffer(); | |
await pc.setLocalDescription(offer); | |
const baseUrl = "https://api.openai.com/v1/realtime"; | |
const model = "gpt-4o-realtime-preview-2024-12-17"; | |
const sdpResponse = await fetch(`${baseUrl}?model=${model}`, { | |
method: "POST", | |
body: offer.sdp, | |
headers: { | |
Authorization: `Bearer ${EPHEMERAL_KEY}`, | |
"Content-Type": "application/sdp" | |
}, | |
}); | |
const answer = { | |
type: "answer", | |
sdp: await sdpResponse.text(), | |
}; | |
await pc.setRemoteDescription(answer); | |
} | |
/* | |
* ボタンSTARTとSTOPを押した時に、OpenAIのRealtime APIのモデルとの会話を開始または終了する。 | |
*/ | |
const controls = apex.actions.createContext("controls", document.getElementById("CONTROLS")); | |
controls.add([ | |
{ | |
"name": "START", | |
action: (event, element, args) => { | |
init(); | |
} | |
}, | |
{ | |
"name": "STOP", | |
action: (event, element, args) => { | |
pc.close(); | |
} | |
}, | |
{ | |
"name": "SEND", | |
action: (event, element, args) => { | |
const message = apex.item("P1_TEXT").getValue(); | |
const responseCreate = { | |
type: "response.create", | |
response: { | |
modalities: ["text"], | |
instructions: message | |
}, | |
}; | |
dc.send(JSON.stringify(responseCreate)); | |
} | |
} | |
]); |
以上でアプリケーションは完成です。
Realtime APIの呼び出しは、デバッグしているとクレジットがどんどん減るため、あまりデバッグできていません。
今回作成したAPEXアプリケーションのエクスポートを以下に置きました。
https://github.com/ujnak/apexapps/blob/master/exports/sample-openai-webrtc.zip
Oracle APEXのアプリケーション作成の参考になれば幸いです。
完