Skip to main content
Por padrão a mídia de uma chamada — as legs de relay, SRTP, o codec, o jitter buffer e o clock de áudio — roda no mesmo processo do plugin de VoIP. Esse é o formato certo para um bot: o áudio é sintetizado e consumido onde o signaling está. É o formato errado quando tem uma pessoa na chamada. O microfone está no browser dela, e rotear esse áudio pelo seu servidor para ser codificado lá adiciona um salto, uma dependência de codec e um build nativo a uma máquina que só precisa falar o protocolo. O 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:
No browser:
O @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:
O browser precisa de um contexto seguro — HTTPS ou localhost. Tanto o microfone (getUserMedia) quanto o AudioWorklet dependem disso.

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:
Com media: { mode: 'remote' }, loadAudio, setExternalAudioMode e feedLiveAudio lançam; sendReaction retorna false; feedLiveVideo retorna 0; e voip_call_inbound_audio / voip_call_inbound_video / voip_call_inbound_video_rtp nunca disparam. Áudio, vídeo e reações vivem no host de mídia.
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:
O plano carrega as chaves SRTP e as credenciais de relay da chamada. WaCallMediaMessage.plan.keys e as credenciais dentro de plan.relays são segredos: o canal até o host de mídia precisa ser privado daquele host e criptografado em trânsito. Nunca logue uma mensagem de plano, nunca persista uma, e nunca faça broadcast dela para uma sala.

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. O snapshot entrega a ele o plano inteiro como está:
Retorna 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

O plano é a descrição de mídia que o host precisa: 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

Cada 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

O WaCallMediaReceiver 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 evento resync carregando lastSeq. Responda com snapshot(callId).
  • Uma mensagem full é aceita sempre que o seq dela 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.
Você não precisa implementar nada disso — é trabalho do receiver. Você só precisa responder ao 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:
Registre o listener do socket antes do primeiro await. O receiver.start() carrega o codec WASM, e uma mensagem de plano que chegar durante esse await sem listener registrado simplesmente se perde — o receiver depois vai ver a lacuna e pedir resync, mas você pagou um round-trip à toa.
Chame WaWebCallAudio.start de dentro do handler de clique, não depois de um await que escape do gesto. O AudioContext é criado e retomado antes do primeiro await internamente, mas o gesto precisa ainda estar vivo quando você chamar, ou o getUserMedia e o contexto podem travar.
O accept fica no servidor de propósito: ele é uma stanza <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.
O 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 com onInboundVideo para os frames do peer, e entregue ao plane um track de câmera para os seus:
Os dois lados usam WebCodecs. O WaWebCallVideoSender.start lança quando o browser não tem VideoEncoder, ou quando você entrega um track que não é de vídeo.
O onFrame te entrega um VideoFrame que você precisa dar close(). Um frame esquecido prende memória de GPU, e alguns segundos disso travam o decoder.

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óprio crypto.subtle do browser.
  • createPeerConnection — createBrowserPeerConnection, o próprio RTCPeerConnection do browser. Uma configuração que o browser recusa rejeita a promise.
Não existem legs UDP cruas num browser, então 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

O stop() retorna WaCallMediaStats, que é o que logar quando uma chamada soou errada:
O audioCaptureSkewMs é o instante da captura menos o timestamp do último frame de áudio enviado: positivo significa que a timeline está atrasada. Uma subida constante ali é um host que não consegue acompanhar o clock, não um problema de rede.

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á:
O 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:
Leia o aviso sobre 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.
Última modificação em 8 de outubro de 2026