Delphi WebRTC: audio, video en data tussen twee toepassingen

Twee Delphi-toepassingen, op twee verschillende netwerken, die rechtstreeks met elkaar een chatkanaal, een microfoonstream en een camerastream uitwisselen. Geen mediaserver ertussen, geen browser ingebed in het proces, geen JavaScript-bridge. Deze pagina loopt de hele klus door, van het eerste signalling-bericht tot het eerste gedecodeerde audioframe, met API's die echt in de meegeleverde broncode zitten.

SDP offer en answer
ICE, STUN en TURN
SCTP-datakanalen
Opus- en VP8-mediatracks
DTLS-SRTP-versleuteld
Geen browser of WebView

Wat er echt moet gebeuren

WebRTC bestaat uit vier losse problemen onder één naam. Maar één daarvan gaat over media, en dat is het makkelijke. Dit zijn de vier, in de volgorde waarin je ze moet oplossen.

1. Beschrijf de sessie

De ene kant bouwt een offer, een tekstdocument (SDP) dat vertelt welke media het wil versturen, welke codecs het spreekt, de fingerprint van het certificaat dat het gaat aanbieden en de ICE-inloggegevens die het gaat gebruiken. De andere kant antwoordt met de deelverzameling die het accepteert. CreateOffer en CreateAnswer maken die documenten, SetRemoteDescription verwerkt ze.

2. Breng het naar de andere kant

WebRTC laat bewust in het midden hoe de offer bij de andere peer terechtkomt. Dat kanaal heet signalling en dat is jouw werk. Het gaat om een paar honderd bytes tekst per kant, dus een WebSocket-verbinding naar een kleine relay is genoeg, en sgcWebSockets geeft je beide helften daarvan al.

3. Vind een pad door de NATs

Geen van beide peers kent zijn publieke adres, en allebei zitten ze meestal achter een router. ICE verzamelt elk adres waarop een peer bereikbaar zou kunnen zijn, stuurt ze over het signalling-kanaal zodra ze verschijnen, en test elke combinatie tot er één werkt. STUN vindt het publieke adres, TURN levert een relay wanneer niets rechtstreeks werkt.

4. Verplaats de bytes

Zodra er één kandidatenpaar genomineerd is, loopt daar een DTLS-handshake overheen en is alles daarna versleuteld. Een datakanaal is SCTP over dat DTLS-transport, een audio- of videotrack is SRTP daaroverheen. Allebei delen ze dezelfde verbinding en dezelfde open poort.

Is er een WebRTC-server?

Niet in het mediapad, en dat is precies het idee. Zodra de twee toepassingen elkaar gevonden hebben, reizen de audio, de video en de data rechtstreeks tussen hen heen en weer. Niets wat jij host ziet de payload, en niets wat jij host hoeft mee te schalen met het aantal minuten dat je gebruikers bellen.

Er zitten wel twee servers in het plaatje, en het helpt om precies te zijn over wat elk van beide doet, want ze worden vaak door elkaar gehaald.

De signalling-server is van jou. Die geeft een handvol tekstberichten door tussen twee peers voordat het gesprek begint, en valt daarna stil. Hij ziet nooit media. In deze uitleg is het vijftien regels Delphi gebouwd op TsgcWebSocketServer.

De STUN- en TURN-server bestaat vanwege NAT, niet vanwege WebRTC. Een STUN-server beantwoordt één vraag, "van welk publiek adres kwam dit pakket binnen", en meer niet. Een TURN-server geeft pakketten door voor de paren die elkaar op geen enkele andere manier kunnen bereiken, dus dat is het enige onderdeel dat media draagt, en alleen voor de gesprekken die het nodig hebben. Publieke STUN-servers zijn gratis en er zijn er genoeg, TURN host je zelf, en sgcWebSockets Enterprise levert zowel een STUN-server als een TURN-server-component mee als je liever geen aparte daemon draait.

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

Edities, units en platforms

De peer connection en de media-engine zijn twee verschillende licentiestappen. Het loont om dit helder te hebben voordat je code schrijft, want anders ziet de compiler simpelweg de helft van de API niet.

Wat je wilt doenWat daarvoor nodig isWaar het vandaan komt
STUN-client, om een publiek adres te ontdekken TsgcSTUNClient sgcWebSockets Standard en hoger
Je eigen STUN-server draaien TsgcSTUNServer sgcWebSockets Professional en hoger
Een WebSocket-signalling-kanaal doorgeven TsgcWebSocketServer sgcWebSockets Professional en hoger voor de serverhelft. De clienthelft, TsgcWebSocketClient, komt al met Standard.
ICE, TURN-client en -server, en het peer connection-component zelf TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
Offer en answer, datakanalen, audio- en videotracks CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack Het sgcWebRTC-pack, bovenop Enterprise. Zit ook in All-Access.

Waarom die splitsing, in de woorden van de compiler zelf

Het Enterprise-blok van sgcVer.inc definieert SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION en SGC_TURN. Dat is wat TsgcRTCPeerConnection op het palet zet en het een ICE- en TURN-transport geeft.

Alles waar deze pagina eigenlijk over gaat zit een niveau dieper. SGC_PACK_WEBRTC is wat SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP en SGC_SRTP definieert, en het controleert eerst of ICE, DTLS en de peer connection er al zijn. De handmatige signalling-methoden, de DataChannel-API en de media-API zitten respectievelijk binnen {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL} en {$IFDEF SGC_RTP}, dus met alleen Enterprise compileren ze niet.

Als CreateOffer niet oplost, is dat wat er aan de hand is. De DataChannel-demo die wordt meegeleverd zegt dat hardop in plaats van stilletjes te falen.

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}

De units die beide toepassingen nodig hebben

sgcP2P is de barrel-unit die TsgcRTCPeerConnection publiceert en de handlertypes opnieuw exporteert. Dat is genoeg om het component te declareren, maar enumeratieconstanten komen uit de units die hun types declareren, dus haal die er ook bij zodra je rtctkAudio of cctAudioOpus noemt.

De twee toepassingen in deze uitleg zijn hetzelfde programma met een andere knop ingedrukt. Alles hieronder komt in allebei.

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

Het signalling-kanaal

Drie soorten berichten, één domme relay. Dit is het stuk waar elke WebRTC-tutorial naar wuift, en het stuk dat je echt zelf moet schrijven.

Een relay, geen broker

De signalling-server hoeft geen enkele byte te begrijpen van wat hij doorstuurt. Hij neemt de tekst die de ene peer stuurde en geeft die aan de andere. Broadcast heeft al een Exclude-parameter die een connection-Guid aanneemt, dus "stuur naar iedereen behalve de afzender" is één regel.

Houd hem zo dom. Zodra de relay SDP gaat parsen wordt het een component dat je bij elke codecwijziging moet bijwerken, en kan hij een gesprek niet meer naar een browser doorgeven.

In productie zou je de relay sleutelen op een room-identifier zodat twee gesprekken niet kunnen botsen, en hem achter TLS zetten. TsgcWebSocketServer heeft hetzelfde TLSOptions-, Authentication- en WatchDog-oppervlak als de rest van de bibliotheek.

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;

De peer-kant van hetzelfde kanaal

Elke toepassing opent een TsgcWebSocketClient naar die relay en spreekt een vocabulaire van drie woorden: offer, answer en candidate. TsgcJSON uit dezelfde bibliotheek serialiseert ze, dus er is geen extra afhankelijkheid.

Let op waar de vertakkingen liggen. Een binnenkomende offer wordt als remote description gezet en meteen beantwoord. Een binnenkomende answer wordt alleen gezet. Een binnenkomende candidate gaat naar AddIceCandidate, en die kan voor of na de description aankomen, en dat is precies het punt van 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;

Bouw de peer connection

Identiek in beide toepassingen. De enige asymmetrie in de hele uitwisseling is welke van de twee op Call drukt.

Configuratie, en de events die ertoe doen

RTCOptions.ICEServers is de W3C-lijst iceServers. AddURL neemt een stun:- of turn:-URL en vult het type, de host, de poort en de TLS-vlag in op basis van het schema, met een optionele username en credential voor TURN.

RTCOptions.DTLS staat standaard op False. Laat je dat zo, dan is er geen encryptie en geen SRTP-sleutelmateriaal, dus media werkt niet. CreateDataChannel zet het voor je aan, want een datakanaal is SCTP over DTLS en er is geen geldige configuratie met DTLS uit. AddTrack doet dat niet, dus zet het zelf zodra je media toevoegt.

Je hebt geen certificaatbestand nodig. Laat RTCOptions.DTLSOptions.CertFile leeg en er wordt één keer per component een zelfondertekend certificaat in het geheugen gegenereerd, en dat is precies het WebRTC-model: identiteit wordt verankerd in de regel a=fingerprint in de SDP, niet in een keten. Een remote description zonder fingerprint wordt geweigerd in plaats van toegelaten om tegen wat dan ook een handshake te doen.

Elk event hieronder vuurt op een worker thread, de ICE-, netwerk- of timerthread, nooit op de main thread. Marshal met TThread.Queue voordat je een control aanraakt.

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;

De local description publiceren

OnLocalDescription geeft je het type, de string 'offer' of 'answer', en de SDP zelf. Beide peers gebruiken dezelfde handler, en die doet één ding: hem op het signalling-kanaal zetten.

De SDP komt al compleet binnen. CreateOffer verzamelt eerst ICE-kandidaten en wacht tot RTCOptions.GatheringTimeout milliseconden, standaard 3000, en stopt eerder na GatheringIdleTimeout milliseconden zonder nieuwe kandidaat, standaard 500. Dat is het pad zonder trickle.

Zet TrickleICE op True en de description gaat meteen de deur uit, met de kandidaten die erachteraan komen. Dat hoef je zelden te doen. RTCOptions.TrickleICEAuto staat standaard op True, dus zodra de remote description a=ice-options:trickle meldt, wat elke browser doet, schakelt het component zichzelf om en verbrandt het de gathering timeout niet langer.

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-kandidaten, STUN en TURN

Hier gaan peer to peer verbindingen mis, en hier zijn de fouten het lastigst te lezen. Drie soorten kandidaat, drie redenen waarom ze bestaan.

host

Een adres dat de machine op zichzelf kan zien, één per netwerkinterface. Gratis, direct, en genoeg wanneer beide toepassingen op hetzelfde LAN of hetzelfde VPN zitten. Als je twee Delphi-toepassingen alleen ooit binnen één kantoor draaien, zijn host-kandidaten alles wat je nodig hebt en kun je STUN helemaal overslaan.

srflx, server reflexive

Het publieke adres waarvandaan een STUN-server het pakket van de peer zag binnenkomen. Dit is wat twee peers achter gewone thuisrouters rechtstreeks met elkaar laat praten, en het dekt de grote meerderheid van de echte verbindingen. Het kost één round trip naar een STUN-server die daarna geen verkeer meer draagt.

relay

Een adres op een TURN-server die doorstuurt naar de peer. Nodig bij symmetrische NAT, restrictieve bedrijfsfirewalls en sommige mobiele providers. Elke byte van het gesprek gaat over jouw TURN-server, dus dat is het dure pad en het pad waar je alleen op terugvalt.

Ze stuk voor stuk oversturen

OnIceCandidate vuurt één keer per kandidaat, op het moment dat die ontdekt wordt, met de kandidaatregel, zijn sdpMid en zijn sdpMLineIndex. Die drie velden zijn precies wat de browser-API verwacht, dus dezelfde JSON werkt of de overkant nu Delphi of Chrome is.

Stuur elke kandidaat meteen door. Niet wachten, niet bundelen. Een kandidaat die voor de remote description binnenkomt wordt vastgehouden en toegepast zodra de description er is, dus de volgorde is niet jouw probleem.

Wanneer het paar uiteindelijk genomineerd is, vertellen SelectedLocalCandidate en SelectedRemoteCandidate je welke twee adressen gewonnen hebben. Die ene logregel beantwoordt "waarom loopt dit gesprek via mijn TURN-server" sneller dan wat dan ook.

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 toevoegen, en de schakelaar die je niet mag vergeten

Een turn:-item in ICEServers draagt zijn eigen host, poort, username en credential, en dat is wat de allocatie gebruikt. Toevoegen is nog één AddURL.

De val zit in de andere richting. RTCOptions.ICE.TURN staat standaard op True, en wanneer de serverlijst helemaal geen TURN-item bevat valt de gathering terug op de ene server in RTCOptions.ICE, waarvan de host standaard 127.0.0.1 is en de poort 3478. Een peer die met niets anders dan een STUN-URL is geconfigureerd probeert dus alsnog een TURN-allocatie tegen localhost, faalt, en meldt dat. Het is ruis en geen fout, maar het ziet er alarmerend uit in een log en het stuurt je op zoek op de verkeerde plek. Zet RTCOptions.ICE.TURN := False tot je daadwerkelijk een TURN-server hebt.

RTCOptions.ICE.STUN gedraagt zich op dezelfde manier en staat ook standaard op 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-clientreferentie Draai je eigen TURN-server

Het datakanaal

Tekst en binaire data tussen de twee toepassingen, met de betrouwbaarheid die je per kanaal kiest. Dit is meestal het eerste wat je aan de praat krijgt, en het bewijst het hele transport.

Er een openen, en die van de andere kant ontvangen

De peer die CreateDataChannel aanroept krijgt het object meteen terug. De peer die dat niet deed krijgt hetzelfde kanaal via OnDataChannel. Koppel de handlers op beide plekken, want elke kant kan op elk moment in de sessie een kanaal openen.

Het kanaal is niet bruikbaar op het moment dat je het aanmaakt. Zijn Id blijft zonder waarde totdat de SCTP-associatie staat en de DTLS-rol bepaald is, en Send geeft False terug zolang het niet open is. Wacht op OnOpen.

De betrouwbaarheid wordt bij het aanmaken bepaald. De standaardwaarden zijn geordend en volledig betrouwbaar, een TCP-achtig kanaal. Geef aOrdered = False mee voor ongeordende bezorging, of een aMaxRetransmits of aMaxPacketLifeTime voor gedeeltelijke betrouwbaarheid, wat je wilt bij positie-updates of alles waarbij een laat pakket erger is dan een verloren pakket.

Geef een kanaal niet vrij, het is eigendom van de peer connection. Close start de afbouw en OnClose vuurt zodra de overkant instemt.

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;

Versturen, zonder te overspoelen

Send neemt een string en SendBytes neemt een TBytes. Allebei geven ze False terug in plaats van een exception op te werpen wanneer het kanaal niet open is, dus een verzending tijdens de afbouw levert een False op en geen exception op een worker thread.

MaxMessageSize is het grootste bericht dat de peer zei te accepteren, gelezen uit het attribuut a=max-message-size van zijn description. Een bericht boven die limiet wordt lokaal geweigerd in plaats van op de lijn gezet, waar het de hele associatie zou afbreken en elk ander kanaal zou meesleuren. Nul betekent dat geen enkele description ooit een limiet noemde en dat de controle uit staat.

BufferedAmount is hoeveel bytes er in SCTP voor deze stream in de wachtrij staan en nog niet bevestigd zijn. Houd het in de gaten als je een bestand streamt: verstuur tot het een drempel passeert en wacht dan tot het leegloopt, in plaats van gigabytes in het geheugen op te stapelen.

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;

Audio- en videotracks

Een track is geen datakanaal met plaatjes erin. Ander transport, andere manieren om te falen, andere code. Dit is het onderscheid dat de meeste mensen eerst verkeerd hebben.

 DatakanaalMediatrack
Gedragen door SCTP over DTLS (RFC 8831) SRTP over hetzelfde DTLS-transport (RFC 3711)
Bezorging Jouw keuze, van volledig betrouwbaar en geordend tot fire and forget Altijd lossy van opzet. Laat is erger dan verloren, dus niets wordt eindeloos opnieuw verzonden
Werkeenheid Een bericht. Jij bepaalt wanneer je er een stuurt Een klok. Audio gaat erin in frames van 20 ms, video op een framerate
Geopend met CreateDataChannel, op elk moment in de sessie AddTrack, waarvoor een nieuwe offer nodig is om hem te publiceren
Ontvangen via OnDataChannel, daarna de eigen OnMessage van het kanaal OnTrack, daarna de OnAudio of OnVideoFrame van de track
Heeft DTLS aan nodig Ja, en CreateDataChannel zet het voor je Ja, en AddTrack doet dat niet. Zet RTCOptions.DTLS zelf
Gebruik het voor Chat, bestandsoverdracht, afstandsbediening, spelstatus, telemetrie Microfoon, camera, schermdelen, alles met een tijdlijn

De microfoon versturen

AddTrack neemt een soort en een codec en geeft een TsgcRTCTrack terug. De audiocodecs zijn cctAudioOpus, cctAudioPCMU en cctAudioPCMA, de videocodecs zijn cctVideoVP8, cctVideoVP9, cctVideoH264 en cctVideoJPEG.

Capture is een apart object, want misschien wil je de microfoon van het platform helemaal niet. sgcCreateAudioCapture bouwt de juiste implementatie voor het platform waarvoor de code gecompileerd is, waveIn op Windows, ALSA op Linux, AudioRecord op Android, een VoiceProcessingIO Audio Unit op iOS en macOS, zodat niets in jouw code een platformklasse noemt. Op een target zonder implementatie geeft het nil terug, dus test het resultaat.

SendPCM wil 16-bits signed interleaved PCM op de sample rate en het kanaalaantal van de encoder, wat voor Opus 48000 Hz is en voor G.711 8000 Hz. De capture source publiceert wat hij daadwerkelijk levert via AudioSampleRate, AudioChannels en AudioFrameDurationMs, zodat je kunt controleren in plaats van aannemen.

Een track toevoegen nadat de sessie al staat zet de negotiation-needed-vlag en vuurt OnNegotiationNeeded. Roep CreateOffer opnieuw aan om hem te publiceren, en de nieuwe offer wordt gebouwd zonder het transport aan te raken.

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;

Afspelen wat de andere kant stuurde

OnTrack vuurt één keer per remote mediaregel, wanneer de remote description er een meebrengt. De track die je krijgt is eigendom van de peer connection, dus koppel zijn events en geef hem nooit vrij.

Audio komt binnen als gedecodeerde PCM via OnAudio, met de sample rate en het kanaalaantal die de decoder produceerde. Geef dat rechtstreeks door aan de TsgcMediaRenderer die sgcCreateAudioRenderer bouwde, die het formaat omzet wanneer het apparaat niet passend geopend kon worden.

Video komt binnen als een gedecodeerde TsgcVideoFrame via OnVideoFrame: ruwe pixels in Data, met Width, Height, Format en Stride. De formaten zijn vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24 en vffBGRA32, en de twee BGR-varianten volgen de byte-volgorde van Windows GDI, dus een vffBGR24-frame naar een bitmap blitten is een geheugenkopie en geen conversie.

Als een frame kapot binnenkomt na pakketverlies, vraagt RequestKeyFrame de afzender om een verse.

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;

De camera, op Windows

Audiocapture zit achter de factory verborgen omdat elk ondersteund platform een implementatie heeft. Videocapture niet, dus daar noem je de platformklasse zelf. Op Windows is dat TsgcVideoCapture_Win uit sgcP2P_MediaCapture_Win, die Video for Windows aanstuurt en frames aflevert via hetzelfde OnVideoCapture-event dat de basisklasse declareert.

De naburige unit sgcP2P_ScreenCapture_Win geeft je TsgcScreenCapture_Win en TsgcWindowCapture_Win, allebei afstammelingen van TsgcMediaCaptureSource, dus schermdelen is dezelfde drie regels met een andere constructor.

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;

De fouten die je van tevoren wilt kennen

Peer to peer verbindingen falen op manieren die helemaal geen foutmelding opleveren, en dat maakt ze lastig. Dit zijn de gevallen die het vaakst voorkomen.

De media is stil en er komt geen fout

RTCOptions.DTLS staat op False. Dat is de standaard, CreateDataChannel zet het aan maar AddTrack niet, dus een sessie die alleen media draagt voert nooit een DTLS-handshake uit en krijgt daarom nooit SRTP-sleutels. Zet het expliciet.

Een TURN-fout waar je niet om vroeg

RTCOptions.ICE.TURN staat standaard op True en valt terug op 127.0.0.1:3478 wanneer de serverlijst geen TURN-item heeft. Zet het op False tot je echt een TURN-server hebt, anders loopt het log vol met allocatiefouten die niets met jouw probleem te maken hebben.

De remote description wordt geweigerd

Een description zonder a=fingerprint wordt rechtstreeks afgewezen en gemeld via OnError. In het vertrouwensmodel van WebRTC is die regel het enige dat de peer authenticeert, dus een description zonder die regel accepteren zou de handshake tegen elk willekeurig certificaat laten slagen.

Een access violation in een eventhandler

Elk event van een peer connection, datakanaal en track vuurt op een worker thread, de ICE-, netwerk-, SCTP- of tickthread, nooit op de main thread. Vanuit zo'n thread rechtstreeks een VCL- of FMX-control aanraken is ongedefinieerd. Verpak het in TThread.Queue.

Beide kanten doen tegelijk een nieuwe offer

Dat heet glare, en het wordt opgelost met de perfect-negotiation-regel van het W3C. De onbeleefde peer, Polite = False, houdt zijn eigen offer aan en meldt de binnenkomende via OnError. De beleefde peer rolt de zijne terug en antwoordt. De twee peers van een sessie mogen niet allebei beleefd zijn.

Één groot bericht sloopt elk kanaal

Een bericht boven de a=max-message-size van de peer zou de hele SCTP-associatie afbreken en elk ander datakanaal meenemen. Send en SendBytes controleren MaxMessageSize en weigeren het in plaats daarvan lokaal. Splits grote payloads zelf op.

De verbinding doet er drie seconden over om te starten

Dat is RTCOptions.GatheringTimeout, de wachttijd zonder trickle. GatheringIdleTimeout beëindigt hem eerder zodra er geen kandidaten meer binnenkomen, en TrickleICEAuto schakelt over naar trickle-modus zodra de remote description dat aankondigt. Verlaag beide timeouts op een LAN.

De audio is te snel, te traag of vervormd

Een mismatch in sample rate of kanaalaantal tussen het opnameapparaat en de encoder. Opus wordt op 48000 Hz onderhandeld en G.711 op 8000 Hz. Lees AudioSampleRate en AudioChannels terug van de capture source in plaats van aan te nemen dat het apparaat deed wat je vroeg.

Delphi WebRTC, veelgestelde vragen

Wat ontwikkelaars vragen voordat ze twee toepassingen peer to peer aan elkaar knopen.

Niet in de RTL, en niet via de VCL. TsgcRTCPeerConnection is een native Object Pascal-implementatie van het W3C peer connection-oppervlak: CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel en AddTrack, met ICE, DTLS, SCTP en SRTP eronder. Er zit geen ingebedde Chromium, geen TWebBrowser en geen JavaScript-bridge in het proces.
Je hebt iets nodig dat een paar honderd bytes tekst tussen de twee peers kan vervoeren voordat het gesprek begint, want geen van beide weet nog hoe hij de ander moet bereiken. Dat is signalling, en dat kan een WebSocket-relay zijn, een bestaande message queue, een REST-endpoint, zelfs kopiëren en plakken voor een demo. Zodra de offer, de answer en de ICE-kandidaten over zijn, gaan de media en de data rechtstreeks tussen de twee toepassingen en kan het signalling-kanaal dicht. Er staat geen server in het mediapad, tenzij een TURN-relay de enige route bleek te zijn die werkte.
Bijna altijd een WebSocket, omdat die tweerichtingsverkeer aankan en de server een binnenkomende offer kan pushen zonder dat de peer ernaar hoeft te pollen. Deze pagina bouwt er een uit TsgcWebSocketServer en TsgcWebSocketClient in een regel of vijftien, waarbij elk bericht met Broadcast naar de andere peer wordt doorgegeven en de Connection.Guid van de afzender als exclude dient. sgcWebSockets levert ook een kant-en-klaar signalling-protocolcomponent mee, TsgcWSPServer_RTCPeerConnection, dat de uitwisseling voor je aanstuurt via RTCOptions.WebSocket en GatherCandidates als je de relay liever helemaal niet zelf schrijft.
Als beide toepassingen op hetzelfde LAN of hetzelfde VPN zitten, geen van beide. Host-kandidaten beschrijven dan al bereikbare adressen. Zitten ze op verschillende netwerken achter gewone routers, dan heb je STUN nodig, dat elke peer vertelt van welk publiek adres zijn pakketten lijken te komen, en dat dekt de meeste echte verbindingen. TURN heb je nodig wanneer geen enkele directe combinatie werkt: symmetrische NAT, restrictieve bedrijfsfirewalls en sommige mobiele providers. TURN geeft elke byte van het gesprek door, dus het is de dure terugvaloptie en niet de standaard. Voeg allebei toe aan RTCOptions.ICEServers en ICE kiest het goedkoopste paar dat daadwerkelijk verbinding maakt.
Een datakanaal is SCTP over DTLS en verplaatst berichten, met de betrouwbaarheid die jij kiest: geordend en volledig betrouwbaar zoals TCP, of ongeordend met een limiet op hertransmissies of levensduur voor alles waarbij een laat pakket nutteloos is. Een mediatrack is SRTP over hetzelfde DTLS-transport en verplaatst een tijdlijn: audio in frames van 20 ms, video op een framerate, altijd lossy van opzet. Gebruik een datakanaal voor chat, bestandsoverdracht, afstandsbediening en spelstatus. Gebruik een track voor een microfoon, een camera of een scherm. Ze delen één verbinding en één open poort.
Twee stappen. Enterprise definieert SGC_ICE, SGC_DTLS, SGC_TURN en SGC_RTCPEERCONNECTION, en dat is wat TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient en TsgcTURNServer op het palet zet. De API voor offer en answer, datakanalen en mediatracks zit achter SGC_SDP, SGC_DATACHANNEL en SGC_RTP, die alleen SGC_PACK_WEBRTC definieert, en dat is de sgcWebRTC-add-on, die ook in All-Access zit. Daaronder: een STUN-client zit in Standard, en een STUN-server en het WebSocket-servercomponent zitten in Professional.
Ja, en er verandert niets aan deze pagina. De SDP is standaard, de kandidaatregels dragen dezelfde sdpMid en sdpMLineIndex die de browser-API verwacht, en de statemachine voor offer en answer volgt RFC 8829, dus de JSON die jouw relay doorstuurt werkt ongewijzigd in beide richtingen. Browsers sturen kandidaten vanaf de eerste milliseconde stuk voor stuk door, en dat is precies wat RTCOptions.TrickleICEAuto detecteert: het ziet a=ice-options:trickle in de remote description en antwoordt meteen in plaats van de gathering timeout uit te zitten.
Altijd op worker threads. OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack en OnError komen binnen op de ICE-, netwerk- of timerthread. Events van een datakanaal komen binnen op de thread die de SCTP-associatie aanstuurt. Audio- en video-events van een track komen binnen op de netwerk- of tickthread. Geen daarvan wordt voor je naar de main thread gemarshald, dus verpak alles wat een control aanraakt in TThread.Queue, en dat is wat de meegeleverde demo's doen.
Nee. Laat RTCOptions.DTLSOptions.CertFile leeg en er worden een zelfondertekend certificaat en sleutel in het geheugen gegenereerd, één keer per component, en voor elke peer hergebruikt. De fingerprint daarvan wordt gepubliceerd als het attribuut a=fingerprint van de local description, en dat is wat jou authenticeert bij de andere kant. Dat is het vertrouwensmodel van WebRTC: de keten wordt nooit geverifieerd, de fingerprint die over het signalling-kanaal gaat is het anker. Je kunt CertFile en KeyFile nog steeds naar je eigen PEM-bestanden wijzen als je een vaste identiteit wilt.
Er loopt een DTLS-handshake over het genomineerde kandidatenpaar en die leidt de SRTP-sleutels af, dus elk RTP-, RTCP- en SCTP-pakket op de verbinding is versleuteld. Voor datakanalen is er geen manier om het uit te zetten: CreateDataChannel zet RTCOptions.DTLS onvoorwaardelijk op True, want een RTCDataChannel is per definitie SCTP over DTLS en er is geen geldige configuratie zonder.
De P2P- en WebRTC-units zitten in elk runtime-package van Delphi 7 tot en met RAD Studio 13, en in de bijbehorende C++ Builder-packages. Audio-opname en -weergave hebben platformimplementaties voor Windows, Linux, Android, iOS en macOS, dus de factory-functies geven op alle vijf een werkend object terug. Videocapture is de uitzondering: die wordt per platform benoemd in plaats van door een factory gebouwd, en op Windows is dat TsgcVideoCapture_Win, naast TsgcScreenCapture_Win en TsgcWindowCapture_Win voor schermdelen.
Ja, dat is renegotiatie. AddTrack op een lopende sessie markeert die als onderhandeling-nodig en vuurt OnNegotiationNeeded. Roep CreateOffer opnieuw aan en er wordt synchroon een nieuwe offer gebouwd, zonder nieuwe ICE-gathering en zonder nieuwe DTLS- of SCTP-handshake, dus het transport wordt nooit onderbroken. Bestaande mediaregels houden hun positie en hun mid, de nieuwe wordt achteraan toegevoegd. RemoveTrack werkt op dezelfde manier in omgekeerde richting: de mediaregel blijft staan en wordt opnieuw gepubliceerd als recvonly of inactive.

Componentreferenties en technische documenten

Elk onderdeel dat deze pagina gebruikt heeft een eigen referentiepagina, en de meeste hebben een losse technische PDF met de volledige lijst van properties, methoden en events.

TsgcRTCPeerConnection

Het component waar deze hele pagina over gaat. Offer en answer, ICE, DTLS, SCTP-datakanalen en RTP-media in één klasse.

Componentpagina →

sgcWebRTC

Het media-engine-pack: SDP, SCTP, RTP, SRTP, Opus- en G.711-audio, VP8- en H.264-video, bandbreedteschatting.

Productpagina →

Functies uitgesplitst

Codec voor codec en platform voor platform, wat de media-engine doet en waar elke encoder vandaan komt.

Bekijk de functies →

ICE-client

Het verzamelen van kandidaten, de check list, nominatie en de ICE-servercollectie, de laag onder de peer connection.

Componentpagina →

STUN-client en -server

Binding requests, opties voor hertransmissie, en het servercomponent als je liever je eigen server host.

STUN-client →

TURN-client en -server

Allocaties, permissies, channel binds, en een TURN-servercomponent voor de gesprekken die een relay nodig hebben.

TURN-server →

Alle P2P-componenten

UDP, STUN, TURN, ICE en RTCPeerConnection, de hele peer to peer familie in één overzicht.

Bekijk P2P →

Delphi WebRTC-overzicht

De blik op WebRTC in sgcWebSockets op bibliotheekniveau, met de signalling-protocolcomponenten en de demolijst.

Lees meer →

Welke editie heb ik nodig?

De volledige editiematrix, functie voor functie, wanneer WebRTC niet het enige is dat je afweegt.

Edities vergelijken →

Andere use cases

Deze pagina is een van de Delphi use cases, die elk één klus van begin tot eind behandelen. De andere zijn tot nu toe een LLM aanroepen vanuit Delphi en een gebruiker laten inloggen met OAuth2 en PKCE.

Alle use cases →
RTCPeerConnection technisch document (PDF) Properties, methoden, events en codevoorbeelden voor alleen het peer connection-component.
ICE-client technisch document (PDF) Het verzamelen van kandidaten, de check list en de ICE-servercollectie in detail.
TURN-client technisch document (PDF) Allocaties, permissies en channel binds, de client die ICE aanstuurt voor een relayed kandidaat.
STUN-client technisch document (PDF) Binding requests en hertransmissie, de goedkoopste manier om je eigen publieke adres te weten te komen.
Demoprojecten Demos\35.P2P\05.RTCPeerConnection en Demos\35.P2P\06.DataChannel worden meegeleverd in het package.

De specificaties achter elke stap

Primaire bronnen, voor wie liever leest wat het component implementeert dan ons op ons woord gelooft.

RFC 8829, JSEP

De statemachine voor offer en answer achter CreateOffer, CreateAnswer en SetRemoteDescription, inclusief renegotiatie en rollback.

Lees de RFC →

RFC 8445, ICE

Het verzamelen van kandidaten, prioriteit, de check list en nominatie. De reden dat een verbinding soms een seconde duurt en soms mislukt.

Lees de RFC →

RFC 8489 en RFC 8656

STUN en TURN. Wat een binding request vraagt, en wat een allocatie je kost.

Lees de RFC →

RFC 8831 en RFC 8832

WebRTC-datakanalen over SCTP, en de DCEP-open-handshake die stream-id's toewijst op basis van de DTLS-rol.

Lees de RFC →

RFC 8122, SDP-fingerprints

Waarom a=fingerprint de identiteit van een peer connection is, en waarom een description zonder die regel geweigerd wordt.

Lees de RFC →

WebRTC 1.0 (W3C)

De API die dit component spiegelt, inclusief perfect negotiation, waar Polite vandaan komt.

Lees de specificatie →

Zet twee van je toepassingen met elkaar in gesprek

Download de proefversie, draai de RTCPeerConnection- en DataChannel-demo's tegen elkaar, en bouw daarna hetzelfde in je eigen project.