Delphi WebRTC: audio, vídeo y datos entre dos aplicaciones

Dos aplicaciones Delphi, en dos redes distintas, intercambiando un canal de chat, un flujo de micrófono y un flujo de cámara directamente entre ellas. Sin servidor de medios en medio, sin navegador embebido en el proceso, sin puente de JavaScript. Esta página recorre el trabajo entero, desde el primer mensaje de señalización hasta la primera trama de audio decodificada, usando APIs que existen en el código que se distribuye.

Oferta y respuesta SDP
ICE, STUN y TURN
Canales de datos SCTP
Pistas de medios Opus y VP8
Cifrado con DTLS-SRTP
Sin navegador ni WebView

Qué tiene que pasar en realidad

WebRTC son cuatro problemas distintos con un solo nombre. Solo uno de ellos va de medios, y es el fácil. Estos son los cuatro, en el orden en que hay que resolverlos.

1. Describe la sesión

Un lado construye una oferta, un documento de texto (SDP) que dice qué medios quiere enviar, qué códecs habla, la huella del certificado que va a presentar y las credenciales ICE que va a usar. El otro lado responde con el subconjunto que acepta. CreateOffer y CreateAnswer producen esos documentos, SetRemoteDescription los consume.

2. Llévalo al otro lado

WebRTC no dice a propósito cómo llega la oferta al otro par. Ese canal se llama señalización y es cosa tuya. Son unos cientos de bytes de texto en cada sentido, así que una conexión WebSocket a un pequeño relé es suficiente, y sgcWebSockets ya te da las dos mitades de uno.

3. Encuentra un camino entre los NAT

Ninguno de los dos pares conoce su dirección pública, y los dos suelen estar detrás de un router. ICE reúne todas las direcciones en las que un par podría ser alcanzable, las envía por el canal de señalización según van apareciendo, y prueba cada emparejamiento hasta que uno funciona. STUN encuentra la dirección pública, TURN aporta un relé cuando nada directo funciona.

4. Mueve los bytes

Una vez nominado un par de candidatos, un handshake DTLS se ejecuta sobre él y todo lo que viene después va cifrado. Un canal de datos es SCTP sobre ese transporte DTLS, una pista de audio o vídeo es SRTP sobre él. Los dos comparten la misma conexión y el mismo puerto abierto.

¿Hay un servidor de WebRTC?

En la ruta de los medios no, que es justo el objetivo. Una vez que las dos aplicaciones se han encontrado, el audio, el vídeo y los datos viajan directamente entre ellas. Nada de lo que alojes ve la carga útil, y nada de lo que alojes tiene que escalar con los minutos que tus usuarios pasan en una llamada.

Aun así hay dos servidores en el cuadro, y ayuda ser preciso sobre lo que hace cada uno, porque a menudo se confunden.

El servidor de señalización es tuyo. Retransmite un puñado de mensajes de texto entre dos pares antes de que empiece la llamada, y después se calla. Nunca ve los medios. En este recorrido son quince líneas de Delphi construidas sobre TsgcWebSocketServer.

El servidor STUN y TURN existe por culpa del NAT, no por WebRTC. Un servidor STUN responde a una sola pregunta, "de qué dirección pública llegó este paquete", y ya está. Un servidor TURN retransmite paquetes para los pares que no pueden alcanzarse de ninguna otra forma, así que es la única pieza que transporta medios, y solo para las llamadas que lo necesitan. Los servidores STUN públicos son gratuitos y abundantes, TURN lo alojas tú, y sgcWebSockets Enterprise incluye un componente de servidor STUN y un servidor TURN por si prefieres no ejecutar un demonio aparte.

quien-habla-con-quien.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

Ediciones, unidades y plataformas

La conexión entre pares y el motor de medios son dos pasos de licencia distintos. Conviene tenerlo claro antes de escribir código, porque si no el compilador sencillamente no verá la mitad de la API.

Qué quieres hacerQué necesitaDe dónde viene
Cliente STUN, para descubrir una dirección pública TsgcSTUNClient sgcWebSockets Standard y superiores
Ejecutar tu propio servidor STUN TsgcSTUNServer sgcWebSockets Professional y superiores
Retransmitir un canal de señalización WebSocket TsgcWebSocketServer sgcWebSockets Professional y superiores para la mitad de servidor. La mitad de cliente, TsgcWebSocketClient, llega con Standard.
ICE, cliente y servidor TURN, y el propio componente de conexión entre pares TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
Oferta y respuesta, canales de datos, pistas de audio y vídeo CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack El pack sgcWebRTC, encima de Enterprise. También incluido en All-Access.

Por qué la separación, en palabras del propio compilador

El bloque Enterprise de sgcVer.inc define SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION y SGC_TURN. Eso es lo que pone TsgcRTCPeerConnection en la paleta y le da un transporte ICE y TURN.

Todo aquello de lo que trata realmente esta página está un nivel más adentro. SGC_PACK_WEBRTC es lo que define SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP y SGC_SRTP, y comprueba primero que ICE, DTLS y la conexión entre pares ya estén ahí. Los métodos de señalización manual, la API de canal de datos y la API de medios viven dentro de {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL} y {$IFDEF SGC_RTP} respectivamente, así que solo con Enterprise no compilan.

Si CreateOffer no resuelve, eso es lo que ha pasado. La demo de canal de datos que viene en el paquete lo dice en voz alta en lugar de fallar en silencio.

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}

Las unidades que necesitan las dos aplicaciones

sgcP2P es la unidad barrel que publica TsgcRTCPeerConnection y reexporta los tipos de manejador. Basta para declarar el componente, pero las constantes enumeradas vienen de las unidades que declaran sus tipos, así que inclúyelas también cuando nombres rtctkAudio o cctAudioOpus.

Las dos aplicaciones de este recorrido son el mismo programa con un botón distinto pulsado. Todo lo de abajo va en las dos.

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

El canal de señalización

Tres tipos de mensaje, un relé tonto. Esta es la parte a la que todo tutorial de WebRTC hace un gesto vago, y la parte que de verdad tienes que escribir.

Un relé, no un intermediario

El servidor de señalización no necesita entender ni un byte de lo que reenvía. Coge el texto que envió un par y se lo entrega al otro. Broadcast ya tiene un parámetro Exclude que acepta un Guid de conexión, así que "envíalo a todos menos al remitente" es una línea.

Mantenlo así de tonto. En cuanto el relé empieza a parsear SDP se convierte en un componente que tienes que actualizar cada vez que cambia un códec, y deja de poder retransmitir una llamada a un navegador.

En producción indexarías el relé por un identificador de sala para que dos llamadas no choquen, y lo pondrías detrás de TLS. TsgcWebSocketServer lleva la misma superficie de TLSOptions, Authentication y WatchDog que el resto de la biblioteca.

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;

El lado del par en ese mismo canal

Cada aplicación abre un TsgcWebSocketClient hacia ese relé y habla un vocabulario de tres palabras: offer, answer y candidate. TsgcJSON de la misma biblioteca los serializa, así que no hay ninguna dependencia extra.

Fíjate en dónde van las ramas. Una offer entrante se asigna como descripción remota y se responde de inmediato. Una answer entrante solo se asigna. Un candidate entrante se pasa a AddIceCandidate, y puede llegar antes o después de la descripción, que es justo el sentido 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;

Construye la conexión entre pares

Idéntica en las dos aplicaciones. La única asimetría de todo el intercambio es cuál de las dos pulsa Llamar.

Configuración, y los eventos que importan

RTCOptions.ICEServers es la lista iceServers del W3C. AddURL acepta una URL stun: o turn: y rellena el tipo, el host, el puerto y el flag de TLS a partir del esquema, con un nombre de usuario y una credencial opcionales para TURN.

RTCOptions.DTLS vale por defecto False. Déjalo así y no hay cifrado ni material de claves SRTP, así que los medios no funcionarán. CreateDataChannel lo activa por ti, porque un canal de datos es SCTP sobre DTLS y no hay ninguna configuración válida con DTLS apagado. AddTrack no lo hace, así que actívalo tú cuando añadas medios.

No necesitas ningún archivo de certificado. Deja RTCOptions.DTLSOptions.CertFile vacío y se genera en memoria un certificado autofirmado, una vez por componente, que es exactamente el modelo de WebRTC: la identidad se ancla en la línea a=fingerprint del SDP, no en una cadena. Una descripción remota que no lleva huella se rechaza en lugar de dejarla hacer el handshake contra cualquier cosa.

Todos los eventos de aquí abajo se disparan en un hilo de trabajo, el hilo de ICE, de red o de temporizador, nunca en el hilo principal. Sincroniza con TThread.Queue antes de tocar un control.

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;

Publicar la descripción local

OnLocalDescription te entrega el tipo, la cadena 'offer' o 'answer', y el propio SDP. Los dos pares usan el mismo manejador, y hace una sola cosa: ponerlo en el canal de señalización.

El SDP llega ya completo. CreateOffer reúne primero los candidatos ICE y espera hasta RTCOptions.GatheringTimeout milisegundos, 3000 por defecto, y termina antes tras GatheringIdleTimeout milisegundos sin ningún candidato nuevo, 500 por defecto. Ese es el camino sin trickle.

Pon TrickleICE a True y la descripción sale de inmediato, con los candidatos llegando detrás. Rara vez hace falta. RTCOptions.TrickleICEAuto vale True por defecto, así que cuando la descripción remota dice a=ice-options:trickle, cosa que hace cualquier navegador, el componente se cambia solo y deja de quemar el tiempo de espera de recopilación.

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;

Candidatos ICE, STUN y TURN

Aquí es donde fallan las conexiones entre pares, y donde los fallos son más difíciles de leer. Tres tipos de candidato, tres razones para que existan.

host

Una dirección que la máquina ve en sí misma, una por interfaz de red. Gratis, instantánea, y suficiente cuando las dos aplicaciones están en la misma LAN o en la misma VPN. Si tus dos aplicaciones Delphi solo se ejecutan dentro de una oficina, los candidatos host son todo lo que necesitas y puedes saltarte STUN por completo.

srflx, reflexivo de servidor

La dirección pública desde la que un servidor STUN vio llegar el paquete del par. Esto es lo que hace que dos pares detrás de routers domésticos normales hablen directamente, y cubre la gran mayoría de las conexiones reales. Cuesta un viaje de ida y vuelta a un servidor STUN que después no transporta nada.

relay

Una dirección en un servidor TURN que reenvía al par. Hace falta para NAT simétrico, cortafuegos corporativos restrictivos y algunos operadores móviles. Cada byte de la llamada cruza tu servidor TURN, así que es el camino caro y aquel al que solo recurres.

Enviarlos con trickle

OnIceCandidate se dispara una vez por candidato, según se descubre, con la línea del candidato, su sdpMid y su sdpMLineIndex. Esos tres campos son exactamente lo que espera la API del navegador, así que el mismo JSON funciona tanto si el otro extremo es Delphi como si es Chrome.

Envía cada uno de inmediato. No esperes, no los agrupes. Un candidato que llega antes que la descripción remota se retiene y se aplica cuando llega la descripción, así que el orden no es problema tuyo.

Cuando por fin se nomina el par, SelectedLocalCandidate y SelectedRemoteCandidate te dicen qué dos direcciones ganaron. Esa única línea de log responde a "por qué esta llamada está pasando por mi servidor TURN" más rápido que ninguna otra 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;

Añadir TURN, y el interruptor que no debes olvidar

Una entrada turn: en ICEServers lleva su propio host, puerto, nombre de usuario y credencial, y eso es lo que usa la asignación. Añadirla es un AddURL.

La trampa está en el otro sentido. RTCOptions.ICE.TURN vale por defecto True, y cuando la lista de servidores no tiene ninguna entrada TURN, la recopilación recae en el único servidor de RTCOptions.ICE, cuyo host vale por defecto 127.0.0.1 y cuyo puerto vale 3478. Así que un par configurado con nada más que una URL de STUN sigue intentando una asignación TURN contra localhost, falla, y lo reporta. Es ruido más que un fallo, pero en un log resulta alarmante y te manda a buscar en el sitio equivocado. Pon RTCOptions.ICE.TURN := False hasta que tengas de verdad un servidor TURN.

RTCOptions.ICE.STUN se comporta igual y también vale True por defecto.

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;

Referencia del cliente ICE Ejecuta tu propio servidor TURN

El canal de datos

Texto y binario entre las dos aplicaciones, con la fiabilidad que elijas por canal. Suele ser lo primero que consigues hacer funcionar, y demuestra el transporte entero.

Abrir uno, y recibir el del otro lado

El par que llama a CreateDataChannel recibe el objeto de vuelta al momento. El par que no lo hizo recibe el mismo canal a través de OnDataChannel. Engancha los manejadores en los dos sitios, porque cualquiera de los dos lados puede abrir un canal en cualquier momento de la sesión.

El canal no es utilizable en el instante en que lo creas. Su Id queda sin asignar hasta que la asociación SCTP está levantada y se ha resuelto el rol DTLS, y Send devuelve False mientras no está abierto. Espera a OnOpen.

La fiabilidad se decide al crearlo. Por defecto es ordenado y totalmente fiable, un canal parecido a TCP. Pasa aOrdered = False para entrega sin orden, o un aMaxRetransmits o un aMaxPacketLifeTime para fiabilidad parcial, que es lo que quieres para actualizaciones de posición o para cualquier cosa en la que un paquete tardío sea peor que uno perdido.

No liberes un canal, es propiedad de la conexión entre pares. Close inicia el cierre y OnClose se dispara cuando el otro extremo lo acepta.

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;

Enviar, y no inundar

Send acepta una cadena y SendBytes acepta un TBytes. Los dos devuelven False en lugar de lanzar una excepción cuando el canal no está abierto, así que un envío durante el cierre es un False devuelto, no una excepción en un hilo de trabajo.

MaxMessageSize es el mensaje más grande que el par dijo que acepta, leído del atributo a=max-message-size de su descripción. Un mensaje por encima de ese límite se rechaza en local en lugar de ponerse en el cable, donde abortaría la asociación entera y se llevaría por delante todos los demás canales. Cero significa que ninguna descripción nombró nunca un límite y la comprobación está apagada.

BufferedAmount es cuántos bytes hay encolados en SCTP para este flujo y todavía sin confirmar. Vigílalo cuando estés enviando un archivo por streaming: envía hasta que pase un umbral, y luego espera a que se vacíe en lugar de encolar gigabytes en 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;

Pistas de audio y vídeo

Una pista no es un canal de datos con imágenes dentro. Otro transporte, otros modos de fallo, otro código. Esta es la distinción que la mayoría de la gente confunde al principio.

 Canal de datosPista de medios
Transportado por SCTP sobre DTLS (RFC 8831) SRTP sobre el mismo transporte DTLS (RFC 3711)
Entrega A tu elección, desde totalmente fiable y ordenada hasta enviar y olvidar Siempre con pérdidas por diseño. Tarde es peor que perdido, así que nada se retransmite eternamente
Unidad de trabajo Un mensaje. Tú decides cuándo enviar uno Un reloj. El audio se alimenta en tramas de 20 ms, el vídeo a una cadencia de fotogramas
Se abre con CreateDataChannel, en cualquier momento de la sesión AddTrack, que necesita una nueva oferta para publicarla
Se recibe a través de OnDataChannel, y luego el OnMessage OnTrack, y luego el OnAudio o el OnVideoFrame
Necesita DTLS activado Sí, y CreateDataChannel lo activa por ti Sí, y AddTrack no. Pon RTCOptions.DTLS tú mismo
Úsalo para Chat, transferencia de archivos, control remoto, estado de juego, telemetría Micrófono, cámara, compartir pantalla, cualquier cosa con una línea temporal

Enviar el micrófono

AddTrack acepta un tipo y un códec y devuelve un TsgcRTCTrack. Los códecs de audio son cctAudioOpus, cctAudioPCMU y cctAudioPCMA, los códecs de vídeo son cctVideoVP8, cctVideoVP9, cctVideoH264 y cctVideoJPEG.

La captura es un objeto aparte, porque puede que no quieras el micrófono de la plataforma en absoluto. sgcCreateAudioCapture construye la implementación adecuada para la plataforma en la que se compiló el código, waveIn en Windows, ALSA en Linux, AudioRecord en Android, una Audio Unit VoiceProcessingIO en iOS y macOS, así que nada en tu código nombra una clase de plataforma. Devuelve nil en un objetivo sin implementación, así que comprueba el resultado.

SendPCM quiere PCM entrelazado con signo de 16 bits a la frecuencia y al número de canales del codificador, que para Opus es 48000 Hz y para G.711 es 8000 Hz. La fuente de captura publica lo que entrega realmente a través de AudioSampleRate, AudioChannels y AudioFrameDurationMs, así que puedes comprobarlo en lugar de suponerlo.

Añadir una pista cuando la sesión ya está levantada activa el flag de negociación pendiente y dispara OnNegotiationNeeded. Llama a CreateOffer otra vez para publicarla, y la nueva oferta se construye sin tocar el transporte.

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;

Reproducir lo que envió el otro lado

OnTrack se dispara una vez por cada línea de medios remota, cuando la descripción remota trae una. La pista que te entrega es propiedad de la conexión entre pares, así que engancha sus eventos y no la liberes nunca.

El audio llega como PCM decodificado a través de OnAudio, con la frecuencia y el número de canales que produjo el decodificador. Pásalo directo al TsgcMediaRenderer que construyó sgcCreateAudioRenderer, que convierte el formato cuando el dispositivo no se pudo abrir para que coincidiera.

El vídeo llega como un TsgcVideoFrame decodificado a través de OnVideoFrame: píxeles en crudo en Data, con Width, Height, Format y Stride. Los formatos son vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24 y vffBGRA32, y los dos BGR siguen el orden de bytes de GDI de Windows, así que volcar una trama vffBGR24 en un bitmap es una copia de memoria y no una conversión.

Si una trama llega rota tras una pérdida de paquetes, RequestKeyFrame pide al emisor una nueva.

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 cámara, en Windows

La captura de audio está abstraída detrás de la factoría porque todas las plataformas soportadas tienen implementación. La de vídeo no, así que nombras la clase de plataforma. En Windows esa es TsgcVideoCapture_Win de sgcP2P_MediaCapture_Win, que maneja Video for Windows y entrega tramas por el mismo evento OnVideoCapture que declara la clase base.

La unidad vecina sgcP2P_ScreenCapture_Win te da TsgcScreenCapture_Win y TsgcWindowCapture_Win, los dos descendientes de TsgcMediaCaptureSource, así que compartir pantalla son las mismas tres líneas con otro constructor.

uPeer.pas
uses
  sgcP2P_MediaCapture_Win;   // MSWINDOWS only

procedure TFormPeer.StartCamera;
begin
  FVideoCapture := TsgcVideoCapture_Win.Create(640,
    480, 30);
  FVideoCapture.DeviceIndex := 0;
  FVideoCapture.OnVideoCapture := OnVideoCaptured;
  FVideoCapture.Start;
end;

procedure TFormPeer.OnVideoCaptured(Sender: TObject;
  const aFrame: TsgcVideoFrame);
begin
  if Assigned(FVideoTrack) then
    FVideoTrack.SendVideoFrame(aFrame);
end;

Los fallos que conviene conocer de antemano

Las conexiones entre pares fallan de formas que no producen ningún error, que es lo que las hace difíciles. Estos son los que más aparecen.

Los medios van en silencio y no da ningún error

RTCOptions.DTLS vale False. Ese es el valor por defecto, CreateDataChannel lo activa pero AddTrack no, así que una sesión que solo lleva medios nunca ejecuta un handshake DTLS y por tanto nunca obtiene claves SRTP. Actívalo explícitamente.

Un error de TURN que no pediste

RTCOptions.ICE.TURN vale True por defecto y recae en 127.0.0.1:3478 cuando la lista de servidores no tiene ninguna entrada TURN. Ponlo a False hasta que tengas de verdad un servidor TURN, o el log se llenará de fallos de asignación que no tienen nada que ver con tu problema.

Se rechaza la descripción remota

Una descripción sin a=fingerprint se rechaza de plano y se reporta a través de OnError. En el modelo de confianza de WebRTC esa línea es lo único que autentica al par, así que aceptar una descripción sin ella dejaría que el handshake se completara contra cualquier certificado.

Una violación de acceso en un manejador de eventos

Todos los eventos de conexión entre pares, de canal de datos y de pista se disparan en un hilo de trabajo, el hilo de ICE, de red, de SCTP o de tick, nunca en el hilo principal. Tocar un control VCL o FMX directamente desde uno de ellos es comportamiento indefinido. Envuélvelo en TThread.Queue.

Los dos lados vuelven a ofertar a la vez

Eso se llama glare, y se resuelve con la regla de negociación perfecta del W3C. El par descortés, Polite = False, se queda con su propia oferta y reporta la entrante a través de OnError. El par cortés retira la suya y responde. Los dos pares de una sesión no pueden ser los dos corteses.

Un mensaje grande se lleva por delante todos los canales

Un mensaje por encima del a=max-message-size del par abortaría la asociación SCTP entera, y se llevaría con ella todos los demás canales de datos. Send y SendBytes comprueban MaxMessageSize y lo rechazan en local en su lugar. Parte tú las cargas grandes.

La conexión tarda tres segundos en arrancar

Eso es RTCOptions.GatheringTimeout, la espera sin trickle. GatheringIdleTimeout la termina antes en cuanto dejan de llegar candidatos, y TrickleICEAuto cambia a modo trickle cuando la descripción remota lo anuncia. Baja los dos tiempos de espera en una LAN.

El audio va rápido, lento o distorsionado

Un desajuste de frecuencia de muestreo o de número de canales entre el dispositivo de captura y el codificador. Opus se negocia a 48000 Hz y G.711 a 8000 Hz. Lee AudioSampleRate y AudioChannels de vuelta desde la fuente de captura en lugar de suponer que el dispositivo respetó lo que pediste.

WebRTC en Delphi, preguntas frecuentes

Lo que pregunta la gente que desarrolla antes de conectar dos aplicaciones entre pares.

En la RTL no, y a través de la VCL tampoco. TsgcRTCPeerConnection es una implementación nativa en Object Pascal de la superficie de conexión entre pares del W3C: CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel y AddTrack, con ICE, DTLS, SCTP y SRTP por debajo. No hay Chromium embebido, ni TWebBrowser, ni puente de JavaScript en el proceso.
Necesitas algo que pueda llevar unos cientos de bytes de texto entre los dos pares antes de que empiece la llamada, porque todavía ninguno sabe cómo llegar al otro. Eso es la señalización, y puede ser un relé WebSocket, una cola de mensajes que ya tengas, un endpoint REST, incluso un copiar y pegar para una demo. Una vez que la oferta, la respuesta y los candidatos ICE han cruzado, los medios y los datos van directamente entre las dos aplicaciones y el canal de señalización se puede cerrar. Ningún servidor se sitúa en la ruta de los medios, salvo que un relé TURN resulte ser la única ruta que funcionaba.
Un WebSocket, casi siempre, porque es bidireccional y el servidor puede empujar una oferta entrante sin que el par tenga que sondear. Esta página construye uno con TsgcWebSocketServer y TsgcWebSocketClient en unas quince líneas, retransmitiendo cada mensaje al otro par con Broadcast y el Connection.Guid del remitente como exclusión. sgcWebSockets también incluye un componente de protocolo de señalización ya preparado, TsgcWSPServer_RTCPeerConnection, que conduce el intercambio por ti mediante RTCOptions.WebSocket y GatherCandidates por si prefieres no escribir el relé en absoluto.
Si las dos aplicaciones están en la misma LAN o en la misma VPN, ninguno. Los candidatos host ya describen direcciones alcanzables. Si están en redes distintas detrás de routers normales, necesitas STUN, que le dice a cada par la dirección pública desde la que parecen venir sus paquetes, y eso cubre la mayoría de las conexiones reales. Necesitas TURN cuando no funciona ningún emparejamiento directo: NAT simétrico, cortafuegos corporativos restrictivos y algunos operadores móviles. TURN retransmite cada byte de la llamada, así que es el recurso caro y no la opción por defecto. Añade los dos a RTCOptions.ICEServers e ICE elige el par más barato que de verdad conecta.
Un canal de datos es SCTP sobre DTLS y mueve mensajes, con la fiabilidad que elijas: ordenado y totalmente fiable como TCP, o sin orden y con un límite de retransmisiones o de vida útil para cualquier cosa en la que un paquete tardío no sirva. Una pista de medios es SRTP sobre el mismo transporte DTLS y mueve una línea temporal: audio en tramas de 20 ms, vídeo a una cadencia de fotogramas, siempre con pérdidas por diseño. Usa un canal de datos para chat, transferencia de archivos, control remoto y estado de juego. Usa una pista para un micrófono, una cámara o una pantalla. Comparten una conexión y un puerto abierto.
Dos pasos. Enterprise define SGC_ICE, SGC_DTLS, SGC_TURN y SGC_RTCPEERCONNECTION, que es lo que pone TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient y TsgcTURNServer en la paleta. La API de oferta y respuesta, los canales de datos y las pistas de medios están condicionados a SGC_SDP, SGC_DATACHANNEL y SGC_RTP, que solo define SGC_PACK_WEBRTC, y eso es el add-on sgcWebRTC, también incluido en All-Access. Por debajo de eso, un cliente STUN está en Standard, y un servidor STUN y el componente de servidor WebSocket están en Professional.
Sí, y nada de esta página cambia. El SDP es estándar, las líneas de candidato llevan los mismos sdpMid y sdpMLineIndex que espera la API del navegador, y la máquina de estados de oferta y respuesta sigue RFC 8829, así que el JSON que reenvía tu relé funciona sin cambios en los dos sentidos. Los navegadores envían candidatos con trickle desde el primer milisegundo, que es justo lo que detecta RTCOptions.TrickleICEAuto: ve a=ice-options:trickle en la descripción remota y responde de inmediato en lugar de agotar el tiempo de espera de recopilación.
En hilos de trabajo, siempre. OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack y OnError llegan en el hilo de ICE, de red o de temporizador. Los eventos de canal de datos llegan en el hilo que conduce la asociación SCTP. Los eventos de audio y vídeo de pista llegan en el hilo de red o de tick. Ninguno de ellos se sincroniza con el hilo principal por ti, así que envuelve todo lo que toque un control en TThread.Queue, que es lo que hacen las demos que se distribuyen.
No. Deja RTCOptions.DTLSOptions.CertFile vacío y se generan en memoria un certificado autofirmado y su clave, una vez por componente, reutilizados para cada par. Su huella se publica como el atributo a=fingerprint de la descripción local, que es lo que te autentica ante el otro lado. Ese es el modelo de confianza de WebRTC: la cadena nunca se verifica, el ancla es la huella transportada por el canal de señalización. Aun así puedes apuntar CertFile y KeyFile a tus propios archivos PEM cuando quieras una identidad estable.
Un handshake DTLS se ejecuta sobre el par de candidatos nominado y deriva las claves SRTP, así que cada paquete RTP, RTCP y SCTP de la conexión va cifrado. Para los canales de datos no hay forma de desactivarlo: CreateDataChannel pone RTCOptions.DTLS a True incondicionalmente, porque un RTCDataChannel es SCTP sobre DTLS por definición y no hay configuración válida sin ello.
Las unidades de P2P y WebRTC vienen en todos los paquetes de runtime desde Delphi 7 hasta RAD Studio 13, y en los paquetes de C++ Builder correspondientes. La captura y la reproducción de audio tienen implementaciones de plataforma para Windows, Linux, Android, iOS y macOS, así que las funciones de factoría devuelven un objeto que funciona en las cinco. La captura de vídeo es la excepción: se nombra por plataforma en lugar de construirse con una factoría, y en Windows esa es TsgcVideoCapture_Win, junto a TsgcScreenCapture_Win y TsgcWindowCapture_Win para compartir pantalla.
Sí, eso es renegociación. AddTrack sobre una sesión ya establecida la marca como pendiente de negociación y dispara OnNegotiationNeeded. Llama a CreateOffer otra vez y se construye una nueva oferta de forma síncrona, sin nueva recopilación ICE y sin nuevo handshake DTLS o SCTP, así que el transporte no se interrumpe nunca. Las líneas de medios existentes conservan su posición y su mid, la nueva se añade al final. RemoveTrack funciona igual a la inversa: la línea de medios se queda y se republica como recvonly o inactive.

Referencias de componente y documentos técnicos

Cada pieza que usa esta página tiene su propia página de referencia, y la mayoría tiene un PDF técnico independiente con la lista completa de propiedades, métodos y eventos.

TsgcRTCPeerConnection

El componente del que trata toda esta página. Oferta y respuesta, ICE, DTLS, canales de datos SCTP y medios RTP en una sola clase.

Página del componente →

sgcWebRTC

El pack del motor de medios: SDP, SCTP, RTP, SRTP, audio Opus y G.711, vídeo VP8 y H.264, estimación de ancho de banda.

Página del producto →

Desglose de características

Códec a códec y plataforma a plataforma, qué hace el motor de medios y de dónde sale cada codificador.

Ver las características →

Cliente ICE

Recopilación de candidatos, la lista de comprobación, la nominación y la colección de servidores ICE, la capa que hay debajo de la conexión entre pares.

Página del componente →

Cliente y servidor STUN

Peticiones de binding, opciones de retransmisión, y el componente de servidor para cuando prefieras alojar el tuyo.

Cliente STUN →

Cliente y servidor TURN

Asignaciones, permisos, vínculos de canal, y un componente de servidor TURN para las llamadas que necesitan un relé.

Servidor TURN →

Todos los componentes P2P

UDP, STUN, TURN, ICE y RTCPeerConnection, toda la familia entre pares en un solo índice.

Explorar P2P →

Visión general de WebRTC en Delphi

La vista a nivel de biblioteca de WebRTC en sgcWebSockets, con los componentes de protocolo de señalización y la lista de demos.

Leer más →

¿Qué edición necesito?

La matriz completa de ediciones, característica a característica, para cuando WebRTC no es lo único que estás sopesando.

Comparar ediciones →

Otros casos de uso

Esta página es uno de los casos de uso de Delphi, y cada uno lleva un único trabajo de principio a fin. Los otros que hay hasta ahora son llamar a un LLM desde Delphi y iniciar la sesión de una persona con OAuth2 y PKCE.

Todos los casos de uso →
Documento técnico de RTCPeerConnection (PDF) Propiedades, métodos, eventos y ejemplos de código solo del componente de conexión entre pares.
Documento técnico del cliente ICE (PDF) La recopilación de candidatos, la lista de comprobación y la colección de servidores ICE en detalle.
Documento técnico del cliente TURN (PDF) Asignaciones, permisos y vínculos de canal, el cliente que ICE conduce para un candidato retransmitido.
Documento técnico del cliente STUN (PDF) Peticiones de binding y retransmisión, la forma más barata de conocer tu propia dirección pública.
Proyectos de demo Demos\35.P2P\05.RTCPeerConnection y Demos\35.P2P\06.DataChannel vienen dentro del paquete.

Las especificaciones detrás de cada paso

Fuentes primarias, para cuando prefieres leer lo que implementa el componente en lugar de creernos a nosotros.

RFC 8829, JSEP

La máquina de estados de oferta y respuesta que hay detrás de CreateOffer, CreateAnswer y SetRemoteDescription, incluidas la renegociación y el rollback.

Leer la RFC →

RFC 8445, ICE

Recopilación de candidatos, prioridad, lista de comprobación y nominación. La razón de que una conexión a veces tarde un segundo y a veces falle.

Leer la RFC →

RFC 8489 y RFC 8656

STUN y TURN. Qué pide una petición de binding, y qué te cuesta una asignación.

Leer la RFC →

RFC 8831 y RFC 8832

Canales de datos de WebRTC sobre SCTP, y el handshake de apertura DCEP que asigna ids de flujo según el rol DTLS.

Leer la RFC →

RFC 8122, huellas SDP

Por qué a=fingerprint es la identidad de una conexión entre pares, y por qué se rechaza una descripción que no la lleva.

Leer la RFC →

WebRTC 1.0 (W3C)

La API que refleja este componente, incluida la negociación perfecta, de donde viene Polite.

Leer la especificación →

Pon dos de tus aplicaciones en una llamada

Descarga la prueba gratuita, ejecuta las demos de RTCPeerConnection y DataChannel una contra otra, y después construye lo mismo dentro de tu propio proyecto.