Delphi WebRTC: Audio, Video und Daten zwischen zwei Anwendungen

Zwei Delphi-Anwendungen in zwei verschiedenen Netzen tauschen einen Chat-Kanal, einen Mikrofon-Stream und einen Kamera-Stream direkt untereinander aus. Kein Medienserver dazwischen, kein Browser im Prozess eingebettet, keine JavaScript-Brücke. Diese Seite geht die ganze Aufgabe durch, von der ersten Signalling-Nachricht bis zum ersten dekodierten Audio-Frame, mit APIs, die es im ausgelieferten Quellcode gibt.

SDP-Offer und -Answer
ICE, STUN und TURN
SCTP-Datenkanäle
Opus- und VP8-Medienspuren
DTLS-SRTP-verschlüsselt
Kein Browser, kein WebView

Was tatsächlich passieren muss

WebRTC sind vier getrennte Probleme unter einem Namen. Nur eines davon dreht sich um Medien, und es ist das leichte. Das sind die vier, in der Reihenfolge, in der du sie lösen musst.

1. Die Sitzung beschreiben

Eine Seite baut ein offer, ein Textdokument (SDP), das sagt, welche Medien sie senden will, welche Codecs sie spricht, den Fingerabdruck des Zertifikats, das sie vorlegen wird, und die ICE-Zugangsdaten, die sie nutzt. Die andere Seite antwortet mit der Teilmenge, die sie akzeptiert. CreateOffer und CreateAnswer erzeugen diese Dokumente, SetRemoteDescription nimmt sie entgegen.

2. Es zur anderen Seite bringen

WebRTC sagt bewusst nicht, wie das Offer den anderen Peer erreicht. Dieser Kanal heißt signalling und ist deine Aufgabe. Es sind ein paar hundert Bytes Text pro Richtung, eine WebSocket-Verbindung zu einem kleinen Relay genügt also, und sgcWebSockets liefert dir beide Hälften davon bereits mit.

3. Einen Weg durch die NATs finden

Keiner der beiden Peers kennt seine öffentliche Adresse, und beide sitzen meist hinter einem Router. ICE sammelt jede Adresse, unter der ein Peer erreichbar sein könnte, schickt sie über den Signalling-Kanal, sobald sie auftauchen, und probt jede Paarung, bis eine funktioniert. STUN findet die öffentliche Adresse, TURN stellt ein Relay bereit, wenn nichts Direktes klappt.

4. Die Bytes bewegen

Sobald ein Kandidatenpaar nominiert ist, läuft darüber ein DTLS-Handshake, und alles danach ist verschlüsselt. Ein Datenkanal ist SCTP über diesen DTLS-Transport, eine Audio- oder Videospur ist SRTP darüber. Beide teilen sich die eine Verbindung und den einen offenen Port.

Gibt es einen WebRTC-Server?

Nicht im Medienpfad, und genau darum geht es. Sobald sich die beiden Anwendungen gefunden haben, laufen Audio, Video und Daten direkt zwischen ihnen. Nichts, was du hostest, sieht die Nutzlast, und nichts, was du hostest, muss mit der Anzahl der Gesprächsminuten deiner Nutzer skalieren.

Es sind trotzdem zwei Server im Bild, und es hilft, genau zu sagen, was jeder tut, weil sie oft verwechselt werden.

Der Signalling-Server gehört dir. Er reicht vor dem Gesprächsbeginn eine Handvoll Textnachrichten zwischen zwei Peers weiter und wird dann still. Medien sieht er nie. In dieser Durchsprache sind das fünfzehn Zeilen Delphi auf Basis von TsgcWebSocketServer.

Der STUN- und TURN-Server existiert wegen NAT, nicht wegen WebRTC. Ein STUN-Server beantwortet genau eine Frage, "von welcher öffentlichen Adresse kam dieses Paket", und das war es. Ein TURN-Server leitet Pakete für die Paare weiter, die sich anders nicht erreichen, er ist also das einzige Stück, das Medien trägt, und nur für die Gespräche, die es brauchen. Öffentliche STUN-Server gibt es kostenlos und reichlich, TURN hostest du selbst, und sgcWebSockets Enterprise liefert sowohl eine Komponente STUN-Server als auch eine Komponente TURN-Server, wenn du lieber keinen eigenen Daemon betreiben willst.

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

Editionen, Units und Plattformen

Die Peer-Verbindung und die Medien-Engine sind zwei verschiedene Lizenzschritte. Das lohnt sich zu klären, bevor du Code schreibst, weil der Compiler sonst schlicht die halbe API nicht sieht.

Was du tun willstWas es brauchtWoher es kommt
STUN-Client, um eine öffentliche Adresse zu ermitteln TsgcSTUNClient sgcWebSockets Standard und höher
Einen eigenen STUN-Server betreiben TsgcSTUNServer sgcWebSockets Professional und höher
Einen WebSocket-Signalling-Kanal weiterleiten TsgcWebSocketServer sgcWebSockets Professional und höher für die Serverhälfte. Die Clienthälfte, TsgcWebSocketClient, kommt mit Standard.
ICE, TURN-Client und -Server und die Peer-Verbindungskomponente selbst TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
Offer und Answer, Datenkanäle, Audio- und Videospuren CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack Das sgcWebRTC-Pack, aufbauend auf Enterprise. Auch in All-Access enthalten.

Warum die Teilung, in den Worten des Compilers

Der Enterprise-Block von sgcVer.inc definiert SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION und SGC_TURN. Das ist es, was TsgcRTCPeerConnection auf die Palette bringt und ihr einen ICE- und TURN-Transport gibt.

Alles, worum es auf dieser Seite tatsächlich geht, sitzt eine Ebene weiter innen. SGC_PACK_WEBRTC ist das, was SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP und SGC_SRTP definiert, und es prüft zuerst, ob ICE, DTLS und die Peer-Verbindung schon da sind. Die manuellen Signalling-Methoden, die Datenkanal-API und die Medien-API liegen jeweils in {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL} und {$IFDEF SGC_RTP}, auf Enterprise allein kompilieren sie also nicht.

Wenn CreateOffer nicht auflöst, ist genau das passiert. Die mitgelieferte Datenkanal-Demo sagt das laut, statt still zu scheitern.

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}

Die Units, die beide Anwendungen brauchen

sgcP2P ist die Sammel-Unit, die TsgcRTCPeerConnection veröffentlicht und die Handler-Typen re-exportiert. Das reicht, um die Komponente zu deklarieren, aber Aufzählungskonstanten kommen aus den Units, die ihre Typen deklarieren, zieh die also mit herein, wenn du rtctkAudio oder cctAudioOpus nennst.

Die beiden Anwendungen in dieser Durchsprache sind dasselbe Programm mit einem anderen gedrückten Knopf. Alles unten kommt in beide.

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

Der Signalling-Kanal

Drei Nachrichtenarten, ein dummes Relay. Das ist der Teil, den jedes WebRTC-Tutorial nur andeutet, und der Teil, den du tatsächlich schreiben musst.

Ein Relay, kein Broker

Der Signalling-Server muss kein einziges Byte von dem verstehen, was er weiterleitet. Er nimmt den Text, den ein Peer gesendet hat, und gibt ihn dem anderen. Broadcast hat bereits einen Parameter Exclude, der eine Verbindungs-GUID nimmt, "an alle außer den Absender senden" ist also eine Zeile.

Halte es so dumm. In dem Moment, in dem das Relay anfängt, SDP zu parsen, wird es zu einer Komponente, die du bei jeder Codec-Änderung anpassen musst, und es kann ein Gespräch nicht mehr an einen Browser weiterleiten.

Im Produktivbetrieb würdest du das Relay auf eine Raumkennung schlüsseln, damit zwei Gespräche nicht kollidieren, und es hinter TLS setzen. TsgcWebSocketServer trägt dieselbe Oberfläche aus TLSOptions, Authentication und WatchDog wie der Rest der Bibliothek.

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;

Die Peer-Seite desselben Kanals

Jede Anwendung öffnet einen TsgcWebSocketClient zu diesem Relay und spricht ein Vokabular aus drei Wörtern: offer, answer und candidate. TsgcJSON aus derselben Bibliothek serialisiert sie, es gibt also keine zusätzliche Abhängigkeit.

Achte darauf, wohin die Verzweigungen führen. Ein eingehendes offer wird als Remote Description gesetzt und sofort beantwortet. Ein eingehendes answer wird nur gesetzt. Ein eingehender candidate wird an AddIceCandidate gegeben, und er kann vor oder nach der Description ankommen, und genau darum geht es bei 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;

Die Peer-Verbindung bauen

In beiden Anwendungen identisch. Die einzige Asymmetrie im ganzen Austausch ist, welche von beiden auf Anrufen drückt.

Konfiguration und die Ereignisse, die zählen

RTCOptions.ICEServers ist die W3C-Liste iceServers. AddURL nimmt eine stun:- oder turn:-URL und füllt Typ, Host, Port und TLS-Flag aus dem Schema, mit optionalem Benutzernamen und Credential für TURN.

RTCOptions.DTLS steht standardmäßig auf False. Lässt du das so, gibt es keine Verschlüsselung und kein SRTP-Schlüsselmaterial, Medien funktionieren also nicht. CreateDataChannel schaltet es für dich ein, weil ein Datenkanal SCTP über DTLS ist und es keine gültige Konfiguration mit ausgeschaltetem DTLS gibt. AddTrack tut das nicht, setze es also selbst, wenn du Medien hinzufügst.

Du brauchst keine Zertifikatsdatei. Lass RTCOptions.DTLSOptions.CertFile leer, und es wird einmal je Komponente ein selbstsigniertes Zertifikat im Speicher erzeugt, und genau das ist das WebRTC-Modell: Die Identität hängt an der Zeile a=fingerprint im SDP, nicht an einer Kette. Eine Remote Description ohne Fingerabdruck wird abgewiesen, statt einen Handshake gegen irgendetwas zuzulassen.

Jedes Ereignis unten wird auf einem Worker-Thread ausgelöst, dem ICE-, Netzwerk- oder Timer-Thread, nie auf dem Haupt-Thread. Marshalle mit TThread.Queue, bevor du ein Steuerelement anfasst.

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;

Die lokale Description veröffentlichen

OnLocalDescription gibt dir den Typ, die Zeichenfolge 'offer' oder 'answer', und das SDP selbst. Beide Peers nutzen denselben Handler, und er tut genau eines: es auf den Signalling-Kanal legen.

Das SDP kommt bereits vollständig an. CreateOffer sammelt zuerst ICE-Kandidaten und wartet bis zu RTCOptions.GatheringTimeout Millisekunden, standardmäßig 3000, und endet früher nach GatheringIdleTimeout Millisekunden ohne neuen Kandidaten, standardmäßig 500. Das ist der Nicht-Trickle-Pfad.

Setze TrickleICE auf True, und die Description geht sofort raus, die Kandidaten folgen dahinter. Nötig ist das selten. RTCOptions.TrickleICEAuto ist standardmäßig True, wenn die Remote Description also a=ice-options:trickle sagt, was jeder Browser tut, schaltet die Komponente selbst um und verbrennt das Sammel-Timeout nicht mehr.

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 und TURN

Hier scheitern Peer-to-Peer-Verbindungen, und hier sind die Fehler am schwersten zu lesen. Drei Arten von Kandidaten, drei Gründe, warum es sie gibt.

host

Eine Adresse, die die Maschine an sich selbst sieht, eine je Netzwerkschnittstelle. Kostenlos, sofort da und ausreichend, wenn beide Anwendungen im selben LAN oder im selben VPN laufen. Laufen deine zwei Delphi-Anwendungen nur innerhalb eines Büros, reichen Host-Kandidaten, und du kannst STUN komplett überspringen.

srflx, server reflexive

Die öffentliche Adresse, von der aus ein STUN-Server das Paket des Peers ankommen sah. Das ist es, was zwei Peers hinter gewöhnlichen Heimroutern direkt miteinander sprechen lässt, und es deckt die große Mehrheit echter Verbindungen ab. Es kostet einen Roundtrip zu einem STUN-Server, der danach keinen Verkehr mehr trägt.

relay

Eine Adresse auf einem TURN-Server, die zum Peer weiterleitet. Nötig bei symmetrischem NAT, restriktiven Unternehmens-Firewalls und manchen Mobilfunkanbietern. Jedes Byte des Gesprächs läuft über deinen TURN-Server, es ist also der teure Pfad und der, auf den du nur zurückfällst.

Kandidaten durchtrickeln

OnIceCandidate wird einmal je Kandidat ausgelöst, sobald er entdeckt wird, mit der Kandidatenzeile, ihrer sdpMid und ihrem sdpMLineIndex. Diese drei Felder sind genau das, was die Browser-API erwartet, dasselbe JSON funktioniert also, egal ob die Gegenstelle Delphi oder Chrome ist.

Schicke jeden sofort. Nicht warten, nicht bündeln. Ein Kandidat, der vor der Remote Description ankommt, wird gehalten und angewendet, wenn die Description eintrifft, die Reihenfolge ist also nicht dein Problem.

Wenn das Paar schließlich nominiert ist, sagen dir SelectedLocalCandidate und SelectedRemoteCandidate, welche beiden Adressen gewonnen haben. Diese eine Logzeile beantwortet "warum läuft dieses Gespräch über meinen TURN-Server" schneller als alles andere.

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 ergänzen, und der Schalter, den du nicht vergessen darfst

Ein Eintrag turn: in ICEServers trägt eigenen Host, Port, Benutzernamen und Credential, und genau das nutzt die Allocation. Ihn hinzuzufügen ist ein weiteres AddURL.

Die Falle liegt in der anderen Richtung. RTCOptions.ICE.TURN steht standardmäßig auf True, und wenn die Serverliste überhaupt keinen TURN-Eintrag enthält, fällt das Sammeln auf den einzelnen Server in RTCOptions.ICE zurück, dessen Host standardmäßig 127.0.0.1 und dessen Port standardmäßig 3478 ist. Ein Peer, der mit nichts als einer STUN-URL konfiguriert ist, versucht also trotzdem eine TURN-Allocation gegen localhost, scheitert und meldet das. Es ist Rauschen und kein Fehler, aber im Log sieht es alarmierend aus und schickt dich an die falsche Stelle auf die Suche. Setze RTCOptions.ICE.TURN := False, bis du wirklich einen TURN-Server hast.

RTCOptions.ICE.STUN verhält sich genauso und steht ebenfalls standardmäßig auf 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-Client-Referenz Einen eigenen TURN-Server betreiben

Der Datenkanal

Text und Binärdaten zwischen den beiden Anwendungen, mit der Zuverlässigkeit, die du je Kanal wählst. Das ist meist das Erste, was du zum Laufen bringst, und es beweist den ganzen Transport.

Einen öffnen und den der anderen Seite empfangen

Der Peer, der CreateDataChannel aufruft, bekommt das Objekt sofort zurück. Der Peer, der das nicht getan hat, bekommt denselben Kanal über OnDataChannel. Hänge die Handler an beiden Stellen an, weil jede Seite jederzeit in der Sitzung einen Kanal öffnen kann.

Der Kanal ist nicht in dem Moment nutzbar, in dem du ihn erzeugst. Seine Id bleibt unbelegt, bis die SCTP-Association steht und die DTLS-Rolle geklärt ist, und Send gibt False zurück, solange er nicht offen ist. Warte auf OnOpen.

Die Zuverlässigkeit wird beim Erzeugen entschieden. Die Standardwerte sind geordnet und voll zuverlässig, ein TCP-artiger Kanal. Übergib aOrdered = False für ungeordnete Zustellung, oder ein aMaxRetransmits oder aMaxPacketLifeTime für teilweise Zuverlässigkeit, und die willst du bei Positionsaktualisierungen oder allem, wo ein spätes Paket schlimmer ist als ein verlorenes.

Gib einen Kanal nicht frei, er gehört der Peer-Verbindung. Close startet das Herunterfahren, und OnClose wird ausgelöst, wenn die Gegenstelle zustimmt.

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;

Senden, ohne zu fluten

Send nimmt einen String und SendBytes nimmt ein TBytes. Beide geben False zurück, statt eine Exception auszulösen, wenn der Kanal nicht offen ist, ein Senden während des Abbaus ist also ein zurückgegebenes False und keine Exception auf einem Worker-Thread.

MaxMessageSize ist die größte Nachricht, die der Peer laut eigener Angabe akzeptiert, gelesen aus dem Attribut a=max-message-size seiner Description. Eine Nachricht über dieser Grenze wird lokal abgelehnt, statt auf die Leitung gelegt zu werden, wo sie die ganze Association abbrechen und jeden anderen Kanal mitnehmen würde. Null bedeutet, dass keine Description je eine Grenze genannt hat und die Prüfung aus ist.

BufferedAmount ist die Anzahl der Bytes, die in SCTP für diesen Stream anstehen und noch nicht bestätigt sind. Behalte es im Auge, wenn du eine Datei streamst: Sende, bis es eine Schwelle überschreitet, und warte dann, bis es abfließt, statt Gigabytes in den Speicher zu stellen.

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- und Videospuren

Eine Spur ist kein Datenkanal mit Bildern darin. Anderer Transport, andere Fehlerbilder, anderer Code. Diese Unterscheidung bekommen die meisten zuerst falsch.

 DatenkanalMedienspur
Getragen von SCTP über DTLS (RFC 8831) SRTP über denselben DTLS-Transport (RFC 3711)
Zustellung Deine Wahl, von voll zuverlässig und geordnet bis abschicken und vergessen Von Natur aus verlustbehaftet. Zu spät ist schlimmer als verloren, es wird also nichts endlos erneut gesendet
Arbeitseinheit Eine Nachricht. Du entscheidest, wann eine gesendet wird Eine Uhr. Audio wird in 20-ms-Frames eingespeist, Video mit einer Bildrate
Geöffnet mit CreateDataChannel, jederzeit in der Sitzung AddTrack, das ein neues Offer braucht, um veröffentlicht zu werden
Empfangen über OnDataChannel, dann das kanaleigene OnMessage OnTrack, dann das spureigene OnAudio oder OnVideoFrame
DTLS muss an sein Ja, und CreateDataChannel setzt es für dich Ja, und AddTrack nicht. Setze RTCOptions.DTLS selbst
Verwende es für Chat, Dateiübertragung, Fernsteuerung, Spielzustand, Telemetrie Mikrofon, Kamera, Bildschirmfreigabe, alles mit einer Zeitachse

Das Mikrofon senden

AddTrack nimmt eine Art und einen Codec und gibt eine TsgcRTCTrack zurück. Die Audio-Codecs sind cctAudioOpus, cctAudioPCMU und cctAudioPCMA, die Video-Codecs sind cctVideoVP8, cctVideoVP9, cctVideoH264 und cctVideoJPEG.

Die Aufnahme ist ein eigenes Objekt, weil du das Plattform-Mikrofon vielleicht gar nicht willst. sgcCreateAudioCapture baut die passende Implementierung für die Plattform, für die der Code kompiliert wurde, waveIn unter Windows, ALSA unter Linux, AudioRecord unter Android, eine VoiceProcessingIO Audio Unit unter iOS und macOS, in deinem Code steht also nirgends ein Plattformklassenname. Auf einem Ziel ohne Implementierung kommt nil zurück, prüfe also das Ergebnis.

SendPCM will 16-Bit-PCM mit Vorzeichen, interleaved, mit der Rate und Kanalzahl des Encoders, bei Opus also 48000 Hz und bei G.711 8000 Hz. Die Aufnahmequelle veröffentlicht über AudioSampleRate, AudioChannels und AudioFrameDurationMs, was sie tatsächlich liefert, du kannst also prüfen statt annehmen.

Eine Spur hinzuzufügen, wenn die Sitzung schon steht, setzt das Negotiation-Needed-Flag und löst OnNegotiationNeeded aus. Rufe CreateOffer erneut auf, um sie zu veröffentlichen, und das erneute Offer wird gebaut, ohne den Transport anzufassen.

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;

Abspielen, was die andere Seite geschickt hat

OnTrack wird einmal je entfernter Medienzeile ausgelöst, wenn die Remote Description eine mitbringt. Die übergebene Spur gehört der Peer-Verbindung, verdrahte also ihre Ereignisse und gib sie nie frei.

Audio kommt als dekodiertes PCM über OnAudio an, mit der Rate und Kanalzahl, die der Decoder erzeugt hat. Gib es direkt an die TsgcMediaRenderer weiter, die sgcCreateAudioRenderer gebaut hat, sie wandelt das Format um, wenn das Gerät nicht passend geöffnet werden konnte.

Video kommt als dekodiertes TsgcVideoFrame über OnVideoFrame an: Rohpixel in Data, mit Width, Height, Format und Stride. Die Formate sind vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24 und vffBGRA32, und die beiden BGR-Formate folgen der Byte-Reihenfolge von Windows GDI, das Blitten eines vffBGR24-Frames in eine Bitmap ist also eine Speicherkopie und keine Umwandlung.

Kommt ein Frame nach Paketverlust kaputt an, fordert RequestKeyFrame beim Sender einen frischen an.

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;

Die Kamera, unter Windows

Die Audioaufnahme ist hinter der Factory abstrahiert, weil jede unterstützte Plattform eine Implementierung hat. Die Videoaufnahme ist es nicht, du nennst also die Plattformklasse. Unter Windows ist das TsgcVideoCapture_Win aus sgcP2P_MediaCapture_Win, die Video for Windows ansteuert und Frames über dasselbe Ereignis OnVideoCapture liefert, das die Basisklasse deklariert.

Die benachbarte Unit sgcP2P_ScreenCapture_Win gibt dir TsgcScreenCapture_Win und TsgcWindowCapture_Win, beides TsgcMediaCaptureSource-Nachfahren, Bildschirmfreigabe ist also dieselben drei Zeilen mit einem anderen Konstruktor.

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;

Die Fehler, die man vorher kennen sollte

Peer-to-Peer-Verbindungen scheitern auf Arten, die überhaupt keinen Fehler erzeugen, und genau das macht sie schwer. Das sind die, die am häufigsten vorkommen.

Medien sind still und nichts meldet einen Fehler

RTCOptions.DTLS ist False. Das ist der Standard, CreateDataChannel schaltet es ein, AddTrack nicht, eine Sitzung, die nur Medien trägt, führt also nie einen DTLS-Handshake aus und bekommt daher nie SRTP-Schlüssel. Setze es ausdrücklich.

Ein TURN-Fehler, um den du nicht gebeten hast

RTCOptions.ICE.TURN steht standardmäßig auf True und fällt auf 127.0.0.1:3478 zurück, wenn die Serverliste keinen TURN-Eintrag hat. Setze es auf False, bis du wirklich einen TURN-Server hast, sonst füllt sich das Log mit Allocation-Fehlern, die nichts mit deinem Problem zu tun haben.

Die Remote Description wird abgewiesen

Eine Description ohne a=fingerprint wird rundweg abgelehnt und über OnError gemeldet. Im Vertrauensmodell von WebRTC ist diese Zeile das Einzige, was den Peer authentifiziert, eine Description ohne sie zu akzeptieren würde den Handshake gegen jedes beliebige Zertifikat gelingen lassen.

Eine Zugriffsverletzung in einem Ereignishandler

Jedes Ereignis von Peer-Verbindung, Datenkanal und Spur wird auf einem Worker-Thread ausgelöst, dem ICE-, Netzwerk-, SCTP- oder Tick-Thread, nie auf dem Haupt-Thread. Ein VCL- oder FMX-Steuerelement daraus direkt anzufassen ist undefiniert. Pack es in TThread.Queue.

Beide Seiten machen gleichzeitig ein neues Offer

Das heißt Glare und wird mit der W3C-Regel Perfect Negotiation aufgelöst. Der unhöfliche Peer, Polite = False, behält sein eigenes Offer und meldet das eingehende über OnError. Der höfliche Peer nimmt sein eigenes zurück und antwortet. Die beiden Peers einer Sitzung dürfen nicht beide höflich sein.

Eine große Nachricht killt jeden Kanal

Eine Nachricht über der a=max-message-size des Peers würde die ganze SCTP-Association abbrechen und jeden anderen Datenkanal mitnehmen. Send und SendBytes prüfen MaxMessageSize und lehnen stattdessen lokal ab. Teile große Nutzlasten selbst auf.

Der Verbindungsaufbau dauert drei Sekunden

Das ist RTCOptions.GatheringTimeout, das Warten ohne Trickle. GatheringIdleTimeout beendet es früher, sobald keine Kandidaten mehr kommen, und TrickleICEAuto schaltet in den Trickle-Modus, wenn die Remote Description das ankündigt. Setze beide Timeouts im LAN herunter.

Das Audio ist zu schnell, zu langsam oder verzerrt

Eine Abweichung bei Abtastrate oder Kanalzahl zwischen Aufnahmegerät und Encoder. Opus wird mit 48000 Hz ausgehandelt, G.711 mit 8000 Hz. Lies AudioSampleRate und AudioChannels von der Aufnahmequelle zurück, statt anzunehmen, dass das Gerät sich an deine Vorgabe gehalten hat.

Delphi WebRTC, häufig gefragt

Was Entwickler fragen, bevor sie zwei Anwendungen Peer-to-Peer verbinden.

Nicht in der RTL und nicht über die VCL. TsgcRTCPeerConnection ist eine native Object-Pascal-Umsetzung der W3C-Peer-Connection-Oberfläche: CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel und AddTrack, mit ICE, DTLS, SCTP und SRTP darunter. Es gibt kein eingebettetes Chromium, kein TWebBrowser und keine JavaScript-Brücke im Prozess.
Du brauchst etwas, das vor dem Gesprächsbeginn ein paar hundert Bytes Text zwischen den beiden Peers transportieren kann, weil noch keiner weiß, wie er den anderen erreicht. Das ist Signalling, und es kann ein WebSocket-Relay sein, eine vorhandene Message Queue, ein REST-Endpunkt, für eine Demo sogar Kopieren und Einfügen. Sind Offer, Answer und die ICE-Kandidaten übergegangen, laufen Medien und Daten direkt zwischen den beiden Anwendungen, und der Signalling-Kanal kann sich schließen. Kein Server sitzt im Medienpfad, es sei denn, ein TURN-Relay war der einzige Weg, der funktioniert hat.
Fast immer ein WebSocket, weil es bidirektional ist und der Server ein eingehendes Offer pushen kann, ohne dass der Peer pollt. Diese Seite baut eines aus TsgcWebSocketServer und TsgcWebSocketClient in etwa fünfzehn Zeilen und leitet jede Nachricht mit Broadcast an den anderen Peer weiter, mit der Connection.Guid des Absenders als Ausschluss. sgcWebSockets liefert außerdem eine fertige Signalling-Protokollkomponente, TsgcWSPServer_RTCPeerConnection, die den Austausch über RTCOptions.WebSocket und GatherCandidates für dich steuert, wenn du das Relay lieber gar nicht schreiben willst.
Sind beide Anwendungen im selben LAN oder im selben VPN, keines von beiden. Host-Kandidaten beschreiben bereits erreichbare Adressen. Sind sie in verschiedenen Netzen hinter gewöhnlichen Routern, brauchst du STUN, das jedem Peer die öffentliche Adresse nennt, aus der seine Pakete zu kommen scheinen, und das deckt die meisten echten Verbindungen ab. TURN brauchst du, wenn überhaupt keine direkte Paarung funktioniert: symmetrisches NAT, restriktive Unternehmens-Firewalls und manche Mobilfunkanbieter. TURN leitet jedes Byte des Gesprächs weiter, es ist also der teure Rückfall und nicht der Standard. Trag beide in RTCOptions.ICEServers ein, und ICE wählt das günstigste Paar, das tatsächlich verbindet.
Ein Datenkanal ist SCTP über DTLS und bewegt Nachrichten, mit der Zuverlässigkeit, die du wählst: geordnet und voll zuverlässig wie TCP, oder ungeordnet mit einer Grenze für Erneutsendungen oder Lebensdauer, für alles, wo ein spätes Paket nutzlos ist. Eine Medienspur ist SRTP über denselben DTLS-Transport und bewegt eine Zeitachse: Audio in 20-ms-Frames, Video mit einer Bildrate, von Natur aus verlustbehaftet. Nimm einen Datenkanal für Chat, Dateiübertragung, Fernsteuerung und Spielzustand. Nimm eine Spur für ein Mikrofon, eine Kamera oder einen Bildschirm. Beide teilen sich eine Verbindung und einen offenen Port.
Zwei Schritte. Enterprise definiert SGC_ICE, SGC_DTLS, SGC_TURN und SGC_RTCPEERCONNECTION, und das bringt TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient und TsgcTURNServer auf die Palette. Die Offer-und-Answer-API, Datenkanäle und Medienspuren hängen hinter SGC_SDP, SGC_DATACHANNEL und SGC_RTP, die nur SGC_PACK_WEBRTC definiert, und das ist das Add-on sgcWebRTC, ebenfalls in All-Access enthalten. Darunter liegt ein STUN-Client in Standard, und ein STUN-Server sowie die WebSocket-Server-Komponente in Professional.
Ja, und nichts auf dieser Seite ändert sich. Das SDP ist Standard, die Kandidatenzeilen tragen dieselbe sdpMid und dasselbe sdpMLineIndex, die die Browser-API erwartet, und der Zustandsautomat für Offer und Answer folgt RFC 8829, das JSON, das dein Relay weiterleitet, funktioniert also unverändert in beide Richtungen. Browser trickeln Kandidaten ab der ersten Millisekunde, und genau das erkennt RTCOptions.TrickleICEAuto: Es sieht a=ice-options:trickle in der Remote Description und antwortet sofort, statt das Sammel-Timeout abzuwarten.
Immer auf Worker-Threads. OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack und OnError kommen auf dem ICE-, Netzwerk- oder Timer-Thread an. Datenkanal-Ereignisse kommen auf dem Thread an, der die SCTP-Association antreibt. Audio- und Video-Ereignisse einer Spur kommen auf dem Netzwerk- oder Tick-Thread an. Keines davon wird für dich auf den Haupt-Thread gemarshallt, pack also alles, was ein Steuerelement anfasst, in TThread.Queue, genau das machen die mitgelieferten Demos.
Nein. Lass RTCOptions.DTLSOptions.CertFile leer, und ein selbstsigniertes Zertifikat samt Schlüssel wird im Speicher erzeugt, einmal je Komponente, und für jeden Peer wiederverwendet. Sein Fingerabdruck wird als Attribut a=fingerprint der lokalen Description veröffentlicht, und das authentifiziert dich gegenüber der anderen Seite. Das ist das Vertrauensmodell von WebRTC: Die Kette wird nie geprüft, der Anker ist der Fingerabdruck, der über den Signalling-Kanal läuft. Du kannst CertFile und KeyFile trotzdem auf eigene PEM-Dateien zeigen lassen, wenn du eine stabile Identität willst.
Über dem nominierten Kandidatenpaar läuft ein DTLS-Handshake und leitet die SRTP-Schlüssel ab, jedes RTP-, RTCP- und SCTP-Paket auf der Verbindung ist also verschlüsselt. Bei Datenkanälen gibt es keine Möglichkeit, das abzuschalten: CreateDataChannel setzt RTCOptions.DTLS bedingungslos auf True, weil ein RTCDataChannel per Definition SCTP über DTLS ist und es ohne das keine gültige Konfiguration gibt.
Die P2P- und WebRTC-Units liegen in jedem Runtime-Package von Delphi 7 bis RAD Studio 13 und in den passenden C++-Builder-Packages. Audioaufnahme und -wiedergabe haben Plattformimplementierungen für Windows, Linux, Android, iOS und macOS, die Factory-Funktionen geben also auf allen fünf ein funktionierendes Objekt zurück. Die Videoaufnahme ist die Ausnahme, benannt je Plattform statt von einer Factory gebaut, und unter Windows ist das TsgcVideoCapture_Win, daneben TsgcScreenCapture_Win und TsgcWindowCapture_Win für die Bildschirmfreigabe.
Ja, das ist Renegotiation. AddTrack an einer bestehenden Sitzung markiert sie als verhandlungsbedürftig und löst OnNegotiationNeeded aus. Rufe CreateOffer erneut auf, und ein neues Offer wird synchron gebaut, ohne neues ICE-Sammeln und ohne neuen DTLS- oder SCTP-Handshake, der Transport wird also nie unterbrochen. Bestehende Medienzeilen behalten ihre Position und ihre mid, die neue wird hinten angehängt. RemoveTrack funktioniert genauso in umgekehrter Richtung: Die Medienzeile bleibt und wird als recvonly oder inactive erneut veröffentlicht.

Komponentenreferenzen und technische Dokumente

Jedes Stück, das diese Seite nutzt, hat eine eigene Referenzseite, und die meisten haben ein eigenständiges technisches PDF mit der vollständigen Liste an Eigenschaften, Methoden und Ereignissen.

TsgcRTCPeerConnection

Die Komponente, um die es auf dieser ganzen Seite geht. Offer und Answer, ICE, DTLS, SCTP-Datenkanäle und RTP-Medien in einer Klasse.

Komponentenseite →

sgcWebRTC

Das Medien-Engine-Pack: SDP, SCTP, RTP, SRTP, Opus- und G.711-Audio, VP8- und H.264-Video, Bandbreitenschätzung.

Produktseite →

Funktionsübersicht

Codec für Codec und Plattform für Plattform, was die Medien-Engine tut und woher jeder Encoder kommt.

Funktionen ansehen →

ICE-Client

Kandidatensammlung, die Check-Liste, Nominierung und die ICE-Server-Sammlung, die Schicht unter der Peer-Verbindung.

Komponentenseite →

STUN-Client und -Server

Binding Requests, Optionen für Erneutsendungen und die Server-Komponente, wenn du lieber selbst hostest.

STUN-Client →

TURN-Client und -Server

Allocations, Permissions, Channel Binds und eine TURN-Server-Komponente für die Gespräche, die ein Relay brauchen.

TURN-Server →

Alle P2P-Komponenten

UDP, STUN, TURN, ICE und RTCPeerConnection, die ganze Peer-to-Peer-Familie in einem Index.

P2P durchsehen →

Delphi-WebRTC-Übersicht

Die Sicht auf WebRTC in sgcWebSockets auf Bibliotheksebene, mit den Signalling-Protokollkomponenten und der Demoliste.

Mehr erfahren →

Welche Edition brauche ich?

Die vollständige Editionsmatrix, Funktion für Funktion, wenn WebRTC nicht das Einzige ist, was du abwägst.

Editionen vergleichen →

Weitere Anwendungsfälle

Diese Seite ist einer der Delphi-Anwendungsfälle, die jeweils eine einzelne Aufgabe von Anfang bis Ende durchgehen. Die anderen bisher sind ein LLM aus Delphi aufrufen und eine Anmeldung mit OAuth2 und PKCE.

Alle Anwendungsfälle →
Technisches Dokument RTCPeerConnection (PDF) Eigenschaften, Methoden, Ereignisse und Codebeispiele nur für die Peer-Verbindungskomponente.
Technisches Dokument ICE-Client (PDF) Kandidatensammlung, die Check-Liste und die ICE-Server-Sammlung im Detail.
Technisches Dokument TURN-Client (PDF) Allocations, Permissions und Channel Binds, der Client, den ICE für einen weitergeleiteten Kandidaten ansteuert.
Technisches Dokument STUN-Client (PDF) Binding Requests und Erneutsendungen, der günstigste Weg, die eigene öffentliche Adresse zu erfahren.
Demoprojekte Demos\35.P2P\05.RTCPeerConnection und Demos\35.P2P\06.DataChannel liegen im Paket bei.

Die Spezifikationen hinter jedem Schritt

Primärquellen, wenn du lieber liest, was die Komponente umsetzt, statt uns beim Wort zu nehmen.

RFC 8829, JSEP

Der Zustandsautomat für Offer und Answer hinter CreateOffer, CreateAnswer und SetRemoteDescription, einschließlich Renegotiation und Rollback.

RFC lesen →

RFC 8445, ICE

Kandidatensammlung, Priorität, die Check-Liste und Nominierung. Der Grund, warum eine Verbindung mal eine Sekunde braucht und mal scheitert.

RFC lesen →

RFC 8489 und RFC 8656

STUN und TURN. Was ein Binding Request fragt, und was eine Allocation kostet.

RFC lesen →

RFC 8831 und RFC 8832

WebRTC-Datenkanäle über SCTP und der DCEP-Open-Handshake, der Stream-IDs nach DTLS-Rolle vergibt.

RFC lesen →

RFC 8122, SDP-Fingerabdrücke

Warum a=fingerprint die Identität einer Peer-Verbindung ist, und warum eine Description ohne sie abgewiesen wird.

RFC lesen →

WebRTC 1.0 (W3C)

Die API, die diese Komponente spiegelt, einschließlich Perfect Negotiation, woher Polite kommt.

Spezifikation lesen →

Bring zwei deiner Anwendungen in ein Gespräch

Lade die Testversion herunter, lass die Demos RTCPeerConnection und DataChannel gegeneinander laufen und bau dann dasselbe in dein eigenes Projekt ein.