注: 以下の翻訳の正確性は検証されていません。AIPを利用して英語版の原文から機械的に翻訳されたものです。

音声対応のオントロジー SDK アプリケーションを作成する

録音と同意の取得はお客様の責任です

人の音声を録音または文字起こしするアプリケーションをデプロイする前に、参加者に通知し、運用する法域で必要とされる場合は同意を取得するフローを用意してください。これは、参加者がアプリケーションのユーザー、通話中の第三者、または音声が取得されるその他の人であるかどうかにかかわらず、適用される場合があります。録音、文字起こし、同意を参照してください。

このチュートリアルでは、マイクの音声を取得し、リアルタイムモデルにストリーミングして、モデルの音声レスポンスを再生するオントロジー SDK アプリケーションを作成する手順を説明します。アプリケーションは Foundry ユーザーとして認証されるため、すべてのインタラクションは、そのユーザーの権限とアクセスできるオントロジーに基づきます。Foundry のリアルタイム音声の概要については、リアルタイム音声を参照してください。

作成するもの

このチュートリアルを完了すると、ブラウザーで動作し、次の機能を備えた TypeScript アプリケーションが完成します。

  1. 標準のオントロジー SDK の OAuth フローでユーザーを認証します。
  2. ブラウザーから、リアルタイムモデルに転送する Foundry プロキシエンドポイントへ直接 WebSocket 接続を開きます。
  3. ユーザーの権限で動作し、オントロジーから読み取るツールをエージェントに提供します。
  4. マイクの音声をモデルにストリーミングし、モデルの音声レスポンスを再生します。

前提条件

始める前に、次のものを用意してください。

  • @osdk/create-app を使用してセットアップしたオントロジー SDK アプリケーション。生成されたアプリケーションには、このチュートリアルで使用する OAuth のひな形が含まれています。オントロジー SDK 自体の詳細なリファレンスについては、オントロジー SDK を参照してください。
  • Developer Console に登録された制限なしのカスタムアプリケーション。オントロジー SDK のフロントエンドは、このアプリケーションに対して認証を行います。ステップ1で使用する language-model-service:use-model スコープをアクセストークンに含められるようにするには、制限なしである必要があります。
  • エンロールメントで有効になっているリアルタイムモデル。一覧については、利用可能な音声モデルを参照してください。このチュートリアルでは gpt-realtime-2 を使用します。
  • アプリケーションにインストールされた @openai/agents パッケージ。
Copied!
1 npm install @openai/agents

ステップ1:OAuth アクセストークンを取得する

@osdk/create-app で生成されたオントロジー SDK アプリケーションには、src/client.ts に OAuth クライアントが含まれています。このクライアントは、@osdk/oauth の createPublicOauthClient を使用して作成され、現在のアクセストークンを取得するメソッドを公開します。clientId、foundryUrl、redirectUrl の値は、.env の設定に基づいて @osdk/create-app が index.html に挿入する osdk-* メタタグから、実行時に読み取られます。

生成されるアクセストークンでリアルタイムエンドポイントに接続できるように、OAuth クライアントに language-model-service:use-model スコープを設定します。このスコープがない場合、リアルタイムエンドポイントは認証時に WebSocket 接続を拒否します。このスコープは制限なしのカスタムアプリケーションでのみ利用可能です。続行する前に、アプリケーションが制限なしであることを確認してください。

以下のスニペットは、リアルタイム音声用のスコープを追加した後の src/client.ts 全体の形式を示しています。このファイルの大部分は @osdk/create-app によって生成されます。このチュートリアルで必要な変更は、scopes 配列に language-model-service:use-model を含めることだけです。エクスポートは、チュートリアルのほかの箇所で使用します。auth は以下でトークンを取得するために、foundryUrl はステップ2で WebSocket URL を組み立てるために、client はステップ3でオントロジー SDK のクエリを実行するために使用します。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 import { createClient, type Client } from "@osdk/client"; import { createPublicOauthClient, type PublicOauthClient } from "@osdk/oauth"; function getMetaTagContent(tagName: string): string { const elements = document.querySelectorAll(`meta[name="${tagName}"]`); const element = elements.item(elements.length - 1); const value = element ? element.getAttribute("content") : null; if (value == null || value === "") { throw new Error(`Meta tag ${tagName} not found or empty`); } return value; } export const foundryUrl = getMetaTagContent("osdk-foundryUrl"); const clientId = getMetaTagContent("osdk-clientId"); const redirectUrl = getMetaTagContent("osdk-redirectUrl"); const ontologyRid = getMetaTagContent("osdk-ontologyRid"); const scopes = [ "language-model-service:use-model", // アプリケーションに必要なほかのスコープの例: // "api:read-data", // "api:use-ontologies-read", ]; export const auth: PublicOauthClient = createPublicOauthClient( clientId, foundryUrl, redirectUrl, { scopes } ); export const client: Client = createClient(foundryUrl, ontologyRid, auth); export default client;

実行時にアクセストークンを取得するには、auth.getTokenOrUndefined() を呼び出します。ユーザーがサインインしていない場合は、先に auth.signIn() を呼び出してから再試行します。

Copied!
1 2 3 4 5 6 7 8 9 10 import { auth } from "../client"; let token = await auth.getTokenOrUndefined(); if (!token) { await auth.signIn(); token = await auth.getTokenOrUndefined(); } if (!token) { throw new Error("Unable to obtain access token. Please try again."); }

ステップ2:リアルタイムエンドポイントに接続する

リアルタイムエンドポイントは、次の URL で WebSocket 接続を受け付けます。<your-foundry-domain> は Foundry 環境のホスト名(src/client.ts の foundryUrl に使用する値と同じ)で、model クエリパラメーターは使用するリアルタイムモデルを選択します。

wss://<your-foundry-domain>/language-model-service/ws/v1/open-ai/realtime?model=gpt-realtime-2

これは、REST の LLM プロバイダー互換 API に相当する Foundry プロキシエンドポイントですが、WebSocket 経由で公開されています。プロキシはセッションを基盤となるプロバイダー(エンロールメントに応じて OpenAI Direct または Azure OpenAI)に転送し、プロバイダーのネイティブのリアルタイムプロトコルを維持します。そのため、OpenAI のリアルタイム SDK(@openai/agents/realtime)を変更せずに使用できます。

ブラウザーは WebSocket 接続にカスタムの Authorization ヘッダーを設定できないため、アクセストークンは WebSocket サブプロトコルの値として渡します。サブプロトコルの値は Bearer-<access-token> の形式にします。

サブプロトコルの値を資格情報として扱う

アクセストークンは、WebSocket サブプロトコルの値の一部として、ブラウザーの開発者ツールのネットワークパネルに表示されます。資格情報として扱ってください。ログに記録したり、ユーザー向けの文字列に表示したり、Foundry エンドポイント以外のシステムに送信したりしないでください。アクセストークンの有効期間は短く、WebSocket セッションは接続時のトークンを使用するため、長時間実行するセッションではトークンの有効期限が切れたときに再接続が必要になる場合があります。

OpenAI のリアルタイム SDK を使用して接続を開きます。その際、サブプロトコルの値を指定してブラウザーの WebSocket を構築するカスタムの createWebSocket 関数を渡します。接続フロー全体は、セッションの準備ができると戻る startVoiceSession という async 関数にまとめます。この関数は、名前を付けた3つの小さな構成要素から作成します。以下でそれぞれを定義し、このセクションの最後で組み合わせます。作成したコードは、たとえば src/voice/session.ts として保存します。

まず、インポートと定数を定義します。

Copied!
1 2 3 4 5 6 7 8 9 10 11 import { RealtimeAgent, RealtimeSession, OpenAIRealtimeWebSocket, } from "@openai/agents/realtime"; import { foundryUrl } from "../client"; const PROXY_URL = `wss://${new URL(foundryUrl).host}/language-model-service/ws/v1/open-ai/realtime` + `?model=gpt-realtime-2`; const CONNECT_TIMEOUT_MS = 10_000;

トランスポートを構築する createTransport を定義します。createWebSocket コールバックは、Bearer トークンのサブプロトコルを指定してブラウザーの WebSocket を構築し、診断情報をログに記録するエラーハンドラーとクローズハンドラーを追加します。useInsecureApiKey: true フラグは、一時的ではない OpenAI キーの使用を防ぐ、SDK のブラウザー専用の保護機能を無効にします。ここでは、認証が OpenAI キーではなく WebSocket サブプロトコルによって処理されるため、この保護機能は適用されません。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function createTransport(token: string): OpenAIRealtimeWebSocket { return new OpenAIRealtimeWebSocket({ url: PROXY_URL, useInsecureApiKey: true, // 認証には OpenAI の apiKey ではなく、以下のサブプロトコルを使用します // eslint-disable-next-line @typescript-eslint/no-explicit-any createWebSocket: async ({ url }: { url: string }): Promise<any> => { const ws = new WebSocket(url, [`Bearer-${token}`]); ws.addEventListener("error", () => { console.error("WebSocket connection failed (check network tab for details)"); }); ws.addEventListener("close", (ev) => { // 1000 = 通常の切断のため、エラーとして表示しません。 if (ev.code !== 1000) { console.error(`WebSocket closed: ${ev.code}${ev.reason ? ` (${ev.reason})` : ""}`); } }); return ws; }, }); }

音声の入力/出力形式、サーバー側の音声アクティビティ検出、および会話中にユーザーの音声を文字起こしするためのインラインの文字起こしモデルを指定してセッションを構築する createSession を定義します。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 function createSession( agent: RealtimeAgent, transport: OpenAIRealtimeWebSocket, ): RealtimeSession { return new RealtimeSession(agent, { transport, model: "gpt-realtime-2", tracingDisabled: true, config: { outputModalities: ["audio"], audio: { input: { format: "pcm16", transcription: { model: "whisper-1" }, turnDetection: { type: "server_vad", threshold: 0.8, silenceDurationMs: 500, prefixPaddingMs: 300, }, }, output: { format: "pcm16" }, }, }, }); }

セッションが使用可能になるまで待機する awaitSessionReady を定義します。session.connect() の呼び出しは、セッションが音声を送受信できるようになる前に戻るため、session.created、続いて最初の session.updated イベントを待ちます。その後の 200 ms の待機により、SDK の準備が整うまでの時間を確保します。この待機がないと、初期の音声チャンクが失われる場合があります。CONNECT_TIMEOUT_MS 以内にセッションがこの状態にならない場合、Promise はタイムアウトエラーで拒否されます。認証は WebSocket サブプロトコルによって処理されるため、apiKey フィールドは使用されません。SDK が空ではない文字列を要求するだけです。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 function awaitSessionReady(session: RealtimeSession): Promise<RealtimeSession> { return new Promise<RealtimeSession>((resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Connection timeout")); }, CONNECT_TIMEOUT_MS); let created = false; let updatedCount = 0; session.on("transport_event", (ev) => { if (ev.type === "session.created") { created = true; } if (ev.type === "session.updated" && created) { updatedCount++; if (updatedCount === 1) { setTimeout(() => { clearTimeout(timeout); resolve(session); }, 200); } } }); session.connect({ apiKey: "unused" }); }); }

最後に、3つの構成要素を組み合わせて startVoiceSession を作成します。

Copied!
1 2 3 4 5 6 7 8 export async function startVoiceSession( agent: RealtimeAgent, token: string, ): Promise<RealtimeSession> { const transport = createTransport(token); const session = createSession(agent, transport); return awaitSessionReady(session); }

ステップ1のアクセストークンとエージェントの定義(ステップ3を参照)を用意したら、startVoiceSession(agent, token) を呼び出します。この関数は、セッションで音声を送受信する準備ができると戻ります。

ステップ3:オントロジーから読み取るツールをエージェントに与える

上の例では、汎用アシスタントをセットアップしています。役立つものにするには、オントロジーからの読み取りとオントロジーへの書き込みを行うツールをエージェントに与えます。OpenAI realtime SDK はツール呼び出しをサポートしています。ツールを定義してエージェントに登録すると、モデルは情報が必要だと判断したときに、そのツールの execute 関数を呼び出します。

ツールの execute 関数は、ユーザーの OAuth トークンとオントロジー SDK クライアントを使ってブラウザー内で実行されます。モデルがオントロジーを直接見ることはなく、ツールが返す内容だけを見ます。公開するデータ、適用するフィルター、書き戻す内容は、ツールを作成する側で決めます。

以下の例は、ツールのスタブを示しています。関数の本体を、ご自身のオントロジーに対するオントロジー SDK クエリに置き換えます。オントロジー SDK のクエリ構文と例については、TypeScript オントロジー SDK を参照してください。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import { RealtimeAgent, tool } from "@openai/agents/realtime"; import { z } from "zod"; import { client } from "../client"; const lookupProduct = tool({ name: "lookup_product", description: "Look up a product by name. Use this when the user asks about a specific product.", parameters: z.object({ name: z.string().describe("The product name to look up"), }), execute: async ({ name }) => { // TODO: このスタブを、オントロジーに対するオントロジー SDK のクエリに置き換えます。 // たとえば、オントロジーに Product オブジェクトタイプがある場合: // // const page = await client(Product) // .where({ name: { $startsWith: name } }) // .fetchPage({ $pageSize: 5 }); // return JSON.stringify(page.data.map((p) => ({ // id: p.id, name: p.name, price: p.price, stock: p.stock, // }))); // return JSON.stringify({ id: "stub-1", name, price: 0, stock: 0, note: "Replace the tool body with an OSDK query against your Ontology.", }); }, }); const agent = new RealtimeAgent({ name: "Assistant", instructions: "You help users find products. When the user asks about a product, call the lookup_product tool.", tools: [lookupProduct], });

このエージェントを、ステップ2の startVoiceSession(agent, token) に渡します。ユーザーが話すと、モデルはツールを呼び出すかどうかを判断します。ツールの execute 関数は、ブラウザー内でオントロジー SDK クライアントに対して実行され、その結果がレスポンスを生成するためにモデルに渡されます。

ツールはオントロジーに書き込むこともできます。これを行うには、ツールの execute 関数内からアクションを呼び出します。アクションには、Foundry の標準的な権限確認と検証の処理が適用されます。

ステップ4:マイク音声をストリーミングしてレスポンスを再生する

startVoiceSession が返す RealtimeSession を使って、マイク音声の取得と音声再生を接続します。ブラウザーの MediaDevices API を使ってマイク音声を取得し、24 kHz の16ビット PCM に変換して、チャンクが届くたびに session.sendAudio(chunk) を通じてセッションに転送します。アプリケーションは、セッションから受信した音声チャンクを再生キューに追加します。

マイク音声の取得と再生キューの実装は、このチュートリアルでは扱いません。音声を受信するためのセッションレベルの接続は、次のようになります。

Copied!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 const session = await startVoiceSession(agent, token); // セッション中に SDK で発生したエラーを表示します。WebSocket レベルのトランスポートエラーは、 // (接続の確立に失敗した場合など)コンソールに記録されます。 // この記録は `createTransport` のハンドラーが行います。接続が確立されない場合、 // `startVoiceSession` は `CONNECT_TIMEOUT_MS` の経過後に拒否されます。 session.on("error", (event) => { console.error("Realtime session error:", event.error); }); // モデルからの音声を再生します session.on("audio", (event) => { playbackQueue.enqueue(event.data); // 音声再生の実装 }); // モデルが中断された場合(たとえば、ユーザーが話し始めた場合)、再生をクリアします session.on("audio_interrupted", () => { playbackQueue.clear(); }); // 24 kHz の16ビット PCM チャンクが到着するたびに、マイクの音声をモデルに送信します startMicCapture((pcm16) => session.sendAudio(pcm16)); // マイク音声の取り込みの実装 // 任意:ユーザーが先に話すのを待つのではなく、 // エージェントにすぐにユーザーへ挨拶するよう指示します。 session.sendMessage("(Session started. Greet the user briefly.)");

次のステップ

  • アプリケーションを作り込む。 セッションをフロントエンドに接続し、アプリケーションに必要なツールを追加します。オントロジーと連携するには、ツールの execute 関数内からオントロジー SDK のクエリとアクションを呼び出します。
  • エージェントへの指示を改善する。 リアルタイムモデルに効果的な指示を記述するためのガイダンスについては、OpenAI リアルタイムプロンプティングガイド ↗ を参照してください。