Delphi の WebRTC:2 つのアプリケーション間で音声、映像、データを流す

異なるネットワーク上にある 2 つの Delphi アプリケーションが、チャットチャネル、マイクのストリーム、カメラのストリームを互いに直接やり取りします。間にメディアサーバーはなく、プロセスにブラウザーを埋め込むこともなく、JavaScript のブリッジもありません。このページは最初のシグナリングメッセージから最初にデコードされた音声フレームまで、作業全体をたどります。使うのは製品ソースに実在する API だけです。

SDP のオファーとアンサー
ICE、STUN、TURN
SCTP データチャネル
Opus と VP8 のメディアトラック
DTLS-SRTP による暗号化
ブラウザーも WebView も不要

実際に起こす必要があること

WebRTC は 1 つの名前をまとった 4 つの別々の問題です。メディアに関わるのはそのうち 1 つだけで、しかもそれが一番易しいものです。解くべき順に並べたのが次の 4 つです。

1. セッションを記述する

一方がオファーを組み立てます。これは送信したいメディア、話せるコーデック、提示する証明書のフィンガープリント、使用する ICE の資格情報を記したテキスト文書(SDP)です。もう一方は受け入れる部分集合をアンサーとして返します。CreateOfferCreateAnswer がこれらの文書を生成し、SetRemoteDescription がそれを取り込みます。

2. 相手側へ届ける

WebRTC は、オファーが相手のピアにどう届くかをあえて定めていません。その経路はシグナリングと呼ばれ、実装するのは開発者の仕事です。往復それぞれ数百バイトのテキストなので、小さなリレーへの WebSocket 接続で十分です。sgcWebSockets はその両側をすでに提供しています。

3. NAT を越える経路を見つける

どちらのピアも自分の公開アドレスを知らず、たいていは両方ともルーターの背後にいます。ICE は、ピアに到達しうるアドレスをすべて収集し、見つかり次第シグナリングチャネルで送り、成功するまで各組み合わせを試します。STUN は公開アドレスを見つけ、TURN は直接の経路がまったく通らないときに中継を提供します。

4. バイトを流す

候補の組が 1 つ指名されると、その上で DTLS ハンドシェイクが走り、それ以降はすべて暗号化されます。データチャネルはその DTLS トランスポート上の SCTP で、音声トラックや映像トラックはその上の SRTP です。どちらも 1 本の接続と 1 つの開放ポートを共有します。

WebRTC サーバーは存在するのか

メディアの経路上には存在しません。それこそが要点です。2 つのアプリケーションが互いを見つけてしまえば、音声も映像もデータも両者の間を直接流れます。自分がホストするものがペイロードを見ることはなく、通話時間の合計に応じてスケールさせる必要もありません。

それでも図の中には 2 種類のサーバーが残ります。しばしば混同されるので、それぞれが何をするのかを正確に述べておくと役立ちます。

シグナリングサーバーは自前のものです。通話が始まる前に、2 つのピアの間でわずかな数のテキストメッセージを中継し、その後は静かになります。メディアを見ることはありません。この解説では、TsgcWebSocketServer を土台にした 15 行の Delphi コードです。

STUN サーバーと TURN サーバーが存在するのは NAT のためであって、WebRTC のためではありません。STUN サーバーは「このパケットはどの公開アドレスから届いたか」という 1 つの問いに答えるだけです。TURN サーバーは、ほかにどうしても互いに到達できない組のためにパケットを中継します。したがってメディアを運ぶ唯一の要素であり、それも中継が必要な通話に限られます。公開の STUN サーバーは無料で数多くありますが、TURN は自分でホストします。別のデーモンを動かしたくない場合のために、sgcWebSockets Enterprise は STUN サーバーTURN サーバーのコンポーネントを同梱しています。

who-talks-to-whom.txt
  App A                                App B
    |                                    |
    |---- offer / answer / candidate --->|   your signalling
    |<-------- (WebSocket relay) --------|   server, text only
    |                                    |
    |---> "what is my public address?"   |   STUN, once per
    |     (STUN binding request)         |   candidate
    |                                    |
    |====== audio, video and data ======>|   direct, encrypted,
    |<===================================|   no server involved
    |                                    |
    |== only when nothing direct works ==|   TURN relay,
    |    (relayed candidate pair)        |   your server

エディション、ユニット、プラットフォーム

ピア接続とメディアエンジンは、2 つの異なるライセンス段階です。コードを書く前にここを整理しておく価値があります。そうしないと、API の半分がコンパイラから見えないままになるからです。

やりたいこと必要なものどこに含まれるか
公開アドレスを調べる STUN クライアント TsgcSTUNClient sgcWebSockets Standard 以上
自前の STUN サーバーを動かす TsgcSTUNServer sgcWebSockets Professional 以上
WebSocket のシグナリングチャネルを中継する TsgcWebSocketServer サーバー側は sgcWebSockets Professional 以上です。クライアント側の TsgcWebSocketClientStandard から利用できます。
ICE、TURN のクライアントとサーバー、そしてピア接続コンポーネント本体 TsgcICEClientTsgcTURNClientTsgcTURNServerTsgcRTCPeerConnection sgcWebSockets Enterprise
オファーとアンサー、データチャネル、音声と映像のトラック CreateOfferCreateAnswerSetRemoteDescriptionAddIceCandidateCreateDataChannelAddTrack Enterprise に加えて sgcWebRTC パック。All-Access にも含まれます。

この分割の理由を、コンパイラの言葉で

sgcVer.inc の Enterprise ブロックは SGC_ICESGC_DTLSSGC_RTCPEERCONNECTIONSGC_TURN を定義します。これによって TsgcRTCPeerConnection がパレットに載り、ICE と TURN のトランスポートが与えられます。

このページが実際に扱っている内容は、そのさらに 1 段内側にあります。SGC_SDPSGC_SCTPSGC_DATACHANNELSGC_RTPSGC_SRTP を定義するのは SGC_PACK_WEBRTC で、これはまず ICE、DTLS、ピア接続がすでに存在するかを確認します。手動のシグナリングメソッド、データチャネル API、メディア API は、それぞれ {$IFDEF SGC_SDP}{$IFDEF SGC_DATACHANNEL}{$IFDEF SGC_RTP} の中にあるため、Enterprise だけではコンパイルされません。

CreateOffer が解決できないなら、原因はそれです。同梱のデータチャネルデモは、黙って失敗するのではなく、その旨をはっきり表示します。

sgcVer.inc
{$IFDEF SGC_PACK_WEBRTC} { PACK WEBRTC }
  {$IFDEF SGC_INDY_LIB}
    {$IFDEF SGC_ICE} { requires ICE + DTLS + RTCPeerConnection }
      {$IFDEF SGC_DTLS}
        {$IFDEF SGC_RTCPEERCONNECTION}
          {$DEFINE SGC_SDP}
          {$DEFINE SGC_SCTP}
          {$DEFINE SGC_DATACHANNEL}
          {$DEFINE SGC_RTP}
          {$DEFINE SGC_SRTP}
          {$DEFINE SGC_CODEC_OPUS}
          {$DEFINE SGC_CODEC_VP8}
          {$DEFINE SGC_CODEC_H264}
        {$ENDIF}
      {$ENDIF}
    {$ENDIF}
  {$ENDIF}
{$ENDIF}

両方のアプリケーションに必要なユニット

sgcP2PTsgcRTCPeerConnection を公開し、ハンドラー型を再エクスポートするバレルユニットです。コンポーネントを宣言するにはこれで足りますが、列挙型の定数はその型を宣言しているユニットから来るので、rtctkAudiocctAudioOpus を書くときはそれらも uses に加えてください。

この解説に登場する 2 つのアプリケーションは、押すボタンが違うだけの同じプログラムです。以下の内容は両方に入れます。

uPeer.pas
uses
  Classes, SysUtils,
  // sgc
  sgcWebSocket,             // signalling carrier
  sgcWebSocket_Classes,     // TsgcWSConnection
  sgcJSON,                  // wraps the SDP and the candidates
  sgcP2P,                   // TsgcRTCPeerConnection
  sgcP2P_RTCPeerConnection, // TsgcRTCConnectionState
  sgcP2P_DataChannel,       // TsgcRTCDataChannel
  sgcP2P_RTC_Media,         // TsgcRTCTrack, rtctkAudio
  sgcP2P_Codec_Types,       // cctAudioOpus, TsgcVideoFrame
  sgcP2P_Media_Factory,     // sgcCreateAudioCapture
  sgcP2P_MediaCapture,      // TsgcMediaCaptureSource
  sgcP2P_MediaRenderer;     // TsgcMediaRenderer

シグナリングチャネル

メッセージは 3 種類、中継は単純なもの 1 つです。ここは WebRTC のチュートリアルがどれも手を振って済ませる部分であり、実際に自分で書く必要がある部分です。

ブローカーではなく、リレー

シグナリングサーバーは、転送する内容を 1 バイトたりとも理解する必要がありません。一方のピアが送ったテキストを、もう一方に渡すだけです。Broadcast には接続 Guid を取る Exclude パラメーターがすでにあるので、「送信者以外の全員に送る」は 1 行で書けます。

その単純さを保ってください。リレーが SDP の解析を始めた瞬間、それはコーデックが変わるたびに更新が必要なコンポーネントになり、ブラウザーへの通話を中継できなくなります。

本番ではルーム識別子でリレーを分けて 2 つの通話が混ざらないようにし、TLS の背後に置くことになります。TsgcWebSocketServer は、ライブラリのほかの部分と同じ TLSOptionsAuthenticationWatchDog を備えています。

uSignallingServer.pas
procedure TFormServer.Start;
begin
  FServer := TsgcWebSocketServer.Create(nil);
  FServer.Port := 5000;
  FServer.OnMessage := OnSignallingMessage;
  FServer.Active := True;
end;

procedure TFormServer.OnSignallingMessage(
  Connection: TsgcWSConnection; const Text: string);
begin
  // relay verbatim to the other peer. The server never
  // parses the SDP, so it never learns about codecs.
  FServer.Broadcast(Text, '', '', Connection.Guid);
end;

同じチャネルのピア側

各アプリケーションはそのリレーへ TsgcWebSocketClient を開き、offeranswercandidate という 3 語だけの語彙で話します。シリアライズには同じライブラリの TsgcJSON を使うので、依存関係は増えません。

分岐の置き場所に注目してください。受信した offer はリモート記述として設定し、ただちにアンサーを返します。受信した answer は設定するだけです。受信した candidateAddIceCandidate に渡します。これは記述の前後どちらに届いても構いません。それがトリクル ICE の要点です。

uPeer.pas
procedure TFormPeer.OnSignallingMessage(
  Connection: TsgcWSConnection; const Text: string);
var
  oJSON: TsgcJSON;
  vKind: string;
begin
  oJSON := TsgcJSON.Create(nil);
  try
    oJSON.Read(Text);
    vKind := oJSON.Node['kind'].Value;

    if vKind = 'offer' then
    begin
      FPeer.SetRemoteDescription('offer',
        oJSON.Node['sdp'].Value);
      FPeer.CreateAnswer;  // fires OnLocalDescription
    end
    else if vKind = 'answer' then
      FPeer.SetRemoteDescription('answer',
        oJSON.Node['sdp'].Value)
    else if vKind = 'candidate' then
      FPeer.AddIceCandidate(oJSON.Node['candidate'].Value,
        oJSON.Node['sdpMid'].Value,
        oJSON.Node['sdpMLineIndex'].Value);
  finally
    FreeAndNil(oJSON);
  end;
end;

ピア接続を組み立てる

両方のアプリケーションで同一です。このやり取り全体で唯一非対称なのは、どちらが発信ボタンを押すかだけです。

設定と、重要なイベント

RTCOptions.ICEServers は W3C の iceServers リストです。AddURLstun: または turn: の URL を取り、スキームから種別、ホスト、ポート、TLS フラグを埋めます。TURN 用にユーザー名と資格情報を任意で指定できます。

RTCOptions.DTLS の既定値は False です。そのままにすると暗号化も SRTP の鍵材料もないため、メディアは動作しません。CreateDataChannel はこれを自動で有効にします。データチャネルは DTLS 上の SCTP であり、DTLS を切った有効な構成は存在しないからです。AddTrack は有効にしないので、メディアを追加するときは自分で設定してください。

証明書ファイルは必要ありません。RTCOptions.DTLSOptions.CertFile を空のままにすると、コンポーネントごとに 1 度だけメモリ上の自己署名証明書が生成されます。これはまさに WebRTC のモデルどおりで、身元は証明書チェーンではなく SDP の a=fingerprint 行に紐づきます。フィンガープリントを持たないリモート記述は、任意の証明書とハンドシェイクさせるのではなく拒否されます。

以下のイベントはすべてワーカースレッド、つまり ICE、ネットワーク、タイマーのいずれかのスレッドで発火し、メインスレッドで発火することはありません。コントロールに触れる前に TThread.Queue でマーシャリングしてください。

uPeer.pas
procedure TFormPeer.CreatePeer;
begin
  FPeer := TsgcRTCPeerConnection.Create(nil);

  FPeer.RTCOptions.ICEServers.AddURL(
    'stun:stun.l.google.com:19302');
  FPeer.RTCOptions.ICE.STUN := True;
  FPeer.RTCOptions.ICE.TURN := False;  // no TURN server yet
  FPeer.RTCOptions.DTLS     := True;   // default is False

  FPeer.OnLocalDescription      := OnLocalDescription;
  FPeer.OnIceCandidate          := OnIceCandidate;
  FPeer.OnConnectionStateChange := OnConnectionStateChange;
  FPeer.OnDataChannel           := OnDataChannel;
  FPeer.OnTrack                 := OnTrack;
  FPeer.OnError                 := OnError;

  FSignalling := TsgcWebSocketClient.Create(nil);
  FSignalling.Host := 'signalling.example.com';
  FSignalling.Port := 5000;
  FSignalling.OnMessage := OnSignallingMessage;
  FSignalling.Active := True;
end;

ローカル記述を公開する

OnLocalDescription は、種別を表す文字列 'offer' または 'answer' と、SDP そのものを渡します。両方のピアが同じハンドラーを使い、行うことは 1 つ、シグナリングチャネルに載せることだけです。

SDP は完成した状態で届きます。CreateOffer は先に ICE 候補を収集し、RTCOptions.GatheringTimeout ミリ秒(既定は 3000)まで待ちます。新しい候補が来ない状態が GatheringIdleTimeout ミリ秒(既定は 500)続くと早めに終了します。これがトリクルを使わない経路です。

TrickleICE を True にすると、記述はただちに送出され、候補は後から続きます。ただし自分で設定する必要はめったにありません。RTCOptions.TrickleICEAuto は既定で True なので、リモート記述に a=ice-options:trickle があれば、つまりどのブラウザーもそうしていますが、コンポーネントが自動的に切り替わり、収集タイムアウトを浪費しなくなります。

uPeer.pas
procedure TFormPeer.OnLocalDescription(Sender: TObject;
  const aType, aSDP: string);
var
  oJSON: TsgcJSON;
begin
  oJSON := TsgcJSON.Create(nil);
  try
    oJSON.AddPair('kind', aType);  // 'offer' or 'answer'
    oJSON.AddPair('sdp', aSDP);
    FSignalling.WriteData(oJSON.Text);
  finally
    FreeAndNil(oJSON);
  end;
end;

// App A only. App B answers from OnSignallingMessage.
procedure TFormPeer.btnCallClick(Sender: TObject);
begin
  FPeer.CreateDataChannel('chat');  // forces DTLS on
  FPeer.CreateOffer;
end;

ICE 候補、STUN、TURN

ここがピアツーピア接続の失敗する場所であり、失敗の内容が最も読み取りにくい場所です。候補は 3 種類あり、それぞれに存在する理由があります。

host

マシンが自分自身で見えるアドレスで、ネットワークインターフェースごとに 1 つあります。無料で即座に得られ、2 つのアプリケーションが同じ LAN や同じ VPN 上にあるならこれで十分です。2 つの Delphi アプリケーションが 1 つのオフィス内でしか動かないなら、必要なのは host 候補だけで、STUN はまるごと省けます。

srflx(server reflexive)

STUN サーバーから見た、ピアのパケットが届いた公開アドレスです。ごく普通の家庭用ルーターの背後にいる 2 つのピアを直接つなぐのがこれで、実際の接続の大多数をカバーします。コストは STUN サーバーへの往復 1 回だけで、そのサーバーは以降トラフィックを運びません。

relay

ピアへ転送する TURN サーバー上のアドレスです。対称型 NAT、制限の強い企業ファイアウォール、一部のモバイルキャリアで必要になります。通話のすべてのバイトが自分の TURN サーバーを通るため、これは高コストな経路であり、最後の手段としてだけ使うものです。

候補をトリクルで送る

OnIceCandidate は候補が見つかるたびに 1 回ずつ発火し、候補の行、その sdpMidsdpMLineIndex を渡します。この 3 つのフィールドはブラウザー API が期待するものそのままなので、相手が Delphi でも Chrome でも同じ JSON が使えます。

それぞれをすぐ送ってください。待つ必要も、まとめる必要もありません。リモート記述より先に届いた候補は保留され、記述が届いた時点で適用されるので、順序は気にしなくて構いません。

組がついに指名されると、SelectedLocalCandidateSelectedRemoteCandidate がどの 2 つのアドレスが選ばれたかを教えます。そのログ 1 行が「なぜこの通話は TURN サーバーを通っているのか」に、ほかの何よりも早く答えます。

uPeer.pas
procedure TFormPeer.OnIceCandidate(Sender: TObject;
  const aCandidate, aSdpMid: string;
  aSdpMLineIndex: Integer);
var
  oJSON: TsgcJSON;
begin
  oJSON := TsgcJSON.Create(nil);
  try
    oJSON.AddPair('kind', 'candidate');
    oJSON.AddPair('candidate', aCandidate);
    oJSON.AddPair('sdpMid', aSdpMid);
    oJSON.AddPair('sdpMLineIndex', aSdpMLineIndex);
    FSignalling.WriteData(oJSON.Text);
  finally
    FreeAndNil(oJSON);
  end;
end;

procedure TFormPeer.OnConnectionStateChange(Sender: TObject;
  aState: TsgcRTCConnectionState);
begin
  // rtccsNew, rtccsGathering, rtccsConnecting, rtccsConnected,
  // rtccsDisconnected, rtccsFailed, rtccsClosed
  if aState = rtccsConnected then
    Log(FPeer.SelectedLocalCandidate + ' -> ' +
        FPeer.SelectedRemoteCandidate);
end;

TURN を追加する、そして忘れてはいけないスイッチ

ICEServers 内の turn: エントリは、自身のホスト、ポート、ユーザー名、資格情報を保持し、アロケーションはそれを使います。追加するには AddURL をもう 1 回呼ぶだけです。

落とし穴は逆方向にあります。RTCOptions.ICE.TURN の既定値は True で、サーバーリストに TURN のエントリがまったくない場合、収集処理は RTCOptions.ICE にある単一のサーバーにフォールバックします。そのホストの既定値は 127.0.0.1、ポートは 3478 です。つまり STUN の URL しか設定していないピアも localhost に対して TURN のアロケーションを試み、失敗し、それを報告します。障害ではなくノイズですが、ログでは不安を誘い、見当違いの場所を探させます。実際に TURN サーバーを用意するまでは RTCOptions.ICE.TURN := False を設定してください。

RTCOptions.ICE.STUN も同じ挙動で、こちらも既定は True です。

uPeer.pas
// STUN for the public address, TURN for the fallback relay
FPeer.RTCOptions.ICEServers.AddURL(
  'stun:stun.example.com:3478');
FPeer.RTCOptions.ICEServers.AddURL(
  'turn:turn.example.com:3478', 'user', 'secret');

FPeer.RTCOptions.ICE.STUN := True;
FPeer.RTCOptions.ICE.TURN := True;

// a stalled call is usually a candidate problem. Lower the
// gathering waits on a LAN, where there is nothing to gather.
FPeer.RTCOptions.GatheringTimeout     := 1000;
FPeer.RTCOptions.GatheringIdleTimeout := 200;

ICE クライアントのリファレンス 自前の TURN サーバーを動かす

データチャネル

2 つのアプリケーション間でテキストとバイナリをやり取りします。信頼性はチャネルごとに選べます。たいていはこれが最初に動かす部分であり、トランスポート全体が機能していることの証明になります。

チャネルを開き、相手のチャネルを受け取る

CreateDataChannel を呼んだピアは、オブジェクトをその場で受け取ります。呼ばなかったピアは OnDataChannel を通じて同じチャネルを受け取ります。どちらの側もセッション中の任意の時点でチャネルを開けるため、両方でハンドラーを割り当ててください。

チャネルは生成した瞬間から使えるわけではありません。Id は SCTP のアソシエーションが確立して DTLS のロールが決まるまで未割り当てのままで、開いていない間は Send が False を返します。OnOpen を待ってください。

信頼性は生成時に決まります。既定は順序保証あり、完全に信頼性ありの、TCP に近いチャネルです。順序保証なしの配送には aOrdered = False を渡し、部分的な信頼性には aMaxRetransmits または aMaxPacketLifeTime を渡します。位置情報の更新など、遅れて届くくらいなら失われたほうがよいものには、後者が向いています。

チャネルを解放しないでください。所有者はピア接続です。Close がシャットダウンを開始し、相手側が同意すると OnClose が発火します。

uPeer.pas
// caller: ordered and reliable, the default
FChannel := FPeer.CreateDataChannel('chat');
AttachChannel(FChannel);

// unordered, give up after 3 retransmits
FState := FPeer.CreateDataChannel('state', False, 3);

// callee: the same channel arrives here
procedure TFormPeer.OnDataChannel(Sender: TObject;
  aChannel: TsgcRTCDataChannel);
begin
  AttachChannel(aChannel);
end;

procedure TFormPeer.AttachChannel(
  aChannel: TsgcRTCDataChannel);
begin
  FChannel := aChannel;
  FChannel.OnOpen          := OnChannelOpen;
  FChannel.OnMessage       := OnChannelText;
  FChannel.OnMessageBinary := OnChannelBinary;
  FChannel.OnClose         := OnChannelClose;
  FChannel.OnError         := OnChannelError;
end;

procedure TFormPeer.OnChannelText(Sender: TObject;
  const aText: string);
begin
  // fires on the SCTP thread, queue before touching a control
  TThread.Queue(nil,
    procedure
    begin
      memoChat.Lines.Add(aText);
    end);
end;

送信する、そしてあふれさせない

Send は文字列を、SendBytesTBytes を取ります。どちらもチャネルが開いていないときは例外を出さず False を返すので、終了処理中の送信はワーカースレッド上の例外ではなく、戻り値の False になります。

MaxMessageSizeピアが受け入れると表明した最大のメッセージサイズで、その記述の a=max-message-size 属性から読み取られます。この上限を超えるメッセージは、回線に載せる前にローカルで拒否されます。載せてしまうとアソシエーション全体が中断し、ほかのすべてのチャネルも道連れになるからです。0 は、どの記述も上限を示さなかったためチェックが無効であることを意味します。

BufferedAmount は、このストリームで SCTP にキューイングされ、まだ確認応答されていないバイト数です。ファイルを送るときはこれを監視し、しきい値を超えるまで送ってから、はけるのを待ってください。ギガバイト単位でメモリにキューイングしないためです。

uPeer.pas
procedure TFormPeer.btnSendClick(Sender: TObject);
begin
  if not Assigned(FChannel) then
    Exit;

  if not FChannel.Send(txtMessage.Text) then
    Log('channel not open');
end;

procedure TFormPeer.SendChunk(const aBytes: TBytes);
begin
  if (FChannel.MaxMessageSize > 0) and
     (Cardinal(Length(aBytes)) > FChannel.MaxMessageSize) then
  begin
    Log('too big for this peer, split it');
    Exit;
  end;

  if FChannel.BufferedAmount < 262144 then
    FChannel.SendBytes(aBytes);
end;

音声トラックと映像トラック

トラックは、絵の入ったデータチャネルではありません。トランスポートも、失敗の仕方も、コードも違います。ここが多くの人が最初に取り違える区別です。

 データチャネルメディアトラック
運ぶ仕組み DTLS 上の SCTP(RFC 8831) 同じ DTLS トランスポート上の SRTP(RFC 3711)
配送 完全な信頼性と順序保証から、送りっぱなしまで選べます 設計上、常に損失があります。遅れて届くのは失われるより悪いので、いつまでも再送はしません
処理の単位 メッセージです。いつ送るかは自分で決めます 時計です。音声は 20 ms のフレームで、映像はフレームレートで送り込みます
開き方 CreateDataChannel。セッション中の任意の時点で使えます AddTrack。公開するには新しいオファーが必要です
受け取り方 OnDataChannel、続いてチャネル自身の OnMessage OnTrack、続いてトラックの OnAudio または OnVideoFrame
DTLS が必要か 必要です。CreateDataChannel が代わりに設定します 必要です。ただし AddTrack は設定しません。RTCOptions.DTLS を自分で設定してください
用途 チャット、ファイル転送、リモート操作、ゲームの状態、テレメトリ マイク、カメラ、画面共有、時間軸を持つあらゆるもの

マイクを送る

AddTrack は種別とコーデックを取り、TsgcRTCTrack を返します。音声コーデックは cctAudioOpuscctAudioPCMUcctAudioPCMA、映像コーデックは cctVideoVP8cctVideoVP9cctVideoH264cctVideoJPEG です。

キャプチャは別のオブジェクトです。プラットフォームのマイクをまったく使いたくない場合もあるからです。sgcCreateAudioCapture は、コンパイル対象のプラットフォームに合った実装を組み立てます。Windows なら waveIn、Linux なら ALSA、Android なら AudioRecord、iOS と macOS なら VoiceProcessingIO Audio Unit です。そのため、コードのどこにもプラットフォーム固有のクラス名は現れません。実装のないターゲットでは nil を返すので、結果を確認してください。

SendPCM は、エンコーダーのレートとチャネル数に合わせた 16 ビット符号付きインターリーブ PCM を要求します。Opus なら 48000 Hz、G.711 なら 8000 Hz です。キャプチャ元は実際に供給している値を AudioSampleRateAudioChannelsAudioFrameDurationMs で公開しているので、推測せずに確認できます。

セッションが確立した後にトラックを追加すると、再ネゴシエーションが必要というフラグが立ち、OnNegotiationNeeded が発火します。公開するにはもう一度 CreateOffer を呼びます。再オファーはトランスポートに触れずに組み立てられます。

uPeer.pas
procedure TFormPeer.StartCall;
begin
  FPeer.RTCOptions.DTLS := True;  // SRTP keys come from DTLS

  FAudioTrack := FPeer.AddTrack(rtctkAudio, cctAudioOpus);
  FVideoTrack := FPeer.AddTrack(rtctkVideo, cctVideoVP8);

  FCapture := sgcCreateAudioCapture;
  if Assigned(FCapture) then
  begin
    FCapture.OnAudioCapture := OnAudioCaptured;
    FCapture.Start;
    if not FCapture.Active then
      Log(FCapture.LastError);
  end;

  FRenderer := sgcCreateAudioRenderer;
  if Assigned(FRenderer) then
    FRenderer.Start;

  FPeer.CreateOffer;
end;

procedure TFormPeer.OnAudioCaptured(Sender: TObject;
  const aPCM: TBytes;
  aSampleRate, aChannels, aSamplesPerChannel: Integer);
begin
  if Assigned(FAudioTrack) then
    FAudioTrack.SendPCM(aPCM, aSamplesPerChannel);
end;

相手が送ってきたものを再生する

OnTrack は、リモート記述がメディア行を持ち込んだときに、その行ごとに 1 回発火します。渡されるトラックの所有者はピア接続なので、イベントを割り当てるだけにして、解放しないでください。

音声はデコード済みの PCM として OnAudio から届きます。レートとチャネル数はデコーダーが生成したものです。これを sgcCreateAudioRenderer が構築した TsgcMediaRenderer にそのまま渡してください。デバイスを一致する形式で開けなかった場合は、そこで形式を変換します。

映像はデコード済みの TsgcVideoFrame として OnVideoFrame から届きます。生のピクセルが Data に入り、WidthHeightFormatStride が付きます。形式は vffI420vffNV12vffRGB24vffRGBA32vffBGR24vffBGRA32 で、BGR の 2 つは Windows GDI のバイト順に従うため、vffBGR24 のフレームをビットマップへ転送するのは変換ではなくメモリコピーで済みます。

パケットロスの後にフレームが壊れて届いた場合は、RequestKeyFrame で送信側に新しいフレームを要求します。

uPeer.pas
procedure TFormPeer.OnTrack(Sender: TObject;
  aTrack: TsgcRTCTrack);
begin
  if aTrack.Kind = rtctkAudio then
    aTrack.OnAudio := OnRemoteAudio
  else
  begin
    FRemoteVideo := aTrack;
    aTrack.OnVideoFrame := OnRemoteVideoFrame;
  end;
  aTrack.OnEnded := OnRemoteTrackEnded;
end;

procedure TFormPeer.OnRemoteAudio(Sender: TObject;
  const aPCM: TBytes;
  aSampleRate, aChannels, aSamplesPerChannel: Integer);
begin
  if Assigned(FRenderer) then
    FRenderer.RenderAudio(aPCM, aSampleRate, aChannels,
      aSamplesPerChannel);
end;

procedure TFormPeer.OnRemoteVideoFrame(Sender: TObject;
  const aFrame: TsgcVideoFrame);
begin
  // aFrame.Data holds Width x Height pixels in aFrame.Format
  if aFrame.Format = vffBGR24 then
    BlitToBitmap(aFrame);
end;

カメラ、Windows の場合

音声キャプチャは、対応するすべてのプラットフォームに実装があるためファクトリの背後に抽象化されています。映像キャプチャはそうではないので、プラットフォームのクラス名を自分で書きます。Windows では sgcP2P_MediaCapture_WinTsgcVideoCapture_Win で、Video for Windows を駆動し、基底クラスが宣言する同じ OnVideoCapture イベントでフレームを渡します。

隣接するユニット sgcP2P_ScreenCapture_WinTsgcScreenCapture_WinTsgcWindowCapture_Win を提供します。どちらも TsgcMediaCaptureSource の派生なので、画面共有はコンストラクタが違うだけの同じ 3 行で済みます。

uPeer.pas
uses
  sgcP2P_MediaCapture_Win;   // MSWINDOWS only

procedure TFormPeer.StartCamera;
begin
  FVideoCapture := TsgcVideoCapture_Win.Create(640,
    480, 30);
  FVideoCapture.DeviceIndex := 0;
  FVideoCapture.OnVideoCapture := OnVideoCaptured;
  FVideoCapture.Start;
end;

procedure TFormPeer.OnVideoCaptured(Sender: TObject;
  const aFrame: TsgcVideoFrame);
begin
  if Assigned(FVideoTrack) then
    FVideoTrack.SendVideoFrame(aFrame);
end;

先に知っておく価値のある失敗

ピアツーピア接続は、エラーをまったく出さない形で失敗します。それが難しさの理由です。よく出てくるのが以下のものです。

メディアが無音で、エラーも出ない

RTCOptions.DTLS が False です。これが既定値で、CreateDataChannel は有効にしますが AddTrack は有効にしないため、メディアだけを運ぶセッションでは DTLS ハンドシェイクが走らず、SRTP の鍵も得られません。明示的に設定してください。

頼んでいない TURN のエラー

RTCOptions.ICE.TURN は既定で True で、サーバーリストに TURN のエントリがないと 127.0.0.1:3478 にフォールバックします。実際に TURN サーバーを用意するまでは False に設定してください。さもないと、問題とは無関係なアロケーション失敗でログが埋まります。

リモート記述が拒否される

a=fingerprint のない記述は即座に拒否され、OnError で報告されます。WebRTC の信頼モデルでは、その行だけがピアを認証するものなので、それのない記述を受け入れれば、どんな証明書に対してもハンドシェイクが成立してしまいます。

イベントハンドラーでのアクセス違反

ピア接続、データチャネル、トラックのイベントはすべてワーカースレッド、つまり ICE、ネットワーク、SCTP、tick のいずれかのスレッドで発火し、メインスレッドで発火することはありません。そこから VCL や FMX のコントロールに直接触れる動作は未定義です。TThread.Queue で包んでください。

両側が同時に再オファーする

これはグレアと呼ばれ、W3C の perfect negotiation の規則で解決します。無礼な側、つまり Polite = False のピアは自分のオファーを保持し、受信したオファーを OnError で報告します。丁寧な側は自分のオファーをロールバックしてアンサーを返します。1 つのセッションの 2 つのピアが両方とも丁寧であってはいけません。

大きなメッセージ 1 つですべてのチャネルが落ちる

ピアの a=max-message-size を超えるメッセージは SCTP のアソシエーション全体を中断させ、ほかのすべてのデータチャネルも道連れにします。SendSendBytesMaxMessageSize を確認し、代わりにローカルで拒否します。大きなペイロードは自分で分割してください。

接続の開始に 3 秒かかる

これはトリクルを使わない場合の待ち時間 RTCOptions.GatheringTimeout です。候補が届かなくなると GatheringIdleTimeout が早めに終了させ、リモート記述がトリクルを表明していれば TrickleICEAuto がトリクルモードに切り替えます。LAN 上では両方のタイムアウトを下げてください。

音声が速い、遅い、または崩れる

キャプチャデバイスとエンコーダーの間で、サンプルレートかチャネル数が食い違っています。Opus は 48000 Hz、G.711 は 8000 Hz でネゴシエートされます。デバイスが要求どおりに動いたと決めつけず、AudioSampleRateAudioChannels をキャプチャ元から読み戻してください。

Delphi の WebRTC、よくある質問

2 つのアプリケーションをピアツーピアでつなぐ前に、開発者が尋ねることです。

RTL にはなく、VCL 経由でもありません。TsgcRTCPeerConnection は、W3C のピア接続の面をネイティブな Object Pascal で実装したものです。CreateOfferCreateAnswerSetLocalDescriptionSetRemoteDescriptionAddIceCandidateCreateDataChannelAddTrack を備え、その下に ICE、DTLS、SCTP、SRTP があります。プロセス内に組み込みの Chromium も、TWebBrowser も、JavaScript のブリッジもありません。
通話が始まる前に、2 つのピアの間で数百バイトのテキストを運べる何かが必要です。この時点ではどちらも相手への到達方法を知らないからです。それがシグナリングで、WebSocket のリレーでも、既存のメッセージキューでも、REST のエンドポイントでも、デモならコピーアンドペーストでも構いません。オファー、アンサー、ICE 候補が行き交ってしまえば、メディアとデータは 2 つのアプリケーションの間を直接流れ、シグナリングチャネルは閉じて構いません。TURN の中継だけが唯一通る経路だった場合を除き、メディアの経路上にサーバーは入りません。
ほとんどの場合は WebSocket です。双方向であり、ピアがポーリングしなくてもサーバーが着信したオファーをプッシュできるからです。このページでは TsgcWebSocketServerTsgcWebSocketClient からおよそ 15 行で 1 つ組み立て、Broadcast と送信者の Connection.Guid を除外指定に使って、すべてのメッセージを相手のピアへ中継しています。リレーをまったく書きたくない場合のために、sgcWebSockets はすぐに使えるシグナリングプロトコルコンポーネント TsgcWSPServer_RTCPeerConnection も同梱しており、RTCOptions.WebSocketGatherCandidates を通じてやり取りを代行します。
2 つのアプリケーションが同じ LAN や同じ VPN 上にあるなら、どちらも要りません。host 候補がすでに到達可能なアドレスを表しています。ごく普通のルーターの背後で別々のネットワークにいるなら STUN が必要です。STUN は各ピアに、そのパケットがどの公開アドレスから来たように見えるかを教えます。これで実際の接続の大半をカバーできます。TURN が必要になるのは、直接の組み合わせがまったく通らないときです。対称型 NAT、制限の強い企業ファイアウォール、一部のモバイルキャリアが該当します。TURN は通話のすべてのバイトを中継するため、既定ではなく高コストなフォールバックです。両方を RTCOptions.ICEServers に追加すれば、ICE が実際につながる中で最も安価な組を選びます。
データチャネルは DTLS 上の SCTP で、メッセージを運びます。信頼性は選べます。TCP のように順序保証あり・完全に信頼性あり、または順序保証なしで再送回数や寿命に上限を設ける形にもでき、後者は遅れて届いても無意味なものに向きます。メディアトラックは同じ DTLS トランスポート上の SRTP で、時間軸を運びます。音声は 20 ms のフレーム、映像はフレームレートで、設計上つねに損失があります。チャット、ファイル転送、リモート操作、ゲームの状態にはデータチャネルを、マイク、カメラ、画面にはトラックを使ってください。両者は 1 本の接続と 1 つの開放ポートを共有します。
2 段階あります。Enterprise は SGC_ICESGC_DTLSSGC_TURNSGC_RTCPEERCONNECTION を定義し、これによって TsgcRTCPeerConnectionTsgcICEClientTsgcTURNClientTsgcTURNServer がパレットに載ります。オファーとアンサーの API、データチャネル、メディアトラックは SGC_SDPSGC_DATACHANNELSGC_RTP で制御されており、これらを定義するのは SGC_PACK_WEBRTC だけです。それが sgcWebRTC アドオンで、All-Access にも含まれます。その下では、STUN クライアントが Standard に、STUN サーバーと WebSocket サーバーコンポーネントが Professional に入っています。
できます。しかもこのページの内容は何も変わりません。SDP は標準どおりで、候補の行はブラウザー API が期待するのと同じ sdpMidsdpMLineIndex を持ち、オファーとアンサーの状態遷移は RFC 8829 に従うため、リレーが転送する JSON は双方向とも無修正で機能します。ブラウザーは最初の 1 ミリ秒から候補をトリクルで送ってきますが、それこそ RTCOptions.TrickleICEAuto が検出するものです。リモート記述に a=ice-options:trickle を見つけると、収集タイムアウトを待たずにただちに応答します。
つねにワーカースレッドです。OnLocalDescriptionOnIceCandidateOnConnectionStateChangeOnTrackOnError は ICE、ネットワーク、タイマーのいずれかのスレッドで届きます。データチャネルのイベントは SCTP のアソシエーションを駆動しているスレッドで届きます。トラックの音声と映像のイベントはネットワークまたは tick スレッドで届きます。いずれもメインスレッドへのマーシャリングは行われないので、コントロールに触れる処理は TThread.Queue で包んでください。同梱のデモもそうしています。
必要ありません。RTCOptions.DTLSOptions.CertFile を空のままにすると、自己署名の証明書と鍵がコンポーネントごとに 1 度だけメモリ上で生成され、すべてのピアで再利用されます。そのフィンガープリントはローカル記述の a=fingerprint 属性として公開され、これが相手側に対する自分の認証になります。これが WebRTC の信頼モデルです。証明書チェーンは検証されず、シグナリングチャネルで運ばれるフィンガープリントが拠り所になります。安定した身元が必要な場合は、CertFileKeyFile に自分の PEM ファイルを指定することもできます。
指名された候補の組の上で DTLS ハンドシェイクが走り、SRTP の鍵を導出するため、接続上の RTP、RTCP、SCTP のパケットはすべて暗号化されます。データチャネルについては、これをオフにする方法はありません。CreateDataChannelRTCOptions.DTLS を無条件に True に設定します。RTCDataChannel は定義上 DTLS 上の SCTP であり、それなしの有効な構成は存在しないからです。
P2P と WebRTC のユニットは、Delphi 7 から RAD Studio 13 までのすべてのランタイムパッケージと、対応する C++ Builder のパッケージに同梱されています。音声のキャプチャと再生は Windows、Linux、Android、iOS、macOS 向けの実装があるため、ファクトリ関数は 5 つすべてで動作するオブジェクトを返します。例外は映像キャプチャで、ファクトリで作るのではなくプラットフォームごとにクラス名を指定します。Windows では TsgcVideoCapture_Win で、画面共有用の TsgcScreenCapture_WinTsgcWindowCapture_Win もあります。
できます。それが再ネゴシエーションです。確立済みのセッションで AddTrack を呼ぶと、ネゴシエーションが必要という印が付き、OnNegotiationNeeded が発火します。もう一度 CreateOffer を呼ぶと再オファーが同期的に組み立てられます。ICE の再収集も、DTLS や SCTP の再ハンドシェイクもないため、トランスポートが中断されることはありません。既存のメディア行は位置と mid を保ち、新しい行が末尾に追加されます。RemoveTrack は逆方向に同じように働きます。メディア行は残り、recvonly または inactive として再公開されます。

コンポーネントリファレンスと技術文書

このページで使う要素にはそれぞれ専用のリファレンスページがあり、多くにはプロパティ、メソッド、イベントの全一覧を収めた単独の技術 PDF もあります。

TsgcRTCPeerConnection

このページ全体が扱っているコンポーネントです。オファーとアンサー、ICE、DTLS、SCTP データチャネル、RTP メディアを 1 つのクラスにまとめています。

コンポーネントページ →

sgcWebRTC

メディアエンジンのパックです。SDP、SCTP、RTP、SRTP、Opus と G.711 の音声、VP8 と H.264 の映像、帯域推定を含みます。

製品ページ →

機能の内訳

コーデックごと、プラットフォームごとに、メディアエンジンが何をするか、各エンコーダーがどこから来ているかを示します。

機能を見る →

ICE クライアント

候補の収集、チェックリスト、指名、ICE サーバーのコレクション。ピア接続の 1 つ下の層です。

コンポーネントページ →

STUN のクライアントとサーバー

バインディングリクエスト、再送のオプション、そして自分でホストしたい場合のサーバーコンポーネントです。

STUN クライアント →

TURN のクライアントとサーバー

アロケーション、パーミッション、チャネルバインド、そして中継が必要な通話のための TURN サーバーコンポーネントです。

TURN サーバー →

P2P コンポーネント一覧

UDP、STUN、TURN、ICE、RTCPeerConnection。ピアツーピア関連のすべてを 1 つの索引にまとめています。

P2P を見る →

Delphi WebRTC の概要

sgcWebSockets における WebRTC のライブラリ全体の視点です。シグナリングプロトコルのコンポーネントとデモの一覧を含みます。

続きを読む →

どのエディションが必要か

機能ごとの完全なエディションマトリクスです。検討対象が WebRTC だけではない場合に役立ちます。

エディションを比較 →

ほかのユースケース

このページは Delphi ユースケースの 1 つで、いずれも 1 つのタスクを最初から最後まで扱います。現時点でのほかのページは、Delphi から LLM を呼び出すOAuth2 と PKCE でユーザーをサインインさせるです。

ユースケース一覧 →
RTCPeerConnection 技術文書(PDF) ピア接続コンポーネント単体のプロパティ、メソッド、イベント、コードサンプル。
ICE クライアント技術文書(PDF) 候補の収集、チェックリスト、ICE サーバーのコレクションを詳しく解説します。
TURN クライアント技術文書(PDF) アロケーション、パーミッション、チャネルバインド。中継候補のために ICE が駆動するクライアントです。
STUN クライアント技術文書(PDF) バインディングリクエストと再送。自分の公開アドレスを知る最も安価な方法です。
デモプロジェクト Demos\35.P2P\05.RTCPeerConnection と Demos\35.P2P\06.DataChannel がパッケージに同梱されています。

各ステップの背後にある仕様

こちらの説明を鵜呑みにせず、コンポーネントが実装しているものを直接読みたいときのための一次資料です。

RFC 8829、JSEP

CreateOfferCreateAnswerSetRemoteDescription の背後にあるオファー/アンサーの状態遷移です。再ネゴシエーションとロールバックも含みます。

RFC を読む →

RFC 8445、ICE

候補の収集、優先度、チェックリスト、指名。接続に 1 秒かかることもあれば失敗することもある理由がここにあります。

RFC を読む →

RFC 8489 と RFC 8656

STUN と TURN です。バインディングリクエストが何を尋ねるのか、アロケーションにどれだけコストがかかるのかを扱います。

RFC を読む →

RFC 8831 と RFC 8832

SCTP 上の WebRTC データチャネルと、DTLS のロールに応じてストリーム ID を割り当てる DCEP のオープンハンドシェイクです。

RFC を読む →

RFC 8122、SDP のフィンガープリント

なぜ a=fingerprint がピア接続の身元なのか、そしてなぜそれのない記述が拒否されるのかを扱います。

RFC を読む →

WebRTC 1.0(W3C)

このコンポーネントが写し取っている API です。Polite の由来である perfect negotiation も含みます。

仕様を読む →

自分のアプリケーション 2 つを通話させてみましょう

体験版をダウンロードし、RTCPeerConnection と DataChannel のデモを互いに接続して動かしてから、同じものを自分のプロジェクトに組み込んでください。