media: { mode: 'remote' } separa os dois. O signaling fica no servidor; a mídia roda onde o áudio está, sobre o @zapo-js/voip-media — um package sem dependência de zapo-js e sem dependência de Node, que roda num browser como está.
Nenhuma mídia passa pelo seu servidor. O browser fala direto com os relays do WhatsApp; o servidor só entrega o plano para fazer isso. Por isso ele não precisa nem de
@roamhq/wrtc nem de libmlow-wasm-fork.Instalação
No servidor, nada novo — só largue as peers de mídia:@zapo-js/voip-media já é uma dependência do @zapo-js/voip, então o servidor o tem transitivamente; no browser você o instala como dependência direta do bundle. As duas peers dele são opcionais:
A divisão
O que fica no servidor e o que se muda:
Por causa dessa divisão, isto lança ou fica em silêncio no servidor com mídia remota:
Todo o resto da chamada — aceitar, encerrar, eventos de estado, mute, mãos, screen share, upgrades — fica exatamente onde estava.
Conectando o lado servidor
Duas linhas ligam o plugin ao transporte que você roda entre servidor e browser:Um host que entra atrasado
Um browser que conecta depois da chamada ter começado — um reload, uma segunda aba assumindo — perdeu todas as mensagens até ali. Osnapshot entrega a ele o plano inteiro como está:
null para uma chamada desconhecida, ou quando a mídia é local. O host também pede isso por conta própria — veja resync.
client.voip.media
O protocolo de fio
Dois tipos de mensagem trafegam, ambos JSON puro com um campo de versão. O zapo te dá os codecs; o transporte é seu — um WebSocket, um stream SSE mais uma rota POST, uma fila de mensagens, qualquer coisa mais ou menos ordenada.Servidor → host: WaCallMediaMessage
keys (material SRTP), relays (endpoints e credenciais), ssrcs, settings e video. Uma mensagem full carrega tudo; as outras carregam só as seções que mudaram.
Host → servidor: WaCallMediaEventMessage
Codificação
encode* retorna uma string e cada decode* recebe uma. JSON não tem tipo binário nem bigint, então a codificação marca os dois: arrays de bytes viajam sob $bytes (base64) e o transaction id uint64 de uma reação sob $bigint. Sempre passe por essas funções — um JSON.stringify feito à mão perde as chaves SRTP e o transaction id.
O WA_CALL_MEDIA_WIRE_VERSION é 1. Só uma mudança quebradora o move; um campo novo é opcional.
Ordem, lacunas e resync
OWaCallMediaReceiver aplica as mensagens uma de cada vez, na ordem da chamada, e guarda o último seq que aceitou:
- Uma mensagem mais antiga que a última aceita é descartada.
- Uma lacuna (
seq > lastSeq + 1) emite um eventoresynccarregandolastSeq. Responda comsnapshot(callId). - Uma mensagem
fullé aceita sempre que oseqdela não está atrasado, o que é o que faz a resposta de snapshot funcionar. - Uma mensagem cuja aplicação rejeita não conta como recebida, então a próxima encontra a lacuna e pede resync sozinha.
resync com um snapshot.
O browser
Áudio
A chamada mínima viável. Construa o receiver, comece a escutar, e então abra o áudio a partir de um gesto do usuário:<call>, e só o servidor fala o protocolo. O clique que atende pede o accept ao servidor e abre o áudio localmente; as duas coisas acontecem juntas.
WaWebCallAudio
O WaWebCallAudio.start(sink, options?) carrega o áudio entre o microfone e o alto-falante do browser e um sink — o receiver.plane satisfaz esse sink. O clock do dispositivo de áudio ritma as duas direções, então uma aba em segundo plano estrangulada não deixa a chamada sem áudio.
MediaStream
Microfone a capturar. Padrão:
getUserMedia com cancelamento de eco, supressão de ruído e ganho automático — o codec não faz nenhum dos três, eles vêm do browser. Um stream que você passa não é parado pelo stop().AudioContext
Contexto em que rodar. Padrão: um
AudioContext novo de 16 kHz, caindo para a taxa do dispositivo se o browser recusar. Um contexto que você passa não é fechado nem retomado pelo stop().string
URL do módulo do worklet. Padrão: uma Blob URL. Sob uma CSP que proíbe
blob:, sirva o WA_CALL_AUDIO_WORKLET_SOURCE exportado como arquivo e passe a URL dele aqui.stop() libera apenas o que o adapter adquiriu, e uma chamada repetida retorna a mesma promise.
O Firefox recusa um microfone numa sample rate estrangeira. O adapter lida com isso: se ele é dono do contexto e a taxa de 16 kHz é recusada, ele reabre na taxa do dispositivo e tenta de novo.
Vídeo
Construa o receiver comonInboundVideo para os frames do peer, e entregue ao plane um track de câmera para os seus:
WaWebCallVideoSender.start lança quando o browser não tem VideoEncoder, ou quando você entrega um track que não é de vídeo.
Opções do WaWebCallVideoSender
O
sender.stats reporta framesCaptured, framesEncoded, framesSent, framesDropped, packetsSent, keyFrames, width, height.
Opções do WaWebCallVideoReceiver
O
receiver.stats reporta framesReceived, framesDecoded, framesDropped (antes do primeiro key frame, ou depois de uma falha), decodeErrors, e codec — lido do sequence parameter set do último stream decodificado.
webMediaHost
Espalhar ...webMediaHost nas opções do receiver fornece as duas primitivas de plataforma que o plane de mídia precisa num browser:
crypto—webCrypto, construído sobre o própriocrypto.subtledo browser.createPeerConnection—createBrowserPeerConnection, o próprioRTCPeerConnectiondo browser. Uma configuração que o browser recusa rejeita a promise.
useRawUdpTransport não tem significado lá.
WaCallMediaReceiver
As opções estendem as do próprio plane, mais:
string
obrigatório
A chamada que este receiver serve. Uma mensagem para outro call id rejeita.
(message: WaCallMediaEventMessage) => void
obrigatório
Entrega um evento de volta ao signaling, pelo transporte que você roda.
(reaction: WaCallReaction) => void
Também é informado das reações que vão para o signaling, para um host que queira renderizá-las.
(frame: InboundVideoFrame) => void
Uma access unit H.264 remontada de um stream de vídeo do peer, indexada por
frame.ssrc.(packet: InboundVideoRtpPacket) => void
Cada pacote RTP de vídeo de entrada decriptado, antes da remontagem.
Logger
Opcional. O
createNoopLogger() é exportado para quando você quer o formato sem a saída.onActive e onRelayLost não são seus para definir — o receiver é dono deles e os transforma nos eventos active e relay_lost que manda de volta ao signaling.Estatísticas da chamada
Ostop() retorna WaCallMediaStats, que é o que logar quando uma chamada soou errada:
Mídia num segundo processo Node
O browser é o caso comum, mas nada na divisão é específico de browser. Um segundo processo Node é a mesma ligação, com o host de Node e as peers de Node instaladas lá:nodeMediaHost fornece nodeCrypto (node:crypto) e createWrtcPeerConnection (@roamhq/wrtc). Esse host precisa de @roamhq/wrtc e libmlow-wasm-fork, exatamente como um servidor com mídia local — as dependências seguem a mídia, não o signaling.
Legs UDP cruas ficam desligadas lá também; um host que as queira passa a factory de leg explicitamente:
useRawUdpTransport antes de fazer isso — é um experimento, não um botão de tuning.
Veja também
Chamadas (VoIP)
O lado do signaling: fazer e atender chamadas, mute, mãos, screen share, upgrades de vídeo.
Sistema de plugins
Como o
voipPlugin() se conecta ao WaClient.