このコンテンツは自動機械翻訳サービスによる翻訳版であり、皆さまの便宜のために提供しています。原本の英語版と異なる誤り、省略、解釈の微妙な違いが含まれる場合があります。ご不明な点がある場合は、英語版原本をご確認ください。
私たちの多くにとって、AIエージェントの初めての体験は、チャットボックスに入力することでした。また、日常的にエージェントを使用している人にとっては、詳細なプロンプトやマークダウンファイルを作成してガイドするのが得意になっています。
しかし、エージェントが最も有用な瞬間は、必ずしもテキスト優先であるとは限りません。通勤中、公開セッションがいくつかある中、エージェントに自然に話を聞いてもらい、やり取りを続けたい場合などです。
エージェントに音声を追加する際、そのエージェントを別の音声フレームワークに移動する必要はありません。本日、私たちはAgents SDK向けの実験的な音声パイプラインをリリースします。
@cloudflare/voiceを使用すると、すでに使用しているのと同じエージェントアーキテクチャにリアルタイムの音声を追加できます。音声は、Agents SDKがすでに提供しているのと同じツール、持続性、WebSocket接続モデルで、同じDurable Objectに対話するための別の方法になります。
@cloudflare/voiceは、Agents SDKの実験的なパッケージであり、以下を提供します:
完全会話型音声エージェント用のVoice(Agent)
指示や音声検索など、音声からテキストへの変換のみのユースケースには、VoiceInput(Agent)を使用
VoiceAgentとReactアプリのVoiceInputフックを使用
フレームワークに依存しないクライアント向けのVoiceClient
組み込み型のWorkers AIプロバイダーにより、外部APIキーなしで始められます。
つまり、同じAgentクラス、Durable Objectインスタンス、同じSQLiteによる会話履歴を維持しながら、単一のWebSocket接続でリアルタイムに対話できるエージェントを構築できるということです。
重要なこととして、私たちはこれを固定されたデフォルトスタックよりも大きくしたいと考えています。@cloudflare/voiceのプロバイダーインタフェースは意図的に小さく、音声、電話、トランスポートのプロバイダーが私たちと一緒に構築できるようにすることで、開発者が単一の音声アーキテクチャに縛られるのではなく、ユースケースに合わせて適切なコンポーネントを組み合わせることができるようにします。
Agents SDKにおける音声エージェントの最小限のサーバー側パターンは次のとおりです。
import { Agent, routeAgentRequest } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAITTS,
type VoiceTurnContext
} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async onTurn(transcript: string, context: VoiceTurnContext) {
return `You said: ${transcript}`;
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
}
} satisfies ExportedHandler<Env>;
これがサーバー全体です。継続的な文字起こし、音声合成プロバイダーを追加し、onTurn()を実装します。
クライアント側は、Reactフックで接続することができます。
import { useVoiceAgent } from "@cloudflare/voice/react";
function App() {
const {
status,
transcript,
interimTranscript,
startCall,
endCall,
toggleMute
} = useVoiceAgent({ agent: "my-agent" });
return (
<div>
<p>Status: {status}</p>
{interimTranscript && <p><em>{interimTranscript}</em></p>}
<ul>
{transcript.map((msg, i) => (
<li key={i}>
<strong>{msg.role}:</strong> {msg.text}
</li>
))}
</ul>
<button onClick={startCall}>Start Call</button>
<button onClick={endCall}>End Call</button>
<button onClick={toggleMute}>Mute / Unmute</button>
</div>
);
}
Reactを使用していない場合は、VoiceClientを@cloudflare/voice/clientから直接使用することができます。
Agents SDKを使用すると、すべてのエージェントはDurable Objectです。これは、独自のSQLiteデータベース、WebSocket接続、およびアプリケーションロジックを備えたステートフルでアドレス可能なサーバーインスタンスです。音声パイプラインは、このモデルを置き換えるのではなく、拡張するものです。
大まかに説明すると、フローは次のようになります:
パイプラインの詳細を、順を追って説明します:
音声トランスポート: ブラウザはマイクの音声をキャプチャし、エージェントが既に使用しているのと同じWebSocket接続を介して16 kHzのモノPCMをストリーミングします。
STTセッションセットアップ:通話が開始されると、エージェントは通話時間中、継続的な文字起こしセッションを作成します。
STT入力: そのセッションに継続的に音声をストリーミングします。
STTターン検出: 音声・テキストモデル自体がユーザーの発語終了時期を判断し、そのターンの安定したトランスクリプトを出力します。
LLM/アプリケーションロジック:音声パイプラインは、その文字起こしをonTurn()メソッドに渡します。
TTS出力: 応答は音声に合成され、クライアントに返送されます。onTurn()がストリームを返すと、パイプラインが文単位でチャンク化し、文の準備が整い次第、音声の送信を開始します。
永続性: ユーザーとエージェントのメッセージはSQLiteに永続化されるため、会話履歴は再接続やデプロイメント後も維持されます。
エージェントの他の部分とともに音声を拡張すべき理由
多くのボイスフレームワークは、音声入力、文字起こし、モデル応答、音声出力といった、音声のループ自体に焦点を当てています。これらは重要なプリミティブですが、エージェントにとっては音声だけではありません。
本番環境で稼働するリアルエージェントは成長する状態、スケジューリング、永続性、ツール、ワークフロー、テレフォニー、そしてチャネル間でこれらすべての一貫性を維持する方法が必要です。エージェントが複雑化すると、音声は単独の機能ではなくなり、大きなシステムの一部となります。
私たちは、その前提からAIエージェントSDKに音声を取り入れたいと考えました。別個のスタックとしてボイスを構築するのではなく、同じDurable Objectベースのエージェントプラットフォーム上に構築しました。そのため、後でアプリケーションを再構築することなく、必要な残りのプリミティブを取り込むことができます。
ユーザーは入力から始めて、音声に切り替えて、テキストに戻るかもしれません。Agents SDKを使用すると、これらはすべて同じエージェントへの異なるインプットに過ぎません。SQLiteにも同じ会話履歴があり、同じツールが利用可能です。これにより、すっきりとしたメンタルモデルと、はるかにシンプルなアプリケーションアーキテクチャの両方が得られます。
音声体験はすぐに良し悪しを感じます。ユーザーが話すのを止めたら、システムは会話を感じさせるために十分な速さで文字起こし、考え、話し始める必要があります。
音声遅延の多くは、純粋なモデル時間ではありません。さまざまな場所のさまざまなサービス間で音声やテキストが行き来するためのコストです。音声はSTTに送信され、文字起こしはLLMに送信され、応答はTTSモデルに送信される必要があります。そして、それぞれの引き渡しがネットワークのオーバーヘッドを追加します。
Agents SDKの音声パイプラインを使用すると、エージェントはCloudflareのネットワーク上で実行され、内蔵プロバイダーはWorkers AIバインディングを使用します。それにより、パイプラインが緊密になり、自社でつなぎ合わせる必要があるインフラの量が減ります。
最初の文章をすばやく話すと、音声エージェントとの対話はより自然に感じられます(Time-to-First Audioとも呼ばれます)。onTurn()がストリームを返すと、パイプラインはそれをセンテンスに分割し、センテンスが完了すると合成を開始します。つまり、ユーザーは、残りの応答がまだ生成されている間にも、応答の最初を聞くことができるのです。
以下は、LLMレスポンスをストリーミングし、文章ごとに伝え始める完全な例です:
import { Agent, routeAgentRequest } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAITTS,
type VoiceTurnContext
} from "@cloudflare/voice";
import { streamText } from "ai";
import { createWorkersAI } from "workers-ai-provider";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async onTurn(transcript: string, context: VoiceTurnContext) {
const ai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/cloudflare/gpt-oss-20b"),
system: "You are a helpful voice assistant. Be concise.",
messages: [
...context.messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content
})),
{ role: "user" as const, content: transcript }
],
abortSignal: context.signal
});
return result.textStream;
}
}
export default {
async fetch(request: Request, env: Env) {
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
}
} satisfies ExportedHandler<Env>;
Context.messagesは、SQLiteでサポートされる最近の会話履歴を提供し、context.signalは、ユーザーが中断した場合にLLM呼び出しをパイプラインに中断させます。
すべての音声インターフェースが音声を返す必要はありません。時には、口頭、文字起こし、音声検索が必要になることもあります。このようなユースケースでは、withVoiceInput を使用できます。
import { Agent, type Connection } from "agents";
import { withVoiceInput, WorkersAINova3STT } from "@cloudflare/voice";
const InputAgent = withVoiceInput(Agent);
export class DictationAgent extends InputAgent<Env> {
transcriber = new WorkersAINova3STT(this.env.AI);
onTranscript(text: string, _connection: Connection) {
console.log("User said:", text);
}
}
クライアント側では、useVoiceInput を使用すると、文字起こしを中心とした軽量インターフェースが提供されます。
import { useVoiceInput } from "@cloudflare/voice/react";
const { transcript, interimTranscript, isListening, start, stop, clear } =
useVoiceInput({ agent: "DictationAgent" });
これは、音声が入力手段であり、完全な会話ループが必要でない場合に便利です。
同じクライアントが sendText("What's the weather?") を呼び出すことができます。これはSTTをバイパスし、テキストを onTurn() に直接送信します。アクティブコール中に、応答を音声でテキストとして表示することができます。通話以外は、テキストのみでも可能です。
これにより、実装を異なるコードパスに分割することなく、真のマルチモーダルエージェントが得られます。
音声エージェントはあくまでエージェントであることに変わりはありません。通常のAgents SDKの機能はすべて適用されます。
セッションが開始されると、通話者に挨拶することができます。
import { Agent, type Connection } from "agents";
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async onTurn(transcript: string) {
return `You said: ${transcript}`;
}
async onCallStart(connection: Connection) {
await this.speak(connection, "Hi! How can I help you today?");
}
}
音声によるリマインダーをスケジュールし、他のエージェントと同じようにLLMにツールを公開することができます。
import { Agent } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAITTS,
type VoiceTurnContext
} from "@cloudflare/voice";
import { streamText, tool } from "ai";
import { createWorkersAI } from "workers-ai-provider";
import { z } from "zod";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async speakReminder(payload: { message: string }) {
await this.speakAll(`Reminder: ${payload.message}`);
}
async onTurn(transcript: string, context: VoiceTurnContext) {
const ai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: ai("@cf/cloudflare/gpt-oss-20b"),
messages: [
...context.messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content
})),
{ role: "user" as const, content: transcript }
],
tools: {
set_reminder: tool({
description: "Set a spoken reminder after a delay",
inputSchema: z.object({
message: z.string(),
delay_seconds: z.number()
}),
execute: async ({ message, delay_seconds }) => {
await this.schedule(delay_seconds, "speakReminder", { message });
return { confirmed: true };
}
})
},
abortSignal: context.signal
});
return result.textStream;
}
}
また、音声パイプラインでは、接続ごとに動的に文字起こしモデルを選択することもできます。
例えば、会話型ターン取得にはFluxを、より精度の高い予測にはNova 3を好むことがあります。createTranscriber()をオーバーライドすることで、実行時に切り替えることができます。
import { Agent, type Connection } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAINova3STT,
WorkersAITTS,
type Transcriber
} from "@cloudflare/voice";
export class MyAgent extends VoiceAgent<Env> {
tts = new WorkersAITTS(this.env.AI);
createTranscriber(connection: Connection): Transcriber {
const url = new URL(connection.url ?? "http://localhost");
const model = url.searchParams.get("model");
if (model === "nova-3") {
return new WorkersAINova3STT(this.env.AI);
}
return new WorkersAIFluxSTT(this.env.AI);
}
}
クライアント側では、フックを介してクエリーパラメータを渡すことができます。
const voiceAgent = useVoiceAgent({
agent: "my-voice-agent",
query: { model: "nova-3" }
});
また、各ステージ間でデータを傍受することもできます。
afterTranscribe(文字起こし、接続)
beforeSynthesize(text, connection)
afterSynthesize(オーディオ、テキスト、接続)
これらのフックは、コンテンツのフィルタリング、テキストの正規化、言語固有のTransformations、カスタムロギングなどに便利です。
デフォルトでは、音声パイプラインは、1対1の音声エージェントのための最も簡単なパスとして、単一のWebSocket接続を使用します。しかし、それが唯一の選択肢ではありません。
Twilioアダプタを使用して、同じエージェントに通話を接続することができます。
import { TwilioAdapter } from "@cloudflare/voice-twilio";
export default {
async fetch(request: Request, env: Env) {
if (new URL(request.url).pathname === "/twilio") {
return TwilioAdapter.handleRequest(request, env, "MyAgent");
}
return (
(await routeAgentRequest(request, env)) ??
new Response("Not found", { status: 404 })
);
}
};
これにより、同じエージェントがWeb音声、テキスト入力、電話通話を処理することができます。
1つ注意すべき点は、デフォルトのWorkers AI TTSプロバイダーはMP3を返しますが、Twilioは8スムーズの音声を期待しています。本番環境のテレフォニーでは、PCMやMulawを直接出力するTTSプロバイダーを使いたい。
難しいネットワーク条件に適したトランスポートが必要な場合や、複数の参加者を含むトランスポートが必要な場合は、音声パッケージにSFUユーティリティが含まれ、カスタムトランスポートをサポートすることもできます。現在、デフォルトモデルはWebSocketネイティブですが、グローバルSFUインフラストラクチャに接続するためのアダプターも開発する予定です。
音声パイプラインは設計上、プロバイダーに依存しません。
内部では、各ステージは小さなインターフェイスによって定義されます。トランスクリプトは継続的なセッションを開き、到着した音声フレームを受け入れますが、TTSプロバイダーはテキストを取得して音声を返します。プロバイダーが音声出力をストリーミングできる場合、パイプラインはそれも使用できます。
interface Transcriber {
createSession(options?: TranscriberSessionOptions): TranscriberSession;
}
interface TranscriberSession {
feed(chunk: ArrayBuffer): void;
close(): void;
}
interface TTSProvider {
synthesize(text: string, signal?: AbortSignal): Promise<ArrayBuffer | null>;
}
Agents SDKの音声サポートが、モデルとトランスポートの1つの固定された組み合わせだけで動作するのは望みませんでした。私たちは、デフォルトのパスをシンプルにしつつ、エコシステムが成長するにつれて、他のプロバイダーを簡単にプラグインできるようにしたかったのです。
ビルトインのプロバイダーはWorkers AIを使用しているため、外部のAPIキーなしで始められます。
しかし、より大きな目標は相互運用性です。音声サービスや音声サービスを維持している場合、これらのインターフェースは十分に小さく、SDKの残りの内部を理解する必要はありません。STTプロバイダーが音声のストリーミングを受け入れて、音声の境界を検出できれば、トランスクリプトインターフェースを満たすことができます。TTSプロバイダーが音声出力をストリーミング可能であれば、さらに良いでしょう。
以下との相互運用性の実現に取り組みたいと考えています:
AssemblyAI、Rev.ai、SpeechmaticsなどのSTTプロバイダーや、リアルタイムの文字起こしAPIを持つあらゆるサービス
PlayHT、LMNT、Cartesia、Coqui、Amazon Polly、Google Cloud TTSなどのTTSプロバイダー
Vonage、Telnyx、Bandwidthなどのプラットフォーム用テレフォそれらのアダプター
WebRTCデータチャンネル、SFUブリッジ、その他の音声トランスポート層のトランスポート実装
また、個々のプロバイダーを超えたコラボレーションにも関心を持っています。
STT、LLM、TTSを組み合わせた遅延ベンチマーキング
非英語のボイスエージェント向けの多言語サポートとより良いドキュメント
アクセシビリティの作業、特にマルチモーダルインターフェースや音声障害に関するものです
音声インフラを構築していて、第一級の統合を確認したい場合は、PRを開くか、ご連絡ください。
ボイスパイプラインは、実験的なパッケージとしてご利用いただけます。
npm create cloudflare@latest -- --template cloudflare/agents-starter
@cloudflare/voiceを追加し、エージェントにトランスクライバーとTTSプロバイダーを提供し、デプロイして会話を始めましょう。APIリファレンスも読むことができます。
何か興味深いものを構築された方は、github.com/cloudflare/agentsでissueまたはPRを立ててください。ボイスには別のスタックを必要とすべきではなく、最高の音声エージェントは他のすべてのものと同じ耐久性のあるアプリケーションモデル上に構築されるものだと考えています。