Delphi WebRTC: audio, video e dati tra due applicazioni

Due applicazioni Delphi, su due reti diverse, che si scambiano un canale di chat, un flusso dal microfono e un flusso dalla videocamera direttamente tra loro. Nessun media server in mezzo, nessun browser incorporato nel processo, nessun bridge JavaScript. Questa pagina percorre tutto il lavoro, dal primo messaggio di signalling al primo frame audio decodificato, usando API che esistono davvero nel codice sorgente distribuito.

offer e answer SDP
ICE, STUN e TURN
data channel SCTP
tracce media Opus e VP8
cifratura DTLS-SRTP
Nessun browser o WebView

Cosa deve succedere davvero

WebRTC sono quattro problemi distinti sotto un solo nome. Solo uno riguarda i media, ed è quello facile. Eccoli tutti e quattro, nell'ordine in cui devi risolverli.

1. Descrivi la sessione

Un lato costruisce un offer, un documento di testo (SDP) che dichiara quali media vuole inviare, quali codec parla, il fingerprint del certificato che presenterà e le credenziali ICE che userà. L'altro lato risponde con il sottoinsieme che accetta. CreateOffer e CreateAnswer producono quei documenti, SetRemoteDescription li consuma.

2. Portalo all'altro lato

WebRTC di proposito non dice come l'offer arriva all'altro peer. Quel canale si chiama signalling ed è compito tuo. Sono poche centinaia di byte di testo per direzione, quindi basta una connessione WebSocket verso un piccolo relay, e sgcWebSockets ti dà già entrambe le metà.

3. Trova un percorso attraverso i NAT

Nessuno dei due peer conosce il proprio indirizzo pubblico, e di solito entrambi stanno dietro a un router. ICE raccoglie ogni indirizzo su cui un peer potrebbe essere raggiungibile, li invia sul canale di signalling man mano che compaiono e prova ogni accoppiamento finché uno funziona. STUN trova l'indirizzo pubblico, TURN fornisce un relay quando nessun percorso diretto funziona.

4. Sposta i byte

Quando una coppia di candidate viene nominata, su di essa gira un handshake DTLS e tutto quello che segue è cifrato. Un data channel è SCTP sopra quel trasporto DTLS, una traccia audio o video è SRTP sopra lo stesso. Entrambi condividono l'unica connessione e l'unica porta aperta.

Esiste un server WebRTC?

Non nel percorso dei media, che è tutto il punto. Una volta che le due applicazioni si sono trovate, l'audio, il video e i dati viaggiano direttamente tra loro. Niente di quello che ospiti vede il payload, e niente di quello che ospiti deve scalare con il numero di minuti che i tuoi utenti passano in chiamata.

Restano comunque due server nel quadro, e conviene essere precisi su cosa fa ciascuno, perché vengono spesso confusi.

Il server di signalling è tuo. Inoltra una manciata di messaggi di testo tra due peer prima che la chiamata inizi, poi tace. Non vede mai i media. In questa guida sono quindici righe di Delphi costruite su TsgcWebSocketServer.

Il server STUN e TURN esiste per via del NAT, non per via di WebRTC. Un server STUN risponde a una sola domanda, "da quale indirizzo pubblico è arrivato questo pacchetto", e nient'altro. Un server TURN inoltra i pacchetti per le coppie che non riescono a raggiungersi in altro modo, quindi è l'unico pezzo che trasporta media, e solo per le chiamate che ne hanno bisogno. I server STUN pubblici sono gratuiti e abbondanti, il TURN lo ospiti tu, e sgcWebSockets Enterprise include sia un componente server STUN sia un componente server TURN se preferisci non far girare un demone separato.

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

Edizioni, unit e piattaforme

La peer connection e il motore media sono due passaggi di licenza diversi. Conviene chiarirlo prima di scrivere codice, perché altrimenti il compilatore semplicemente non vedrà metà dell'API.

Cosa vuoi fareCosa richiedeDa dove arriva
Client STUN, per scoprire un indirizzo pubblico TsgcSTUNClient sgcWebSockets Standard e superiori
Far girare il tuo server STUN TsgcSTUNServer sgcWebSockets Professional e superiori
Inoltrare un canale di signalling WebSocket TsgcWebSocketServer sgcWebSockets Professional e superiori per la metà server. La metà client, TsgcWebSocketClient, arriva con Standard.
ICE, client e server TURN, e il componente peer connection stesso TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
Offer e answer, data channel, tracce audio e video CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack Il pacchetto sgcWebRTC, sopra Enterprise. Incluso anche in All-Access.

Perché la divisione, nelle parole del compilatore

Il blocco Enterprise di sgcVer.inc definisce SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION e SGC_TURN. È questo che mette TsgcRTCPeerConnection nella palette e gli dà un trasporto ICE e TURN.

Tutto ciò di cui parla davvero questa pagina sta un livello più dentro. SGC_PACK_WEBRTC è quello che definisce SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP e SGC_SRTP, e prima verifica che ICE, DTLS e la peer connection ci siano già. I metodi di signalling manuale, l'API dei data channel e l'API media stanno rispettivamente dentro {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL} e {$IFDEF SGC_RTP}, quindi con la sola Enterprise non compilano.

Se CreateOffer non si risolve, è successo questo. La demo del data channel inclusa nel pacchetto lo dice a voce alta invece di fallire in silenzio.

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}

Le unit che servono a entrambe le applicazioni

sgcP2P è la unit barrel che pubblica TsgcRTCPeerConnection e riesporta i tipi degli handler. Basta per dichiarare il componente, ma le costanti enumerate arrivano dalle unit che dichiarano i loro tipi, quindi includi anche quelle quando nomini rtctkAudio o cctAudioOpus.

Le due applicazioni di questa guida sono lo stesso programma con un pulsante diverso premuto. Tutto quello che segue vale per entrambe.

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

Il canale di signalling

Tre tipi di messaggio, un relay stupido. È la parte a cui ogni tutorial su WebRTC accenna di sfuggita, ed è la parte che devi davvero scrivere.

Un relay, non un broker

Il server di signalling non ha bisogno di capire un solo byte di quello che inoltra. Prende il testo che un peer ha inviato e lo passa all'altro. Broadcast ha già un parametro Exclude che accetta il Guid di una connessione, quindi "invia a tutti tranne al mittente" è una riga sola.

Tienilo così stupido. Nel momento in cui il relay inizia a fare il parsing dell'SDP diventa un componente che devi aggiornare ogni volta che cambia un codec, e smette di poter inoltrare una chiamata verso un browser.

In produzione indicizzeresti il relay su un identificatore di stanza, così due chiamate non possono collidere, e lo metteresti dietro TLS. TsgcWebSocketServer espone le stesse TLSOptions, Authentication e WatchDog del resto della libreria.

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;

Il lato peer dello stesso canale

Ogni applicazione apre un TsgcWebSocketClient verso quel relay e parla un vocabolario di tre parole: offer, answer e candidate. TsgcJSON della stessa libreria li serializza, quindi non serve nessuna dipendenza in più.

Guarda dove vanno i rami. Un offer in arrivo viene impostato come remote description e subito risposto. Un answer in arrivo viene solo impostato. Un candidate in arrivo viene passato ad AddIceCandidate, e può arrivare prima o dopo la description, che è tutto il senso del 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;

Costruisci la peer connection

Identica in entrambe le applicazioni. L'unica asimmetria di tutto lo scambio è quale delle due preme il pulsante Call.

Configurazione, e gli eventi che contano

RTCOptions.ICEServers è la lista iceServers del W3C. AddURL accetta un URL stun: o turn: e ricava dallo schema il tipo, l'host, la porta e il flag TLS, con username e credential facoltativi per TURN.

RTCOptions.DTLS vale False per impostazione predefinita. Lascialo così e non c'è cifratura né materiale di chiave SRTP, quindi i media non funzioneranno. CreateDataChannel lo attiva al posto tuo, perché un data channel è SCTP su DTLS e non esiste una configurazione valida con DTLS spento. AddTrack no, quindi impostalo tu quando aggiungi media.

Non ti serve un file di certificato. Lascia RTCOptions.DTLSOptions.CertFile vuoto e viene generato in memoria un certificato autofirmato, una volta per componente, che è esattamente il modello WebRTC: l'identità è ancorata alla riga a=fingerprint dell'SDP, non a una catena. Una remote description che non porta un fingerprint viene rifiutata invece di essere lasciata negoziare contro qualsiasi cosa.

Ogni evento qui sotto scatta su un thread di lavoro, il thread ICE, di rete o del timer, mai sul thread principale. Fai il marshalling con TThread.Queue prima di toccare un controllo.

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;

Pubblicare la local description

OnLocalDescription ti passa il tipo, la stringa 'offer' o 'answer', e l'SDP stesso. Entrambi i peer usano lo stesso handler, che fa una cosa sola: metterlo sul canale di signalling.

L'SDP arriva già completo. CreateOffer raccoglie prima i candidate ICE e attende fino a RTCOptions.GatheringTimeout millisecondi, 3000 per impostazione predefinita, chiudendo in anticipo dopo GatheringIdleTimeout millisecondi senza nuovi candidate, 500 per impostazione predefinita. È il percorso non-trickle.

Imposta TrickleICE a True e la description parte subito, con i candidate che la seguono. Raramente devi farlo. RTCOptions.TrickleICEAuto è True per impostazione predefinita, quindi quando la remote description dichiara a=ice-options:trickle, come fa ogni browser, il componente passa da solo a quella modalità e smette di bruciare il timeout di gathering.

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;

Candidate ICE, STUN e TURN

È qui che le connessioni peer to peer falliscono, ed è qui che i guasti sono più difficili da leggere. Tre tipi di candidate, tre ragioni per cui esistono.

host

Un indirizzo che la macchina vede su se stessa, uno per interfaccia di rete. Gratuito, istantaneo e sufficiente quando entrambe le applicazioni sono sulla stessa LAN o sulla stessa VPN. Se le tue due applicazioni Delphi girano sempre e solo dentro un ufficio, i candidate host sono tutto quello che ti serve e puoi saltare STUN del tutto.

srflx, server reflexive

L'indirizzo pubblico da cui un server STUN ha visto arrivare il pacchetto del peer. È questo che mette in comunicazione diretta due peer dietro normali router domestici, e copre la grande maggioranza delle connessioni reali. Costa un solo round trip verso un server STUN che poi non trasporta traffico.

relay

Un indirizzo su un server TURN che inoltra verso il peer. Serve per il NAT simmetrico, i firewall aziendali restrittivi e alcuni operatori mobili. Ogni byte della chiamata attraversa il tuo server TURN, quindi è il percorso costoso e quello su cui ripieghi soltanto.

Inviarli con il trickle

OnIceCandidate scatta una volta per candidate, appena viene scoperto, con la riga del candidate, il suo sdpMid e il suo sdpMLineIndex. Quei tre campi sono esattamente quello che si aspetta l'API del browser, quindi lo stesso JSON funziona sia che dall'altra parte ci sia Delphi sia che ci sia Chrome.

Invia ciascuno subito. Non aspettare, non raggrupparli. Un candidate che arriva prima della remote description viene trattenuto e applicato quando la description arriva, quindi l'ordine non è un tuo problema.

Quando la coppia viene finalmente nominata, SelectedLocalCandidate e SelectedRemoteCandidate ti dicono quali due indirizzi hanno vinto. Quella singola riga di log risponde a "perché questa chiamata passa dal mio server TURN" più in fretta di qualsiasi altra cosa.

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;

Aggiungere TURN, e l'interruttore da non dimenticare

Una voce turn: in ICEServers porta con sé host, porta, username e credential propri, ed è quello che usa l'allocazione. Aggiungerla è un altro AddURL.

La trappola è nell'altra direzione. RTCOptions.ICE.TURN vale True per impostazione predefinita, e quando la lista dei server non contiene nessuna voce TURN il gathering ripiega sul singolo server in RTCOptions.ICE, il cui host vale 127.0.0.1 e la cui porta vale 3478. Così un peer configurato con il solo URL STUN prova comunque un'allocazione TURN verso localhost, fallisce e lo segnala. È rumore, non un guasto, ma in un log sembra allarmante e ti manda a cercare nel posto sbagliato. Imposta RTCOptions.ICE.TURN := False finché non hai davvero un server TURN.

RTCOptions.ICE.STUN si comporta allo stesso modo e vale anch'esso True per impostazione predefinita.

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;

Riferimento del client ICE Fai girare il tuo server TURN

Il data channel

Testo e binario tra le due applicazioni, con l'affidabilità che scegli canale per canale. Di solito è la prima cosa che fai funzionare, e dimostra tutto il trasporto.

Aprirne uno, e ricevere quello dell'altro lato

Il peer che chiama CreateDataChannel riceve subito indietro l'oggetto. Il peer che non l'ha chiamato riceve lo stesso canale tramite OnDataChannel. Collega gli handler in entrambi i punti, perché ciascun lato può aprire un canale in qualsiasi momento della sessione.

Il canale non è utilizzabile nell'istante in cui lo crei. Il suo Id resta non assegnato finché l'associazione SCTP non è attiva e il ruolo DTLS non è risolto, e Send restituisce False finché non è aperto. Aspetta OnOpen.

L'affidabilità si decide alla creazione. I valori predefiniti sono ordinato e pienamente affidabile, un canale simile a TCP. Passa aOrdered = False per una consegna non ordinata, oppure un aMaxRetransmits o un aMaxPacketLifeTime per l'affidabilità parziale, che è quello che vuoi per gli aggiornamenti di posizione o per qualsiasi cosa in cui un pacchetto in ritardo è peggio di uno perso.

Non liberare un canale, appartiene alla peer connection. Close avvia la chiusura e OnClose scatta quando l'altro capo è d'accordo.

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;

Inviare, senza inondare

Send accetta una stringa e SendBytes accetta un TBytes. Entrambi restituiscono False invece di sollevare un'eccezione quando il canale non è aperto, quindi un invio durante la chiusura è un False restituito, non un'eccezione su un thread di lavoro.

MaxMessageSize è il messaggio più grande che il peer ha dichiarato di accettare, letto dall'attributo a=max-message-size della sua description. Un messaggio oltre quel limite viene rifiutato in locale invece di finire sul filo, dove interromperebbe l'intera associazione portandosi dietro ogni altro canale. Zero significa che nessuna description ha mai indicato un limite e il controllo è disattivato.

BufferedAmount indica quanti byte sono in coda in SCTP per questo stream e non ancora confermati. Tienilo d'occhio quando trasmetti un file: invia finché non supera una soglia, poi aspetta che si svuoti invece di accodare gigabyte in memoria.

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;

Tracce audio e video

Una traccia non è un data channel con dentro delle immagini. Trasporto diverso, modi di fallire diversi, codice diverso. È la distinzione che quasi tutti sbagliano per prima.

 Data channelTraccia media
Trasportato da SCTP su DTLS (RFC 8831) SRTP sullo stesso trasporto DTLS (RFC 3711)
Consegna A tua scelta, da pienamente affidabile e ordinata a fire and forget Sempre con perdite per progetto. In ritardo è peggio di perso, quindi niente viene ritrasmesso all'infinito
Unità di lavoro Un messaggio. Decidi tu quando inviarne uno Un orologio. L'audio viene alimentato in frame da 20 ms, il video a un frame rate
Si apre con CreateDataChannel, in qualsiasi momento della sessione AddTrack, che richiede un nuovo offer per pubblicarla
Si riceve tramite OnDataChannel, poi l'OnMessage del canale stesso OnTrack, poi l'OnAudio o l'OnVideoFrame della traccia
Richiede DTLS attivo Sì, e CreateDataChannel lo imposta al posto tuo Sì, e AddTrack non lo fa. Imposta RTCOptions.DTLS tu stesso
Usalo per Chat, trasferimento file, controllo remoto, stato di gioco, telemetria Microfono, videocamera, condivisione schermo, qualsiasi cosa con una linea temporale

Inviare il microfono

AddTrack accetta un tipo e un codec e restituisce un TsgcRTCTrack. I codec audio sono cctAudioOpus, cctAudioPCMU e cctAudioPCMA, i codec video sono cctVideoVP8, cctVideoVP9, cctVideoH264 e cctVideoJPEG.

La cattura è un oggetto separato, perché potresti non volere affatto il microfono della piattaforma. sgcCreateAudioCapture costruisce l'implementazione giusta per la piattaforma per cui il codice è stato compilato, waveIn su Windows, ALSA su Linux, AudioRecord su Android, una Audio Unit VoiceProcessingIO su iOS e macOS, così niente nel tuo codice nomina una classe di piattaforma. Restituisce nil su un target senza implementazione, quindi verifica il risultato.

SendPCM vuole PCM interleaved con segno a 16 bit alla frequenza e al numero di canali dell'encoder, che per Opus sono 48000 Hz e per G.711 8000 Hz. La sorgente di cattura pubblica quello che consegna davvero tramite AudioSampleRate, AudioChannels e AudioFrameDurationMs, così puoi verificare invece di dare per scontato.

Aggiungere una traccia quando la sessione è già attiva imposta il flag di negoziazione necessaria e fa scattare OnNegotiationNeeded. Chiama di nuovo CreateOffer per pubblicarla, e il nuovo offer viene costruito senza toccare il trasporto.

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;

Riprodurre quello che ha inviato l'altro lato

OnTrack scatta una volta per ogni media line remota, quando la remote description ne porta una. La traccia che ti passa appartiene alla peer connection, quindi collega i suoi eventi e non liberarla mai.

L'audio arriva come PCM decodificato tramite OnAudio, con la frequenza e il numero di canali prodotti dal decoder. Passalo direttamente al TsgcMediaRenderer costruito da sgcCreateAudioRenderer, che converte il formato quando il dispositivo non è stato aperto con gli stessi parametri.

Il video arriva come TsgcVideoFrame decodificato tramite OnVideoFrame: pixel grezzi in Data, con Width, Height, Format e Stride. I formati sono vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24 e vffBGRA32, e i due BGR seguono l'ordine dei byte della GDI di Windows, quindi copiare un frame vffBGR24 dentro una bitmap è una copia di memoria e non una conversione.

Se un frame arriva rovinato dopo una perdita di pacchetti, RequestKeyFrame chiede al mittente di inviarne uno nuovo.

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 videocamera, su Windows

La cattura audio è astratta dietro la factory perché ogni piattaforma supportata ha un'implementazione. La cattura video no, quindi nomini tu la classe di piattaforma. Su Windows è TsgcVideoCapture_Win da sgcP2P_MediaCapture_Win, che pilota Video for Windows e consegna i frame tramite lo stesso evento OnVideoCapture dichiarato dalla classe base.

La unit vicina sgcP2P_ScreenCapture_Win ti dà TsgcScreenCapture_Win e TsgcWindowCapture_Win, entrambi discendenti di TsgcMediaCaptureSource, quindi la condivisione dello schermo sono le stesse tre righe con un costruttore diverso.

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;

I guasti che conviene conoscere in anticipo

Le connessioni peer to peer falliscono in modi che non producono alcun errore, ed è questo che le rende difficili. Questi sono i casi più frequenti.

I media sono muti e nessuno segnala errori

RTCOptions.DTLS è False. È il valore predefinito, CreateDataChannel lo attiva ma AddTrack no, quindi una sessione che trasporta solo media non esegue mai un handshake DTLS e perciò non ottiene mai le chiavi SRTP. Impostalo esplicitamente.

Un errore TURN che non hai chiesto

RTCOptions.ICE.TURN vale True per impostazione predefinita e ripiega su 127.0.0.1:3478 quando la lista dei server non ha nessuna voce TURN. Impostalo a False finché non hai davvero un server TURN, altrimenti il log si riempie di fallimenti di allocazione che non c'entrano niente con il tuo problema.

La remote description viene rifiutata

Una description senza a=fingerprint viene respinta subito e segnalata tramite OnError. Nel modello di fiducia di WebRTC quella riga è l'unica cosa che autentica il peer, quindi accettare una description che ne è priva lascerebbe completare l'handshake contro un certificato qualsiasi.

Un access violation dentro un gestore di eventi

Ogni evento di peer connection, data channel e traccia scatta su un thread di lavoro, il thread ICE, di rete, SCTP o di tick, mai sul thread principale. Toccare un controllo VCL o FMX direttamente da lì ha comportamento indefinito. Racchiudilo in TThread.Queue.

Entrambi i lati rifanno un offer nello stesso momento

Si chiama glare, e si risolve con la regola della perfect negotiation del W3C. Il peer scortese, Polite = False, tiene il proprio offer e segnala quello in arrivo tramite OnError. Il peer cortese annulla il proprio e risponde. I due peer di una sessione non devono essere entrambi cortesi.

Un solo messaggio grande uccide ogni canale

Un messaggio oltre l'a=max-message-size del peer interromperebbe l'intera associazione SCTP, portandosi dietro ogni altro data channel. Send e SendBytes controllano MaxMessageSize e rifiutano in locale. Spezza tu i payload grandi.

La connessione impiega tre secondi per partire

È RTCOptions.GatheringTimeout, l'attesa senza trickle. GatheringIdleTimeout la chiude in anticipo quando i candidate smettono di arrivare, e TrickleICEAuto passa alla modalità trickle quando la remote description la annuncia. Abbassa entrambi i timeout su una LAN.

L'audio è veloce, lento o distorto

Una discrepanza di sample rate o di numero di canali tra il dispositivo di cattura e l'encoder. Opus viene negoziato a 48000 Hz e G.711 a 8000 Hz. Rileggi AudioSampleRate e AudioChannels dalla sorgente di cattura invece di dare per scontato che il dispositivo abbia rispettato quello che gli hai chiesto.

Delphi WebRTC, domande frequenti

Quello che gli sviluppatori chiedono prima di collegare due applicazioni peer to peer.

Non nella RTL, e non attraverso la VCL. TsgcRTCPeerConnection è un'implementazione nativa in Object Pascal della superficie peer connection del W3C: CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel e AddTrack, con ICE, DTLS, SCTP e SRTP sotto. Non c'è nessun Chromium incorporato, nessun TWebBrowser e nessun bridge JavaScript nel processo.
Ti serve qualcosa che porti poche centinaia di byte di testo tra i due peer prima che la chiamata inizi, perché nessuno dei due sa ancora come raggiungere l'altro. Quello è il signalling, e può essere un relay WebSocket, una coda di messaggi che hai già, un endpoint REST, perfino un copia e incolla per una demo. Una volta che l'offer, l'answer e i candidate ICE sono passati, i media e i dati vanno direttamente tra le due applicazioni e il canale di signalling può chiudersi. Nessun server sta nel percorso dei media, a meno che un TURN relay non sia risultato l'unica strada percorribile.
Un WebSocket, quasi sempre, perché è bidirezionale e il server può spingere un offer in arrivo senza che il peer faccia polling. Questa pagina ne costruisce uno con TsgcWebSocketServer e TsgcWebSocketClient in una quindicina di righe, inoltrando ogni messaggio all'altro peer con Broadcast e il Connection.Guid del mittente come esclusione. sgcWebSockets include anche un componente di protocollo di signalling già pronto, TsgcWSPServer_RTCPeerConnection, che guida lo scambio al posto tuo tramite RTCOptions.WebSocket e GatherCandidates quando preferisci non scrivere affatto il relay.
Se entrambe le applicazioni sono sulla stessa LAN o sulla stessa VPN, nessuno dei due. I candidate host descrivono già indirizzi raggiungibili. Se sono su reti diverse dietro router normali, ti serve STUN, che dice a ogni peer l'indirizzo pubblico da cui sembrano arrivare i suoi pacchetti, e questo copre la maggior parte delle connessioni reali. Ti serve TURN quando nessun accoppiamento diretto funziona: NAT simmetrico, firewall aziendali restrittivi e alcuni operatori mobili. TURN inoltra ogni byte della chiamata, quindi è il ripiego costoso e non l'impostazione predefinita. Aggiungili entrambi a RTCOptions.ICEServers e ICE sceglie la coppia più economica che si connette davvero.
Un data channel è SCTP su DTLS e sposta messaggi, con l'affidabilità che scegli tu: ordinato e pienamente affidabile come TCP, oppure non ordinato con un limite di ritrasmissioni o di durata per tutto ciò in cui un pacchetto in ritardo è inutile. Una traccia media è SRTP sullo stesso trasporto DTLS e sposta una linea temporale: audio in frame da 20 ms, video a un frame rate, sempre con perdite per progetto. Usa un data channel per chat, trasferimento file, controllo remoto e stato di gioco. Usa una traccia per un microfono, una videocamera o uno schermo. Condividono una sola connessione e una sola porta aperta.
Due passaggi. Enterprise definisce SGC_ICE, SGC_DTLS, SGC_TURN e SGC_RTCPEERCONNECTION, ed è questo che mette TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient e TsgcTURNServer nella palette. L'API di offer e answer, i data channel e le tracce media sono protetti da SGC_SDP, SGC_DATACHANNEL e SGC_RTP, che definisce solo SGC_PACK_WEBRTC, cioè l'add-on sgcWebRTC, incluso anche in All-Access. Sotto, un client STUN è in Standard, mentre un server STUN e il componente server WebSocket sono in Professional.
Sì, e niente di questa pagina cambia. L'SDP è standard, le righe dei candidate portano gli stessi sdpMid e sdpMLineIndex che si aspetta l'API del browser, e la macchina a stati di offer e answer segue l'RFC 8829, quindi il JSON che il tuo relay inoltra funziona senza modifiche in entrambe le direzioni. I browser inviano i candidate a trickle dal primo millisecondo, ed è esattamente quello che rileva RTCOptions.TrickleICEAuto: vede a=ice-options:trickle nella remote description e risponde subito invece di aspettare la fine del timeout di gathering.
Thread di lavoro, sempre. OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack e OnError arrivano sul thread ICE, di rete o del timer. Gli eventi dei data channel arrivano sul thread che guida l'associazione SCTP. Gli eventi audio e video delle tracce arrivano sul thread di rete o di tick. Nessuno di questi viene marshalato al thread principale al posto tuo, quindi racchiudi in TThread.Queue tutto ciò che tocca un controllo, come fanno le demo incluse.
No. Lascia RTCOptions.DTLSOptions.CertFile vuoto e vengono generati in memoria un certificato autofirmato e una chiave, una volta per componente, riutilizzati per ogni peer. Il suo fingerprint viene pubblicato come attributo a=fingerprint della local description, ed è questo che ti autentica verso l'altro lato. È il modello di fiducia di WebRTC: la catena non viene mai verificata, l'ancora è il fingerprint trasportato sul canale di signalling. Puoi comunque puntare CertFile e KeyFile ai tuoi file PEM quando vuoi un'identità stabile.
Un handshake DTLS gira sulla coppia di candidate nominata e deriva le chiavi SRTP, quindi ogni pacchetto RTP, RTCP e SCTP sulla connessione è cifrato. Per i data channel non c'è modo di disattivarla: CreateDataChannel imposta RTCOptions.DTLS a True senza condizioni, perché un RTCDataChannel è per definizione SCTP su DTLS e non esiste una configurazione valida senza.
Le unit P2P e WebRTC sono incluse in ogni package runtime da Delphi 7 fino a RAD Studio 13, e nei package C++ Builder corrispondenti. La cattura e la riproduzione audio hanno implementazioni di piattaforma per Windows, Linux, Android, iOS e macOS, quindi le funzioni factory restituiscono un oggetto funzionante su tutte e cinque. La cattura video è l'eccezione: si nomina per piattaforma invece di essere costruita da una factory, e su Windows è TsgcVideoCapture_Win, insieme a TsgcScreenCapture_Win e TsgcWindowCapture_Win per la condivisione dello schermo.
Sì, è la rinegoziazione. AddTrack su una sessione già stabilita la marca come bisognosa di negoziazione e fa scattare OnNegotiationNeeded. Chiama di nuovo CreateOffer e un nuovo offer viene costruito in modo sincrono, senza nuovo gathering ICE e senza un nuovo handshake DTLS o SCTP, quindi il trasporto non viene mai interrotto. Le media line esistenti mantengono la loro posizione e il loro mid, la nuova viene aggiunta in fondo. RemoveTrack funziona allo stesso modo al contrario: la media line resta e viene ripubblicata come recvonly o inactive.

Riferimenti dei componenti e documenti tecnici

Ogni pezzo usato in questa pagina ha la sua pagina di riferimento, e la maggior parte ha anche un PDF tecnico a sé con l'elenco completo di proprietà, metodi ed eventi.

TsgcRTCPeerConnection

Il componente di cui parla tutta questa pagina. Offer e answer, ICE, DTLS, data channel SCTP e media RTP in un'unica classe.

Pagina del componente →

sgcWebRTC

Il pacchetto del motore media: SDP, SCTP, RTP, SRTP, audio Opus e G.711, video VP8 e H.264, stima della banda.

Pagina del prodotto →

Dettaglio delle funzionalità

Codec per codec e piattaforma per piattaforma, cosa fa il motore media e da dove arriva ogni encoder.

Vedi le funzionalità →

Client ICE

Raccolta dei candidate, la check list, la nomination e la collezione dei server ICE, il livello sotto la peer connection.

Pagina del componente →

Client e server STUN

Binding request, opzioni di ritrasmissione e il componente server quando preferisci ospitare il tuo.

Client STUN →

Client e server TURN

Allocazioni, permessi, channel bind e un componente server TURN per le chiamate che hanno bisogno di un relay.

Server TURN →

Tutti i componenti P2P

UDP, STUN, TURN, ICE e RTCPeerConnection, tutta la famiglia peer to peer in un solo indice.

Sfoglia P2P →

Panoramica su Delphi WebRTC

La vista a livello di libreria di WebRTC in sgcWebSockets, con i componenti di protocollo di signalling e l'elenco delle demo.

Leggi di più →

Di quale edizione ho bisogno?

La matrice completa delle edizioni, funzionalità per funzionalità, quando WebRTC non è l'unica cosa che stai valutando.

Confronta le edizioni →

Altri casi d'uso

Questa pagina fa parte dei casi d'uso Delphi, ognuno dei quali affronta un singolo compito dall'inizio alla fine. Gli altri finora sono chiamare un LLM da Delphi e l'accesso di un utente con OAuth2 e PKCE.

Tutti i casi d'uso →
Documento tecnico RTCPeerConnection (PDF) Proprietà, metodi, eventi ed esempi di codice per il solo componente peer connection.
Documento tecnico del client ICE (PDF) Raccolta dei candidate, la check list e la collezione dei server ICE nel dettaglio.
Documento tecnico del client TURN (PDF) Allocazioni, permessi e channel bind, il client che ICE pilota per un candidate relayed.
Documento tecnico del client STUN (PDF) Binding request e ritrasmissione, il modo più economico per conoscere il tuo indirizzo pubblico.
Progetti demo Demos\35.P2P\05.RTCPeerConnection e Demos\35.P2P\06.DataChannel sono inclusi nel pacchetto.

Le specifiche dietro ogni passo

Fonti primarie, quando preferisci leggere cosa implementa il componente invece di fidarti della nostra parola.

RFC 8829, JSEP

La macchina a stati di offer e answer dietro CreateOffer, CreateAnswer e SetRemoteDescription, inclusi rinegoziazione e rollback.

Leggi l'RFC →

RFC 8445, ICE

Raccolta dei candidate, priorità, check list e nomination. Il motivo per cui una connessione a volte impiega un secondo e a volte fallisce.

Leggi l'RFC →

RFC 8489 e RFC 8656

STUN e TURN. Cosa chiede una binding request, e quanto ti costa un'allocazione.

Leggi l'RFC →

RFC 8831 e RFC 8832

I data channel WebRTC su SCTP, e l'handshake di apertura DCEP che assegna gli id degli stream in base al ruolo DTLS.

Leggi l'RFC →

RFC 8122, fingerprint SDP

Perché a=fingerprint è l'identità di una peer connection, e perché una description che ne è priva viene rifiutata.

Leggi l'RFC →

WebRTC 1.0 (W3C)

L'API che questo componente rispecchia, inclusa la perfect negotiation, da cui arriva Polite.

Leggi la specifica →

Metti due delle tue applicazioni in chiamata

Scarica la versione di prova, esegui le demo RTCPeerConnection e DataChannel una contro l'altra, poi costruisci la stessa cosa nel tuo progetto.