Delphi WebRTC:在两个应用之间传输音频、视频与数据

两个 Delphi 应用,位于两个不同的网络,彼此直接交换一条聊天通道、一路麦克风流和一路摄像头流。中间没有媒体服务器,进程里没有嵌入浏览器,也没有 JavaScript 桥接。本页把整件事走一遍,从第一条信令消息到第一帧解码后的音频,用的都是正式发行源码中真实存在的 API。

SDP 的 offer 与 answer
ICE、STUN 与 TURN
SCTP 数据通道
Opus 与 VP8 媒体轨道
DTLS-SRTP 加密
无需浏览器或 WebView

实际上需要发生什么

WebRTC 其实是顶着同一个名字的四个独立问题。其中只有一个和媒体有关,而且它是最简单的那个。下面就是这四个问题,按你必须解决它们的顺序排列。

1. 描述会话

一方构建一个 offer,那是一份文本文档(SDP),说明它想发送哪些媒体、会说哪些编解码器、将出示的证书指纹,以及将使用的 ICE 凭据。另一方用它能接受的子集作答。CreateOfferCreateAnswer 生成这些文档,SetRemoteDescription 消费它们。

2. 把它送到对端

WebRTC 有意不规定 offer 如何抵达对端。那条通道叫做信令,由你来负责。它每个方向只有几百字节的文本,因此一条连到小型中继的 WebSocket 连接就够了,而 sgcWebSockets 已经把两端都给了你。

3. 在 NAT 之间找到通路

两端都不知道自己的公网地址,而且通常都在路由器后面。ICE 会收集一个对端可能被访问到的每一个地址,一边发现一边通过信令通道发出去,然后逐对探测,直到有一对能通。STUN 用来找到公网地址,当没有任何直连方式可行时,TURN 提供中继。

4. 把字节传出去

一旦某一对候选被提名,就会在它上面跑一次 DTLS 握手,此后一切都是加密的。数据通道是跑在该 DTLS 传输之上的 SCTP,音频或视频轨道则是跑在它之上的 SRTP。两者共用同一条连接和同一个开放端口。

有没有 WebRTC 服务器?

媒体路径上没有,这正是重点所在。两个应用一旦找到彼此,音频、视频和数据就在它们之间直接传输。你托管的任何东西都看不到载荷,也不必随着用户通话分钟数的增长而扩容。

这幅图里仍然有两种服务器,把各自的职责说清楚是有帮助的,因为它们经常被混为一谈。

信令服务器是你自己的。它在通话开始前在两端之间转发少量文本消息,然后就安静下来。它从不接触媒体。在本文的演练中,它是基于 TsgcWebSocketServer 的十五行 Delphi 代码。

STUN 和 TURN 服务器的存在是因为 NAT,而不是因为 WebRTC。STUN 服务器只回答一个问题,“这个数据包是从哪个公网地址过来的”,仅此而已。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

版本、单元与平台

对等连接和媒体引擎是两个不同的授权层级。在动手写代码之前把这一点弄清楚是值得的,否则编译器根本看不到其中一半的 API。

你想做什么需要什么来自哪里
STUN 客户端,用于发现公网地址 TsgcSTUNClient sgcWebSockets Standard 及以上版本
运行你自己的 STUN 服务器 TsgcSTUNServer sgcWebSockets Professional 及以上版本
中继一条 WebSocket 信令通道 TsgcWebSocketServer 服务器这一半需要 sgcWebSockets Professional 及以上版本。客户端那一半 TsgcWebSocketClientStandard 中即可获得。
ICE、TURN 客户端与服务器,以及对等连接组件本身 TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
offer 与 answer、数据通道、音视频轨道 CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack 在 Enterprise 之上再加 sgcWebRTC 包。该包也包含在 All-Access 中。

为什么要这样拆分,用编译器自己的话说

sgcVer.inc 的 Enterprise 区块定义了 SGC_ICESGC_DTLSSGC_RTCPEERCONNECTIONSGC_TURN。正是它们把 TsgcRTCPeerConnection 放上组件面板,并为它提供 ICE 和 TURN 传输。

本页真正讲的所有内容都在更里面一层。定义 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}

两个应用都需要的单元

sgcP2P 是公开 TsgcRTCPeerConnection 并重新导出各类处理程序类型的汇总单元。声明组件用它就够了,但枚举常量来自声明其类型的那些单元,所以当你写出 rtctkAudiocctAudioOpus 时,也要把那些单元引进来。

本演练中的两个应用其实是同一个程序,只是按下的按钮不同。下面的所有内容两边都要有。

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

信令通道

三种消息类型,一个不动脑筋的中继。这正是每一篇 WebRTC 教程一笔带过的部分,也正是你真正必须自己写的部分。

是中继,不是中间人

信令服务器不需要理解它转发的任何一个字节。它把一端发来的文本交给另一端。Broadcast 本来就有一个接受连接 Guid 的 Exclude 参数,因此“发给除发送方之外的所有人”只需要一行。

就让它这么笨。中继一旦开始解析 SDP,它就变成一个每次编解码器有变动你都要更新的组件,而且它也就没法再把通话中继到浏览器了。

在生产环境里,你会按房间标识来区分中继,使两通话不会互相串扰,并把它放到 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。序列化由同一个库里的 TsgcJSON 完成,因此不引入额外依赖。

注意分支的走向。收到的 offer 被设为远端描述并立即作答。收到的 answer 只做设置。收到的 candidate 被送进 AddIceCandidate,它可以在描述之前或之后到达,这正是 trickle 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 列表。AddURL 接受一个 stun:turn: URL,并从该 scheme 推断出类型、主机、端口和 TLS 标志,TURN 还可以另外带上用户名和凭据。

RTCOptions.DTLS 默认为 False。保持这个默认值就没有加密,也没有 SRTP 密钥材料,媒体因此无法工作。CreateDataChannel 会替你打开它,因为数据通道就是跑在 DTLS 之上的 SCTP,关闭 DTLS 的配置根本不合法。AddTrack 不会替你打开,所以添加媒体时要自己设置。

你不需要证书文件。把 RTCOptions.DTLSOptions.CertFile 留空,组件就会在内存中生成一份自签名证书,每个组件生成一次,这恰好就是 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 本身。两端使用同一个处理程序,它只做一件事:把内容放上信令通道。

SDP 到达时已经是完整的。CreateOffer 会先收集 ICE 候选,最多等待 RTCOptions.GatheringTimeout 毫秒,默认 3000,若连续 GatheringIdleTimeout 毫秒没有新候选就提前结束,该值默认 500。这就是非 trickle 路径。

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

点对点连接就是在这里失败的,而且这里的失败最难看懂。三类候选,三个存在的理由。

host

机器在自身上就能看到的地址,每个网络接口一个。免费、即时,当两个应用位于同一个局域网或同一个 VPN 时就已经够用。如果你的两个 Delphi 应用永远只在一间办公室里运行,host 候选就是你需要的全部,STUN 完全可以跳过。

srflx,服务器自反

STUN 服务器看到对端数据包来自的那个公网地址。正是它让两个处在普通家用路由器后面的对端能够直接通信,而这覆盖了绝大多数真实连接。代价只是与 STUN 服务器的一次往返,之后该服务器不承载任何流量。

relay,中继

TURN 服务器上的一个地址,由它转发给对端。对称型 NAT、限制严格的企业防火墙和部分移动运营商都需要它。通话的每一个字节都要经过你的 TURN 服务器,所以这是昂贵的路径,只在别无选择时才退而求其次。

把候选一点点送过去

OnIceCandidate 在每发现一个候选时触发一次,带上候选行、它的 sdpMidsdpMLineIndex。这三个字段正是浏览器 API 所期望的,因此不论对端是 Delphi 还是 Chrome,同一份 JSON 都能用。

每发现一个就立即发送。不要等待,也不要攒批。在远端描述之前到达的候选会被暂存,等描述到位后再应用,所以顺序不是你要操心的问题。

当候选对最终被提名时,SelectedLocalCandidateSelectedRemoteCandidate 会告诉你是哪两个地址胜出。这一行日志回答“这通电话为什么走了我的 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

陷阱在另一个方向。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 服务器

数据通道

在两个应用之间传输文本和二进制数据,可靠性由你按通道选择。这通常是你最先跑通的东西,而它证明了整条传输链路是通的。

打开一条,并接收对端打开的那条

调用 CreateDataChannel 的那一端会立刻拿到对象。没有调用的那一端则通过 OnDataChannel 拿到同一条通道。两处都要挂上事件处理程序,因为任何一方都可以在会话的任何时刻打开通道。

通道并不是一创建就能用。在 SCTP 关联建立、DTLS 角色确定之前,它的 Id 一直未分配,而在通道尚未打开时 Send 会返回 False。请等待 OnOpen

可靠性在创建时就决定了。默认是有序且完全可靠,类似 TCP 的通道。传入 aOrdered = False 可获得无序投递,传入 aMaxRetransmitsaMaxPacketLifeTime 则获得部分可靠,后者正是位置更新之类场景所需要的,因为在那些场景里迟到的包比丢失的包更糟。

不要释放通道,它归对等连接所有。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 接受字符串,SendBytes 接受 TBytes。通道未打开时两者都返回 False 而不是抛异常,因此在拆除过程中的一次发送只是返回 False,而不是在工作线程上抛出异常。

MaxMessageSize对端声明它能接受的最大消息尺寸,读自其描述中的 a=max-message-size 属性。超过该上限的消息会在本地被拒绝,而不是被送上线路,否则它会中止整个关联,把其他所有通道一起带走。为零表示从来没有描述给出过上限,该检查处于关闭状态。

BufferedAmount 是这条流在 SCTP 中已排队但尚未被确认的字节数。传输文件时请盯住它:发送到超过某个阈值就停下来等它排空,而不是把几个 GB 排进内存。

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 毫秒为一帧送入,视频按帧率送入
打开方式 CreateDataChannel,会话中的任何时刻都可以 AddTrack,需要一个新的 offer 才能把它发布出去
接收途径 OnDataChannel,然后是通道自己的 OnMessage OnTrack,然后是轨道的 OnAudioOnVideoFrame
是否需要开启 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 就能把它发布出去,重新生成的 offer 不会动到传输层。

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 就触发一次。它交给你的轨道归对等连接所有,因此只需挂上它的事件,永远不要释放它。

音频通过 OnAudio 以解码后的 PCM 形式到达,附带解码器产出的采样率和声道数。把它直接交给 sgcCreateAudioRenderer 构建出来的 TsgcMediaRenderer,当设备无法按相同格式打开时,它会做格式转换。

视频通过 OnVideoFrame 以解码后的 TsgcVideoFrame 形式到达:原始像素放在 Data 中,并附带 WidthHeightFormatStride。可用的格式有 vffI420vffNV12vffRGB24vffRGBA32vffBGR24vffBGRA32,其中两个 BGR 格式遵循 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_Win 中的 TsgcVideoCapture_Win,它驱动 Video for Windows,并通过基类声明的同一个 OnVideoCapture 事件交付帧数据。

相邻的单元 sgcP2P_ScreenCapture_Win 提供了 TsgcScreenCapture_WinTsgcWindowCapture_Win,两者都是 TsgcMediaCaptureSource 的派生类,因此屏幕共享就是换一个构造函数的同样三行代码。

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 或时钟线程,绝不会在主线程上。从中直接操作 VCL 或 FMX 控件属于未定义行为。请用 TThread.Queue 包起来。

双方同时重新发起 offer

这种情况叫做 glare,用 W3C 的完美协商规则来解决。不礼貌的一方,也就是 Polite = False 的那一方,保留自己的 offer,并通过 OnError 报告收到的那一个。礼貌的一方回滚自己的 offer 并作答。同一会话的两个对端不能都是礼貌的。

一条超大消息干掉所有通道

超过对端 a=max-message-size 的消息会中止整个 SCTP 关联,把其他所有数据通道一起带走。SendSendBytes 会检查 MaxMessageSize 并在本地拒绝。大的载荷请自己拆分。

连接要三秒才开始

那是 RTCOptions.GatheringTimeout,也就是非 trickle 模式下的等待。候选不再到来时,GatheringIdleTimeout 会提前结束等待,而当远端描述声明支持时,TrickleICEAuto 会切换到 trickle 模式。在局域网里请把这两个超时都调小。

音频忽快忽慢或者含糊不清

采集设备和编码器之间的采样率或声道数不匹配。Opus 按 48000 Hz 协商,G.711 按 8000 Hz 协商。请从采集源读回 AudioSampleRateAudioChannels,而不要假定设备照你要求的参数执行了。

Delphi WebRTC 常见问题

开发者在把两个应用点对点连起来之前会问的那些问题。

RTL 里没有,VCL 里也没有。TsgcRTCPeerConnection 是对 W3C 对等连接接口的原生 Object Pascal 实现:CreateOfferCreateAnswerSetLocalDescriptionSetRemoteDescriptionAddIceCandidateCreateDataChannelAddTrack,底层是 ICE、DTLS、SCTP 和 SRTP。进程里没有嵌入 Chromium,没有 TWebBrowser,也没有 JavaScript 桥接。
你需要某种能在通话开始前于两端之间传递几百字节文本的东西,因为此时双方都还不知道怎样联系到对方。那就是信令,它可以是一个 WebSocket 中继、一个已有的消息队列、一个 REST 端点,做演示的话甚至可以靠复制粘贴。一旦 offer、answer 和 ICE 候选都交换完毕,媒体和数据就在两个应用之间直接传输,信令通道可以关闭。除非最终只有 TURN 中继这条路走得通,否则媒体路径上不会有任何服务器。
几乎总是 WebSocket,因为它是双向的,服务器可以主动推送到来的 offer,而不需要对端轮询。本页用 TsgcWebSocketServerTsgcWebSocketClient 用大约十五行代码搭了一个,通过 Broadcast 把每条消息转发给另一端,并把发送方的 Connection.Guid 作为排除项。sgcWebSockets 还提供了一个现成的信令协议组件 TsgcWSPServer_RTCPeerConnection,当你完全不想自己写中继时,它会通过 RTCOptions.WebSocketGatherCandidates 替你驱动整个交换过程。
如果两个应用在同一个局域网或同一个 VPN 里,两个都不需要。host 候选已经描述了可达的地址。如果它们位于普通路由器后面的不同网络,就需要 STUN,它告诉每一端它的数据包看起来来自哪个公网地址,这覆盖了大多数真实连接。当完全找不到可用的直连候选对时才需要 TURN:对称型 NAT、限制严格的企业防火墙,以及部分移动运营商。TURN 中继通话的每一个字节,所以它是昂贵的兜底方案,而不是默认方案。把两者都加进 RTCOptions.ICEServers,ICE 会挑出真正能连通的最省成本的那一对。
数据通道是跑在 DTLS 之上的 SCTP,传输的是消息,可靠性由你选择:像 TCP 那样有序且完全可靠,或者无序并带上重传次数或存活时间上限,适用于迟到的包毫无用处的场景。媒体轨道是跑在同一 DTLS 传输之上的 SRTP,传输的是一条时间线:音频以 20 毫秒为一帧,视频按帧率发送,按设计始终允许丢包。聊天、文件传输、远程控制和游戏状态用数据通道,麦克风、摄像头或屏幕用轨道。它们共用一条连接和一个开放端口。
分两级。Enterprise 定义 SGC_ICESGC_DTLSSGC_TURNSGC_RTCPEERCONNECTION,正是它们把 TsgcRTCPeerConnectionTsgcICEClientTsgcTURNClientTsgcTURNServer 放上组件面板。offer 与 answer 的 API、数据通道和媒体轨道则被 SGC_SDPSGC_DATACHANNELSGC_RTP 控制,而只有 SGC_PACK_WEBRTC 才定义它们,那就是 sgcWebRTC 附加包,同样包含在 All-Access 中。再往下,STUN 客户端在 Standard 里,STUN 服务器和 WebSocket 服务器组件在 Professional 里。
可以,而且本页的内容一点都不用改。SDP 是标准的,候选行携带浏览器 API 所期望的同样的 sdpMidsdpMLineIndex,offer 与 answer 的状态机遵循 RFC 8829,因此你的中继转发的 JSON 在两个方向上都能原样工作。浏览器从第一毫秒起就开始 trickle 候选,而这正是 RTCOptions.TrickleICEAuto 所检测的:它在远端描述中看到 a=ice-options:trickle,于是立即作答,而不是耗完收集超时。
永远是工作线程。OnLocalDescriptionOnIceCandidateOnConnectionStateChangeOnTrackOnError 到达 ICE 线程、网络线程或定时器线程。数据通道事件到达驱动 SCTP 关联的那个线程。轨道的音频和视频事件到达网络线程或时钟线程。它们都不会替你编组到主线程,所以任何会碰到控件的代码都要用 TThread.Queue 包起来,随包附带的示例就是这么做的。
不需要。把 RTCOptions.DTLSOptions.CertFile 留空,组件会在内存中生成一份自签名证书和密钥,每个组件生成一次,并对每个对端复用。它的指纹会作为本地描述的 a=fingerprint 属性发布出去,正是它向对方证明你的身份。这就是 WebRTC 的信任模型:证书链从不被验证,通过信令通道传递的指纹才是信任锚。如果你想要一个稳定的身份,仍然可以把 CertFileKeyFile 指向你自己的 PEM 文件。
DTLS 握手在被提名的候选对上运行并派生出 SRTP 密钥,因此这条连接上的每一个 RTP、RTCP 和 SCTP 数据包都是加密的。对数据通道来说没有办法关掉它:CreateDataChannel 无条件地把 RTCOptions.DTLS 设为 True,因为按定义 RTCDataChannel 就是跑在 DTLS 之上的 SCTP,没有 DTLS 的配置根本不合法。
P2P 和 WebRTC 单元包含在从 Delphi 7 到 RAD Studio 13 的每一个运行时包里,以及对应的 C++ Builder 包里。音频采集与播放在 Windows、Linux、Android、iOS 和 macOS 上都有平台实现,因此工厂函数在这五个平台上都会返回一个可用的对象。视频采集是例外:它按平台命名,而不是由工厂构建,在 Windows 上是 TsgcVideoCapture_Win,用于屏幕共享的还有 TsgcScreenCapture_WinTsgcWindowCapture_Win
可以,这就是重新协商。在已建立的会话上调用 AddTrack 会把它标记为需要协商,并触发 OnNegotiationNeeded。再调用一次 CreateOffer,就会同步构建出一个新的 offer,不会重新收集 ICE,也不会重新进行 DTLS 或 SCTP 握手,因此传输不会中断。已有的媒体行保持原来的位置和 mid,新的一行追加在末尾。RemoveTrack 以相反的方式工作:媒体行保留下来,并以 recvonly 或 inactive 重新发布。

组件参考与技术文档

本页用到的每一个部件都有自己的参考页面,其中大多数还有一份独立的技术 PDF,列出完整的属性、方法和事件。

TsgcRTCPeerConnection

整页都在讲的那个组件。offer 与 answer、ICE、DTLS、SCTP 数据通道和 RTP 媒体,全在一个类里。

组件页面 →

sgcWebRTC

媒体引擎扩展包:SDP、SCTP、RTP、SRTP、Opus 与 G.711 音频、VP8 与 H.264 视频,以及带宽估计。

产品页面 →

功能明细

逐个编解码器、逐个平台地说明媒体引擎做了什么,以及每个编码器来自哪里。

查看功能 →

ICE 客户端

候选收集、检查列表、提名与 ICE 服务器集合,也就是对等连接下面的那一层。

组件页面 →

STUN 客户端与服务器

绑定请求、重传选项,以及当你想自己托管时可用的服务器组件。

STUN 客户端 →

TURN 客户端与服务器

分配、权限、通道绑定,以及为需要中继的通话准备的 TURN 服务器组件。

TURN 服务器 →

全部 P2P 组件

UDP、STUN、TURN、ICE 和 RTCPeerConnection,整个点对点家族汇总在一个索引里。

浏览 P2P →

Delphi WebRTC 概览

从库的层面看 sgcWebSockets 中的 WebRTC,包括信令协议组件和示例清单。

了解更多 →

我需要哪个版本?

完整的版本矩阵,逐项功能列出,适合你要权衡的不止 WebRTC 的情况。

对比版本 →

其他使用场景

本页属于 Delphi 使用场景之一,每一篇都把一个任务从头做到尾。目前还有 从 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 背后的 offer 与 answer 状态机,包括重新协商和回滚。

阅读 RFC →

RFC 8445,ICE

候选收集、优先级、检查列表和提名。这也是连接有时要花一秒、有时干脆失败的原因。

阅读 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 正是从那里来的。

阅读规范 →

让你的两个应用通上话

下载试用版,把 RTCPeerConnection 和 DataChannel 两个示例互相对着跑起来,然后把同样的东西做进你自己的工程。