speech-to-text、text-to-speech、会話の永続化でリアルタイム音声エージェントを作ります。音声は WebSocket でストリーミングします。SFU や会議基盤は不要です。 ベータ
@cloudflare/voice は、2 つのサーバーサイド mixin と対応するクライアントライブラリを提供します。
| Export | Import | 用途 |
|---|---|---|
withVoice |
@cloudflare/voice |
完全な音声エージェント: STT、LLM、TTS、永続化 |
withVoiceInput |
@cloudflare/voice |
STT のみ: 応答なしの文字起こし |
useVoiceAgent |
@cloudflare/voice/react |
withVoice エージェント向けの React フック |
useVoiceInput |
@cloudflare/voice/react |
withVoiceInput エージェント向けの React フック |
VoiceClient |
@cloudflare/voice/client |
フレームワーク非依存のクライアント |
Cloudflare Durable Objects 上に構築されており、次が得られます。
- リアルタイム音声 — マイク音声はバイナリ WebSocket フレームとして流れ、TTS 音声が戻ります
- 会話の自動永続化 — メッセージは SQLite に保存され、再起動後も残ります
- ストリーミング TTS — LLM トークンを文単位に分割し、並行して合成します
- 割り込み処理 — 再生中のユーザー発話が現在の応答をキャンセルします
- 連続 STT — 通話ごとの文字起こしセッション。モデルがターン検出を扱います
- パイプラインフック — 各段階でテキストを傍受し、変換します
npm install @cloudflare/voice agentsimport { Agent } from "agents";
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async onTurn(transcript, context) {
return "Hello! I heard you say: " + transcript;
}
}import { Agent } 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 "Hello! I heard you say: " + transcript;
}
}import { useVoiceAgent } from "@cloudflare/voice/react";
function VoiceUI() {
const {
status,
transcript,
interimTranscript,
audioLevel,
isMuted,
startCall,
endCall,
toggleMute,
} = useVoiceAgent({ agent: "MyAgent" });
return (
<div>
<p>Status: {status}</p>
<button onClick={status === "idle" ? startCall : endCall}>
{status === "idle" ? "Start Call" : "End Call"}
</button>
<button onClick={toggleMute}>{isMuted ? "Unmute" : "Mute"}</button>
{interimTranscript && (
<p>
<em>{interimTranscript}</em>
</p>
)}
{transcript.map((msg, i) => (
<p key={i}>
<strong>{msg.role}:</strong> {msg.text}
</p>
))}
</div>
);
}{
"ai": {
"binding": "AI"
},
"durable_objects": {
"bindings": [
{
"name": "MyAgent",
"class_name": "MyAgent"
}
]
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["MyAgent"]
}
]
}[ai]
binding = "AI"
[[durable_objects.bindings]]
name = "MyAgent"
class_name = "MyAgent"
[[migrations]]
tag = "v1"
new_sqlite_classes = [ "MyAgent" ]Browser Durable Object (withVoice)
┌──────────┐ ┌──────────────────────────┐
│ Mic │ binary PCM (16kHz) │ Transcriber session │
│ │ ──────────────────────► │ (per-call, continuous) │
│ │ │ ↓ model detects turn │
│ │ JSON: transcript │ onTurn() → your LLM code │
│ │ ◄────────────────────── │ ↓ (sentence chunking) │
│ │ binary: audio │ TTS │
│ Speaker │ ◄────────────────────── │ │
└──────────┘ └──────────────────────────┘- クライアントはマイク音声を取り込み、バイナリ WebSocket フレーム(16kHz モノラル 16-bit PCM)として送ります。
- 音声は文字起こしセッションへ継続的に流れます(
start_callで作成され、通話全体のあいだ存続します)。 - STT モデルはユーザーの発話終了を検出し、
onUtteranceを発火します。すべてのプロバイダーは モデル駆動のターン検出 を使います。クライアントが STT 向けに発話終了を通知する必要はありません。 onTurn()メソッドが実行されます。通常は LLM 呼び出しです。- 応答は文単位に分割され、TTS で合成されます。
- 音声がクライアントへ流れ、再生されます。
ユーザーが話しているあいだ、クライアントは部分結果を含む transcript_interim メッセージを受け取るため、UI にリアルタイムのフィードバックを出せます。
withVoice(Agent) は、Agent クラスに音声パイプライン一式を追加します。
プロバイダーはクラスプロパティとして設定します。クラスフィールドの初期化は super() のあとに走るため、this.env を使えます。
| Property | Type | Required | 説明 |
|---|---|---|---|
transcriber |
Transcriber |
Yes | 通話ごとの連続 STT プロバイダー |
tts |
TTSProvider |
Yes | Text-to-speech |
import { withVoice, WorkersAIFluxSTT, WorkersAITTS } from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
}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);
}実行時のモデル切り替え(Flux と Nova 3 のドロップダウンなど)には、createTranscriber をオーバーライドします。
export class MyAgent extends VoiceAgent {
tts = new WorkersAITTS(this.env.AI);
createTranscriber(connection) {
return new WorkersAIFluxSTT(this.env.AI);
}
}export class MyAgent extends VoiceAgent<Env> {
tts = new WorkersAITTS(this.env.AI);
createTranscriber(connection: Connection): Transcriber {
return new WorkersAIFluxSTT(this.env.AI);
}
}必須です。 ユーザーが話し終わり、文字起こしの準備ができたときに呼ばれます。context.messages には、この文字起こしより前の完了済み会話履歴が含まれます。LLM メッセージリストを組み立てるときは、transcript をちょうど 1 回追加します。
ストリーミング応答には string、AsyncIterable<string>、または ReadableStream を返します。
単純な応答:
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async onTurn(transcript, context) {
return "You said: " + transcript;
}
}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;
}
}ストリーミング応答(LLM では推奨):
import { streamText } from "ai";
import { createWorkersAI } from "workers-ai-provider";
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
async onTurn(transcript, context) {
const workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/moonshotai/kimi-k2.6"),
system: "You are a helpful voice assistant. Keep responses concise.",
messages: [
...context.messages.map((m) => ({
role: m.role,
content: m.content,
})),
{ role: "user", content: transcript },
],
abortSignal: context.signal,
});
return result.textStream;
}
}import { streamText } from "ai";
import { createWorkersAI } from "workers-ai-provider";
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 workersai = createWorkersAI({ binding: this.env.AI });
const result = streamText({
model: workersai("@cf/moonshotai/kimi-k2.6"),
system: "You are a helpful voice assistant. Keep responses concise.",
messages: [
...context.messages.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user", content: transcript },
],
abortSignal: context.signal,
});
return result.textStream;
}
}context オブジェクトは次を提供します。
| Field | Type | 説明 |
|---|---|---|
connection |
Connection |
WebSocket 接続 |
messages |
Array<{ role: string; content: string }> |
文字起こしより前の完了済み履歴 |
signal |
AbortSignal |
割り込みまたは切断時に中止 |
| Method | 説明 |
|---|---|
beforeCallStart(connection) |
通話を拒否するには false を返します |
onCallStart(connection) |
通話が受け入れられたあとに呼ばれます |
onCallEnd(connection) |
通話が終了したときに呼ばれます |
onInterrupt(connection) |
再生中にユーザーが割り込んだときに呼ばれます |
パイプラインの各段階でデータを傍受し、変換します。現在の発話をスキップするには null を返します。
| Method | 受け取るもの | スキップ可? |
|---|---|---|
afterTranscribe(transcript, connection) |
STT テキスト | 可 |
beforeSynthesize(text, connection) |
TTS 前のテキスト | 可 |
afterSynthesize(audio, text, connection) |
TTS 後の音声 | 可 |
import {} from "agents";
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
afterTranscribe(transcript, connection) {
if (transcript.length < 3) return null;
return transcript;
}
beforeSynthesize(text, connection) {
return text.replace(/\bAI\b/g, "A.I.");
}
async onTurn(transcript, context) {
return transcript;
}
}import { type Connection } from "agents";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
afterTranscribe(transcript: string, connection: Connection) {
if (transcript.length < 3) return null;
return transcript;
}
beforeSynthesize(text: string, connection: Connection) {
return text.replace(/\bAI\b/g, "A.I.");
}
async onTurn(transcript: string, context: VoiceTurnContext) {
return transcript;
}
}| Method | 説明 |
|---|---|
speak(connection, text) |
合成し、1 つの接続へ音声を送ります |
speakAll(text) |
合成し、すべての接続へ音声を送ります |
forceEndCall(connection) |
プログラムから通話を終了します |
saveMessage(role, text) |
メッセージを会話履歴へ永続化します |
getConversationHistory() |
SQLite から会話履歴を取得します |
2 番目の引数としてオプションを withVoice() に渡します。
const VoiceAgent = withVoice(Agent, {
historyLimit: 20,
audioFormat: "mp3",
maxMessageCount: 1000,
});const VoiceAgent = withVoice(Agent, {
historyLimit: 20,
audioFormat: "mp3",
maxMessageCount: 1000,
});| Option | Type | Default | 説明 |
|---|---|---|---|
historyLimit |
number |
20 |
コンテキストとして読み込むメッセージの最大数 |
audioFormat |
string |
"mp3" |
クライアントへ送る音声形式 |
maxMessageCount |
number |
1000 |
SQLite に保存するメッセージの最大数 |
withVoiceInput(Agent) は STT のみの音声入力を追加します。TTS、LLM、応答生成はありません。ディクテーション、音声検索、会話エージェントなしで speech-to-text が必要な UI に使います。
import { Agent } from "agents";
import { withVoiceInput, WorkersAINova3STT } from "@cloudflare/voice";
const InputAgent = withVoiceInput(Agent);
export class DictationAgent extends InputAgent {
transcriber = new WorkersAINova3STT(this.env.AI);
onTranscript(text, connection) {
console.log("User said:", text);
}
}import { Agent } 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);
}
}各発話が文字起こしされたあとに呼ばれます。文字起こしを処理するには、このメソッドをオーバーライドします。
withVoiceInput は withVoice と同じライフサイクルフックをサポートします。
beforeCallStart(connection)— 拒否するにはfalseを返しますonCallStart(connection)、onCallEnd(connection)、onInterrupt(connection)createTranscriber(connection)— 実行時のモデル切り替え用にオーバーライドしますafterTranscribe(transcript, connection)— 文字起こしをフィルタまたは変換します
TTS フック(beforeSynthesize、afterSynthesize)や onTurn は ありません。
withVoice エージェント向けに VoiceClient をラップします。接続、マイク取り込み、再生、無音検出、割り込み検出を管理します。
import { useVoiceAgent } from "@cloudflare/voice/react";
const selectedSpeakerId = "default";
const {
status, // "idle" | "listening" | "thinking" | "speaking"
transcript, // TranscriptMessage[] — conversation history
interimTranscript, // string | null — real-time partial transcript
metrics, // VoicePipelineMetrics | null
audioLevel, // number (0–1) — current mic RMS level
isMuted, // boolean
connected, // boolean — WebSocket connected
error, // string | null
outputDeviceError, // string | null — non-fatal speaker routing issue
startCall, // () => Promise<void>
endCall, // () => void
toggleMute, // () => void
sendText, // (text: string) => void — bypass STT
sendJSON, // (data: Record<string, unknown>) => void
lastCustomMessage, // unknown — last non-voice message from server
} = useVoiceAgent({
agent: "MyAgent",
name: "default",
host: window.location.host,
outputDeviceId: selectedSpeakerId, // Optional audiooutput device ID
enabled: true,
});ユーザー単位の capability トークンなど、非同期の接続前提条件を待つ必要があるときは enabled: false を使います。無効のあいだ、フックは VoiceClient を作成も接続もしません。アイドルの切断状態を返し、startCall()、sendText()、sendJSON() などのアクションコールバックは安全な no-op です。
enabled が true になると、フックは現在のオプションで接続します。最初の有効化は初期接続として扱われるため、onReconnect はフックが有効のまま後続の接続 ID が変わったときだけ発火します。
ブラウザーが HTMLMediaElement.setSinkId() をサポートする場合、outputDeviceId を渡すとアシスタントの再生を選択したスピーカーへルーティングします。
const [outputDeviceId, setOutputDeviceId] = useState("default");
const voice = useVoiceAgent({
agent: "MyAgent",
outputDeviceId,
});const [outputDeviceId, setOutputDeviceId] = useState("default");
const voice = useVoiceAgent({
agent: "MyAgent",
outputDeviceId,
});kind === "audiooutput" の navigator.mediaDevices.enumerateDevices() から得た MediaDeviceInfo.deviceId を使います。"default" と undefined はシステムのデフォルト出力を使います。シンク選択をサポートしないブラウザーはデフォルト出力で再生を続け、デフォルト以外の出力が要求されると outputDeviceError を設定します。マイク許可が降りるまでデバイスラベルは空のことがあるため、スピーカー選択 UI を出す場合は startCall() のあとにデバイス一覧を更新してください。
| Option | Type | Default | 説明 |
|---|---|---|---|
enabled |
boolean |
true |
false のときクライアントの作成と接続を遅らせます |
silenceThreshold |
number |
0.04 |
この値未満の RMS を無音とみなします |
silenceDurationMs |
number |
500 |
end_of_speech までの無音時間(ミリ秒) |
interruptThreshold |
number |
0.05 |
再生中の発話を検出する RMS |
interruptChunks |
number |
2 |
割り込みを起こす連続した高 RMS チャンク数 |
チューニングオプションを変えるとクライアントが再接続します(接続キーに含まれるためです)。
ディクテーションと音声テキスト化向けの軽量フックです。ユーザーの文字起こしを 1 つの文字列に蓄積します。
import { useVoiceInput } from "@cloudflare/voice/react";
function Dictation() {
const {
transcript, // string — accumulated text from all utterances
interimTranscript, // string | null — current partial transcript
isListening, // boolean
audioLevel, // number (0–1)
isMuted, // boolean
error, // string | null
start, // () => Promise<void>
stop, // () => void
toggleMute, // () => void
clear, // () => void — clear accumulated transcript
} = useVoiceInput({ agent: "DictationAgent" });
return (
<div>
<textarea
value={transcript + (interimTranscript ? " " + interimTranscript : "")}
readOnly
/>
<button onClick={isListening ? stop : start}>
{isListening ? "Stop" : "Dictate"}
</button>
</div>
);
}React のない環境向けのフレームワーク非依存クライアントです。
import { VoiceClient } from "@cloudflare/voice/client";
const client = new VoiceClient({ agent: "MyAgent" });
client.addEventListener("statuschange", (status) => {
console.log("Status:", status);
});
client.addEventListener("transcriptchange", (messages) => {
console.log("Transcript:", messages);
});
client.addEventListener("error", (err) => {
console.error("Error:", err);
});
client.connect();
await client.startCall();
// Switch assistant playback without reconnecting the call.
await client.setOutputDevice(selectedSpeakerId);
// Later:
client.endCall();
client.disconnect();import { VoiceClient } from "@cloudflare/voice/client";
const client = new VoiceClient({ agent: "MyAgent" });
client.addEventListener("statuschange", (status) => {
console.log("Status:", status);
});
client.addEventListener("transcriptchange", (messages) => {
console.log("Transcript:", messages);
});
client.addEventListener("error", (err) => {
console.error("Error:", err);
});
client.connect();
await client.startCall();
// Switch assistant playback without reconnecting the call.
await client.setOutputDevice(selectedSpeakerId);
// Later:
client.endCall();
client.disconnect();| Event | Data type | 説明 |
|---|---|---|
statuschange |
VoiceStatus |
パイプラインの状態が変わりました |
transcriptchange |
TranscriptMessage[] |
文字起こしが更新されました |
interimtranscript |
string | null |
ストリーミング STT からの途中の文字起こし |
metricschange |
VoicePipelineMetrics |
パイプラインのタイミング指標 |
audiolevelchange |
number |
マイク音声レベル(0–1) |
connectionchange |
boolean |
WebSocket の接続 / 切断 |
mutechange |
boolean |
ミュート状態が変わりました |
error |
string | null |
エラーが発生しました |
outputdeviceerror |
string | null |
致命的ではないスピーカールーティングの問題 |
custommessage |
unknown |
サーバーからの非音声メッセージ |
| Option | Type | 説明 |
|---|---|---|
transport |
VoiceTransport |
カスタムトランスポート(デフォルト: PartySocket 経由の WebSocket) |
audioInput |
VoiceAudioInput |
カスタムのマイク取り込み(デフォルト: 組み込み AudioWorklet) |
preferredFormat |
VoiceAudioFormat |
サーバー音声形式のヒント(参考情報のみ) |
outputDeviceId |
string |
アシスタント再生に使う希望の audiooutput デバイス |
API キーは不要です。Workers AI バインディングを使います。
| Class | Type | Default model | 推奨用途 |
|---|---|---|---|
WorkersAIFluxSTT |
Continuous STT | @cf/deepgram/flux |
withVoice |
WorkersAINova3STT |
Continuous STT | @cf/deepgram/nova-3 |
withVoiceInput |
WorkersAITTS |
TTS | @cf/deepgram/aura-1 |
Both |
import { Agent } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAINova3STT,
WorkersAITTS,
} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
// Default usage
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
}
// Custom options
export class CustomAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI, {
eotThreshold: 0.8,
keyterms: ["Cloudflare", "Workers"],
});
tts = new WorkersAITTS(this.env.AI, {
model: "@cf/deepgram/aura-1",
speaker: "asteria",
});
}import { Agent } from "agents";
import {
withVoice,
WorkersAIFluxSTT,
WorkersAINova3STT,
WorkersAITTS,
} from "@cloudflare/voice";
const VoiceAgent = withVoice(Agent);
// Default usage
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new WorkersAITTS(this.env.AI);
}
// Custom options
export class CustomAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI, {
eotThreshold: 0.8,
keyterms: ["Cloudflare", "Workers"],
});
tts = new WorkersAITTS(this.env.AI, {
model: "@cf/deepgram/aura-1",
speaker: "asteria",
});
}| Package | Class | 説明 |
|---|---|---|
@cloudflare/voice-deepgram |
DeepgramSTT |
連続 STT |
@cloudflare/voice-elevenlabs |
ElevenLabsTTS |
高品質 TTS |
@cloudflare/voice-telnyx |
TelnyxSTT, TelnyxTTS |
STT、TTS、テレフォニー |
@cloudflare/voice-twilio |
TwilioAdapter |
テレフォニー(電話) |
@cloudflare/voice-plivo |
PlivoAdapter |
テレフォニー(電話) |
ElevenLabs TTS:
import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";
export class MyAgent extends VoiceAgent {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new ElevenLabsTTS({
apiKey: this.env.ELEVENLABS_API_KEY,
voiceId: "21m00Tcm4TlvDq8ikWAM",
});
}import { ElevenLabsTTS } from "@cloudflare/voice-elevenlabs";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new WorkersAIFluxSTT(this.env.AI);
tts = new ElevenLabsTTS({
apiKey: this.env.ELEVENLABS_API_KEY,
voiceId: "21m00Tcm4TlvDq8ikWAM",
});
}Deepgram STT:
import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyAgent extends VoiceAgent {
transcriber = new DeepgramSTT({
apiKey: this.env.DEEPGRAM_API_KEY,
});
tts = new WorkersAITTS(this.env.AI);
}import { DeepgramSTT } from "@cloudflare/voice-deepgram";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new DeepgramSTT({
apiKey: this.env.DEEPGRAM_API_KEY,
});
tts = new WorkersAITTS(this.env.AI);
}Telnyx STT と TTS:
サーバーセーフな /stt と /tts サブパスからインポートします。
import { TelnyxSTT } from "@cloudflare/voice-telnyx/stt";
import { TelnyxTTS } from "@cloudflare/voice-telnyx/tts";
export class MyAgent extends VoiceAgent {
transcriber = new TelnyxSTT({
apiKey: this.env.TELNYX_API_KEY,
engine: "Telnyx", // or "Deepgram"
interimResults: true,
});
tts = new TelnyxTTS({
apiKey: this.env.TELNYX_API_KEY,
voice: "Telnyx.NaturalHD.astra",
});
}import { TelnyxSTT } from "@cloudflare/voice-telnyx/stt";
import { TelnyxTTS } from "@cloudflare/voice-telnyx/tts";
export class MyAgent extends VoiceAgent<Env> {
transcriber = new TelnyxSTT({
apiKey: this.env.TELNYX_API_KEY,
engine: "Telnyx", // or "Deepgram"
interimResults: true,
});
tts = new TelnyxTTS({
apiKey: this.env.TELNYX_API_KEY,
voice: "Telnyx.NaturalHD.astra",
});
}TelnyxTTS のデフォルトは backend: "rest" です。最初の音声までの時間を短くするには backend: "websocket" を設定します。このバックエンドには Workers ランタイムが必要です。
テレフォニーは、ブラウザークライアントと同じ withVoice エージェントへ電話を接続します。通話はそのエージェントインスタンスの会話履歴、状態、ツール、スケジュールを共有します。1 つのエージェントが電話と Web の両方に応答できます。
プロバイダーは 2 つの方式のいずれかを取ります。方式によって通話音声の到達先と、デプロイするものが決まります。
| Provider | 方式 | 通話音声の到達先 | 用途 |
|---|---|---|---|
| Twilio | Worker 内のサーバーサイドアダプター | Worker | サーバー側で応答する着信番号 |
| Plivo | Worker 内のサーバーサイドアダプター | Worker | サーバー側で応答する着信番号 |
| Telnyx | ブラウザー WebRTC ブリッジ | ブラウザー | 配布するアプリ内のソフトフォンとクリックツーコール |
プロバイダー向けのアダプターをインストールします。
npm i @cloudflare/voice-twilioyarn add @cloudflare/voice-twiliopnpm add @cloudflare/voice-twiliobun add @cloudflare/voice-twilionpm i @cloudflare/voice-plivoyarn add @cloudflare/voice-plivopnpm add @cloudflare/voice-plivobun add @cloudflare/voice-plivoアダプターはプロバイダーの音声 WebSocket を Worker 内で終端し、プロバイダーの 8 kHz mulaw 音声とエージェントの 16 kHz PCM プロトコルを変換します。
Phone → provider → WebSocket → adapter → WebSocket → VoiceAgentブラウザーは介在しません。各アダプターは handleRequest() メソッドを公開し、プロバイダーの WebSocket パス向けに fetch ハンドラーから呼び出します。デフォルトでは、各通話はプロバイダーの通話識別子を名前にした独自のエージェントインスタンスを持ちます。
そのパス以外では、2 つのプロバイダーが求めるものは異なります。Twilio は Worker を指す TwiML で設定します。Plivo には auth ID、auth token、電話番号が必要です。デプロイすると Plivo アプリケーションが自動プロビジョニングされ、answer URL が Worker を指します。Plivo コンソールでの手動設定は不要です。
npm i @cloudflare/voice-telnyxyarn add @cloudflare/voice-telnyxpnpm add @cloudflare/voice-telnyxbun add @cloudflare/voice-telnyxTelnyx は PSTN 通話をブラウザーの WebRTC 経由で橋渡しし、既存の音声クライアントトランスポートを再利用します。
Phone ↔ Telnyx ↔ WebRTC ↔ browser bridge ↔ WebSocket → VoiceAgentブラウザーが WebRTC セッションを保持するため、短命の Telnyx 資格情報が必要です。API キーは使わないでください。TelnyxJWTEndpoint はサーバー側でトークンを発行し、authorize コールバックを必須にします。公開ルートが任意の発信者向けに資格情報を発行できないようにするためです。テレフォニーには TELNYX_API_KEY に加えて TELNYX_CREDENTIAL_CONNECTION_ID が必要です。
Telnyx は STT と TTS も提供するため、パイプライン全体を賄えます。詳細は サードパーティプロバイダー を参照してください。
WorkersAITTS は MP3 を返すため、Workers ランタイムでは PCM にデコードできません。Twilio または Plivo アダプターでは、生 PCM を出力する TTS プロバイダーを使います。例として ElevenLabs の outputFormat: "pcm_16000"、または encoding: "linear16" と container: "none" で呼び出す Workers AI モデルです。
この制約は Telnyx には当てはまりません。ブラウザーが再生前に音声をデコードするためです。
各アダプターには、Worker ルート、プロバイダー設定、デプロイ手順を含む実行可能な例が付属します。
Plivo 音声エージェント
Telnyx 音声エージェント
withVoice エージェントはテキストメッセージも受け取れます。STT を完全にバイパスします。音声と並ぶチャット形式の入力に使えます。
const { sendText } = useVoiceAgent({ agent: "MyAgent" });
// Send text — goes straight to onTurn() without STT
sendText("What is the weather like today?");テキストメッセージは、通話中でも通話外でも動作します。通話中は TTS で応答を読み上げます。通話外では、テキストのみの文字起こしメッセージとして応答が送られます。
音声プロトコルメッセージと並んで、アプリケーションレベルの JSON メッセージを送受信します。非音声メッセージはサーバーの onMessage ハンドラーへ渡り、クライアントでは custommessage イベントを発火します。
サーバー:
export class MyAgent extends VoiceAgent {
onMessage(connection, message) {
const data = JSON.parse(message);
if (data.type === "kick_speaker") {
this.forceEndCall(connection);
}
}
}export class MyAgent extends VoiceAgent<Env> {
onMessage(connection: Connection, message: WSMessage) {
const data = JSON.parse(message as string);
if (data.type === "kick_speaker") {
this.forceEndCall(connection);
}
}
}クライアント:
const { sendJSON, lastCustomMessage } = useVoiceAgent({ agent: "MyAgent" });
sendJSON({ type: "kick_speaker" });
useEffect(() => {
if (lastCustomMessage) {
console.log("Custom message:", lastCustomMessage);
}
}, [lastCustomMessage]);通話を開始できる相手を制限するには beforeCallStart を使います。この例は単一スピーカーを強制します。同時にアクティブなスピーカーになれる接続は 1 つだけです。
import {} from "agents";
export class MyAgent extends VoiceAgent {
#speakerId = null;
beforeCallStart(connection) {
if (this.#speakerId !== null) {
return false;
}
this.#speakerId = connection.id;
return true;
}
onCallEnd(connection) {
if (this.#speakerId === connection.id) {
this.#speakerId = null;
}
}
}import { type Connection } from "agents";
export class MyAgent extends VoiceAgent<Env> {
#speakerId: string | null = null;
beforeCallStart(connection: Connection) {
if (this.#speakerId !== null) {
return false;
}
this.#speakerId = connection.id;
return true;
}
onCallEnd(connection: Connection) {
if (this.#speakerId === connection.id) {
this.#speakerId = null;
}
}
}withVoice エージェントは、各ターンのあとにタイミング指標を発行します。
const { metrics } = useVoiceAgent({ agent: "MyAgent" });
// metrics: {
// llm_ms: 850,
// tts_ms: 200,
// first_audio_ms: 950,
// total_ms: 1200,
// }withVoice は会話メッセージを SQLite へ自動的に永続化します。onTurn() では、context.messages は現在の文字起こしより前の完了済み履歴のスナップショットです。パイプラインはフックを呼び出す前に現在の文字起こしを永続化します。そのため、onTurn() 内で直接 getConversationHistory() を呼ぶと、それが含まれます。
// Get stored history, including the current transcript during onTurn()
const history = this.getConversationHistory(20);
this.saveMessage("assistant", "Welcome! How can I help?");// Get stored history, including the current transcript during onTurn()
const history = this.getConversationHistory(20);
this.saveMessage("assistant", "Welcome! How can I help?");履歴は Durable Object の再起動とクライアント再接続後も残ります。音声エージェントは keepAlive を使い、通話中の退避を防ぎます。