Azure Communication Services

ACS Call Automationを介して、ElevenLabsエージェントが応答する電話番号にユーザーが発信できるようにします。

概要

この方法では、エージェントに電話番号を割り当てます。発信者がその番号に電話をかけると、Azure Communication Services(ACS)が双方向メディアストリーミングで応答し、小さなブリッジが標準のエージェントWebSocketプロトコルを使用して、ACSとElevenLabsエージェント間でPCMオーディオを中継します。これはコンタクトセンター/IVRのパターンであり、SIPトランキングのデプロイと同じ構成で、ACSがキャリアとなります。

Teamsにも2通りの方法で接続できます。Calling Planを持つTeamsユーザーはACS番号に直接発信できます。また、Teams Phone Extensibilityを番号の前段に置くことで、Teamsリソースアカウントへの通話をACSにルーティングできます。

ACSがPSTN番号を提供しているのは、限られた 国のみです。 お住まいの地域で番号を利用できない場合は、代わりにSIP トランキング対応のSIPプロバイダー、またはGraph通話 ボットを使用してください。

仕組み

発信者がACS番号に電話をかけると、ACSがEvent Grid経由でIncomingCallをブリッジに送信します。ブリッジは双方向PCM 16kメディアストリーミングで応答し、WebSocket経由でElevenLabsエージェントに中継します
着信通話 → ACS → ブリッジ → ElevenLabs

オーディオは両方の経路でPCM 16kHzモノラル(エージェントの入出力形式はpcm_16000)のため、リサンプリングなしでbase64としてそのまま渡されます。

ブリッジは次のルートを公開します。

ルート用途
POST /api/incomingCallEvent Grid Webhook:サブスクリプションを検証してから、メディアストリーミング付きでanswer_callを実行
POST /api/callbacksCall Automationのライフサイクルイベント(CallConnected、CallDisconnected、AddParticipant*)
GET|WS /wsACSメディアストリーミングソケット ↔ ElevenLabs
POST /api/outboundCall任意:応答者をエージェントに接続する発信通話を開始

要件

  1. 有料のAzureサブスクリプション(MCA/EA/従量課金制)— 無料版、試用版、スポンサーシップのサブスクリプションでは番号を購入できません。
  2. Azure Communication Servicesリソース。
  3. パブリックWebSocketを備えたブリッジ用HTTPSホスト(Azure Container Apps、App Service、またはVM)。
  4. 両方の経路でPCM 16000Hzに設定したElevenLabsエージェント:VoiceタブでTTS出力形式を、Advancedタブでユーザー入力オーディオ形式を設定します。

権限とロール

スコープロール/権限理由
Azure RBACリソースグループの共同作成者ACSリソース、Container App、Event Gridサブスクリプションを作成するため
Azureサブスクリプションサブスクリプションの所有者または共同作成者電話番号を購入するため(それ以外の場合、購入オプションは無効です)
請求MCA/EA/従量課金制のサブスクリプションタイプ無料、試用、スポンサーシップ、Devサブスクリプションでは番号を購入できません

共同作成者権限の場合(所有者ではない場合)、az containerapp upはマネージドIDのACRプル ロール割り当てを作成できません。代わりにレジストリの管理者ユーザーを有効にしてアタッチしてください。手順2の警告を参照してください。

手順1 — ACSリソースと番号をプロビジョニングする

RG=my-rg
# Register providers (once)
az provider register -n Microsoft.Communication --wait
az provider register -n Microsoft.EventGrid --wait
# Create the ACS resource
az communication create --name my-acs --resource-group $RG \
--location global --data-location unitedstates

リソース内で番号を購入します(ポータル → ACSリソース → Phone numbers → Get、またはphone-numbers SDK)。通話に応答するエージェントでは、着信通話対応の番号で十分です。/api/outboundCallも使用する場合は、発信機能も追加してください。

通話機能とともにアクティブな番号を一覧表示するACSリソースのPhone numbersブレード

ACSリソースの電話番号 — Calling列には各番号の発着信方向が表示されます

CLIから確認するには(az extension add --name communicationが必要)、また、ブリッジがACS_CONNECTION_STRINGとして使用する接続文字列を取得するには、次を実行します。

CONN=$(az communication list-key -n my-acs -g $RG --query primaryConnectionString -o tsv)
az communication phonenumber list --connection-string "$CONN" --query "[].phoneNumber"

手順2 — ブリッジをデプロイする

ブリッジは、azure-communication-callautomationを使用する小規模なFlask + flask-sockアプリです。着信フローの中核は次のとおりです。

bridge.py (excerpt)
from azure.communication.callautomation import (
CallAutomationClient, MediaStreamingOptions, StreamingTransportType,
MediaStreamingContentType, MediaStreamingAudioChannelType, AudioFormat,
)
@app.route("/api/incomingCall", methods=["POST"])
def incoming_call():
for event in request.get_json():
# Event Grid subscription validation handshake
if event.get("eventType") == "Microsoft.EventGrid.SubscriptionValidationEvent":
return jsonify({"validationResponse": event["data"]["validationCode"]})
if event.get("eventType") == "Microsoft.Communication.IncomingCall":
client = CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING)
client.answer_call(
incoming_call_context=event["data"]["incomingCallContext"],
callback_url=f"https://{HOST}/api/callbacks",
media_streaming=MediaStreamingOptions(
transport_url=f"wss://{HOST}/ws",
transport_type=StreamingTransportType.WEBSOCKET,
content_type=MediaStreamingContentType.AUDIO,
audio_channel_type=MediaStreamingAudioChannelType.MIXED,
start_media_streaming=True,
enable_bidirectional=True,
audio_format=AudioFormat.PCM16_K_MONO,
),
)
return jsonify({"status": "ok"})

/wsソケットでは、PCM16を双方向に中継します。ACSのAudioDataフレームを{"user_audio_chunk": "<base64>"}としてElevenLabsに転送し、エージェントのオーディオを{"Kind":"AudioData","AudioData":{"Data":"<base64>"},"StopAudio":null}として返送します。ACSが最初に送信するフレームはAudioMetadata(ネゴシエートされた形式)です。ログに記録して無視してください。ElevenLabs側では標準のエージェントWebSocketプロトコルを使用します。

ACSでは方向ごとにJSONの大文字・小文字が異なります。受信フレーム、つまりACSが送信するフレームはキャメルケース(kind、 audioData.data)ですが、送信フレーム、つまりACSが期待するフレームはパスカルケース(Kind、AudioData.Data、 StopAudio)です。2つのケースを区別してください。以下のリレーではこの違いを反映しています。

bridge.py — media relay
import asyncio, json, os, queue, threading, websockets
from flask_sock import Sock
sock = Sock(app)
AGENT_ID = os.environ["ELEVENLABS_AGENT_ID"]
# US default; data residency: wss://api.eu.residency.elevenlabs.io, .in., or .sg.
EL_ORIGIN = os.environ.get("ELEVENLABS_ORIGIN", "wss://api.elevenlabs.io")
EL_WS = f"{EL_ORIGIN}/v1/convai/conversation?agent_id={AGENT_ID}"
@sock.route("/ws")
def media_stream(ws):
loop = asyncio.new_event_loop()
el = {"ws": None}
to_acs = queue.Queue() # outbound frames; only this handler thread touches `ws`
async def el_session():
async with websockets.connect(EL_WS) as elws:
el["ws"] = elws
await elws.send(json.dumps({"type": "conversation_initiation_client_data"}))
async for msg in elws:
data = json.loads(msg)
kind = data.get("type")
if kind == "audio": # agent audio -> caller
b64 = data["audio_event"]["audio_base_64"]
to_acs.put({"Kind": "AudioData", "AudioData": {"Data": b64}, "StopAudio": None})
elif kind == "ping":
await elws.send(json.dumps({"type": "pong", "event_id": data["ping_event"]["event_id"]}))
elif kind == "interruption": # barge-in
to_acs.put({"Kind": "StopAudio", "AudioData": None, "StopAudio": {}})
threading.Thread(target=lambda: loop.run_until_complete(el_session()), daemon=True).start()
# Keep all ACS-socket I/O on this one thread: receive with a short timeout,
# then drain any audio the ElevenLabs thread queued. Sending from the other
# thread would race flask-sock and corrupt the stream.
try:
while True:
raw = ws.receive(timeout=0.02) # None when no frame arrived this tick
if raw:
evt = json.loads(raw)
if evt.get("kind") == "AudioData" and el["ws"]: # caller audio -> agent
asyncio.run_coroutine_threadsafe(
el["ws"].send(json.dumps({"user_audio_chunk": evt["audioData"]["data"]})), loop)
while not to_acs.empty():
ws.send(json.dumps(to_acs.get_nowait()))
except Exception:
pass # ACS socket closed

このリレーは意図的に最小限にしています。本番環境では、ロギング、再接続、正常な 終了処理を追加してください。メッセージの完全なリファレンスはWebSocket ドキュメントにあります。

EL_WSはパブリックエージェントに接続します。プライベートエージェントの場合は、ブリッジからサーバー側で短期間有効な 署名付きURLをリクエストしてください。APIキーを使用してGET /v1/convai/conversation/get-signed-url?agent_id=...を実行し、 代わりに返されたURLに接続します。データ レジデンシーでは、ELEVENLABS_ORIGINをレジデンシーホスト (wss://api.eu.residency.elevenlabs.io、.in.、または.sg.)に設定します。署名付きURLのリクエストでは対応するhttps://ホストを 使用します。

Azure Container Appsにデプロイし、パブリックFQDNを取得します。

az containerapp up --name acs-el-bridge --resource-group $RG \
--source . --ingress external --target-port 8080 \
--env-vars ELEVENLABS_AGENT_ID=$AGENT_ID \
ELEVENLABS_ORIGIN=wss://api.elevenlabs.io
FQDN=$(az containerapp show -n acs-el-bridge -g $RG \
--query properties.configuration.ingress.fqdn -o tsv)

次に、アプリでBRIDGE_PUBLIC_HOST=$FQDNとACS接続文字列(シークレットとして)を設定します。

共同作成者権限の場合(所有者ではない場合)、az containerapp upはマネージドIDのACR プルロールを作成できません。レジストリの管理者ユーザーを有効化し(az acr update --admin-enabled true)、 az containerapp registry setでアタッチしてから、az containerapp update --image ...を実行してください。

手順3 — IncomingCallをブリッジにルーティングする

ACSリソース上に、IncomingCallをブリッジへPOSTするEvent Gridサブスクリプションを作成します。ブリッジの検証ハンドシェイク(上記)により、サブスクリプションは自動的に完了します。

ACS_ID=$(az communication show -n my-acs -g $RG --query id -o tsv)
az eventgrid event-subscription create \
--name acs-incomingcall \
--source-resource-id "$ACS_ID" \
--endpoint "https://$FQDN/api/incomingCall" \
--endpoint-type webhook \
--included-event-types Microsoft.Communication.IncomingCall
# Verify — should print "Succeeded"
az eventgrid event-subscription show --name acs-incomingcall \
--source-resource-id "$ACS_ID" --query provisioningState -o tsv

サブスクリプションはACSリソースのEventsブレードに表示されます。

Microsoft.Communication.IncomingCallでフィルタリングされたacs-incomingcall Webhookサブスクリプションを一覧表示するACSリソースのEventsブレード

ACSリソース → Events → Event Subscriptions

番号に電話をかけると、エージェントが応答します。

Teamsへの接続

  • **直接発信:**Teams PhoneとCalling Planを持つTeamsユーザーは、他の外線番号と同様にACS番号へ発信できます。
  • Teamsリソースアカウント(TPE):Teams Phone ExtensibilityでTeamsリソースアカウントをACSリソースにバインドすると、リソースアカウントへの通話で同じIncomingCall → ブリッジのフローが実行されます。

通話終了

エージェントが会話を終了すると(たとえば、End Callツールを使用した場合)、ElevenLabsはWebSocketを閉じます。発信者が切断された回線に残らないよう、ACS側の通話を切断します。

CallAutomationClient.from_connection_string(ACS_CONNECTION_STRING) \
.get_call_connection(call_connection_id).hang_up(is_for_everyone=True)

担当者へのウォーム転送

ElevenLabsネイティブの転送ツールは、ElevenLabsがテレフォニーを管理している場合にのみ適用されます。そのため、ここではエージェントがカスタムクライアントツール(例:transfer_to_human)を起動し、ブリッジがadd_participantで担当者をライブ通話に追加することで処理します。ブラインド転送ではなくウォーム転送です。

conn = client.get_call_connection(call_connection_id)
conn.add_participant(
PhoneNumberIdentifier(human_number),
source_caller_id_number=PhoneNumberIdentifier(your_outbound_number),
invitation_timeout=30,
)
# then mute the bot and skip the end-of-call hangup so the human's leg survives

ACSはAddParticipantSucceeded/AddParticipantFailedコールバックを/api/callbacksに送信します。エージェントが引き継ぎの案内を話せるよう、client_tool_resultを返してください。エージェント側の設定については、システムツールを参照してください。

ツールの実行直後に(add_participantを呼び出す前に)転送ガードを設定してください。そうしないと、ELの高速な WebSocket切断と通話切断が競合し、担当者が参加する前に通話が切断される可能性があります。

トラブルシューティング

Event Gridサブスクリプションがプロビジョニング済みであること(provisioningState: Succeeded)、および ブリッジの/api/incomingCallが検証エコーを返したことを確認してください。番号に着信通話機能があり、サブスクリプションと 同じACSリソース内にあることも確認してください。サブスクリプションの Filtersタブでは、イベントタイプにIncoming Callが含まれている必要があります。

イベントタイプがIncoming CallにフィルタリングされているイベントサブスクリプションのFiltersタブ

Event subscription → Filters → Incoming Call

一部の宛先(例:インド)へのACS発信は制限されている、または断続的に失敗することがあります。サポート対象の宛先を使用するか、担当者側の回線をSIP/Operator番号にしてください。ブリッジのロジックには影響しません。これは発信側回線におけるキャリアレベルの障害です。

両側ともPCM 16kHzモノラルである必要があります。エージェントの入出力形式をpcm_16000に設定してください。ブリッジはconversation_initiation_metadataからネゴシエートされた形式をログに記録します。

番号の購入には有料のサブスクリプションタイプ(MCA/EA/PAYG)が必要です。ACSがお住まいの国で番号を提供していない場合は、代わりにSIPプロバイダーを使用してください。

便利なリンク