Skip to content

非公式本サイトは非公式の日本語ドキュメントであり、Cloudflare 公式サイトではありません。最新情報はdevelopers.cloudflare.comをご確認ください。

音声

最終更新 Markdown で表示Agent セットアップ

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 agents

サーバー

import { 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;
	}
}

クライアント(React)

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>
	);
}

Wrangler 設定

{
	"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  │ ◄────────────────────── │                          │
└──────────┘                         └──────────────────────────┘
  1. クライアントはマイク音声を取り込み、バイナリ WebSocket フレーム(16kHz モノラル 16-bit PCM)として送ります。
  2. 音声は文字起こしセッションへ継続的に流れます(start_call で作成され、通話全体のあいだ存続します)。
  3. STT モデルはユーザーの発話終了を検出し、onUtterance を発火します。すべてのプロバイダーは モデル駆動のターン検出 を使います。クライアントが STT 向けに発話終了を通知する必要はありません。
  4. onTurn() メソッドが実行されます。通常は LLM 呼び出しです。
  5. 応答は文単位に分割され、TTS で合成されます。
  6. 音声がクライアントへ流れ、再生されます。

ユーザーが話しているあいだ、クライアントは部分結果を含む transcript_interim メッセージを受け取るため、UI にリアルタイムのフィードバックを出せます。

サーバー API: withVoice

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);
	}
}

onTurn(transcript, context)

必須です。 ユーザーが話し終わり、文字起こしの準備ができたときに呼ばれます。context.messages には、この文字起こしより前の完了済み会話履歴が含まれます。LLM メッセージリストを組み立てるときは、transcript をちょうど 1 回追加します。

ストリーミング応答には stringAsyncIterable<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 に保存するメッセージの最大数

サーバー API: withVoiceInput

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);
	}
}

onTranscript(text, connection)

各発話が文字起こしされたあとに呼ばれます。文字起こしを処理するには、このメソッドをオーバーライドします。

フック

withVoiceInputwithVoice と同じライフサイクルフックをサポートします。

  • beforeCallStart(connection) — 拒否するには false を返します
  • onCallStart(connection)onCallEnd(connection)onInterrupt(connection)
  • createTranscriber(connection) — 実行時のモデル切り替え用にオーバーライドします
  • afterTranscribe(transcript, connection) — 文字起こしをフィルタまたは変換します

TTS フック(beforeSynthesizeafterSynthesize)や onTurnありません

クライアント API: React フック

useVoiceAgent

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 です。

enabledtrue になると、フックは現在のオプションで接続します。最初の有効化は初期接続として扱われるため、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 チャンク数

チューニングオプションを変えるとクライアントが再接続します(接続キーに含まれるためです)。

useVoiceInput

ディクテーションと音声テキスト化向けの軽量フックです。ユーザーの文字起こしを 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>
	);
}

クライアント API: VoiceClient

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 デバイス

プロバイダー

組み込み(Workers AI)

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 ブリッジ ブラウザー 配布するアプリ内のソフトフォンとクリックツーコール

サーバーサイドアダプター(Twilio と Plivo)

プロバイダー向けのアダプターをインストールします。

npm i @cloudflare/voice-twilio
npm i @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 コンソールでの手動設定は不要です。

ブラウザー WebRTC ブリッジ(Telnyx)

npm i @cloudflare/voice-telnyx

Telnyx は PSTN 通話をブラウザーの WebRTC 経由で橋渡しし、既存の音声クライアントトランスポートを再利用します。

Phone ↔ Telnyx ↔ WebRTC ↔ browser bridge ↔ WebSocket → VoiceAgent

ブラウザーが WebRTC セッションを保持するため、短命の Telnyx 資格情報が必要です。API キーは使わないでください。TelnyxJWTEndpoint はサーバー側でトークンを発行し、authorize コールバックを必須にします。公開ルートが任意の発信者向けに資格情報を発行できないようにするためです。テレフォニーには TELNYX_API_KEY に加えて TELNYX_CREDENTIAL_CONNECTION_ID が必要です。

Telnyx は STT と TTS も提供するため、パイプライン全体を賄えます。詳細は サードパーティプロバイダー を参照してください。

テレフォニー向け PCM 出力

WorkersAITTS は MP3 を返すため、Workers ランタイムでは PCM にデコードできません。Twilio または Plivo アダプターでは、生 PCM を出力する TTS プロバイダーを使います。例として ElevenLabs の outputFormat: "pcm_16000"、または encoding: "linear16"container: "none" で呼び出す Workers AI モデルです。

この制約は Telnyx には当てはまりません。ブラウザーが再生前に音声をデコードするためです。

完全な例

各アダプターには、Worker ルート、プロバイダー設定、デプロイ手順を含む実行可能な例が付属します。

Telnyx 音声エージェント

JWT エンドポイントとクライアント配線を含め、ブラウザー経由で PSTN 通話を橋渡しします。

テキストメッセージ

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 を使い、通話中の退避を防ぎます。

役に立ちましたか?