WebRTC 其实是顶着同一个名字的四个独立问题。其中只有一个和媒体有关,而且它是最简单的那个。下面就是这四个问题,按你必须解决它们的顺序排列。
一方构建一个 offer,那是一份文本文档(SDP),说明它想发送哪些媒体、会说哪些编解码器、将出示的证书指纹,以及将使用的 ICE 凭据。另一方用它能接受的子集作答。CreateOffer 和 CreateAnswer 生成这些文档,SetRemoteDescription 消费它们。
WebRTC 有意不规定 offer 如何抵达对端。那条通道叫做信令,由你来负责。它每个方向只有几百字节的文本,因此一条连到小型中继的 WebSocket 连接就够了,而 sgcWebSockets 已经把两端都给了你。
两端都不知道自己的公网地址,而且通常都在路由器后面。ICE 会收集一个对端可能被访问到的每一个地址,一边发现一边通过信令通道发出去,然后逐对探测,直到有一对能通。STUN 用来找到公网地址,当没有任何直连方式可行时,TURN 提供中继。
一旦某一对候选被提名,就会在它上面跑一次 DTLS 握手,此后一切都是加密的。数据通道是跑在该 DTLS 传输之上的 SCTP,音频或视频轨道则是跑在它之上的 SRTP。两者共用同一条连接和同一个开放端口。
媒体路径上没有,这正是重点所在。两个应用一旦找到彼此,音频、视频和数据就在它们之间直接传输。你托管的任何东西都看不到载荷,也不必随着用户通话分钟数的增长而扩容。
这幅图里仍然有两种服务器,把各自的职责说清楚是有帮助的,因为它们经常被混为一谈。
信令服务器是你自己的。它在通话开始前在两端之间转发少量文本消息,然后就安静下来。它从不接触媒体。在本文的演练中,它是基于 TsgcWebSocketServer 的十五行 Delphi 代码。
STUN 和 TURN 服务器的存在是因为 NAT,而不是因为 WebRTC。STUN 服务器只回答一个问题,“这个数据包是从哪个公网地址过来的”,仅此而已。TURN 服务器为那些用其他任何方式都联系不上彼此的候选对转发数据包,因此它是唯一承载媒体的部件,而且只服务于确实需要它的通话。公共 STUN 服务器免费而且很多,TURN 则要你自己托管,如果你不想再跑一个独立的守护进程,sgcWebSockets Enterprise 同时提供了 STUN 服务器和 TURN 服务器组件。
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 无法解析,原因就在这里。随包附带的数据通道示例会明确提示这一点,而不是悄无声息地失败。
{$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 时,也要把那些单元引进来。
本演练中的两个应用其实是同一个程序,只是按下的按钮不同。下面的所有内容两边都要有。
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 接口。
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 的意义所在。
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 做线程编组。
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 时,也就是每个浏览器都会写的那一行,组件会自行切换过去,不再白白耗掉收集超时。
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;
点对点连接就是在这里失败的,而且这里的失败最难看懂。三类候选,三个存在的理由。
机器在自身上就能看到的地址,每个网络接口一个。免费、即时,当两个应用位于同一个局域网或同一个 VPN 时就已经够用。如果你的两个 Delphi 应用永远只在一间办公室里运行,host 候选就是你需要的全部,STUN 完全可以跳过。
STUN 服务器看到对端数据包来自的那个公网地址。正是它让两个处在普通家用路由器后面的对端能够直接通信,而这覆盖了绝大多数真实连接。代价只是与 STUN 服务器的一次往返,之后该服务器不承载任何流量。
TURN 服务器上的一个地址,由它转发给对端。对称型 NAT、限制严格的企业防火墙和部分移动运营商都需要它。通话的每一个字节都要经过你的 TURN 服务器,所以这是昂贵的路径,只在别无选择时才退而求其次。
OnIceCandidate 在每发现一个候选时触发一次,带上候选行、它的 sdpMid 和 sdpMLineIndex。这三个字段正是浏览器 API 所期望的,因此不论对端是 Delphi 还是 Chrome,同一份 JSON 都能用。
每发现一个就立即发送。不要等待,也不要攒批。在远端描述之前到达的候选会被暂存,等描述到位后再应用,所以顺序不是你要操心的问题。
当候选对最终被提名时,SelectedLocalCandidate 和 SelectedRemoteCandidate 会告诉你是哪两个地址胜出。这一行日志回答“这通电话为什么走了我的 TURN 服务器”,比任何其他手段都快。
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;
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。
// 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;
在两个应用之间传输文本和二进制数据,可靠性由你按通道选择。这通常是你最先跑通的东西,而它证明了整条传输链路是通的。
调用 CreateDataChannel 的那一端会立刻拿到对象。没有调用的那一端则通过 OnDataChannel 拿到同一条通道。两处都要挂上事件处理程序,因为任何一方都可以在会话的任何时刻打开通道。
通道并不是一创建就能用。在 SCTP 关联建立、DTLS 角色确定之前,它的 Id 一直未分配,而在通道尚未打开时 Send 会返回 False。请等待 OnOpen。
可靠性在创建时就决定了。默认是有序且完全可靠,类似 TCP 的通道。传入 aOrdered = False 可获得无序投递,传入 aMaxRetransmits 或 aMaxPacketLifeTime 则获得部分可靠,后者正是位置更新之类场景所需要的,因为在那些场景里迟到的包比丢失的包更糟。
不要释放通道,它归对等连接所有。Close 启动关闭流程,等对端确认后触发 OnClose。
// 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 排进内存。
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 不会动到传输层。
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 可以向发送方索取一帧新的关键帧。
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 上那就是 sgcP2P_MediaCapture_Win 中的 TsgcVideoCapture_Win,它驱动 Video for Windows,并通过基类声明的同一个 OnVideoCapture 事件交付帧数据。
相邻的单元 sgcP2P_ScreenCapture_Win 提供了 TsgcScreenCapture_Win 和 TsgcWindowCapture_Win,两者都是 TsgcMediaCaptureSource 的派生类,因此屏幕共享就是换一个构造函数的同样三行代码。
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 密钥。请显式设置它。
RTCOptions.ICE.TURN 默认为 True,当服务器列表里没有 TURN 条目时会回落到 127.0.0.1:3478。在你真正拥有 TURN 服务器之前请把它设为 False,否则日志里会塞满与你的问题毫无关系的分配失败记录。
没有 a=fingerprint 的描述会被直接拒绝,并通过 OnError 报出。在 WebRTC 的信任模型里,那一行是认证对端的唯一依据,因此接受一个不带指纹的描述,等于允许握手对着任意证书完成。
对等连接、数据通道和轨道的每一个事件都在工作线程上触发,可能是 ICE、网络、SCTP 或时钟线程,绝不会在主线程上。从中直接操作 VCL 或 FMX 控件属于未定义行为。请用 TThread.Queue 包起来。
这种情况叫做 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,而不要假定设备照你要求的参数执行了。
开发者在把两个应用点对点连起来之前会问的那些问题。
TsgcRTCPeerConnection 是对 W3C 对等连接接口的原生 Object Pascal 实现:CreateOffer、CreateAnswer、SetLocalDescription、SetRemoteDescription、AddIceCandidate、CreateDataChannel 和 AddTrack,底层是 ICE、DTLS、SCTP 和 SRTP。进程里没有嵌入 Chromium,没有 TWebBrowser,也没有 JavaScript 桥接。TsgcWebSocketServer 和 TsgcWebSocketClient 用大约十五行代码搭了一个,通过 Broadcast 把每条消息转发给另一端,并把发送方的 Connection.Guid 作为排除项。sgcWebSockets 还提供了一个现成的信令协议组件 TsgcWSPServer_RTCPeerConnection,当你完全不想自己写中继时,它会通过 RTCOptions.WebSocket 和 GatherCandidates 替你驱动整个交换过程。RTCOptions.ICEServers,ICE 会挑出真正能连通的最省成本的那一对。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 里。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 文件。CreateDataChannel 无条件地把 RTCOptions.DTLS 设为 True,因为按定义 RTCDataChannel 就是跑在 DTLS 之上的 SCTP,没有 DTLS 的配置根本不合法。TsgcVideoCapture_Win,用于屏幕共享的还有 TsgcScreenCapture_Win 和 TsgcWindowCapture_Win。AddTrack 会把它标记为需要协商,并触发 OnNegotiationNeeded。再调用一次 CreateOffer,就会同步构建出一个新的 offer,不会重新收集 ICE,也不会重新进行 DTLS 或 SCTP 握手,因此传输不会中断。已有的媒体行保持原来的位置和 mid,新的一行追加在末尾。RemoveTrack 以相反的方式工作:媒体行保留下来,并以 recvonly 或 inactive 重新发布。本页用到的每一个部件都有自己的参考页面,其中大多数还有一份独立的技术 PDF,列出完整的属性、方法和事件。
一手来源,适合你更愿意亲自读组件实现了什么,而不是听我们说。
CreateOffer、CreateAnswer 和 SetRemoteDescription 背后的 offer 与 answer 状态机,包括重新协商和回滚。