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

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

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

实际上需要发生什么

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

1. 描述会话

一方构建一个 offer,那是一份文本文档(SDP),说明它想发送哪些媒体、会说哪些编解码器、将出示的证书指纹,以及将使用的 ICE 凭据。另一方用它能接受的子集作答。CreateOffer 和 CreateAnswer 生成这些文档,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 及以上版本。客户端那一半 TsgcWebSocketClient 在 Standard 中即可获得。
ICE、TURN 客户端与服务器,以及对等连接组件本身 TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
offer 与 answer、数据通道、音视频轨道 CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack 在 Enterprise 之上再加 sgcWebRTC 包。该包也包含在 All-Access 中。

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

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

本页真正讲的所有内容都在更里面一层。定义 SGC_SDP、SGC_SCTP、SGC_DATACHANNEL、SGC_RTP 和 SGC_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 并重新导出各类处理程序类型的汇总单元。声明组件用它就够了,但枚举常量来自声明其类型的那些单元,所以当你写出 rtctkAudio 或 cctAudioOpus 时,也要把那些单元引进来。

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

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 拥有与库中其他组件相同的 TLSOptions、Authentication 和 WatchDog 接口。

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 连到那个中继,并使用只有三个词的词汇表:offer、answer 和 candidate。序列化由同一个库里的 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 在每发现一个候选时触发一次,带上候选行、它的 sdpMid 和 sdpMLineIndex。这三个字段正是浏览器 API 所期望的,因此不论对端是 Delphi 还是 Chrome,同一份 JSON 都能用。

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

当候选对最终被提名时,SelectedLocalCandidate 和 SelectedRemoteCandidate 会告诉你是哪两个地址胜出。这一行日志回答“这通电话为什么走了我的 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 可获得无序投递,传入 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 接受字符串,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,然后是轨道的 OnAudio 或 OnVideoFrame
是否需要开启 DTLS 需要,而且 CreateDataChannel 会替你设好 需要,但 AddTrack 不会替你设。请自己设置 RTCOptions.DTLS
适用于 聊天、文件传输、远程控制、游戏状态、遥测数据 麦克风、摄像头、屏幕共享,以及任何带时间线的内容

发送麦克风音频

AddTrack 接受一个类型和一个编解码器,返回一个 TsgcRTCTrack。音频编解码器有 cctAudioOpus、cctAudioPCMU 和 cctAudioPCMA,视频编解码器有 cctVideoVP8、cctVideoVP9、cctVideoH264 和 cctVideoJPEG。

采集是一个独立的对象,因为你未必想用平台自带的麦克风。sgcCreateAudioCapture 会为代码所编译的目标平台构建正确的实现,Windows 上是 waveIn,Linux 上是 ALSA,Android 上是 AudioRecord,iOS 和 macOS 上是 VoiceProcessingIO Audio Unit,因此你的代码里不会出现任何平台专有的类名。在没有实现的目标上它返回 nil,所以要检查返回值。

SendPCM 需要按编码器的采样率和声道数交错排列的 16 位有符号 PCM,Opus 是 48000 Hz,G.711 是 8000 Hz。采集源会通过 AudioSampleRate、AudioChannels 和 AudioFrameDurationMs 公开它实际输出的参数,因此你可以核对而不是假设。

在会话已经建立之后添加轨道,会置上“需要重新协商”标志并触发 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 中,并附带 Width、Height、Format 和 Stride。可用的格式有 vffI420、vffNV12、vffRGB24、vffRGBA32、vffBGR24 和 vffBGRA32,其中两个 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_Win 和 TsgcWindowCapture_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 关联,把其他所有数据通道一起带走。Send 和 SendBytes 会检查 MaxMessageSize 并在本地拒绝。大的载荷请自己拆分。

连接要三秒才开始

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

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

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

Delphi WebRTC 常见问题

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

RTL 里没有,VCL 里也没有。TsgcRTCPeerConnection 是对 W3C 对等连接接口的原生 Object Pascal 实现:CreateOffer、CreateAnswer、SetLocalDescription、SetRemoteDescription、AddIceCandidate、CreateDataChannel 和 AddTrack,底层是 ICE、DTLS、SCTP 和 SRTP。进程里没有嵌入 Chromium,没有 TWebBrowser,也没有 JavaScript 桥接。
你需要某种能在通话开始前于两端之间传递几百字节文本的东西,因为此时双方都还不知道怎样联系到对方。那就是信令,它可以是一个 WebSocket 中继、一个已有的消息队列、一个 REST 端点,做演示的话甚至可以靠复制粘贴。一旦 offer、answer 和 ICE 候选都交换完毕,媒体和数据就在两个应用之间直接传输,信令通道可以关闭。除非最终只有 TURN 中继这条路走得通,否则媒体路径上不会有任何服务器。
几乎总是 WebSocket,因为它是双向的,服务器可以主动推送到来的 offer,而不需要对端轮询。本页用 TsgcWebSocketServer 和 TsgcWebSocketClient 用大约十五行代码搭了一个,通过 Broadcast 把每条消息转发给另一端,并把发送方的 Connection.Guid 作为排除项。sgcWebSockets 还提供了一个现成的信令协议组件 TsgcWSPServer_RTCPeerConnection,当你完全不想自己写中继时,它会通过 RTCOptions.WebSocket 和 GatherCandidates 替你驱动整个交换过程。
如果两个应用在同一个局域网或同一个 VPN 里,两个都不需要。host 候选已经描述了可达的地址。如果它们位于普通路由器后面的不同网络,就需要 STUN,它告诉每一端它的数据包看起来来自哪个公网地址,这覆盖了大多数真实连接。当完全找不到可用的直连候选对时才需要 TURN:对称型 NAT、限制严格的企业防火墙,以及部分移动运营商。TURN 中继通话的每一个字节,所以它是昂贵的兜底方案,而不是默认方案。把两者都加进 RTCOptions.ICEServers,ICE 会挑出真正能连通的最省成本的那一对。
数据通道是跑在 DTLS 之上的 SCTP,传输的是消息,可靠性由你选择:像 TCP 那样有序且完全可靠,或者无序并带上重传次数或存活时间上限,适用于迟到的包毫无用处的场景。媒体轨道是跑在同一 DTLS 传输之上的 SRTP,传输的是一条时间线:音频以 20 毫秒为一帧,视频按帧率发送,按设计始终允许丢包。聊天、文件传输、远程控制和游戏状态用数据通道,麦克风、摄像头或屏幕用轨道。它们共用一条连接和一个开放端口。
分两级。Enterprise 定义 SGC_ICE、SGC_DTLS、SGC_TURN 和 SGC_RTCPEERCONNECTION,正是它们把 TsgcRTCPeerConnection、TsgcICEClient、TsgcTURNClient 和 TsgcTURNServer 放上组件面板。offer 与 answer 的 API、数据通道和媒体轨道则被 SGC_SDP、SGC_DATACHANNEL 和 SGC_RTP 控制,而只有 SGC_PACK_WEBRTC 才定义它们,那就是 sgcWebRTC 附加包,同样包含在 All-Access 中。再往下,STUN 客户端在 Standard 里,STUN 服务器和 WebSocket 服务器组件在 Professional 里。
可以,而且本页的内容一点都不用改。SDP 是标准的,候选行携带浏览器 API 所期望的同样的 sdpMid 和 sdpMLineIndex,offer 与 answer 的状态机遵循 RFC 8829,因此你的中继转发的 JSON 在两个方向上都能原样工作。浏览器从第一毫秒起就开始 trickle 候选,而这正是 RTCOptions.TrickleICEAuto 所检测的:它在远端描述中看到 a=ice-options:trickle,于是立即作答,而不是耗完收集超时。
永远是工作线程。OnLocalDescription、OnIceCandidate、OnConnectionStateChange、OnTrack 和 OnError 到达 ICE 线程、网络线程或定时器线程。数据通道事件到达驱动 SCTP 关联的那个线程。轨道的音频和视频事件到达网络线程或时钟线程。它们都不会替你编组到主线程,所以任何会碰到控件的代码都要用 TThread.Queue 包起来,随包附带的示例就是这么做的。
不需要。把 RTCOptions.DTLSOptions.CertFile 留空,组件会在内存中生成一份自签名证书和密钥,每个组件生成一次,并对每个对端复用。它的指纹会作为本地描述的 a=fingerprint 属性发布出去,正是它向对方证明你的身份。这就是 WebRTC 的信任模型:证书链从不被验证,通过信令通道传递的指纹才是信任锚。如果你想要一个稳定的身份,仍然可以把 CertFile 和 KeyFile 指向你自己的 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_Win 和 TsgcWindowCapture_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

CreateOffer、CreateAnswer 和 SetRemoteDescription 背后的 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 正是从那里来的。

阅读规范 →
超值之选:All-AccesseSeGeCe 全部产品,含高级支持,每年 €1,059 起。
查看 All-Access 价格

让你的两个应用通上话

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