Delphi WebRTC : audio, vidéo et données entre deux applications

Deux applications Delphi, sur deux réseaux différents, qui échangent un canal de discussion, un flux de microphone et un flux de caméra directement entre elles. Aucun serveur média au milieu, aucun navigateur embarqué dans le processus, aucun pont JavaScript. Cette page parcourt tout le travail, du premier message de signalisation à la première trame audio décodée, avec des API qui existent dans le code source livré.

Offre et réponse SDP
ICE, STUN et TURN
Canaux de données SCTP
Pistes média Opus et VP8
Chiffré en DTLS-SRTP
Sans navigateur ni WebView

Ce qui doit réellement se produire

WebRTC, ce sont quatre problèmes distincts sous un seul nom. Un seul concerne le média, et c'est le plus facile. Les voici tous les quatre, dans l'ordre où il faut les résoudre.

1. Décrire la session

Un côté construit une offre, un document texte (SDP) qui indique quels médias il veut envoyer, quels codecs il parle, l'empreinte du certificat qu'il présentera et les identifiants ICE qu'il utilisera. L'autre côté répond avec le sous-ensemble qu'il accepte. CreateOffer et CreateAnswer produisent ces documents, SetRemoteDescription les consomme.

2. Le porter jusqu'à l'autre côté

WebRTC ne dit délibérément pas comment l'offre atteint l'autre pair. Ce canal s'appelle la signalisation et c'est ton travail. Cela représente quelques centaines d'octets de texte dans chaque sens, une connexion WebSocket vers un petit relais suffit donc, et sgcWebSockets te donne déjà les deux moitiés d'un tel relais.

3. Trouver un chemin à travers les NAT

Aucun des deux pairs ne connaît son adresse publique, et les deux sont généralement derrière un routeur. ICE rassemble toutes les adresses sur lesquelles un pair pourrait être joignable, les envoie sur le canal de signalisation au fur et à mesure qu'elles apparaissent, et teste chaque appariement jusqu'à ce que l'un fonctionne. STUN trouve l'adresse publique, TURN fournit un relais quand rien de direct ne marche.

4. Faire circuler les octets

Une fois qu'une paire de candidats est retenue, une poignée de main DTLS s'exécute dessus et tout ce qui suit est chiffré. Un canal de données est du SCTP sur ce transport DTLS, une piste audio ou vidéo est du SRTP dessus. Les deux partagent la même connexion et le même port ouvert.

Existe-t-il un serveur WebRTC ?

Pas dans le chemin média, et c'est tout l'intérêt. Une fois que les deux applications se sont trouvées, l'audio, la vidéo et les données voyagent directement entre elles. Rien de ce que tu héberges ne voit la charge utile, et rien de ce que tu héberges n'a besoin de monter en charge avec le nombre de minutes que tes utilisateurs passent en communication.

Il reste tout de même deux serveurs dans le tableau, et il est utile d'être précis sur ce que fait chacun, parce qu'on les confond souvent.

Le serveur de signalisation est le tien. Il relaie une poignée de messages texte entre deux pairs avant le début de l'appel, puis se tait. Il ne voit jamais de média. Dans cette démonstration, ce sont quinze lignes de Delphi bâties sur TsgcWebSocketServer.

Le serveur STUN et TURN existe à cause du NAT, pas à cause de WebRTC. Un serveur STUN répond à une seule question, « de quelle adresse publique ce paquet est-il arrivé », et c'est tout. Un serveur TURN relaie les paquets pour les paires qui ne peuvent se joindre d'aucune autre façon, c'est donc la seule pièce qui transporte du média, et uniquement pour les appels qui en ont besoin. Les serveurs STUN publics sont gratuits et nombreux, TURN s'héberge soi-même, et sgcWebSockets Enterprise livre à la fois un composant serveur STUN et un composant serveur TURN si tu préfères ne pas faire tourner un démon séparé.

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

Éditions, unités et plateformes

La connexion entre pairs et le moteur média sont deux étapes de licence différentes. Il vaut la peine de bien comprendre cela avant d'écrire la moindre ligne de code, car sinon le compilateur ne verra tout simplement pas la moitié de l'API.

Ce que tu veux faireCe qu'il fautD'où cela vient
Client STUN, pour découvrir une adresse publique TsgcSTUNClient sgcWebSockets Standard et au-dessus
Faire tourner ton propre serveur STUN TsgcSTUNServer sgcWebSockets Professional et au-dessus
Relayer un canal de signalisation WebSocket TsgcWebSocketServer sgcWebSockets Professional et au-dessus pour la moitié serveur. La moitié cliente, TsgcWebSocketClient, arrive avec Standard.
ICE, client et serveur TURN, et le composant de connexion entre pairs lui-même TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
Offre et réponse, canaux de données, pistes audio et vidéo CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack Le pack sgcWebRTC, par-dessus Enterprise. Également inclus dans All-Access.

Pourquoi cette séparation, dans les mots du compilateur

Le bloc Enterprise de sgcVer.inc définit SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION et SGC_TURN. C'est ce qui place TsgcRTCPeerConnection sur la palette et lui donne un transport ICE et TURN.

Tout ce dont parle vraiment cette page se situe un cran plus loin. SGC_PACK_WEBRTC est ce qui définit SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP et SGC_SRTP, et il vérifie d'abord qu'ICE, DTLS et la connexion entre pairs sont déjà là. Les méthodes de signalisation manuelles, l'API de canal de données et l'API média se trouvent respectivement dans {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL} et {$IFDEF SGC_RTP}, si bien qu'avec Enterprise seul elles ne compilent pas.

Si CreateOffer ne se résout pas, c'est ce qui s'est passé. La démo de canal de données livrée dans le package le dit à voix haute plutôt que d'échouer en silence.

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}

Les unités dont les deux applications ont besoin

sgcP2P est l'unité de façade qui publie TsgcRTCPeerConnection et réexporte les types de gestionnaires. Cela suffit pour déclarer le composant, mais les constantes énumérées viennent des unités qui déclarent leurs types, ajoute-les donc aussi quand tu nommes rtctkAudio ou cctAudioOpus.

Les deux applications de cette démonstration sont le même programme avec un bouton différent enfoncé. Tout ce qui suit va dans les deux.

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

Le canal de signalisation

Trois sortes de messages, un relais idiot. C'est la partie que tous les tutoriels WebRTC survolent, et celle que tu dois vraiment écrire.

Un relais, pas un courtier

Le serveur de signalisation n'a besoin de comprendre aucun octet de ce qu'il transmet. Il prend le texte qu'un pair a envoyé et le remet à l'autre. Broadcast a déjà un paramètre Exclude qui prend le Guid d'une connexion, si bien que « envoyer à tout le monde sauf à l'expéditeur » tient sur une ligne.

Garde-le aussi idiot que cela. Dès l'instant où le relais commence à analyser du SDP, il devient un composant que tu dois mettre à jour à chaque changement de codec, et il perd la capacité de relayer un appel vers un navigateur.

En production, tu indexerais le relais sur un identifiant de salon pour que deux appels ne se télescopent pas, et tu le placerais derrière TLS. TsgcWebSocketServer porte les mêmes TLSOptions, Authentication et WatchDog que le reste de la bibliothèque.

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;

Le côté pair du même canal

Chaque application ouvre un TsgcWebSocketClient vers ce relais et parle un vocabulaire de trois mots : offer, answer et candidate. TsgcJSON de la même bibliothèque les sérialise, il n'y a donc aucune dépendance supplémentaire.

Regarde où vont les branches. Une offer entrante est posée comme description distante et reçoit immédiatement une réponse. Une answer entrante est seulement posée. Un candidate entrant est passé à AddIceCandidate, et il peut arriver avant ou après la description, ce qui est tout l'intérêt du 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;

Construire la connexion entre pairs

Identique dans les deux applications. La seule asymétrie de tout l'échange est de savoir laquelle appuie sur Appeler.

La configuration, et les événements qui comptent

RTCOptions.ICEServers est la liste iceServers du W3C. AddURL prend une URL stun: ou turn: et en déduit le type, l'hôte, le port et l'indicateur TLS à partir du schéma, avec un nom d'utilisateur et un identifiant facultatifs pour TURN.

RTCOptions.DTLS vaut par défaut False. Laisse-le ainsi et il n'y a ni chiffrement ni matériel de clés SRTP, le média ne fonctionnera donc pas. CreateDataChannel l'active pour toi, parce qu'un canal de données est du SCTP sur DTLS et qu'il n'existe aucune configuration valide avec DTLS désactivé. AddTrack ne le fait pas, active-le donc toi-même quand tu ajoutes du média.

Tu n'as pas besoin d'un fichier de certificat. Laisse RTCOptions.DTLSOptions.CertFile vide et un certificat auto-signé en mémoire est généré une fois par composant, ce qui correspond exactement au modèle WebRTC : l'identité est ancrée sur la ligne a=fingerprint du SDP, pas sur une chaîne. Une description distante qui ne porte pas d'empreinte est refusée plutôt que d'être autorisée à négocier contre n'importe quoi.

Chaque événement ci-dessous se déclenche sur un thread de travail, le thread ICE, réseau ou minuteur, jamais sur le thread principal. Passe par TThread.Queue avant de toucher un contrôle.

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;

Publier la description locale

OnLocalDescription te remet le type, la chaîne 'offer' ou 'answer', et le SDP lui-même. Les deux pairs utilisent le même gestionnaire, et il ne fait qu'une chose : poser cela sur le canal de signalisation.

Le SDP arrive déjà complet. CreateOffer rassemble d'abord les candidats ICE et attend jusqu'à RTCOptions.GatheringTimeout millisecondes, 3000 par défaut, en s'arrêtant plus tôt après GatheringIdleTimeout millisecondes sans nouveau candidat, 500 par défaut. C'est le chemin sans trickle.

Mets TrickleICE à True et la description part immédiatement, les candidats suivant derrière elle. Tu as rarement à le faire. RTCOptions.TrickleICEAuto vaut True par défaut, si bien que lorsque la description distante annonce a=ice-options:trickle, ce que fait tout navigateur, le composant bascule tout seul et cesse de consommer le délai de rassemblement.

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;

Candidats ICE, STUN et TURN

C'est là que les connexions en pair à pair échouent, et là que les échecs sont les plus difficiles à lire. Trois sortes de candidats, trois raisons d'exister.

host

Une adresse que la machine voit sur elle-même, une par interface réseau. Gratuite, immédiate, et suffisante quand les deux applications sont sur le même LAN ou le même VPN. Si tes deux applications Delphi ne tournent jamais qu'à l'intérieur d'un même bureau, les candidats host suffisent et tu peux te passer complètement de STUN.

srflx, server reflexive

L'adresse publique depuis laquelle un serveur STUN a vu arriver le paquet du pair. C'est ce qui fait dialoguer directement deux pairs derrière des routeurs domestiques ordinaires, et cela couvre la grande majorité des connexions réelles. Cela coûte un aller-retour vers un serveur STUN qui ne transporte plus aucun trafic ensuite.

relay

Une adresse sur un serveur TURN qui retransmet vers le pair. Nécessaire pour le NAT symétrique, les firewalls d'entreprise restrictifs et certains opérateurs mobiles. Chaque octet de l'appel traverse ton serveur TURN, c'est donc le chemin coûteux et celui sur lequel on ne se replie qu'en dernier recours.

Les faire circuler en trickle

OnIceCandidate se déclenche une fois par candidat, au moment où il est découvert, avec la ligne du candidat, son sdpMid et son sdpMLineIndex. Ces trois champs sont exactement ce qu'attend l'API du navigateur, le même JSON fonctionne donc que l'autre extrémité soit Delphi ou Chrome.

Envoie chacun immédiatement. N'attends pas, ne regroupe pas. Un candidat qui arrive avant la description distante est mis de côté et appliqué quand la description arrive, l'ordre n'est donc pas ton problème.

Quand la paire est finalement retenue, SelectedLocalCandidate et SelectedRemoteCandidate te disent quelles deux adresses l'ont emporté. Cette seule ligne de journal répond à « pourquoi cet appel passe-t-il par mon serveur TURN » plus vite que n'importe quoi d'autre.

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;

Ajouter TURN, et l'interrupteur à ne pas oublier

Une entrée turn: dans ICEServers porte son propre hôte, son port, son nom d'utilisateur et son identifiant, et c'est ce qu'utilise l'allocation. L'ajouter, c'est un AddURL.

Le piège est dans l'autre sens. RTCOptions.ICE.TURN vaut par défaut True, et quand la liste de serveurs ne contient aucune entrée TURN, le rassemblement se replie sur l'unique serveur de RTCOptions.ICE, dont l'hôte vaut par défaut 127.0.0.1 et le port 3478. Ainsi un pair configuré avec une simple URL STUN tente quand même une allocation TURN contre localhost, échoue, et le signale. C'est du bruit plutôt qu'une panne, mais cela paraît alarmant dans un journal et cela t'envoie chercher au mauvais endroit. Mets RTCOptions.ICE.TURN := False tant que tu n'as pas réellement de serveur TURN.

RTCOptions.ICE.STUN se comporte de la même façon et vaut aussi True par défaut.

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;

Référence du client ICE Faire tourner ton propre serveur TURN

Le canal de données

Du texte et du binaire entre les deux applications, avec la fiabilité que tu choisis pour chaque canal. C'est en général la première chose que l'on fait fonctionner, et elle prouve tout le transport.

En ouvrir un, et recevoir celui de l'autre côté

Le pair qui appelle CreateDataChannel récupère l'objet tout de suite. Celui qui ne l'a pas fait reçoit le même canal par OnDataChannel. Attache les gestionnaires des deux côtés, parce que chacun peut ouvrir un canal à n'importe quel moment de la session.

Le canal n'est pas utilisable à l'instant où tu le crées. Son Id reste non attribué tant que l'association SCTP n'est pas montée et que le rôle DTLS n'est pas résolu, et Send renvoie False tant qu'il n'est pas ouvert. Attends OnOpen.

La fiabilité se décide à la création. Les valeurs par défaut sont ordonnées et totalement fiables, un canal de type TCP. Passe aOrdered = False pour une livraison non ordonnée, ou un aMaxRetransmits ou un aMaxPacketLifeTime pour une fiabilité partielle, ce que tu veux pour des mises à jour de position ou pour tout ce où un paquet en retard est pire qu'un paquet perdu.

Ne libère pas un canal, il appartient à la connexion entre pairs. Close démarre l'arrêt et OnClose se déclenche quand l'autre extrémité est d'accord.

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;

Envoyer, sans inonder

Send prend une chaîne et SendBytes prend un TBytes. Les deux renvoient False plutôt que de lever une exception quand le canal n'est pas ouvert, un envoi pendant la fermeture donne donc un False renvoyé, pas une exception sur un thread de travail.

MaxMessageSize est le plus grand message que le pair a dit accepter, lu depuis l'attribut a=max-message-size de sa description. Un message au-delà de cette limite est refusé localement au lieu d'être posé sur le fil, où il ferait avorter toute l'association et emporterait tous les autres canaux avec elle. Zéro signifie qu'aucune description n'a jamais annoncé de limite et que la vérification est désactivée.

BufferedAmount est le nombre d'octets en file d'attente dans SCTP pour ce flux et pas encore acquittés. Surveille-le quand tu diffuses un fichier : envoie jusqu'à ce qu'il dépasse un seuil, puis attends qu'il se vide plutôt que de mettre des gigaoctets en file d'attente en mémoire.

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;

Pistes audio et vidéo

Une piste n'est pas un canal de données avec des images dedans. Transport différent, modes de défaillance différents, code différent. C'est la distinction que la plupart des gens ratent en premier.

 Canal de donnéesPiste média
Transporté par SCTP sur DTLS (RFC 8831) SRTP sur le même transport DTLS (RFC 3711)
Livraison À ton choix, du totalement fiable et ordonné au tire et oublie Toujours avec pertes par conception. En retard est pire que perdu, rien n'est donc retransmis indéfiniment
Unité de travail Un message. C'est toi qui décides quand en envoyer un Une horloge. L'audio est alimenté par trames de 20 ms, la vidéo à une cadence d'images
Ouvert avec CreateDataChannel, à tout moment de la session AddTrack, qui a besoin d'une nouvelle offre pour être publiée
Reçu par OnDataChannel, puis par le propre OnMessage OnTrackdu canal, puis par le OnAudio ou le OnVideoFrame
de la piste Nécessite DTLS activé CreateDataChannel Oui, et l'active pour toi AddTrack Oui, et RTCOptions.DTLS ne le fait pas. Active
toi-même À utiliser pour Discussion, transfert de fichiers, contrôle à distance, état de jeu, télémétrie

Microphone, caméra, partage d'écran, tout ce qui a une ligne de temps

AddTrack Envoyer le microphone TsgcRTCTrackprend un genre et un codec et renvoie un cctAudioOpus, cctAudioPCMU. Les codecs audio sont cctAudioPCMAet cctVideoVP8, cctVideoVP9, cctVideoH264, les codecs vidéo sont cctVideoJPEG.

et sgcCreateAudioCapture La capture est un objet séparé, parce que tu ne veux peut-être pas du microphone de la plateforme.

SendPCM construit l'implémentation adaptée à la plateforme pour laquelle le code a été compilé, waveIn sur Windows, ALSA sur Linux, AudioRecord sur Android, une Audio Unit VoiceProcessingIO sur iOS et macOS, si bien que rien dans ton code ne nomme une classe de plateforme. Elle renvoie nil sur une cible sans implémentation, teste donc le résultat. AudioSampleRate, AudioChannels attend du PCM entrelacé signé 16 bits à la fréquence et au nombre de canaux de l'encodeur, soit 48000 Hz pour Opus et 8000 Hz pour G.711. La source de capture publie ce qu'elle livre réellement par AudioFrameDurationMset

, tu peux donc vérifier plutôt que supposer. OnNegotiationNeededAjouter une piste alors que la session est déjà montée positionne l'indicateur de renégociation nécessaire et déclenche CreateOffer. Appelle de nouveau

pour la publier, et la nouvelle offre est construite sans toucher au transport.
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;

uPeer.pas

OnTrack Jouer ce que l'autre côté a envoyé

se déclenche une fois par ligne média distante, quand la description distante en apporte une. La piste qu'il te remet appartient à la connexion entre pairs, câble donc ses événements et ne la libère jamais. OnAudioL'audio arrive sous forme de PCM décodé par TsgcMediaRenderer, avec la fréquence et le nombre de canaux produits par le décodeur. Passe-le directement au sgcCreateAudioRenderer que

a construit, lequel convertit le format quand le périphérique n'a pas pu être ouvert à l'identique. TsgcVideoFrame La vidéo arrive sous forme de OnVideoFramedécodé par Data : des pixels bruts dans Width, Height, Format et Stride. Les formats sont vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24 et vffBGRA32, et les deux formats BGR suivent l'ordre des octets de GDI sous Windows, si bien que recopier une trame vffBGR24 dans un bitmap est une copie mémoire et non une conversion.

Si une trame arrive abîmée après une perte de paquets, RequestKeyFrame en redemande une nouvelle à l'émetteur.

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;

La caméra, sous Windows

La capture audio est abstraite derrière la fabrique parce que toutes les plateformes prises en charge ont une implémentation. La capture vidéo, non, tu nommes donc la classe de plateforme. Sous Windows c'est TsgcVideoCapture_Win de sgcP2P_MediaCapture_Win, qui pilote Video for Windows et livre les trames par le même événement OnVideoCapture que déclare la classe de base.

L'unité voisine sgcP2P_ScreenCapture_Win te donne TsgcScreenCapture_Win et TsgcWindowCapture_Win, tous deux descendants de TsgcMediaCaptureSource, si bien que le partage d'écran tient dans les trois mêmes lignes avec un constructeur différent.

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;

Les échecs qu'il vaut mieux connaître à l'avance

Les connexions en pair à pair échouent d'une manière qui ne produit aucune erreur, et c'est ce qui les rend difficiles. Voici celles qui reviennent le plus souvent.

Le média est muet et rien ne signale d'erreur

RTCOptions.DTLS vaut False. C'est la valeur par défaut, CreateDataChannel l'active mais AddTrack ne le fait pas, si bien qu'une session qui ne transporte que du média n'exécute jamais de poignée de main DTLS et n'obtient donc jamais de clés SRTP. Active-le explicitement.

Une erreur TURN que tu n'as pas demandée

RTCOptions.ICE.TURN vaut True par défaut et se replie sur 127.0.0.1:3478 quand la liste de serveurs ne contient aucune entrée TURN. Mets-le à False tant que tu n'as pas réellement de serveur TURN, sinon le journal se remplit d'échecs d'allocation qui n'ont rien à voir avec ton problème.

La description distante est refusée

Une description sans a=fingerprint est rejetée d'emblée et signalée par OnError. Dans le modèle de confiance de WebRTC, cette ligne est la seule chose qui authentifie le pair, accepter une description sans elle laisserait donc la poignée de main aboutir contre n'importe quel certificat.

Une violation d'accès dans un gestionnaire d'événement

Tous les événements de connexion entre pairs, de canal de données et de piste se déclenchent sur un thread de travail, le thread ICE, réseau, SCTP ou de tick, jamais sur le thread principal. Toucher directement un contrôle VCL ou FMX depuis l'un d'eux est un comportement indéfini. Enveloppe-le dans TThread.Queue.

Les deux côtés réémettent une offre en même temps

Cela s'appelle le glare, et cela se résout avec la règle de négociation parfaite du W3C. Le pair impoli, Polite = False, garde sa propre offre et signale celle qui arrive par OnError. Le pair poli annule la sienne et répond. Les deux pairs d'une session ne doivent pas être polis tous les deux.

Un seul gros message tue tous les canaux

Un message au-delà du a=max-message-size du pair ferait avorter toute l'association SCTP, emportant avec elle tous les autres canaux de données. Send et SendBytes vérifient MaxMessageSize et refusent localement à la place. Découpe toi-même les charges utiles volumineuses.

La connexion met trois secondes à démarrer

C'est RTCOptions.GatheringTimeout, l'attente sans trickle. GatheringIdleTimeout l'écourte dès que les candidats cessent d'arriver, et TrickleICEAuto bascule en mode trickle quand la description distante l'annonce. Abaisse les deux délais sur un LAN.

L'audio est rapide, lent ou haché

Un écart de fréquence d'échantillonnage ou de nombre de canaux entre le périphérique de capture et l'encodeur. Opus est négocié à 48000 Hz et G.711 à 8000 Hz. Relis AudioSampleRate et AudioChannels sur la source de capture plutôt que de supposer que le périphérique a respecté ce que tu as demandé.

Delphi WebRTC, les questions fréquentes

Ce que les développeurs demandent avant de relier deux applications en pair à pair.

Pas dans la RTL, et pas via la VCL. TsgcRTCPeerConnection est une implémentation native en Object Pascal de la surface W3C de connexion entre pairs : CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel et AddTrack, avec ICE, DTLS, SCTP et SRTP en dessous. Il n'y a pas de Chromium embarqué, pas de TWebBrowser et pas de pont JavaScript dans le processus.
Tu as besoin de quelque chose capable de transporter quelques centaines d'octets de texte entre les deux pairs avant le début de l'appel, parce qu'aucun des deux ne sait encore joindre l'autre. C'est la signalisation, et cela peut être un relais WebSocket, une file de messages existante, un endpoint REST, voire un copier-coller pour une démo. Une fois que l'offre, la réponse et les candidats ICE se sont croisés, le média et les données passent directement entre les deux applications et le canal de signalisation peut se fermer. Aucun serveur ne se trouve dans le chemin média, sauf si un relais TURN s'est avéré être la seule route qui fonctionne.
Un WebSocket, presque toujours, parce qu'il est bidirectionnel et que le serveur peut pousser une offre entrante sans que le pair l'interroge. Cette page en construit un à partir de TsgcWebSocketServer et TsgcWebSocketClient en une quinzaine de lignes, relayant chaque message à l'autre pair avec Broadcast et le Connection.Guid de l'expéditeur comme exclusion. sgcWebSockets livre aussi un composant de protocole de signalisation prêt à l'emploi, TsgcWSPServer_RTCPeerConnection, qui pilote l'échange pour toi par RTCOptions.WebSocket et GatherCandidates si tu préfères ne pas écrire le relais du tout.
Si les deux applications sont sur le même LAN ou le même VPN, d'aucun des deux. Les candidats host décrivent déjà des adresses joignables. Si elles sont sur des réseaux différents derrière des routeurs ordinaires, tu as besoin de STUN, qui indique à chaque pair l'adresse publique d'où ses paquets semblent venir, et cela couvre la plupart des connexions réelles. Tu as besoin de TURN quand aucun appariement direct ne fonctionne : NAT symétrique, firewalls d'entreprise restrictifs et certains opérateurs mobiles. TURN relaie chaque octet de l'appel, c'est donc le repli coûteux plutôt que la valeur par défaut. Ajoute les deux à RTCOptions.ICEServers et ICE choisit la paire la moins chère qui se connecte vraiment.
Un canal de données, c'est du SCTP sur DTLS et cela transporte des messages, avec la fiabilité que tu choisis : ordonnée et totalement fiable comme TCP, ou non ordonnée avec une limite de retransmissions ou de durée de vie pour tout ce où un paquet en retard est inutile. Une piste média, c'est du SRTP sur le même transport DTLS et cela transporte une ligne de temps : audio par trames de 20 ms, vidéo à une cadence d'images, toujours avec pertes par conception. Utilise un canal de données pour la discussion, le transfert de fichiers, le contrôle à distance et l'état de jeu. Utilise une piste pour un microphone, une caméra ou un écran. Les deux partagent une seule connexion et un seul port ouvert.
Deux étapes. Enterprise définit SGC_ICE, SGC_DTLS, SGC_TURN et SGC_RTCPEERCONNECTION, ce qui place TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient et TsgcTURNServer sur la palette. L'API d'offre et de réponse, les canaux de données et les pistes média sont conditionnés par SGC_SDP, SGC_DATACHANNEL et SGC_RTP, que seul SGC_PACK_WEBRTC définit, et c'est l'add-on sgcWebRTC, également inclus dans All-Access. En dessous, un client STUN est dans Standard, et un serveur STUN et le composant serveur WebSocket sont dans Professional.
Oui, et rien dans cette page ne change. Le SDP est standard, les lignes de candidats portent les mêmes sdpMid et sdpMLineIndex qu'attend l'API du navigateur, et la machine à états d'offre et de réponse suit la RFC 8829, si bien que le JSON que ton relais transmet fonctionne tel quel dans les deux sens. Les navigateurs envoient leurs candidats en trickle dès la première milliseconde, ce que détecte précisément RTCOptions.TrickleICEAuto  : il voit a=ice-options:trickle dans la description distante et répond immédiatement au lieu d'attendre la fin du délai de rassemblement.
Des threads de travail, toujours. OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack et OnError arrivent sur le thread ICE, réseau ou minuteur. Les événements de canal de données arrivent sur le thread qui pilote l'association SCTP. Les événements audio et vidéo de piste arrivent sur le thread réseau ou de tick. Aucun n'est transféré au thread principal pour toi, enveloppe donc tout ce qui touche à un contrôle dans TThread.Queue, ce que font les démos livrées.
Non. Laisse RTCOptions.DTLSOptions.CertFile vide et un certificat et une clé auto-signés sont générés en mémoire, une fois par composant, et réutilisés pour chaque pair. Son empreinte est publiée comme attribut a=fingerprint de la description locale, et c'est ce qui t'authentifie auprès de l'autre côté. C'est le modèle de confiance de WebRTC : la chaîne n'est jamais vérifiée, l'empreinte transportée par le canal de signalisation est l'ancre. Tu peux quand même pointer CertFile et KeyFile vers tes propres fichiers PEM quand tu veux une identité stable.
Une poignée de main DTLS s'exécute sur la paire de candidats retenue et dérive les clés SRTP, si bien que chaque paquet RTP, RTCP et SCTP de la connexion est chiffré. Pour les canaux de données, il n'y a aucun moyen de le désactiver : CreateDataChannel met RTCOptions.DTLS à True sans condition, parce qu'un RTCDataChannel est du SCTP sur DTLS par définition et qu'il n'existe aucune configuration valide sans cela.
Les unités P2P et WebRTC sont livrées dans tous les packages runtime, de Delphi 7 à RAD Studio 13, et dans les packages C++ Builder correspondants. La capture et la restitution audio ont des implémentations de plateforme pour Windows, Linux, Android, iOS et macOS, les fonctions de fabrique renvoient donc un objet fonctionnel sur les cinq. La capture vidéo fait exception : elle est nommée par plateforme plutôt que construite par une fabrique, et sous Windows c'est TsgcVideoCapture_Win, aux côtés de TsgcScreenCapture_Win et TsgcWindowCapture_Win pour le partage d'écran.
Oui, c'est la renégociation. AddTrack sur une session établie la marque comme nécessitant une négociation et déclenche OnNegotiationNeeded. Appelle de nouveau CreateOffer et une nouvelle offre est construite de façon synchrone, sans nouveau rassemblement ICE et sans nouvelle poignée de main DTLS ou SCTP, le transport n'est donc jamais interrompu. Les lignes média existantes gardent leur position et leur mid, la nouvelle est ajoutée à la fin. RemoveTrack fonctionne de la même façon en sens inverse : la ligne média reste et elle est republiée en recvonly ou en inactive.

Références de composants et documents techniques

Chaque pièce utilisée par cette page a sa propre page de référence, et la plupart ont un PDF technique autonome avec la liste complète des propriétés, méthodes et événements.

TsgcRTCPeerConnection

Le composant dont parle toute cette page. Offre et réponse, ICE, DTLS, canaux de données SCTP et média RTP sur une seule classe.

Page du composant →

sgcWebRTC

Le pack moteur média : SDP, SCTP, RTP, SRTP, audio Opus et G.711, vidéo VP8 et H.264, estimation de bande passante.

Page produit →

Détail des fonctionnalités

Codec par codec et plateforme par plateforme, ce que fait le moteur média et d'où vient chaque encodeur.

Voir les fonctionnalités →

Client ICE

Rassemblement des candidats, liste de vérification, nomination et collection de serveurs ICE, la couche sous la connexion entre pairs.

Page du composant →

Client et serveur STUN

Requêtes binding, options de retransmission, et le composant serveur quand tu préfères héberger le tien.

Client STUN →

Client et serveur TURN

Allocations, permissions, channel binds, et un composant serveur TURN pour les appels qui ont besoin d'un relais.

Serveur TURN →

Tous les composants P2P

UDP, STUN, TURN, ICE et RTCPeerConnection, toute la famille pair à pair dans un seul index.

Parcourir P2P →

Aperçu de Delphi WebRTC

La vue au niveau bibliothèque de WebRTC dans sgcWebSockets, avec les composants de protocole de signalisation et la liste des démos.

En savoir plus →

De quelle édition ai-je besoin ?

La matrice complète des éditions, fonctionnalité par fonctionnalité, quand WebRTC n'est pas la seule chose que tu compares.

Comparer les éditions →

Autres cas d'usage

Cette page fait partie des cas d'usage Delphi, qui prennent chacun une seule tâche de bout en bout. Les autres, pour l'instant, sont appeler un LLM depuis Delphi et authentifier un utilisateur avec OAuth2 et PKCE.

Tous les cas d'usage →
Document technique RTCPeerConnection (PDF) Propriétés, méthodes, événements et exemples de code pour le seul composant de connexion entre pairs.
Document technique du client ICE (PDF) Rassemblement des candidats, liste de vérification et collection de serveurs ICE en détail.
Document technique du client TURN (PDF) Allocations, permissions et channel binds, le client qu'ICE pilote pour un candidat relayé.
Document technique du client STUN (PDF) Requêtes binding et retransmission, la façon la moins coûteuse d'apprendre ta propre adresse publique.
Projets de démonstration Demos\35.P2P\05.RTCPeerConnection et Demos\35.P2P\06.DataChannel sont livrés dans le package.

Les spécifications derrière chaque étape

Les sources primaires, quand tu préfères lire ce que le composant implémente plutôt que nous croire sur parole.

RFC 8829, JSEP

La machine à états d'offre et de réponse derrière CreateOffer, CreateAnswer et SetRemoteDescription, y compris la renégociation et le rollback.

Lire la RFC →

RFC 8445, ICE

Rassemblement des candidats, priorité, liste de vérification et nomination. La raison pour laquelle une connexion met parfois une seconde et parfois échoue.

Lire la RFC →

RFC 8489 et RFC 8656

STUN et TURN. Ce que demande une requête binding, et ce que coûte une allocation.

Lire la RFC →

RFC 8831 et RFC 8832

Les canaux de données WebRTC sur SCTP, et la poignée de main DCEP qui attribue les identifiants de flux selon le rôle DTLS.

Lire la RFC →

RFC 8122, empreintes SDP

Pourquoi a=fingerprint est l'identité d'une connexion entre pairs, et pourquoi une description sans elle est refusée.

Lire la RFC →

WebRTC 1.0 (W3C)

L'API que ce composant reproduit, y compris la négociation parfaite, d'où vient Polite.

Lire la spécification →

Mets deux de tes applications en communication

Télécharge l'essai, fais tourner les démos RTCPeerConnection et DataChannel l'une contre l'autre, puis construis la même chose dans ton propre projet.