@zapo-js/voip é o plugin oficial de chamadas para zapo-js. Ele se apoia no sistema de plugins, expõe um WaVoipCoordinator em client.voip e emite eventos voip_* no host client.
As chamadas são bidirecionais nos dois tipos de mídia: áudio (MLow/Opus) e vídeo (H.264). O plugin transporta mídia — ele não codifica nem decodifica. O codec é sua parte do contrato: PCM 16 kHz mono para áudio, access units H.264 Annex-B para vídeo.
Esta página cobre chamadas 1:1. Desde a 1.4.0 o zapo também faz e atende chamadas em grupo, que compartilham toda esta superfície e acrescentam um roster mais atribuição por participante — veja Chamadas em grupo.
Instalação
zapo-js é obrigatório. As peers de mídia são opcionais desde a 1.2.0 — instale-as onde a mídia roda de fato, que por padrão é este processo:
Com mídia remota, nenhuma das duas peers de mídia é necessária aqui — a mídia roda no host que tem o microfone.
Vídeo nunca é codificado nem decodificado pelo plugin, então
feedLiveVideo recebe H.264 que você mesmo produz — via ffmpeg, um encoder de hardware ou um stream já codificado.
Conecte o plugin
AdicionevoipPlugin() em WaClientOptions.plugins. O coordinator aparece em client.voip e os eventos voip_* ficam disponíveis em client.on.
Opções do plugin
number
padrão:"1"
Máximo de chamadas simultâneas não-ended (ringing, connecting ou active). Aumente para habilitar multi-call em paralelo.
startCall de saída rejeita, e chamadas de entrada chegam com acceptBlocked: true, quando o limite é atingido.LogLevel
Log level mínimo para o (verboso) plugin de VoIP. Por padrão usa o nível do host client; defina-o para limitar diagnósticos de forma independente — por exemplo
'warn' para manter o ruído do VoIP fora de um host logger em trace.boolean
Disca cada relay na porta que o endpoint
<te2> anuncia, em vez da porta fixa do web client (3480).O padrão depende da sessão e é resolvido por chamada: true num companion, false num mobile primary. O fio nunca diz em qual porta um relay responde, e a medição resolveu — as legs de um companion abrem só na porta anunciada, e as de um primary só na 3480. Passar a opção explicitamente continua vencendo.De todo jeito, uma leg que não abre em 5s, ou que abre e não ouve nada em 4s, é rediscada uma vez na outra porta quando existe uma; um endpoint que já anuncia 3480 não tem outra. Então um primeiro palpite errado custa alguns segundos, não a chamada.boolean
padrão:"false"
Fala com cada relay por um socket UDP conectado simples — sem ICE, DTLS ou SCTP — discando a porta que o relay anuncia. SRTP e o pipeline de jitter não mudam.
{ mode: 'local' | 'remote' }
padrão:"{ mode: 'local' }"
Onde a mídia da chamada roda.
'local' carrega ela neste processo, sobre @roamhq/wrtc e libmlow-wasm-fork. 'remote' não carrega nada dela aqui — veja Mídia em outro host.boolean | MLowRedOptions
padrão:"false"
Envia MLow RED: cada pacote carrega a cópia em baixa taxa do frame anterior, e então o frame em si, de modo que um único pacote perdido possa ser reconstruído. Ligar isso desliga o DTX. Com mídia remota, passe para o
WaCallMediaReceiver do host em vez daqui.Uint8Array | ArrayBuffer | string
Pesos do pós-filtro do decoder MLow Companion — os bytes do
mlow_companion_v1.pte, ou um caminho lido na primeira chamada. Não vem com o package, e custa cerca de 40× a CPU de decode; pesos que falham ao carregar deixam as chamadas decodificando sem ele. Com mídia remota, passe para o host em vez daqui.boolean
padrão:"false"
Emite
voip_call_participant_audio: o áudio decodificado de cada participante de chamada em grupo separado, antes da mixagem. Desligado por padrão, já que cada frame é copiado para isso, e só com mídia local. Veja Chamadas em grupo.A superfície do coordinator
Iniciando uma chamada
startCall recebe uma union discriminada: ou o formato 1:1 abaixo, ou o formato de grupo, nunca uma mistura. Para o formato de grupo veja Chamadas em grupo.
string
obrigatório
JID bare ou de dispositivo a chamar. Obrigatório numa chamada 1:1; proibido junto com
groupJid.boolean
padrão:"false"
Faz uma chamada de vídeo. A mídia de vídeo H.264 passa a trafegar junto com o áudio: envie access units com
feedLiveVideo e receba frames de entrada em voip_call_inbound_video. O tipo de mídia é fixado no momento da oferta — não há upgrade de áudio para vídeo no meio da chamada.string
Arquivo de áudio a pré-carregar e tocar assim que a chamada conectar (precisa de
ffmpeg). Equivalente a chamar loadAudio depois que a chamada ficar ativa.string[]
JIDs explícitos de dispositivo do peer para tocar; omita para resolvê-los automaticamente.
startCall resolve com o id da nova chamada uma vez que a oferta é enviada. O progresso então chega via voip_call_state. Rejeita quando você está no limite de maxConcurrentCalls ou se a oferta falha em enviar.
Atendendo e encerrando
acceptCall(callId)— aceita uma chamada de entrada tocando. Lança secallIdfor desconhecido ou não estiver em um estado aceitável.rejectCall(callId, reason?)— rejeita uma chamada de entrada tocando (reason padrãoDeclined). Envia a stanza de reject e em seguida desmonta a chamada.endCall(callId, reason?)— encerra uma chamada ativa ou conectando (reason padrãoUserEnded). No-op se a chamada for desconhecida ou já estiver encerrada.
leaveCall e joinCall. Veja Sair versus encerrar e Tocando, re-tocando e entrando.
Áudio de saída pré-carregado
audioPath via ffmpeg e o enfileira como o áudio de saída, tocado assim que a chamada ficar ativa. Lança se o arquivo estiver ausente ou se ffmpeg não estiver disponível.
Para uma fonte ilimitada ou ao vivo (stream de TTS, captura contínua) use modo de áudio externo — veja abaixo.
Mute, e o microfone do peer
setMute interrompe a sua própria captura e anuncia o novo estado ao peer numa stanza <mute_v2>, que é o que desenha o indicador de microfone desligado do outro lado. A captura continua rodando e alimenta silêncio, então o stream e seu SSRC seguem vivos e desmutar vale já no frame seguinte. O mesmo estado é anunciado uma vez logo depois que a chamada fica ativa, o que dá ao peer algo para renderizar numa chamada que nunca é mutada.
Ele continua void em todas as camadas: a stanza sai fire-and-forget e um envio que falha é apenas logado, já que o microfone está desligado de qualquer jeito. Um toggle redundante, uma chamada inativa e um callId desconhecido não enviam nada.
Os anúncios do peer chegam em stateData.peerAudioMuted e disparam voip_call_peer_mute:
Mutar outro participante viaja na mesma stanza sob um segundo atributo (
request-state) e não está implementado: o WhatsApp restringe isso a chamadas em grupo, este package só faz chamadas 1:1, e um client que recebe esse pedido numa chamada 1:1 o descarta. Um pedido de entrada é logado e ignorado. Um <mute_v2> enviado por outro device desta mesma conta também é ignorado.Levantar a mão
Levantar a mão é estado durável, não uma notificação pontual: o peer continua vendo a mão até ela ser abaixada, e cada participante só controla a própria. Anunciar um estado que já está em vigor não envia nada, nos dois sentidos.call.stateData.handRaised; os remotos são os JIDs de device em call.raisedHands, cada um sobrevivendo até aquele participante abaixar a mão ou a chamada acabar. A chamada precisa estar ativa. O setHandRaised lança em um callId desconhecido ou num envio que falha.
Uma mão levantada viaja como um de dois message types distintos, e o peer escolhe qual por um gate que este lado não enxerga: um envelope
<user_action action='raise_hand'>, ou um <raise_hand> de topo mais antigo. Os dois são lidos e produzem o mesmo estado e o mesmo evento; o zapo anuncia a própria mão com o primeiro.Reações
Uma reação com emoji não é uma stanza de chamada. Ela viaja no próprio socket de mídia da chamada como um pacote RTP no stream de app-data, protegido com a mesma chave ponta-a-ponta por JID do áudio, então não precisa de um segundo transporte.sendReaction retorna false quando nada foi para o fio — a chamada não está ativa, ou o stream de app-data ainda não abriu. Nada negocia o payload type desse stream (cada lado registra o seu e a oferta não carrega nenhum), então uma reação pode sair antes de qualquer pacote de entrada ter chegado.
Áudio ao vivo de saída (modo externo)
Para fontes que você não consegue pré-carregar — TTS streaming, captura em tempo real — coloque a chamada em modo de áudio externo e bombeie samples conforme chegam. O plugin os bufferiza por meio de um jitter buffer limitado e cria watermarks no buffer de modo que um produtor respeitoso nunca perde áudio.Chamadas de vídeo
O vídeo trafega na mesma sessão do áudio, com SSRC e stream RTP próprios. Ele só fica ativo quando omediaType da chamada é CallMediaType.Video — ou seja, quando você passou isVideo: true para startCall, ou quando o peer ofereceu uma chamada de vídeo que você aceitou.
number
Alimenta uma access unit H.264 Annex-B completa (
Uint8Array, delimitada por start codes). timestampUs é o timestamp de captura em microssegundos; o plugin converte para o clock RTP de 90 kHz. Retorna o número de pacotes RTP enviados, ou 0 quando nada saiu — a mídia de vídeo não está ativa naquela chamada, ou os seus frames ainda estão retidos.Seus primeiros frames ficam retidos
O WhatsApp Web monta o stream para o seu vídeo algum tempo depois do accept, e um primeiro pacote que chega antes desse stream existir deixa o seu vídeo preso nos key frames pelo resto da chamada. Por isso o zapo retém os seus frames até o peer estar pronto:feedLiveVideo retorna 0 enquanto retido, e o stream abre no primeiro key frame alimentado depois que a retenção cai — então uma fonte com intervalo longo de key frame soma até um intervalo a mais.
Recebendo vídeo
Dois eventos carregam vídeo de entrada, e você escolhe o nível que quiser:O plugin transporta vídeo; ele não codifica nem decodifica. Produzir H.264 (e exibir o que chega) é o seu lado do contrato —
ffmpeg, um encoder de hardware ou um stream existente. Também não há mute de vídeo no meio da chamada: stateData.videoOff reflete o que o peer sinaliza, e simplesmente parar de chamar feedLiveVideo interrompe o seu stream de saída.De áudio para vídeo, no meio da chamada
Uma chamada de voz vira chamada de vídeo sem uma nova oferta: os dois lados negociam isso com stanzas<video> e a chamada, seu transporte e sua cripto seguem intactos.
É um handshake, não um anúncio. O pedido sai como UpgradeRequestV2 (o código 1:1), nenhum RTP de vídeo sai antes do peer aceitar, e a espera é limitada pelo mesmo guard timer de cinco segundos que o peer roda.
Respondendo um pedido do peer:
acceptVideoUpgrade abre o sender de vídeo local numa chamada negociada como áudio — a sessão RTP e os SSRCs de vídeo que o relay precisa conhecer, nenhum dos quais uma chamada de áudio tem — e então anuncia a câmera ao peer como um <video> próprio. Sem esse anúncio o peer é informado de que o upgrade foi aceito e nunca é informado de que o vídeo entrou no ar.
O cancelVideoUpgrade(callId) retira um pedido que este lado enviou antes do peer responder; o requestVideoUpgrade pendente então resolve com 'cancelled'.
change.state é o número cru do fio, que é exatamente o ordinal do WA_VIDEO_STATE exportado — não há um enum de fio separado e nada é traduzido em nenhum dos lados. O change também carrega transactionId, deviceOrientation, decoderCodec (dec, o que o peer consegue receber), encoderCodec (enc, o que o encoder dele produz) e supportedCodecs (enc_supported, um bitmask; null significa omitido, não sem suporte).Compartilhamento de tela
Um compartilhamento de tela não é um segundo stream. Ele trafega no stream de vídeo que a chamada já tem, nos mesmos SSRCs e no mesmo payload type — então um share é uma afirmação sobre o que a imagem é, não um novo caminho de mídia. Nada no fio se move quando um começa.WA_SCREEN_SHARE_SEND_VERSION (V2): a câmera de quem compartilha fica desligada enquanto o share durar e a tela toma o lugar dela. Pare de alimentar a câmera antes de iniciar um share e retome depois de parar. O seu estado é call.stateData.screenSharing.
Um início ou fim carrega screen_share_id, contado por chamada a partir de 1, como os clients oficiais enviam.
O setScreenShare lança em um callId desconhecido ou num envio que falha, e — apenas ao iniciar um share — numa chamada que ainda não carrega vídeo (negocie um upgrade, ou chame setVideoEnabled numa chamada em grupo, antes). Uma falha lança sem ter mudado nada, então um peer que nunca ouviu nunca é tido como estando renderizando.
Lendo o share do peer:
PeerScreenShare (state, requestState, version, screenWidth, screenHeight, deviceOrientation) é null quando a stanza não o carregou, e números desconhecidos passam adiante em vez de colapsar num nome conhecido. O último valor fica em call.peerScreenShare, então um listener de voip_call_state consegue lê-lo sem assinar separadamente.
A partir de
WA_SCREEN_SHARE_VERSION.V3, quem compartilha roda tela e câmera ao mesmo tempo como dois streams — mas um screen share não tem espaço de SSRC próprio, e como um sender V3 dispõe dois streams sobre esse único espaço não está estabelecido. Por isso o zapo compartilha em V2 e nunca anuncia V3.Snapshots
CallInfo captura tudo que você precisa para renderizar UI: callId, peerJid, callCreator, direction, mediaType, createdAt, callerPn, groupJid, mais os getters derivados isInitiator, isActive, isRinging, isEnded, canAccept, canReject, isAcceptBlocked.
O
isGroupCall se junta aos getters derivados: verdadeiro quando a chamada nomeia um grupo, carrega um roster, ou o manager a marcou como tal.
callerPn é o JID de telefone de quem ligou, lido do atributo caller_pn de uma oferta de entrada. É o que permite associar a um número de telefone alguém que a oferta identifica apenas por LID. Ele não muda a derivação de mídia: o SSRC do peer e as chaves SRTP continuam presos ao JID de device do LID, que é o que o peer deriva.Eventos
AdicionevoipPlugin() a plugins e estes eventos ficam disponíveis em client.on. Sem o plugin eles não existem no tipo — veja Plugins.
O
voip_call_inbound_audio é ritmado pelo jitter buffer num tick de 60 ms com 960 samples. Perda ocultada e underrun do buffer chegam como áudio e silêncio respectivamente, então o stream mantém a base de tempo da chamada; um tick sem nada enfileirado é pulado. O áudio decodificado é copiado para ele apenas enquanto houver um listener — inclusive um adicionado depois da chamada começar — então deixar sem assinar não custa nada.O
participantJid nomeia o device remetente em todo evento que o carrega, então um listener serve tanto para 1:1 quanto para grupo. Ele é opcional porque uma chamada 1:1 tem só um peer, e porque a atribuição depende de o host de mídia conseguir distinguir remetentes. Os eventos de nível de manager (client.voip.on('call_*')) o recebem como argumento final, então listeners escritos antes da 1.4.0 continuam funcionando.Enums
Todos os valores doenum são strings minúsculas — use as constantes do enum para evitar erros de digitação.
CallState
CallDirection
CallMediaType
EndCallReason
AcceptedElsewhere / RejectedElsewhere são o que você renderiza como “atendida em outro dispositivo” em vez de chamada perdida. Um <accept> de outro device seu numa chamada que este está recebendo a encerra como AcceptedElsewhere sem enviar nada, em vez de rodar o fluxo de accept de saída e tocar até dar timeout.WA_VIDEO_STATE
Os números crus do fio que um <video> carrega — change.state em voip_call_peer_video_state é o próprio ordinal.
WA_VIDEO_UPGRADE_TIMEOUT_MS (5000) é o guard timer que os dois lados rodam.
WA_SCREEN_SHARE_STATE e WA_SCREEN_SHARE_VERSION
O
WA_SCREEN_SHARE_VERSION vai de Invalid: -1, Legacy: 0, V1…V4. Ele decide se um share é um segundo stream de vídeo ou uma substituição da câmera: até V2 a tela é o único stream de vídeo da chamada e a câmera é forçada a desligar; a partir de V3 quem compartilha roda as duas ao mesmo tempo. Invalid é negativo de propósito — é o sentinela do próprio client para “sem versão”, não uma versão que possa trafegar. O zapo anuncia WA_SCREEN_SHARE_SEND_VERSION, que é V2.
Formatos de mídia
Áudio
- Sample rate: 16 kHz.
- Canais: mono.
- Formato de sample:
Float32Arrayem[-1.0, 1.0]. - Codec no fio: perfil Opus do WhatsApp, codificado via
libmlow-wasm. O payload type RTP é120(PayloadType.WhatsAppOpus).
voip_call_inbound_audio) quanto a saída (feedLiveAudio) usam o mesmo formato Float32Array — mantenha seus buffers neste formato e você consegue roteá-los direto.
Vídeo
- Codec: H.264, byte stream Annex-B (NAL units delimitadas por start code).
- Unidade de troca: uma access unit completa por chamada de
feedLiveVideo, e uma por eventovoip_call_inbound_video. - Clock: RTP de 90 kHz. Você passa timestamps de captura em microssegundos; o
frame.timestampde entrada já vem em unidades RTP. - Payload types RTP:
97(PayloadType.WhatsAppH264) e103(PayloadType.WhatsAppH264Rtx, o stream de reparo).
Exemplo prático
Um auto-accept mínimo de entrada que grava o PCM de entrada em um buffer em memória:examples/voip-example.ts na árvore de source do zapo.
Veja também
Chamadas em grupo
Fazer, atender, entrar e sair de uma chamada em grupo, e a superfície por participante.
Mídia em outro host
Rode a mídia de uma chamada no browser com
@zapo-js/voip-media enquanto o signaling fica no servidor.Sistema de plugins
Como
voipPlugin() se encaixa no WaClient e como autorar o seu próprio.Eventos
A superfície de eventos do host client; eventos
voip_* ficam ao lado dos built-ins.Créditos
O plugin de VoIP foi construído por:- @vinikjkkj
- @edgardmessias — Edgard Lorraine Messias
- @w3nder — Wender Teixeira
