Voice LiveのWebRTC音声接続を.NETから使った話
Voice Live APIには、会話の音声そのものをWebRTCで運ぶWebRTC音声接続(/voice-live/realtime/calls)があります。通常のWebSocketでは音声をbase64のイベントでやり取りしますが、WebRTCならUDP/RTPで運ぶので、低遅延でパケットの欠落にも強くなります。
これを.NETのコンソールアプリから、WebRTCライブラリのSIPSorceryを使ってつなごうとしたところ、
allocate client error: Remote client allocation failed: ServerError(InternalServerError, "No details provided")
という、詳細の無いエラーで失敗し続けました。最終的に双方向の音声会話まで通せたのですが、いくつかポイントがあります。しかも、プレビューなのでどれもドキュメントにはまだ書かれていないようでした。
本記事では、SIPSorceryでWebRTC音声接続を通すために必要だった5つの対処と、その調べ方について紹介します。
Voice Live機能解説シリーズの記事一覧(SDK 1.2.0 / API 2026-07-15)
-
Voice Live SDK 1.2.0の変更点とAPI 2026-07-15 GA
SDKに型が無い機能の分類 -
Voice Live APIで会話の「間」を扱う
先に話す・言いよどみを待つ・割り込む・待ち時間に話す -
Voice Live の音声入力の品質を上げる
ノイズ抑制・エコー除去・Live-Reference AEC の選び方 -
Voice LiveでMCPサーバのツールを使う
function callingとの違いと並列ツール呼び出し -
Voice LiveでFoundryのエージェントをツールとして呼ぶ
chat-supervisorパターンの使い方と前段モデルの選び方 -
30秒の音声で「自分の声」を作って Microsoft Foundry エージェントを喋らせる
Personal Voice の申請から実装まで -
Voice Live APIのアバターを使い分ける
標準・カスタム写真・カスタムビデオの設定と実装 -
Voice Liveのアバター映像をWebSocketで受ける
WebRTCとの違いと映像の形式
番外編:Voice LiveのWebRTC音声接続に.NETのSIPSorceryでつなぐ(本記事)
SDPを1行ずつ突き合わせた記録
本記事は2026年7月の検証に基づきます。WebRTC音声接続は2026年10月時点でもpreview(GAの2026-07-15でもpreviewのまま)で、SLAはありません。サーバ側の挙動が変わっている可能性があります。
WebRTC音声接続の仕組み
先に、アバターのWebRTCと混同しないように整理しておきます。
| WebRTC音声接続(本記事) | WebRTCアバター | |
|---|---|---|
| WebRTCで運ぶもの | 会話の音声そのもの | アバターの映像と音声だけ |
| 会話の音声 | RTP | WebSocketのイベント |
| 接続先 | /voice-live/realtime/calls |
/voice-live/realtime |
| アバター | 制御チャネル(サイドバンド制御)ではアバターの設定に非対応(公式ドキュメント) | ― |
WebRTC音声接続では、クライアントとVoice Liveの間に3つの経路を張ります。最初にWebSocketを開いて接続の交渉を行い、その結果としてRTCPeerConnection(音声トラックとデータチャネル)ができます。
| 経路 | 通るもの | 主なイベント |
|---|---|---|
| WebSocket(制御チャネル) | 接続の交渉、セッションの設定、ツール呼び出し、エラー |
rtc.call.sdp.create / rtc.call.sdp.created / rtc.call.error、session.update、response.function_call_arguments.*、error
|
| RTPの音声トラック(Opus) | マイクの音声(上り)とエージェントの音声(下り) | イベントではなく、途切れない音声として流れる |
データチャネル(voice-live-events) |
VADの検出、文字起こし、応答の状態 |
input_audio_buffer.speech_started / speech_stopped、response.created / response.done、response.audio_transcript.*
|
接続の流れは次のとおりです。
-
/voice-live/realtime/calls?api-version=...&model=...にWebSocketで接続する - RTCPeerConnectionを作り、音声トラックを追加してofferを作る。ICEの候補集めが終わるのを待つ
-
rtc.call.sdp.createでoffer(とセッション設定)を送る -
rtc.call.sdp.createdでanswerを受け取り、setRemoteDescriptionする - ICEが接続されると音声が流れ始める
{
"type": "rtc.call.sdp.create",
"sdp_offer": "v=0\r\no=- ...",
"session": {
"modalities": ["text", "audio"],
"voice": { "name": "ja-JP-Nanami:DragonHDLatestNeural", "type": "azure-standard" }
}
}
/callsでは最初のsession.updateを送れません。セッション設定はrtc.call.sdp.createのsessionに入れ、modelはURLで指定します。
準備
リソースの作成とAPIキーの確認までは、公式のクイックスタートに沿って進めると準備できます。対応リージョンは、Voice Liveの概要ページで確認できます。
そのうえで、本記事では次を用意しました。
-
.NETのコンソールアプリを作り、SIPSorceryのパッケージを追加する
dotnet add package SIPSorcery --version 10.0.14 -
比較用に、公式のブラウザ向けサンプル(WebRTCのstandaloneサンプル)を同じリソースに向けて動かせるようにしておく
SIPSorceryのバージョンにも注意が必要でした。2026年8月に公開された脆弱性を解消するために上げたところ、10.0.13ではWebRTC音声接続の音声が壊れ、10.0.14で正常になりました(サンプルは現在10.0.14)。
接続できるまでの調査
サービス側の切り分け
厄介なのはエラーには何の手がかりもなかったことです。詳細がなぜか出ていない状態で、サービス側の障害なのか、自分のクライアントの問題なのかをまず切り分けました。使ったのは、公式のブラウザ向けサンプル(WebRTCのstandaloneサンプル)です。同じリソースに対してChromeから接続したところ、別のエラーになりました。
SEEP payload error: Failed to parse SDP: Unsupported parsing error:
Unknown rtcpfb ack parameter: "ccfb" in line(28): a=rtcp-fb:111 ack ccfb
最近のChromeはofferにa=rtcp-fb:111 ack ccfb(RFC 8888の輻輳制御フィードバック)を含めます。検証した時点では、この行があるとSDP全体のパースで止まっていたので、ブラウザから試す場合は、まずこの行を除いてみてください。
そこで、送る前にofferからack ccfbの行を取り除いたところ、connectedまで到達し、双方向で会話できました。これで2つのことがわかりました。
- サービス自体は正常に動いている
- ブラウザのofferなら(
ccfbさえ除けば)通る(あまり直接的には関係ない)
answerを見ると、o=str0m-0.19.0 ...とあったので、サーバ側のWebRTC実装はRustのstr0mのようです。ここからは、通るChromeのofferと、通らないSIPSorceryのofferを1行ずつ突き合わせる作業になりました。
1. 認証方法
最初に引っかかったのは認証でした。
| 認証の渡し方 | WebSocketの接続(HTTP 101) | その後のallocation |
|---|---|---|
| Entra IDのBearerトークン | 通る | 失敗(同じInternalServerError) |
| APIキーをヘッダで | 通る | 失敗 |
APIキーをURLのクエリ(&api-key=)で |
通る | 通る |
接続自体は成功するのに、そのあとで同じ詳細の無いエラーになるので、認証の問題だと気づきにくいです。ブラウザのサンプルはAPIキーをクエリに載せていたので、それに合わせました。
URLにAPIキーが入るので、ログにURLをそのまま出さないように注意してください。サンプルではapi-key=***に伏せて出力しています。
2. SDPのofferに足す属性
SIPSorceryが既定で作るofferには、ブラウザが送っている属性がいくつか足りません。Chromeの成功したofferと突き合わせて、次を補いました。
| 補ったもの | 内容 |
|---|---|
a=rtcp-fb:111 transport-cc |
輻輳制御のフィードバック |
a=extmap:1..4 |
音量レベル、送信時刻、transport-wide-cc、midの拡張ヘッダ |
a=msid+a=ssrc ... msid
|
ストリームの識別子 |
a=msid-semantic / a=extmap-allow-mixed
|
セッションレベルの属性 |
a=rtcp-rsize |
縮小サイズのRTCP |
a=fmtp:111 minptime=10;useinbandfec=1 |
Opusのパラメータ |
m= / c=のアドレスを実際のホスト候補のIPとポートに |
SIPSorceryの既定0.0.0.0:9だと失敗する |
s=- |
SIPSorceryの既定s=sipsorceryは不可 |
a=ice-options:trickle |
ice2を取り除く |
特に気づきにくかったのがm= / c=のアドレスです。ICEの候補はa=candidateで提示しているのに、m=行の既定のアドレスが0.0.0.0:9だとallocationに失敗しました。
コーデックのブロック(payload番号とa=rtpmap)は、SIPSorceryが作ったものをそのまま使ってください。最初はChromeのofferに丸ごと置き換えようとしたのですが、そうするとSIPSorceryの内部の状態とofferの内容が食い違い、次のOpusの交渉が壊れました。足りない属性を足すだけにすると動きました。
3. answerのOpusの扱い
offerが通ってICEが接続されても、今度はこちらの声がサーバに届きません。原因はanswerでした。str0mはOpusを小文字のopus/48000/2(動的なpayload番号)で返してきます。SIPSorceryはこれを既知のOpusと認識できず、コーデックをUnknownとして扱っていました。その結果、SetAudioSourceFormat(Unknown)でエンコーダが動かず、マイクの音声が1つも生成されていませんでした。
交渉が終わったときのイベント(OnAudioFormatsNegotiated)で、payload番号はそのままに、Opusのフォーマットとして作り直して設定し直しました。
pc.OnAudioFormatsNegotiated += formats =>
{
var negotiated = formats[0];
// answer の小文字 "opus"(動的 payload)を SIPSorcery が Unknown と扱うので、
// payload 番号はそのままに Opus として作り直す
var format = negotiated.Codec != AudioCodecsEnum.OPUS &&
string.Equals(negotiated.FormatName, "opus", StringComparison.OrdinalIgnoreCase)
? new AudioFormat(AudioCodecsEnum.OPUS, negotiated.FormatID, 48000, 2, negotiated.Parameters)
: negotiated;
// マイク(source)とスピーカー(sink)は同じフォーマットにする
audioEndPoint.SetAudioSourceFormat(format);
audioEndPoint.SetAudioSinkFormat(format);
};
WindowsAudioEndPointはフォーマットの管理を1つしか持っていないようで、sourceとsinkに別々のフォーマットを設定すると、先に設定したほうが黙って壊れました。両方に同じものを設定します。
4. スピーカーの開始
こちらの声は届くようになったのに、今度はエージェントの声が聞こえません。
SIPSorceryのWindowsAudioEndPoint.StartAudio()は、マイク(source)しか開始しません。スピーカー(sink)はStartAudioSink()を別に呼ぶ必要があり、マイクは送れているのに無音だった原因はこれでした。
await audioEndPoint.StartAudio(); // マイク(source)
await audioEndPoint.StartAudioSink(); // スピーカー(sink)は別に開始する
5. APIバージョン
最後がAPIバージョンです。/callsに2026-06-01-previewを指定すると、接続の時点でHTTP 401が返りました。APIキーのクエリでもEntra IDでも同じだったので、認証ではなくバージョンの制約のようです。
2026-01-01-previewなら通るので、サンプルではWebRTC音声接続のときだけバージョンを2026-01-01-previewに固定しています。
検証した2026年7月の時点では、rtc.call.*のイベントが定義されているのは2026-01-01-previewのリファレンスで、WebRTCのhow-toもエンドポイント例に2026-01-01-previewを使っていました。2026年11月時点のhow-toでは、エンドポイント例がapi-version=2026-07-15に変わっています。現在は新しい版で通る可能性がありますが、再検証はしていません。
なお401は認証エラーのコードなので、キーや権限を疑いたくなりますが、ここでは版の指定を先に見直すとよさそうです。
動作確認
5つをすべて入れると、次の流れが最後まで動きました。
- マイクの音声がRTPで送られる
- サーバのVADが発火する(
input_audio_buffer.speech_started/speech_stopped/committed) - 応答が生成される(
response.*) - エージェントの音声がRTPで返ってきて、スピーカーから鳴る
VADや文字起こし、応答の状態は、データチャネルvoice-live-eventsで受け取れます。
今後変わる可能性がある点
今回のSDPの書き換えは、ライブラリの既定のofferと、サーバ側のWebRTC実装(str0m)が受け付ける形の差を、クライアント側で埋めたものです。ブラウザが送るofferはそのまま通るので、WebRTCとしては正しいが、ブラウザ以外のスタックでは追加の属性が要るという状態です。
WebRTC音声接続はpreviewで、サーバ側の実装もライブラリも更新されます。次のことは、いずれ不要になる可能性があります。
- SDPへの属性の追加(将来SIPSorceryのネイティブな設定で書けるようになるのが望ましい形です)
- APIキーをURLのクエリで渡す必要
-
api-versionを2026-01-01-previewに固定する必要(how-toの例はすでに2026-07-15になっています)
実際、WebRTCのドキュメントはエラー応答の形式が明確になりました。rtc.call.errorにerror.type(invalid_request_error / server_error)・error.code(機械可読)・error.message(人間可読)が定義され、{"code": "missing_sdp", "message": "SDP offer is required"}のような具体例も載っています。原因の切り分けは当時より楽になっているはずです。また、SDPのofferとanswerを、WebSocketの代わりにHTTPのPOSTで交換する方法も追加されています。
まとめ
Voice LiveのWebRTC音声接続をSIPSorceryでつなぐのに必要だったのは、次の5つでした。
- APIキーをURLのクエリで渡します(Bearer・ヘッダはallocationで失敗します)
- offerをstr0mが受け付ける形に補います。コーデックのブロックは触りません
- answerの小文字のopusを、Opusとして作り直します
- スピーカーを明示的に開始します
- APIバージョンは2026-01-01-previewを使います