WebRTC no Delphi: áudio, vídeo e dados entre duas aplicações

Duas aplicações Delphi, em duas redes diferentes, trocando um canal de chat, um fluxo de microfone e um fluxo de câmera diretamente entre si. Sem servidor de mídia no meio, sem navegador embutido no processo, sem ponte JavaScript. Esta página percorre o trabalho inteiro, da primeira mensagem de sinalização até o primeiro quadro de áudio decodificado, usando APIs que existem no código distribuído.

Offer e answer SDP
ICE, STUN e TURN
Canais de dados SCTP
Faixas de mídia Opus e VP8
Criptografia DTLS-SRTP
Sem navegador nem WebView

O que de fato precisa acontecer

WebRTC são quatro problemas distintos usando um único nome. Só um deles é sobre mídia, e é o mais fácil. São estes os quatro, na ordem em que você precisa resolvê-los.

1. Descrever a sessão

Um lado monta um offer, um documento de texto (SDP) que diz qual mídia ele quer enviar, quais codecs fala, o fingerprint do certificado que vai apresentar e as credenciais ICE que vai usar. O outro lado responde com o subconjunto que aceita. CreateOffer e CreateAnswer produzem esses documentos, SetRemoteDescription os consome.

2. Levar isso até o outro lado

O WebRTC deliberadamente não diz como o offer chega ao outro peer. Esse canal se chama sinalização e é tarefa sua. São algumas centenas de bytes de texto em cada direção, então uma conexão WebSocket com um pequeno relé basta, e o sgcWebSockets já entrega as duas metades de um.

3. Achar um caminho através dos NATs

Nenhum dos peers conhece o próprio endereço público, e os dois normalmente estão atrás de um roteador. O ICE reúne todo endereço em que um peer pode estar acessível, envia-os pelo canal de sinalização conforme aparecem e testa cada par até um funcionar. O STUN descobre o endereço público, o TURN fornece um relé quando nada direto funciona.

4. Mover os bytes

Assim que um par de candidatos é nomeado, um handshake DTLS roda sobre ele e tudo dali em diante fica criptografado. Um canal de dados é SCTP sobre esse transporte DTLS, uma faixa de áudio ou vídeo é SRTP sobre ele. Os dois compartilham a mesma conexão e a mesma porta aberta.

Existe um servidor WebRTC?

No caminho da mídia não, e é justamente esse o ponto. Depois que as duas aplicações se encontram, o áudio, o vídeo e os dados trafegam diretamente entre elas. Nada que você hospeda vê a carga útil, e nada que você hospeda precisa escalar com a quantidade de minutos que os seus usuários passam em chamada.

Ainda assim há dois servidores no cenário, e ajuda ser preciso sobre o que cada um faz, porque eles costumam ser confundidos.

O servidor de sinalização é seu. Ele repassa um punhado de mensagens de texto entre dois peers antes de a chamada começar e depois se cala. Ele nunca vê mídia. Neste passo a passo são quinze linhas de Delphi construídas sobre TsgcWebSocketServer.

O servidor STUN e TURN existe por causa do NAT, não por causa do WebRTC. Um servidor STUN responde a uma única pergunta, "de qual endereço público este pacote chegou", e é só. Um servidor TURN repassa pacotes para os pares que não conseguem se alcançar de nenhuma outra forma, então ele é a única peça que carrega mídia, e só nas chamadas que precisam disso. Servidores STUN públicos são gratuitos e abundantes, o TURN você mesmo hospeda, e o sgcWebSockets Enterprise traz tanto um componente de servidor STUN quanto um de servidor TURN caso você prefira não rodar um daemon separado.

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

Edições, units e plataformas

A conexão peer e o motor de mídia são dois degraus de licença diferentes. Vale deixar isso claro antes de escrever qualquer código, porque senão o compilador simplesmente não enxerga metade da API.

O que você quer fazerDo que precisaDe onde vem
Cliente STUN, para descobrir um endereço público TsgcSTUNClient sgcWebSockets Standard ou superior
Rodar o seu próprio servidor STUN TsgcSTUNServer sgcWebSockets Professional ou superior
Repassar um canal de sinalização WebSocket TsgcWebSocketServer sgcWebSockets Professional ou superior para a metade servidor. A metade cliente, TsgcWebSocketClient, já vem com o Standard.
ICE, cliente e servidor TURN, e o próprio componente de conexão peer TsgcICEClient, TsgcTURNClient, TsgcTURNServer, TsgcRTCPeerConnection sgcWebSockets Enterprise
Offer e answer, canais de dados, faixas de áudio e vídeo CreateOffer, CreateAnswer, SetRemoteDescription, AddIceCandidate, CreateDataChannel, AddTrack O pacote sgcWebRTC, sobre o Enterprise. Também incluído no All-Access.

Por que a divisão, nas palavras do próprio compilador

O bloco Enterprise do sgcVer.inc define SGC_ICE, SGC_DTLS, SGC_RTCPEERCONNECTION e SGC_TURN. É isso que coloca o TsgcRTCPeerConnection na paleta e lhe dá um transporte ICE e TURN.

Tudo de que esta página realmente trata fica um nível mais adentro. É o SGC_PACK_WEBRTC que define SGC_SDP, SGC_SCTP, SGC_DATACHANNEL, SGC_RTP e SGC_SRTP, e ele antes verifica se ICE, DTLS e a conexão peer já estão presentes. Os métodos manuais de sinalização, a API de canal de dados e a API de mídia ficam dentro de {$IFDEF SGC_SDP}, {$IFDEF SGC_DATACHANNEL} e {$IFDEF SGC_RTP}, respectivamente, então só com o Enterprise eles não compilam.

Se CreateOffer não resolve, foi isso que aconteceu. A demonstração de canal de dados que acompanha o produto avisa isso em voz alta em vez de falhar em silêncio.

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}

As units de que as duas aplicações precisam

sgcP2P é a unit agregadora que publica o TsgcRTCPeerConnection e reexporta os tipos dos manipuladores. Ela basta para declarar o componente, mas as constantes enumeradas vêm das units que declaram os tipos delas, então inclua essas também quando você citar rtctkAudio ou cctAudioOpus.

As duas aplicações deste passo a passo são o mesmo programa com um botão diferente pressionado. Tudo o que vem abaixo vale para as duas.

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

O canal de sinalização

Três tipos de mensagem, um relé burro. Esta é a parte que todo tutorial de WebRTC menciona de passagem, e a parte que você de fato precisa escrever.

Um relé, não um intermediário

O servidor de sinalização não precisa entender um único byte do que repassa. Ele pega o texto que um peer enviou e entrega ao outro. O Broadcast já tem um parâmetro Exclude que recebe o Guid de uma conexão, então "envie para todos menos o remetente" é uma linha.

Mantenha-o assim de simples. No momento em que o relé começa a interpretar SDP, ele vira um componente que você precisa atualizar toda vez que um codec muda, e deixa de conseguir repassar uma chamada para um navegador.

Em produção você indexaria o relé por um identificador de sala para que duas chamadas não colidam, e o colocaria atrás de TLS. O TsgcWebSocketServer traz a mesma superfície de TLSOptions, Authentication e WatchDog do resto da 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;

O lado do peer no mesmo canal

Cada aplicação abre um TsgcWebSocketClient para esse relé e fala um vocabulário de três palavras: offer, answer e candidate. O TsgcJSON da mesma biblioteca os serializa, então não há dependência extra.

Repare para onde vão os desvios. Um offer recebido é definido como descrição remota e respondido imediatamente. Um answer recebido é apenas definido. Um candidate recebido é passado a AddIceCandidate, e ele pode chegar antes ou depois da descrição, que é justamente o objetivo do 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;

Monte a conexão peer

Idêntico nas duas aplicações. A única assimetria em toda a troca é qual delas aperta Chamar.

Configuração, e os eventos que importam

RTCOptions.ICEServers é a lista iceServers do W3C. AddURL recebe uma URL stun: ou turn: e preenche o tipo, o host, a porta e a flag de TLS a partir do esquema, com um nome de usuário e uma credencial opcionais para TURN.

RTCOptions.DTLS tem False como padrão. Deixe assim e não há criptografia nem material de chave SRTP, então a mídia não funciona. O CreateDataChannel liga isso para você, porque um canal de dados é SCTP sobre DTLS e não existe configuração válida com DTLS desligado. O AddTrack não liga, então defina você mesmo quando adicionar mídia.

Você não precisa de um arquivo de certificado. Deixe RTCOptions.DTLSOptions.CertFile vazio e um certificado autoassinado em memória é gerado uma vez por componente, que é exatamente o modelo do WebRTC: a identidade é ancorada na linha a=fingerprint do SDP, não em uma cadeia. Uma descrição remota que não traz fingerprint é recusada, em vez de ter permissão para fazer handshake contra qualquer coisa.

Todo evento abaixo dispara em uma thread de trabalho, a thread de ICE, de rede ou de timer, nunca na thread principal. Faça o marshalling com TThread.Queue antes de tocar em um controle.

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;

Publicando a descrição local

O OnLocalDescription entrega o tipo, a string 'offer' ou 'answer', e o próprio SDP. Os dois peers usam o mesmo manipulador, e ele faz uma coisa só: colocar isso no canal de sinalização.

O SDP chega já completo. O CreateOffer primeiro reúne candidatos ICE e espera até RTCOptions.GatheringTimeout milissegundos, 3000 por padrão, terminando antes depois de GatheringIdleTimeout milissegundos sem nenhum candidato novo, 500 por padrão. Esse é o caminho sem trickle.

Defina TrickleICE como True e a descrição sai imediatamente, com os candidatos vindo atrás. Raramente é preciso. RTCOptions.TrickleICEAuto é True por padrão, então, quando a descrição remota traz a=ice-options:trickle, o que todo navegador faz, o componente muda sozinho e para de queimar o tempo de 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;

Candidatos ICE, STUN e TURN

É aqui que as conexões peer to peer falham, e onde as falhas são mais difíceis de ler. Três tipos de candidato, três razões para existirem.

host

Um endereço que a máquina enxerga em si mesma, um por interface de rede. Gratuito, instantâneo e suficiente quando as duas aplicações estão na mesma LAN ou na mesma VPN. Se as suas duas aplicações Delphi só rodam dentro de um escritório, os candidatos host bastam e você pode pular o STUN por completo.

srflx, server reflexive

O endereço público de onde um servidor STUN viu o pacote do peer chegar. É isso que faz dois peers atrás de roteadores domésticos comuns conversarem diretamente, e cobre a grande maioria das conexões reais. Custa uma ida e volta a um servidor STUN que depois disso não carrega tráfego nenhum.

relay

Um endereço em um servidor TURN que encaminha para o peer. Necessário em NAT simétrico, firewalls corporativos restritivos e algumas operadoras móveis. Cada byte da chamada atravessa o seu servidor TURN, então é o caminho caro e aquele para o qual você só recorre em último caso.

Enviando-os aos poucos

O OnIceCandidate dispara uma vez por candidato, conforme ele é descoberto, com a linha do candidato, o sdpMid e o sdpMLineIndex dele. Esses três campos são exatamente o que a API do navegador espera, então o mesmo JSON funciona quer a outra ponta seja Delphi ou Chrome.

Envie cada um imediatamente. Não espere, não agrupe. Um candidato que chega antes da descrição remota fica retido e é aplicado quando a descrição chega, então a ordem não é problema seu.

Quando o par é finalmente nomeado, SelectedLocalCandidate e SelectedRemoteCandidate dizem quais dois endereços venceram. Essa única linha de log responde "por que esta chamada está passando pelo meu servidor TURN" mais rápido do que qualquer outra coisa.

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;

Adicionando TURN, e a chave que você não pode esquecer

Uma entrada turn: em ICEServers carrega o próprio host, porta, nome de usuário e credencial, e é isso que a alocação usa. Adicioná-la é mais um AddURL.

A armadilha está na direção oposta. RTCOptions.ICE.TURN tem True como padrão, e quando a lista de servidores não tem entrada TURN nenhuma, o gathering recorre ao servidor único de RTCOptions.ICE, cujo host tem como padrão 127.0.0.1 e a porta 3478. Assim, um peer configurado apenas com uma URL de STUN ainda tenta uma alocação TURN contra o localhost, falha e reporta. É ruído, não um defeito, mas parece alarmante em um log e manda você procurar no lugar errado. Defina RTCOptions.ICE.TURN := False até você realmente ter um servidor TURN.

RTCOptions.ICE.STUN se comporta do mesmo jeito e também tem True como padrão.

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;

Referência do cliente ICE Rode o seu próprio servidor TURN

O canal de dados

Texto e binário entre as duas aplicações, com a confiabilidade que você escolhe por canal. Normalmente é a primeira coisa que você faz funcionar, e ela comprova o transporte inteiro.

Abrir um, e receber o do outro lado

O peer que chama CreateDataChannel recebe o objeto de volta na hora. O peer que não chamou recebe o mesmo canal por OnDataChannel. Ligue os manipuladores nos dois lugares, porque qualquer um dos lados pode abrir um canal em qualquer momento da sessão.

O canal não fica utilizável no instante em que você o cria. O Id dele permanece sem atribuição até a associação SCTP estar de pé e o papel DTLS estar resolvido, e Send devolve False enquanto ele não estiver aberto. Espere pelo OnOpen.

A confiabilidade é decidida na criação. Os padrões são ordenado e totalmente confiável, um canal parecido com TCP. Passe aOrdered = False para entrega sem ordem, ou um aMaxRetransmits ou aMaxPacketLifeTime para confiabilidade parcial, que é o que você quer para atualizações de posição ou qualquer coisa em que um pacote atrasado seja pior do que um pacote perdido.

Não libere um canal, ele pertence à conexão peer. Close inicia o encerramento e OnClose dispara quando a outra ponta concorda.

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, sem inundar

Send recebe uma string e SendBytes recebe um TBytes. Os dois devolvem False em vez de levantar exceção quando o canal não está aberto, então um envio durante o encerramento é um False devolvido, não uma exceção em uma thread de trabalho.

MaxMessageSize é a maior mensagem que o peer disse aceitar, lida do atributo a=max-message-size da descrição dele. Uma mensagem acima desse limite é recusada localmente em vez de ir para a rede, onde abortaria a associação inteira e levaria junto todos os outros canais. Zero significa que nenhuma descrição informou um limite e a verificação está desligada.

BufferedAmount é quantos bytes estão enfileirados no SCTP para este stream e ainda não confirmados. Acompanhe-o quando estiver transmitindo um arquivo: envie até ele passar de um limite e então espere esvaziar, em vez de enfileirar gigabytes na memória.

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;

Faixas de áudio e vídeo

Uma faixa não é um canal de dados com imagens dentro. Transporte diferente, modos de falha diferentes, código diferente. Essa é a distinção que a maioria das pessoas erra primeiro.

 Canal de dadosFaixa de mídia
Transportado por SCTP sobre DTLS (RFC 8831) SRTP sobre o mesmo transporte DTLS (RFC 3711)
Entrega Sua escolha, de totalmente confiável e ordenada a disparar e esquecer Sempre com perdas por projeto. Atrasado é pior do que perdido, então nada é retransmitido para sempre
Unidade de trabalho Uma mensagem. Você decide quando enviar uma Um relógio. O áudio é alimentado em quadros de 20 ms, o vídeo a uma taxa de quadros
Aberto com CreateDataChannel, a qualquer momento da sessão AddTrack, que precisa de um novo offer para publicá-la
Recebido por OnDataChannel, e depois o OnMessage do próprio canal OnTrack, e depois o OnAudio ou o OnVideoFrame da faixa
Precisa de DTLS ligado Sim, e o CreateDataChannel define isso para você Sim, e o AddTrack não define. Defina RTCOptions.DTLS você mesmo
Use para Chat, transferência de arquivos, controle remoto, estado de jogo, telemetria Microfone, câmera, compartilhamento de tela, qualquer coisa com uma linha do tempo

Enviando o microfone

AddTrack recebe um tipo e um codec e devolve um TsgcRTCTrack. Os codecs de áudio são cctAudioOpus, cctAudioPCMU e cctAudioPCMA, os codecs de vídeo são cctVideoVP8, cctVideoVP9, cctVideoH264 e cctVideoJPEG.

A captura é um objeto separado, porque pode ser que você não queira o microfone da plataforma. sgcCreateAudioCapture monta a implementação certa para a plataforma em que o código foi compilado, waveIn no Windows, ALSA no Linux, AudioRecord no Android, uma Audio Unit VoiceProcessingIO no iOS e no macOS, de modo que nada no seu código cita uma classe de plataforma. Ela devolve nil em um alvo sem implementação, então teste o resultado.

SendPCM quer PCM intercalado com sinal de 16 bits na taxa e na contagem de canais do codificador, que para Opus é 48000 Hz e para G.711 é 8000 Hz. A fonte de captura publica o que realmente entrega através de AudioSampleRate, AudioChannels e AudioFrameDurationMs, então você pode conferir em vez de supor.

Adicionar uma faixa depois de a sessão já estar de pé liga a flag de negociação necessária e dispara OnNegotiationNeeded. Chame CreateOffer de novo para publicá-la, e o novo offer é montado sem tocar no 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;

Reproduzindo o que o outro lado enviou

O OnTrack dispara uma vez por linha de mídia remota, quando a descrição remota traz uma. A faixa que ele entrega pertence à conexão peer, então ligue os eventos dela e nunca a libere.

O áudio chega como PCM decodificado por OnAudio, com a taxa e a contagem de canais que o decodificador produziu. Entregue-o direto ao TsgcMediaRenderer que sgcCreateAudioRenderer montou, que converte o formato quando o dispositivo não pôde ser aberto com o formato correspondente.

O vídeo chega como um TsgcVideoFrame decodificado por OnVideoFrame: pixels brutos em Data, com Width, Height, Format e Stride. Os formatos são vffI420, vffNV12, vffRGB24, vffRGBA32, vffBGR24 e vffBGRA32, e os dois BGR seguem a ordem de bytes do GDI do Windows, então jogar um quadro vffBGR24 em um bitmap é uma cópia de memória e não uma conversão.

Se um quadro chegar quebrado depois de perda de pacotes, RequestKeyFrame pede ao remetente um novo.

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;

A câmera, no Windows

A captura de áudio fica abstraída atrás da factory porque toda plataforma suportada tem uma implementação. A captura de vídeo não, então você cita a classe da plataforma. No Windows ela é TsgcVideoCapture_Win, de sgcP2P_MediaCapture_Win, que controla o Video for Windows e entrega os quadros pelo mesmo evento OnVideoCapture que a classe base declara.

A unit vizinha sgcP2P_ScreenCapture_Win traz TsgcScreenCapture_Win e TsgcWindowCapture_Win, os dois descendentes de TsgcMediaCaptureSource, então o compartilhamento de tela são as mesmas três linhas com um construtor diferente.

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;

As falhas que vale conhecer de antemão

Conexões peer to peer falham de maneiras que não produzem erro nenhum, e é isso que as torna difíceis. Estas são as que mais aparecem.

A mídia fica muda e nada dá erro

RTCOptions.DTLS está False. Esse é o padrão, o CreateDataChannel liga, mas o AddTrack não, então uma sessão que só carrega mídia nunca roda um handshake DTLS e, portanto, nunca obtém chaves SRTP. Defina isso explicitamente.

Um erro de TURN que você não pediu

RTCOptions.ICE.TURN tem True como padrão e recorre a 127.0.0.1:3478 quando a lista de servidores não tem entrada TURN. Defina como False até você realmente ter um servidor TURN, senão o log enche de falhas de alocação que não têm nada a ver com o seu problema.

A descrição remota é recusada

Uma descrição sem a=fingerprint é rejeitada de imediato e reportada por OnError. No modelo de confiança do WebRTC, essa linha é a única coisa que autentica o peer, então aceitar uma descrição sem ela deixaria o handshake se completar contra qualquer certificado.

Uma violação de acesso em um manipulador de evento

Todo evento de conexão peer, de canal de dados e de faixa dispara em uma thread de trabalho, a thread de ICE, de rede, de SCTP ou de tick, nunca na thread principal. Tocar em um controle VCL ou FMX diretamente de uma delas tem comportamento indefinido. Envolva com TThread.Queue.

Os dois lados fazem um novo offer ao mesmo tempo

Isso se chama glare, e é resolvido com a regra de perfect negotiation do W3C. O peer não educado, Polite = False, mantém o próprio offer e reporta o que chegou por OnError. O peer educado desfaz o dele e responde. Os dois peers de uma sessão não podem ser educados ao mesmo tempo.

Uma mensagem grande derruba todos os canais

Uma mensagem acima do a=max-message-size do peer abortaria a associação SCTP inteira, levando junto todos os outros canais de dados. Send e SendBytes verificam MaxMessageSize e recusam localmente em vez disso. Divida cargas grandes você mesmo.

A conexão leva três segundos para começar

Isso é RTCOptions.GatheringTimeout, a espera sem trickle. GatheringIdleTimeout a encerra antes assim que os candidatos param de chegar, e TrickleICEAuto muda para o modo trickle quando a descrição remota anuncia isso. Reduza os dois tempos limite em uma LAN.

O áudio está rápido, lento ou embolado

Uma divergência de taxa de amostragem ou de contagem de canais entre o dispositivo de captura e o codificador. O Opus é negociado a 48000 Hz e o G.711 a 8000 Hz. Leia AudioSampleRate e AudioChannels de volta da fonte de captura em vez de supor que o dispositivo respeitou o que você pediu.

WebRTC no Delphi, perguntas frequentes

O que os desenvolvedores perguntam antes de ligar duas aplicações peer to peer.

Na RTL não, e pela VCL também não. O TsgcRTCPeerConnection é uma implementação nativa em Object Pascal da superfície de peer connection do W3C: CreateOffer, CreateAnswer, SetLocalDescription, SetRemoteDescription, AddIceCandidate, CreateDataChannel e AddTrack, com ICE, DTLS, SCTP e SRTP por baixo. Não há Chromium embutido, nem TWebBrowser, nem ponte JavaScript no processo.
Você precisa de algo capaz de levar algumas centenas de bytes de texto entre os dois peers antes de a chamada começar, porque nenhum dos dois sabe ainda como alcançar o outro. Isso é a sinalização, e pode ser um relé WebSocket, uma fila de mensagens já existente, um endpoint REST, até um copiar e colar em uma demonstração. Depois que o offer, o answer e os candidatos ICE atravessaram, a mídia e os dados vão diretamente entre as duas aplicações e o canal de sinalização pode fechar. Nenhum servidor fica no caminho da mídia, a não ser que um relé TURN tenha acabado sendo a única rota que funcionou.
Um WebSocket, quase sempre, porque ele é bidirecional e o servidor consegue empurrar um offer recebido sem que o peer fique consultando. Esta página monta um a partir de TsgcWebSocketServer e TsgcWebSocketClient em cerca de quinze linhas, repassando toda mensagem ao outro peer com Broadcast e o Connection.Guid do remetente como exclusão. O sgcWebSockets também traz um componente de protocolo de sinalização pronto, o TsgcWSPServer_RTCPeerConnection, que conduz a troca por você através de RTCOptions.WebSocket e GatherCandidates, para quando você preferir não escrever o relé.
Se as duas aplicações estão na mesma LAN ou na mesma VPN, nenhum dos dois. Os candidatos host já descrevem endereços alcançáveis. Se estão em redes diferentes atrás de roteadores comuns, você precisa de STUN, que informa a cada peer o endereço público de onde os pacotes dele parecem vir, e isso cobre a maior parte das conexões reais. Você precisa de TURN quando nenhum par direto funciona: NAT simétrico, firewalls corporativos restritivos e algumas operadoras móveis. O TURN repassa cada byte da chamada, então é o recurso caro de último caso, não o padrão. Adicione os dois a RTCOptions.ICEServers e o ICE escolhe o par mais barato que de fato conecta.
Um canal de dados é SCTP sobre DTLS e move mensagens, com a confiabilidade que você escolher: ordenado e totalmente confiável como o TCP, ou sem ordem e com limite de retransmissões ou de tempo de vida para tudo em que um pacote atrasado é inútil. Uma faixa de mídia é SRTP sobre o mesmo transporte DTLS e move uma linha do tempo: áudio em quadros de 20 ms, vídeo a uma taxa de quadros, sempre com perdas por projeto. Use um canal de dados para chat, transferência de arquivos, controle remoto e estado de jogo. Use uma faixa para um microfone, uma câmera ou uma tela. Os dois compartilham uma conexão e uma porta aberta.
Dois degraus. O Enterprise define SGC_ICE, SGC_DTLS, SGC_TURN e SGC_RTCPEERCONNECTION, que é o que coloca TsgcRTCPeerConnection, TsgcICEClient, TsgcTURNClient e TsgcTURNServer na paleta. A API de offer e answer, os canais de dados e as faixas de mídia ficam protegidos por SGC_SDP, SGC_DATACHANNEL e SGC_RTP, que só o SGC_PACK_WEBRTC define, e esse é o complemento sgcWebRTC, também incluído no All-Access. Abaixo disso, um cliente STUN está no Standard, e um servidor STUN e o componente de servidor WebSocket estão no Professional.
Sim, e nada nesta página muda. O SDP é padrão, as linhas de candidato carregam o mesmo sdpMid e o mesmo sdpMLineIndex que a API do navegador espera, e a máquina de estados de offer e answer segue a RFC 8829, então o JSON que o seu relé repassa funciona sem alteração nas duas direções. Os navegadores enviam candidatos em trickle desde o primeiro milissegundo, que é exatamente o que o RTCOptions.TrickleICEAuto detecta: ele vê a=ice-options:trickle na descrição remota e responde imediatamente em vez de esperar o tempo de gathering acabar.
Em threads de trabalho, sempre. OnLocalDescription, OnIceCandidate, OnConnectionStateChange, OnTrack e OnError chegam na thread de ICE, de rede ou de timer. Os eventos de canal de dados chegam na thread que conduz a associação SCTP. Os eventos de áudio e vídeo das faixas chegam na thread de rede ou de tick. Nenhum deles é marshalado para a thread principal por você, então envolva qualquer coisa que toque em um controle com TThread.Queue, que é o que as demonstrações distribuídas fazem.
Não. Deixe RTCOptions.DTLSOptions.CertFile vazio e um certificado autoassinado e uma chave são gerados em memória, uma vez por componente, e reaproveitados para todos os peers. O fingerprint dele é publicado como o atributo a=fingerprint da descrição local, e é isso que autentica você perante o outro lado. Esse é o modelo de confiança do WebRTC: a cadeia nunca é verificada, o fingerprint levado pelo canal de sinalização é a âncora. Você ainda pode apontar CertFile e KeyFile para os seus próprios arquivos PEM quando quiser uma identidade estável.
Um handshake DTLS roda sobre o par de candidatos nomeado e deriva as chaves SRTP, então cada pacote RTP, RTCP e SCTP da conexão é criptografado. Para canais de dados não há como desligar: o CreateDataChannel define RTCOptions.DTLS como True incondicionalmente, porque um RTCDataChannel é SCTP sobre DTLS por definição e não existe configuração válida sem isso.
As units de P2P e WebRTC vêm em todos os pacotes de runtime do Delphi 7 ao RAD Studio 13, e nos pacotes correspondentes do C++ Builder. A captura e a reprodução de áudio têm implementações para Windows, Linux, Android, iOS e macOS, então as funções de factory devolvem um objeto funcional nas cinco. A captura de vídeo é a exceção: ela é nomeada por plataforma em vez de ser construída por uma factory, e no Windows isso é TsgcVideoCapture_Win, ao lado de TsgcScreenCapture_Win e TsgcWindowCapture_Win para compartilhamento de tela.
Sim, isso é renegociação. O AddTrack em uma sessão já estabelecida a marca como precisando de negociação e dispara OnNegotiationNeeded. Chame CreateOffer de novo e um novo offer é montado de forma síncrona, sem novo gathering de ICE e sem novo handshake DTLS ou SCTP, então o transporte nunca é interrompido. As linhas de mídia existentes mantêm a posição e o mid delas, a nova é acrescentada no fim. RemoveTrack funciona do mesmo jeito ao contrário: a linha de mídia permanece e é republicada como recvonly ou inactive.

Referências de componentes e documentos técnicos

Cada peça usada nesta página tem a sua própria página de referência, e a maioria tem um PDF técnico à parte com a lista completa de propriedades, métodos e eventos.

TsgcRTCPeerConnection

O componente de que esta página inteira trata. Offer e answer, ICE, DTLS, canais de dados SCTP e mídia RTP em uma única classe.

Página do componente →

sgcWebRTC

O pacote do motor de mídia: SDP, SCTP, RTP, SRTP, áudio Opus e G.711, vídeo VP8 e H.264, estimativa de largura de banda.

Página do produto →

Detalhamento de recursos

Codec por codec e plataforma por plataforma, o que o motor de mídia faz e de onde vem cada codificador.

Ver os recursos →

Cliente ICE

Coleta de candidatos, a lista de verificação, a nomeação e a coleção de servidores ICE, a camada abaixo da conexão peer.

Página do componente →

Cliente e servidor STUN

Requisições de binding, opções de retransmissão e o componente de servidor para quando você preferir hospedar o seu.

Cliente STUN →

Cliente e servidor TURN

Alocações, permissões, channel binds e um componente de servidor TURN para as chamadas que precisam de um relé.

Servidor TURN →

Todos os componentes P2P

UDP, STUN, TURN, ICE e RTCPeerConnection, toda a família peer to peer em um índice.

Ver os componentes P2P →

Visão geral do WebRTC no Delphi

A visão do WebRTC no sgcWebSockets no nível da biblioteca, com os componentes de protocolo de sinalização e a lista de demonstrações.

Saiba mais →

De qual edição eu preciso?

A matriz completa de edições, recurso por recurso, para quando o WebRTC não é a única coisa que você está avaliando.

Comparar edições →

Outros casos de uso

Esta página faz parte dos casos de uso Delphi, cada um levando um único trabalho do início ao fim. Os outros até agora são chamar um LLM a partir do Delphi e autenticar um usuário com OAuth2 e PKCE.

Todos os casos de uso →
Documento técnico do RTCPeerConnection (PDF) Propriedades, métodos, eventos e exemplos de código somente do componente de conexão peer.
Documento técnico do cliente ICE (PDF) Coleta de candidatos, a lista de verificação e a coleção de servidores ICE em detalhe.
Documento técnico do cliente TURN (PDF) Alocações, permissões e channel binds, o cliente que o ICE conduz para um candidato relay.
Documento técnico do cliente STUN (PDF) Requisições de binding e retransmissão, o jeito mais barato de descobrir o seu próprio endereço público.
Projetos de demonstração Demos\35.P2P\05.RTCPeerConnection e Demos\35.P2P\06.DataChannel vêm dentro do pacote.

As especificações por trás de cada passo

Fontes primárias, para quando você prefere ler o que o componente implementa em vez de acreditar na nossa palavra.

RFC 8829, JSEP

A máquina de estados de offer e answer por trás de CreateOffer, CreateAnswer e SetRemoteDescription, incluindo renegociação e rollback.

Ler a RFC →

RFC 8445, ICE

Coleta de candidatos, prioridade, a lista de verificação e a nomeação. O motivo de uma conexão às vezes levar um segundo e às vezes falhar.

Ler a RFC →

RFC 8489 e RFC 8656

STUN e TURN. O que uma requisição de binding pergunta, e quanto uma alocação custa a você.

Ler a RFC →

RFC 8831 e RFC 8832

Canais de dados WebRTC sobre SCTP, e o handshake de abertura DCEP que atribui ids de stream conforme o papel DTLS.

Ler a RFC →

RFC 8122, fingerprints de SDP

Por que a=fingerprint é a identidade de uma conexão peer, e por que uma descrição sem ele é recusada.

Ler a RFC →

WebRTC 1.0 (W3C)

A API que este componente espelha, incluindo a perfect negotiation, de onde vem o Polite.

Ler a especificação →

Coloque duas das suas aplicações em uma chamada

Baixe a versão de avaliação, rode as demonstrações RTCPeerConnection e DataChannel uma contra a outra e depois construa a mesma coisa dentro do seu próprio projeto.