diff --git a/.github/workflows/manager-v2.yml b/.github/workflows/manager-v2.yml
new file mode 100644
index 00000000..4a1f4beb
--- /dev/null
+++ b/.github/workflows/manager-v2.yml
@@ -0,0 +1,70 @@
+name: Manager V2
+
+on:
+ pull_request:
+ paths:
+ - manager-v2/**
+ - pkg/routes/**
+ - Dockerfile
+ - .github/workflows/manager-v2.yml
+ push:
+ branches:
+ - feat/manager-v2-foundation
+ paths:
+ - manager-v2/**
+ - pkg/routes/**
+ - Dockerfile
+ - .github/workflows/manager-v2.yml
+
+permissions:
+ contents: read
+
+jobs:
+ build-manager-v2:
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22.12"
+ cache: npm
+ cache-dependency-path: manager-v2/package.json
+
+ - name: Install dependencies
+ working-directory: manager-v2
+ run: npm install --no-audit --no-fund
+
+ - name: Typecheck
+ working-directory: manager-v2
+ run: npm run typecheck
+
+ - name: Validate API catalog coverage
+ working-directory: manager-v2
+ run: npm run check:catalog
+
+ - name: Build frontend
+ working-directory: manager-v2
+ run: npm run build
+
+ - name: Validate embedded route
+ run: |
+ test -f manager-v2/dist/index.html
+ grep -q registerManagerV2Routes pkg/routes/routes.go
+ grep -q /manager-v2/assets pkg/routes/manager_v2.go
+ grep -q manager-v2-build Dockerfile
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Test Manager V2 routes
+ run: go test ./pkg/routes
+
+ - name: Build server
+ run: go build -o /tmp/evolution-go-manager-v2 ./cmd/evolution-go
diff --git a/.github/workflows/voip-integration.yml b/.github/workflows/voip-integration.yml
new file mode 100644
index 00000000..cc01bebf
--- /dev/null
+++ b/.github/workflows/voip-integration.yml
@@ -0,0 +1,57 @@
+name: VoIP integration
+
+on:
+ push:
+ branches:
+ - dev/astracalls-integration
+ - feat/manager-call-panel
+ pull_request:
+ paths:
+ - "cmd/evolution-go/main.go"
+ - "pkg/call/**"
+ - "pkg/routes/routes.go"
+ - "manager/dist/**"
+ - "go.mod"
+ - "go.sum"
+ - ".github/workflows/voip-integration.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ test-call-module:
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Go
+ uses: actions/setup-go@v5
+ with:
+ go-version-file: go.mod
+ cache: true
+
+ - name: Validate Manager call panel
+ run: |
+ node --check manager/dist/assets/call-manager.js
+ grep -q '/assets/call-manager.css' manager/dist/index.html
+ grep -q '/assets/call-manager.js' manager/dist/index.html
+ grep -q 'evolution-call-pcm' manager/dist/assets/call-manager.js
+ grep -q '/call/status' manager/dist/assets/call-manager.js
+
+ - name: Download dependencies
+ run: go mod download
+
+ - name: Test default call build
+ run: go test -race ./pkg/call/...
+
+ - name: Test experimental Pion relay build
+ run: go test -race -tags=voip_pion ./pkg/call/...
+
+ - name: Build default server
+ run: go build -o /tmp/evolution-go-default ./cmd/evolution-go
+
+ - name: Build experimental Pion server
+ run: go build -tags=voip_pion -o /tmp/evolution-go-pion ./cmd/evolution-go
diff --git a/Dockerfile b/Dockerfile
index 462ed49d..e2b5b4b5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,3 +1,11 @@
+FROM node:22-alpine AS manager-v2-build
+
+WORKDIR /manager-v2
+COPY manager-v2/package.json ./
+RUN npm install --no-audit --no-fund
+COPY manager-v2/ ./
+RUN npm run typecheck && npm run build
+
FROM golang:1.25.0-alpine AS build
RUN apk update && apk add --no-cache git build-base libjpeg-turbo-dev libwebp-dev
@@ -15,7 +23,10 @@ RUN go mod download
COPY . .
ARG VERSION=dev
-RUN CGO_ENABLED=1 go build -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go
+# Mantém a imagem padrão sem tags. Para habilitar chamadas com Pion, use:
+# docker build --build-arg GO_BUILD_TAGS=voip_pion ...
+ARG GO_BUILD_TAGS=""
+RUN CGO_ENABLED=1 go build -tags "${GO_BUILD_TAGS}" -ldflags "-X main.version=${VERSION}" -o server ./cmd/evolution-go
FROM alpine:3.19.1 AS final
@@ -26,6 +37,7 @@ WORKDIR /app
COPY --from=build /build/server .
COPY --from=build /build/manager/dist ./manager/dist
+COPY --from=manager-v2-build /manager-v2/dist ./manager-v2/dist
COPY --from=build /build/VERSION ./VERSION
ENV TZ=America/Sao_Paulo
diff --git a/cmd/evolution-go/main.go b/cmd/evolution-go/main.go
index 5234583f..efcc97fe 100644
--- a/cmd/evolution-go/main.go
+++ b/cmd/evolution-go/main.go
@@ -22,6 +22,7 @@ import (
_ "modernc.org/sqlite"
call_handler "github.com/evolution-foundation/evolution-go/pkg/call/handler"
+ call_lifecycle "github.com/evolution-foundation/evolution-go/pkg/call/lifecycle"
call_service "github.com/evolution-foundation/evolution-go/pkg/call/service"
chat_handler "github.com/evolution-foundation/evolution-go/pkg/chat/handler"
chat_service "github.com/evolution-foundation/evolution-go/pkg/chat/service"
@@ -179,6 +180,9 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C
natsProducer,
loggerWrapper,
)
+ callCoordinator := call_lifecycle.NewCoordinator()
+ whatsmeowService.SetClientLifecycle(callCoordinator)
+
instanceService := instance_service.NewInstanceService(
instanceRepository,
killChannel,
@@ -192,7 +196,7 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C
messageService := message_service.NewMessageService(clientPointer, messageRepository, whatsmeowService, loggerWrapper)
chatService := chat_service.NewChatService(clientPointer, whatsmeowService, loggerWrapper)
groupService := group_service.NewGroupService(clientPointer, whatsmeowService, loggerWrapper)
- callService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper)
+ callService := call_service.NewCallService(clientPointer, whatsmeowService, loggerWrapper, callCoordinator)
communityService := community_service.NewCommunityService(clientPointer, whatsmeowService, loggerWrapper)
labelService := label_service.NewLabelService(clientPointer, whatsmeowService, labelRepository, loggerWrapper)
newsletterService := newsletter_service.NewNewsletterService(clientPointer, whatsmeowService, loggerWrapper)
diff --git a/docs/examples/call-webrtc-pcm.html b/docs/examples/call-webrtc-pcm.html
new file mode 100644
index 00000000..65c6ecac
--- /dev/null
+++ b/docs/examples/call-webrtc-pcm.html
@@ -0,0 +1,335 @@
+
+
+
+
+
+ Evolution Go — chamada WebRTC PCM
+
+
+
+ Chamada WebRTC PCM
+ Exemplo experimental. Use HTTPS ou localhost para liberar o microfone. A chamada do WhatsApp precisa estar no estado active e o servidor deve ser compilado com -tags=voip_pion.
+
+
+
+ Estado
+
+
+
+
+
diff --git a/docs/examples/call-webrtc-public-deploy.md b/docs/examples/call-webrtc-public-deploy.md
new file mode 100644
index 00000000..9b18b537
--- /dev/null
+++ b/docs/examples/call-webrtc-public-deploy.md
@@ -0,0 +1,140 @@
+# Implantação pública da ponte WebRTC de chamadas
+
+Este guia publica a ponte navegador ⇄ Evolution em uma única porta UDP/TCP, sem depender de STUN ou TURN externo.
+
+> A mídia do navegador só existe na build `voip_pion`. A chamada WhatsApp também precisa chegar ao estado `active` antes da criação da sessão WebRTC.
+
+## Variáveis obrigatórias
+
+Configure o IPv4 anunciado e a porta de mídia:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+Em uma VPS cujo IPv4 público está diretamente associado à interface, também é possível usar:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=auto
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+`auto` detecta o IPv4 escolhido pela rota padrão. Em servidores atrás de NAT, balanceador ou encaminhamento de porta, use explicitamente o endereço externo.
+
+As duas variáveis devem ser definidas juntas. Configuração parcial, endereço inválido, porta inválida ou falha de bind impedem a criação da sessão WebRTC.
+
+## Compilação direta
+
+```bash
+go build -tags=voip_pion -o evolution-go ./cmd/evolution-go
+
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+CALL_WEBRTC_MEDIA_PORT=50000 \
+./evolution-go
+```
+
+## Imagem Docker
+
+O Dockerfile mantém a build padrão quando nenhum argumento é informado. Para incluir o Pion:
+
+```bash
+docker build \
+ --build-arg GO_BUILD_TAGS=voip_pion \
+ -t evolution-go:voip-pion .
+```
+
+### Rede host
+
+É a opção mais simples em uma VPS Linux:
+
+```bash
+docker run --rm \
+ --network host \
+ -e CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+ -e CALL_WEBRTC_MEDIA_PORT=50000 \
+ evolution-go:voip-pion
+```
+
+### Encaminhamento explícito
+
+Quando a rede host não estiver disponível, publique a mesma porta nos dois protocolos:
+
+```bash
+docker run --rm \
+ -p 8080:8080/tcp \
+ -p 50000:50000/udp \
+ -p 50000:50000/tcp \
+ -e CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+ -e CALL_WEBRTC_MEDIA_PORT=50000 \
+ evolution-go:voip-pion
+```
+
+Exemplo equivalente em Compose:
+
+```yaml
+services:
+ evolution:
+ build:
+ context: .
+ args:
+ GO_BUILD_TAGS: voip_pion
+ environment:
+ CALL_WEBRTC_PUBLIC_IP: 203.0.113.10
+ CALL_WEBRTC_MEDIA_PORT: "50000"
+ ports:
+ - "8080:8080/tcp"
+ - "50000:50000/udp"
+ - "50000:50000/tcp"
+```
+
+## Firewall e proxy
+
+Libere no firewall ou security group:
+
+```text
+UDP 50000 entrada
+TCP 50000 entrada
+```
+
+Traefik, Nginx ou Caddy podem publicar a API e a página em HTTPS, mas não devem intermediar a porta ICE. O tráfego WebRTC chega diretamente ao Evolution:
+
+```text
+navegador ── UDP 50000 ──► Evolution
+ └─ TCP 50000 ──► Evolution, quando UDP falha
+```
+
+O HTTPS continua obrigatório para que navegadores remotos liberem `getUserMedia`.
+
+## Comportamento do runtime
+
+Quando as variáveis estão configuradas, o processo cria uma única API Pion compartilhada e:
+
+- anuncia o IPv4 configurado por NAT 1:1;
+- usa `ICEUDPMux` na porta fixa;
+- usa `ICETCPMux` passivo na mesma porta;
+- compartilha os muxes entre todas as sessões e chamadas;
+- mantém os limites de quatro sessões por chamada e oito frames por fila;
+- não entrega chaves WhatsApp ou SRTP ao navegador.
+
+Sem as variáveis, a ponte continua usando candidatos host e portas efêmeras para desenvolvimento local.
+
+## Quando TURN ainda é necessário
+
+Esta estratégia cobre VPSs e servidores com uma porta pública encaminhável. TURN ainda pode ser necessário quando:
+
+- o servidor está atrás de CGNAT sem encaminhamento;
+- a rede do navegador bloqueia UDP e também ICE-TCP nessa porta;
+- somente tráfego por 443 através de um relay é permitido;
+- a implantação exige compatibilidade máxima em redes corporativas restritas.
+
+## Checklist de validação
+
+1. Confirme que a imagem foi compilada com `GO_BUILD_TAGS=voip_pion`.
+2. Confirme que UDP e TCP estão liberados na porta configurada.
+3. Inicie o Evolution com as duas variáveis.
+4. Verifique o log `browser WebRTC fixed ICE endpoint enabled`.
+5. Deixe a chamada WhatsApp chegar a `active`.
+6. Abra `docs/examples/call-webrtc-pcm.html` por HTTPS.
+7. Crie a sessão e confirme candidatos com o IPv4 e a porta pública no SDP answer.
+8. Teste microfone e reprodução a partir de outra rede.
diff --git a/docs/examples/manager-call-panel.md b/docs/examples/manager-call-panel.md
new file mode 100644
index 00000000..dd4809bb
--- /dev/null
+++ b/docs/examples/manager-call-panel.md
@@ -0,0 +1,74 @@
+# Painel de chamadas no `/manager`
+
+O Manager carrega um módulo de chamadas independente do bundle React existente. Um botão de telefone aparece no canto inferior direito de `/manager` e usa as rotas autenticadas de chamadas da instância.
+
+## Requisitos
+
+- servidor compilado com `-tags=voip_pion`;
+- instância WhatsApp conectada;
+- página do Manager em HTTPS ou `localhost` para acesso ao microfone;
+- para redes diferentes, `CALL_WEBRTC_PUBLIC_IP` e `CALL_WEBRTC_MEDIA_PORT` configurados e a porta liberada em UDP e TCP.
+
+## Uso
+
+1. Abra `https://SEU_DOMINIO/manager`.
+2. Clique no botão de telefone.
+3. Confirme a URL da API. Quando o Manager e a API usam o mesmo domínio, o valor padrão já é correto.
+4. Informe a API key da instância.
+5. Clique em **Salvar e consultar**.
+6. Digite o número completo com DDI e clique em **Ligar**.
+7. Quando a chamada ficar `active`, o painel tenta conectar o áudio automaticamente. Também é possível usar **Conectar áudio** manualmente.
+
+O painel permite:
+
+- iniciar chamadas de voz;
+- acompanhar `ringing`, `connecting`, `active`, `ended` e `failed`;
+- atender ou recusar chamadas recebidas;
+- conectar microfone e alto-falante pelo DataChannel PCM;
+- silenciar o microfone;
+- encerrar a chamada;
+- consultar contadores de frames enviados, recebidos e descartados;
+- visualizar diagnóstico local.
+
+## Armazenamento da chave
+
+Por padrão, a configuração fica em `sessionStorage` e desaparece quando a sessão do navegador é encerrada. A opção **Salvar chave neste navegador** usa `localStorage` para manter a API key entre acessos.
+
+Não habilite a persistência em computadores compartilhados. A chave nunca é colocada na URL ou no conteúdo do log do painel.
+
+## Implementação
+
+O Manager versionado contém apenas o bundle compilado original. Por isso, o módulo foi integrado como assets isolados:
+
+```text
+manager/dist/assets/call-manager.js
+manager/dist/assets/call-manager.css
+```
+
+E carregado por:
+
+```text
+manager/dist/index.html
+```
+
+O módulo usa o mesmo protocolo da página de teste independente:
+
+```text
+DataChannel: evolution-call-pcm
+Protocol: evcall.pcm.v1
+PCM: float32 little-endian, mono, 16 kHz
+Frame nominal: 960 amostras / 60 ms
+```
+
+## Diagnóstico
+
+Se o painel mostrar `501`, a imagem foi compilada sem `voip_pion`.
+
+Se a chamada fica ativa, mas o canal de áudio não abre:
+
+- confirme o log `browser WebRTC fixed ICE endpoint enabled`;
+- libere `CALL_WEBRTC_MEDIA_PORT` em UDP e TCP;
+- confira os candidatos em `chrome://webrtc-internals`;
+- confirme que o Manager está sendo acessado por HTTPS.
+
+O painel fecha tracks do microfone, AudioContext, DataChannel e PeerConnection ao desconectar o áudio. O backend também remove a sessão WebRTC quando a chamada termina, a instância desconecta ou o cliente WhatsApp é substituído.
diff --git a/docs/wiki/guias-api/api-calls-experimental.md b/docs/wiki/guias-api/api-calls-experimental.md
new file mode 100644
index 00000000..f5d3e65c
--- /dev/null
+++ b/docs/wiki/guias-api/api-calls-experimental.md
@@ -0,0 +1,356 @@
+# API de chamadas — integração experimental
+
+Esta branch adiciona a integração experimental WaCalls/AstraCalls ao Evolution Go.
+
+> **Estado atual:** a sinalização é real e permite iniciar, receber, aceitar, rejeitar e encerrar chamadas no nível do protocolo. A variante `voip_pion` negocia DataChannels com os relays do WhatsApp, processa RTP/SRTP autenticado, usa codec MLow com jitter/PLC e oferece uma ponte WebRTC PCM para microfone e reprodução no navegador. A conexão com relays reais ainda precisa de validação ponta a ponta. Não use em produção.
+
+Todas as rotas usam a autenticação normal da instância do Evolution.
+
+## Consultar o runtime
+
+```http
+GET /call/status
+apikey: INSTANCE_TOKEN
+```
+
+Os monitores são anexados automaticamente quando o Evolution cria o `whatsmeow.Client`. Durante reconexão, logout ou remoção da instância, handlers, DataChannels, sessões RTP/SRTP, jitter buffers, codecs, sessões WebRTC e material privado são removidos antes que o novo cliente seja registrado.
+
+Exemplo de resposta:
+
+```json
+{
+ "instanceId": "INSTANCE_ID",
+ "connected": true,
+ "calls": []
+}
+```
+
+Chaves de chamada, JIDs internos de dispositivos, chaves SRTP, tokens de relay, pacotes enfileirados e buffers PCM nunca fazem parte dessa resposta.
+
+## Iniciar uma chamada
+
+```http
+POST /call/start
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+ "number": "5511999999999",
+ "video": false
+}
+```
+
+A oferta é enviada como uma consulta do protocolo. Quando o ACK contém relays estruturados, a chave gerada, os participantes e os candidatos são copiados para o registro privado da chamada.
+
+A resposta HTTP `201` contém somente o estado público:
+
+```json
+{
+ "id": "32_CHARACTER_CALL_ID",
+ "peer": "5511999999999:DEVICE@s.whatsapp.net",
+ "direction": "outgoing",
+ "state": "ringing",
+ "video": false,
+ "createdAt": "2026-07-31T23:00:00Z",
+ "updatedAt": "2026-07-31T23:00:00Z"
+}
+```
+
+## Aceitar uma chamada recebida
+
+```http
+POST /call/{callId}/accept
+apikey: INSTANCE_TOKEN
+```
+
+O runtime descriptografa a chave recebida usando a sessão Signal já autenticada, envia `preaccept` automaticamente e mantém o material somente na memória privada. O endpoint envia a stanza `accept` e retorna a chamada no estado `connecting`.
+
+`CallAccept` não marca a chamada como `active` sozinho. Na variante Pion, `active` é publicado somente depois que o relay abre e as sessões RTP/SRTP, jitter e MLow são criadas com sucesso.
+
+## Encerrar ou rejeitar
+
+```http
+DELETE /call/{callId}
+apikey: INSTANCE_TOKEN
+```
+
+A rota envia `terminate`, muda o estado público para `ended` e remove relays, contextos criptográficos, codec, jitter, buffers PCM e sessões WebRTC ligadas à chamada.
+
+A rota de rejeição existente foi preservada:
+
+```http
+POST /call/reject
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+ "callCreator": "5511999999999@s.whatsapp.net",
+ "callId": "CALL_ID"
+}
+```
+
+## Estados rastreados
+
+O snapshot público usa `ringing`, `connecting`, `active`, `ended` e `failed`.
+
+Internamente, a negociação usa uma máquina estrita com `initiating`, `ringing`, `incoming_ringing`, `connecting`, `active`, `on_hold` e `ended`. Transições inválidas são rejeitadas sem alterar o estado.
+
+## Relay e transporte Pion
+
+O módulo reconhece candidatos com atributos diretos e respostas estruturadas `te2`. Os candidatos são ordenados pelo menor RTT e associados ao material privado pelo `callId`.
+
+A implementação Pion experimental inclui:
+
+- PeerConnection e DataChannel `wa-web-call` por relay;
+- transformação do SDP para credenciais e fingerprint do WhatsApp;
+- registro STUN com subscriptions de SSRC;
+- allocation, retries e keepalive;
+- broadcast e recebimento de frames;
+- timeout, fechamento e limpeza de buffers;
+- SSRC determinístico por `callId` e JID de dispositivo.
+
+## RTP e SRTP
+
+O caminho de pacotes inclui:
+
+- RTP versão 2 com CSRC, extensões e padding validados;
+- payload type `120`;
+- derivação HKDF-SHA256 por dispositivo;
+- AES-CTR e HMAC-SHA1 truncado;
+- autenticação verificada antes da descriptografia;
+- rollover counter na transição `65535 → 0`;
+- janela antirreplay de 64 pacotes;
+- pacotes autenticados fora de ordem;
+- rejeição de reutilização do índice de envio;
+- sessão independente por `callId`;
+- limpeza sincronizada durante término, rejeição, logout ou reconexão.
+
+## Jitter buffer e perda de pacotes
+
+Cada chamada possui um jitter buffer antes do decoder MLow. A configuração padrão atual é fixa:
+
+- frames de 60 ms;
+- atraso inicial de dois pacotes, aproximadamente 120 ms;
+- limite de 64 pacotes enfileirados;
+- até cinco frames consecutivos de concealment por lacuna.
+
+O buffer usa sequência estendida para rollover, aceita pacotes fora de ordem antes do prazo, contabiliza duplicatas/atrasados/overflow e chama `Decode(nil)` somente quando um pacote futuro confirma a lacuna. Ele não fabrica áudio ao final do fluxo.
+
+## Codec MLow e PCM
+
+O codec MLow em Go puro foi portado da revisão MIT fixa `edeb31f0427aba896639db503153b777a405eccf` do WaCalls. Não há dependência de CGO ou `libopus`.
+
+O pipeline:
+
+- aceita PCM mono `float32` em 16 kHz;
+- acumula frames de 960 amostras/60 ms;
+- sanitiza `NaN`, infinito e amplitudes fora de `[-1, 1]`;
+- codifica MLow e envia por RTP/SRTP;
+- reordena e aplica PLC antes do decode recebido;
+- entrega PCM por callback interno;
+- serializa encoder/decoder por chamada;
+- espera envio e playout antes do teardown.
+
+## Ponte WebRTC do navegador
+
+A ponte do navegador usa WebRTC para fornecer DTLS/SCTP, mas transmite PCM em um DataChannel em vez de uma media track. Isso evita uma segunda pilha Opus no servidor e reutiliza diretamente o pipeline MLow existente.
+
+Ela só está disponível na build `voip_pion`. A build padrão responde `501 Not Implemented`.
+
+### Criar sessão
+
+A chamada deve estar em `active`.
+
+```http
+POST /call/{callId}/webrtc
+Content-Type: application/json
+apikey: INSTANCE_TOKEN
+```
+
+```json
+{
+ "offer": {
+ "type": "offer",
+ "sdp": "v=0\r\n..."
+ }
+}
+```
+
+O navegador deve criar previamente um DataChannel com:
+
+```text
+label: evolution-call-pcm
+protocol: evcall.pcm.v1
+ordered: true
+```
+
+Resposta `201`:
+
+```json
+{
+ "sessionId": "UUID",
+ "answer": {
+ "type": "answer",
+ "sdp": "v=0\r\n..."
+ },
+ "audio": {
+ "dataChannel": "evolution-call-pcm",
+ "protocol": "evcall.pcm.v1",
+ "format": "f32le",
+ "sampleRate": 16000,
+ "channels": 1,
+ "frameSamples": 960
+ }
+}
+```
+
+A API espera uma oferta completa com os candidatos ICE já coletados. Não há endpoint de trickle ICE nesta etapa.
+
+### Listar e fechar sessões
+
+```http
+GET /call/{callId}/webrtc
+apikey: INSTANCE_TOKEN
+```
+
+A resposta contém estado, frames de entrada/saída e descartes por sessão.
+
+```http
+DELETE /call/{callId}/webrtc/{sessionId}
+apikey: INSTANCE_TOKEN
+```
+
+Há limite de quatro sessões por chamada. Todas são fechadas automaticamente quando a chamada termina, é rejeitada, a instância desconecta ou o cliente WhatsApp é substituído.
+
+### Framing PCM `EVPC` versão 1
+
+Cada mensagem binária possui:
+
+| Offset | Tamanho | Campo |
+|---:|---:|---|
+| 0 | 4 | magic ASCII `EVPC` |
+| 4 | 1 | versão `1` |
+| 5 | 1 | tipo `1` para PCM |
+| 6 | 2 | flags, atualmente zero, little-endian |
+| 8 | 4 | sample rate `16000`, little-endian |
+| 12 | 4 | número de amostras, little-endian |
+| 16 | variável | amostras `float32` little-endian |
+
+O servidor aceita no máximo 3840 amostras por mensagem. O frame nominal contém 960 amostras. Filas internas possuem oito frames e o envio é descartado quando o buffer SCTP ultrapassa 512 KiB.
+
+### Exemplo pronto
+
+Abra o arquivo:
+
+```text
+docs/examples/call-webrtc-pcm.html
+```
+
+Ele implementa:
+
+- troca SDP autenticada;
+- `getUserMedia` com cancelamento de eco, redução de ruído e ganho automático;
+- resampling da taxa do `AudioContext` para 16 kHz;
+- envio em frames de 960 amostras;
+- resampling de 16 kHz para a taxa do dispositivo;
+- reprodução por `AudioWorklet`;
+- mute, backpressure e encerramento da sessão.
+
+O navegador exige HTTPS ou `localhost` para liberar o microfone.
+
+### Redes diferentes sem TURN
+
+Sem configuração adicional, o Pion mantém candidatos host e portas efêmeras, adequado para desenvolvimento local. Para publicar a ponte diretamente na internet, configure as duas variáveis antes de iniciar o processo:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+Também é aceito:
+
+```env
+CALL_WEBRTC_PUBLIC_IP=auto
+CALL_WEBRTC_MEDIA_PORT=50000
+```
+
+`auto` consulta o endereço IPv4 escolhido pela rota padrão. Ele é apropriado quando o Evolution roda diretamente em uma VPS com o IP público na interface. Em máquinas atrás de NAT, informe explicitamente o endereço externo encaminhado.
+
+Quando as variáveis estão presentes, o runtime:
+
+- anuncia `CALL_WEBRTC_PUBLIC_IP` como candidato ICE host por NAT 1:1;
+- abre `0.0.0.0:CALL_WEBRTC_MEDIA_PORT` em UDP;
+- abre a mesma porta em TCP para fallback ICE-TCP passivo;
+- usa um `ICEUDPMux` e um `ICETCPMux` compartilhados por todas as sessões;
+- mantém a troca SDP completa, sem trickle ICE;
+- não depende de um servidor STUN/TURN externo.
+
+As duas variáveis são obrigatórias em conjunto. Endereço, porta ou bind inválidos fazem a criação da sessão WebRTC falhar explicitamente; o sistema não troca silenciosamente para portas efêmeras.
+
+No firewall ou security group, libere a mesma porta nos dois protocolos:
+
+```text
+UDP 50000 entrada
+TCP 50000 entrada
+```
+
+Em Docker, use rede host ou encaminhe a porta fixa diretamente:
+
+```yaml
+ports:
+ - "50000:50000/udp"
+ - "50000:50000/tcp"
+```
+
+Traefik, Nginx e outros proxies HTTP continuam responsáveis apenas por HTTPS/API. A mídia ICE chega diretamente à porta UDP/TCP configurada.
+
+TURN ainda pode ser necessário em redes corporativas que bloqueiem tanto UDP quanto ICE-TCP para portas externas, ou quando o servidor não possui qualquer porta publicamente encaminhável.
+
+## Build experimental
+
+```bash
+go build ./cmd/evolution-go
+go build -tags=voip_pion ./cmd/evolution-go
+```
+
+Exemplo de execução pública:
+
+```bash
+CALL_WEBRTC_PUBLIC_IP=203.0.113.10 \
+CALL_WEBRTC_MEDIA_PORT=50000 \
+./evolution-go
+```
+
+O workflow testa permanentemente as duas variantes:
+
+```bash
+go test -race ./pkg/call/...
+go test -race -tags=voip_pion ./pkg/call/...
+```
+
+## Segurança e limites
+
+- todas as rotas SDP são autenticadas pela instância;
+- a chamada deve estar `active` antes de criar a sessão;
+- ofertas SDP acima de 256 KiB são rejeitadas;
+- mensagens de texto, labels/protocolos incorretos e frames PCM inválidos são descartados;
+- filas e buffer SCTP são limitados;
+- payloads e PCM temporários são sobrescritos antes do descarte;
+- chaves de chamada e SRTP nunca são entregues ao navegador;
+- nenhuma rota HTTP recebe áudio bruto;
+- a porta de mídia aceita tráfego ICE público e deve ser protegida por firewall contra origens e volumes abusivos.
+
+## Limitações atuais
+
+- falta validar uma chamada real ponta a ponta com relay WhatsApp;
+- o exemplo realiza resampling linear, ainda sem filtro de alta qualidade;
+- a publicação de mídia fixa atualmente aceita somente IPv4;
+- não há TURN integrado para redes que bloqueiem UDP e ICE-TCP;
+- jitter do WhatsApp ainda é estático, sem ajuste adaptativo pela rede;
+- sessões e chaves ficam apenas em memória;
+- publicação normalizada de estados nos produtores de eventos continua pendente;
+- API e framing permanecem experimentais enquanto o PR estiver em rascunho.
\ No newline at end of file
diff --git a/go.mod b/go.mod
index c1c97f6a..c4c4936f 100644
--- a/go.mod
+++ b/go.mod
@@ -15,6 +15,7 @@ require (
github.com/minio/minio-go/v7 v7.0.80
github.com/nats-io/nats.go v1.39.0
github.com/patrickmn/go-cache v2.1.0+incompatible
+ github.com/pion/webrtc/v4 v4.2.15
github.com/rabbitmq/amqp091-go v1.10.0
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
github.com/swaggo/files v1.0.1
@@ -76,6 +77,21 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
+ github.com/pion/datachannel v1.6.0 // indirect
+ github.com/pion/dtls/v3 v3.1.4 // indirect
+ github.com/pion/ice/v4 v4.2.7 // indirect
+ github.com/pion/interceptor v0.1.45 // indirect
+ github.com/pion/logging v0.2.4 // indirect
+ github.com/pion/mdns/v2 v2.1.0 // indirect
+ github.com/pion/randutil v0.1.0 // indirect
+ github.com/pion/rtcp v1.2.16 // indirect
+ github.com/pion/rtp v1.10.2 // indirect
+ github.com/pion/sctp v1.10.0 // indirect
+ github.com/pion/sdp/v3 v3.0.18 // indirect
+ github.com/pion/srtp/v3 v3.0.11 // indirect
+ github.com/pion/stun/v3 v3.1.5 // indirect
+ github.com/pion/transport/v4 v4.0.2 // indirect
+ github.com/pion/turn/v5 v5.0.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/rs/xid v1.6.0 // indirect
@@ -83,6 +99,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/vektah/gqlparser/v2 v2.5.27 // indirect
+ github.com/wlynxg/anet v0.0.5 // indirect
go.mau.fi/libsignal v0.2.2 // indirect
go.mau.fi/util v0.9.10 // indirect
golang.org/x/arch v0.10.0 // indirect
@@ -90,6 +107,7 @@ require (
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.38.0 // indirect
+ golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.46.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
diff --git a/go.sum b/go.sum
index c8e3eefc..0061db56 100644
--- a/go.sum
+++ b/go.sum
@@ -136,6 +136,40 @@ github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNH
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
+github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
+github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
+github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
+github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
+github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao=
+github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY=
+github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo=
+github.com/pion/interceptor v0.1.45/go.mod h1:gNDYM/uFKcLe/B3gS2/7+aw6z+RDiMy2qKTnF1LO31w=
+github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
+github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
+github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
+github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
+github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
+github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
+github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo=
+github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo=
+github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo=
+github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk=
+github.com/pion/sctp v1.10.0 h1:qeoD6swF/2M5bYRcAGayqSbTKX3m4AW29CiQxG1+Pfg=
+github.com/pion/sctp v1.10.0/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw=
+github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI=
+github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8=
+github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ=
+github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns=
+github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
+github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
+github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM=
+github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ=
+github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
+github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
+github.com/pion/turn/v5 v5.0.9 h1:zNeBfRyzGn7MPyUTvmvxeltLEjlFdSLPT1tlakoaOXM=
+github.com/pion/turn/v5 v5.0.9/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E=
+github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0=
+github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw=
@@ -176,6 +210,8 @@ github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTd
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vincent-petithory/dataurl v1.0.0 h1:cXw+kPto8NLuJtlMsI152irrVw9fRDX8AbShPRpg2CI=
github.com/vincent-petithory/dataurl v1.0.0/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U=
+github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
+github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mau.fi/libsignal v0.2.2 h1:QV+XdzQkm3x3aSG7FcqfGSZuFXz83pRZPBFaPygHbOU=
go.mau.fi/libsignal v0.2.2/go.mod h1:CRlIQg2J8uYTfDFvNoO8/KcZjs5cey0vbc6oj/bssY0=
@@ -227,6 +263,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
+golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
+golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/manager-v2/.keep b/manager-v2/.keep
new file mode 100644
index 00000000..dfb88c57
--- /dev/null
+++ b/manager-v2/.keep
@@ -0,0 +1 @@
+foundation
\ No newline at end of file
diff --git a/manager-v2/README.md b/manager-v2/README.md
new file mode 100644
index 00000000..705b2e54
--- /dev/null
+++ b/manager-v2/README.md
@@ -0,0 +1,147 @@
+# Evolution GO API Test Manager
+
+Interface de desenvolvimento do Evolution GO, escrita do zero em React e TypeScript.
+
+O Manager V2 não é uma caixa de entrada nem um sistema de atendimento. Ele existe para:
+
+- criar ou configurar o acesso a uma instância;
+- conectar e salvar a sessão do WhatsApp;
+- gerar QR Code ou código de pareamento;
+- testar as funções públicas da API;
+- inspecionar respostas e erros;
+- reproduzir chamadas com cURL;
+- validar telefonia WebRTC no navegador.
+
+## Princípios
+
+- nenhuma dependência do bundle compilado do Manager legado;
+- nenhuma cópia de componentes do AstraCalls/AGPL;
+- `/manager` permanece disponível como fallback;
+- `/manager-v2` é uma ferramenta técnica de conexão e testes;
+- nenhuma API nova é inventada apenas para alimentar a interface;
+- cada rota nova registrada no backend deve entrar no catálogo do API Lab.
+
+## Áreas
+
+### Instância
+
+O perfil de conexão armazena:
+
+- URL do Evolution GO;
+- ID da instância;
+- API key da instância;
+- API key global opcional;
+- preferência para salvar permanentemente ou apenas durante a sessão do navegador.
+
+A tela possui ações rápidas para:
+
+- consultar status;
+- conectar;
+- gerar QR Code;
+- reconectar;
+- desconectar;
+- fazer logout.
+
+O API Lab também contém as rotas administrativas para criar, listar, consultar, excluir e configurar proxy de instâncias.
+
+### API Lab
+
+O catálogo cobre as rotas registradas atualmente no roteador Go, incluindo:
+
+- servidor e instâncias;
+- texto, link, mídia, figurinha, localização e contato;
+- botões reply, copy, URL, call e PIX;
+- listas e carrosséis;
+- enquetes e status;
+- usuários, privacidade, perfil e bloqueios;
+- ações de mensagens e chats;
+- grupos e comunidades;
+- labels;
+- newsletters;
+- chamadas e sessões WebRTC.
+
+Cada operação fornece:
+
+- exemplo inicial de payload;
+- método HTTP editável;
+- caminho editável;
+- autenticação por chave da instância, global ou sem chave;
+- corpo JSON, multipart ou sem corpo;
+- upload de arquivo com nome de campo editável;
+- status HTTP e duração;
+- resposta completa;
+- cURL equivalente;
+- histórico dos últimos testes da sessão.
+
+A operação **Requisição personalizada** permite testar rotas novas ou variações sem esperar uma tela específica.
+
+### Chamadas
+
+A central de voz é um teste especializado para recursos que não podem ser validados apenas com JSON:
+
+- início, aceite, recusa e encerramento;
+- acompanhamento de `/call/status`;
+- criação da sessão WebRTC;
+- captura e reprodução PCM por AudioWorklet;
+- mute;
+- contadores de frames enviados, recebidos e descartados;
+- diagnóstico local de WebRTC, relay e SRTP.
+
+HTTPS é necessário para acesso ao microfone fora de `localhost`.
+
+## Cobertura automática das rotas
+
+O comando abaixo compara `pkg/routes/routes.go` com o catálogo do frontend:
+
+```bash
+npm run check:catalog
+```
+
+O CI falha quando uma rota registrada no Evolution GO não possui entrada no API Lab. Rotas de interface, favicon e Swagger são ignoradas.
+
+## Desenvolvimento
+
+```bash
+cd manager-v2
+npm install
+npm run check:catalog
+npm run typecheck
+npm run dev
+```
+
+O Vite inicia em:
+
+```text
+http://localhost:5173/manager-v2/
+```
+
+## Build
+
+```bash
+npm run check:catalog
+npm run typecheck
+npm run build
+```
+
+O resultado é criado em `manager-v2/dist`.
+
+## Docker e publicação
+
+O Dockerfile compila o frontend em um estágio Node separado e copia o resultado para a imagem final. O servidor Go publica:
+
+```text
+/manager Manager legado
+/manager-v2 API Test Manager
+```
+
+Para compilar com telefonia Pion:
+
+```bash
+docker build --build-arg GO_BUILD_TAGS=voip_pion -t evolution-go:manager-v2 .
+```
+
+Depois do deploy:
+
+```text
+https://SEU_DOMINIO/manager-v2
+```
diff --git a/manager-v2/index.html b/manager-v2/index.html
new file mode 100644
index 00000000..5f9a5ebf
--- /dev/null
+++ b/manager-v2/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Evolution GO Manager V2
+
+
+
+
+
+
diff --git a/manager-v2/package.json b/manager-v2/package.json
new file mode 100644
index 00000000..e75023e7
--- /dev/null
+++ b/manager-v2/package.json
@@ -0,0 +1,23 @@
+{
+ "name": "evolution-go-manager-v2",
+ "private": true,
+ "version": "0.2.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite --host 0.0.0.0",
+ "build": "tsc -b && vite build",
+ "typecheck": "tsc -b --pretty false",
+ "check:catalog": "node scripts/check-api-catalog.mjs",
+ "preview": "vite preview --host 0.0.0.0"
+ },
+ "dependencies": {
+ "react": "^19.2.8",
+ "react-dom": "^19.2.8"
+ },
+ "devDependencies": {
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "typescript": "^5.9.2",
+ "vite": "^8.1.5"
+ }
+}
diff --git a/manager-v2/scripts/check-api-catalog.mjs b/manager-v2/scripts/check-api-catalog.mjs
new file mode 100644
index 00000000..3520bffb
--- /dev/null
+++ b/manager-v2/scripts/check-api-catalog.mjs
@@ -0,0 +1,44 @@
+import { readFileSync } from "node:fs";
+
+const routesSource = readFileSync(new URL("../../pkg/routes/routes.go", import.meta.url), "utf8");
+const catalogSource = readFileSync(new URL("../src/api-catalog.ts", import.meta.url), "utf8");
+
+const ignored = new Set([
+ "/favicon.ico",
+ "/manager",
+ "/manager/*any",
+]);
+
+const routePaths = new Set();
+let currentGroup = "";
+for (const line of routesSource.split(/\r?\n/)) {
+ const groupMatch = line.match(/routes\s*(?::=|=)\s*eng\.Group\("([^"]+)"\)/);
+ if (groupMatch) {
+ currentGroup = groupMatch[1];
+ continue;
+ }
+
+ const groupedRoute = line.match(/routes\.(?:GET|POST|PUT|PATCH|DELETE)\("([^"]+)"/);
+ if (groupedRoute) {
+ routePaths.add(`${currentGroup}${groupedRoute[1]}`);
+ continue;
+ }
+
+ const directRoute = line.match(/eng\.(?:GET|POST|PUT|PATCH|DELETE)\("([^"]+)"/);
+ if (directRoute && !directRoute[1].startsWith("/swagger") && !ignored.has(directRoute[1])) {
+ routePaths.add(directRoute[1]);
+ }
+}
+
+const catalogPaths = new Set(
+ Array.from(catalogSource.matchAll(/"(\/[^"\s]+)"/g), (match) => match[1]),
+);
+
+const missing = Array.from(routePaths).filter((path) => !catalogPaths.has(path)).sort();
+if (missing.length > 0) {
+ console.error("API Lab is missing registered routes:");
+ missing.forEach((path) => console.error(`- ${path}`));
+ process.exit(1);
+}
+
+console.log(`API catalog covers ${routePaths.size} registered routes.`);
diff --git a/manager-v2/src/api-catalog.ts b/manager-v2/src/api-catalog.ts
new file mode 100644
index 00000000..679a86fc
--- /dev/null
+++ b/manager-v2/src/api-catalog.ts
@@ -0,0 +1,152 @@
+import type { ApiAuthMode } from "./api";
+
+export type BodyMode = "none" | "json" | "multipart";
+
+export type ApiOperation = {
+ id: string;
+ category: string;
+ title: string;
+ method: string;
+ path: string;
+ auth: ApiAuthMode;
+ bodyMode: BodyMode;
+ sample?: unknown;
+ description: string;
+ fileField?: string;
+};
+
+const quoted = { messageId: "", participant: "" };
+const commonSend = { delay: 0, mentionAll: false, mentionedJid: [], formatJid: true, quoted };
+
+function op(
+ id: string,
+ category: string,
+ title: string,
+ method: string,
+ path: string,
+ description: string,
+ options: Partial> = {},
+): ApiOperation {
+ return {
+ id,
+ category,
+ title,
+ method,
+ path,
+ description,
+ auth: options.auth ?? "instance",
+ bodyMode: options.bodyMode ?? (["GET", "DELETE"].includes(method) ? "none" : "json"),
+ sample: options.sample,
+ fileField: options.fileField,
+ };
+}
+
+export const API_OPERATIONS: ApiOperation[] = [
+ op("custom", "Personalizado", "Requisição personalizada", "POST", "/send/text", "Edite método, caminho, autenticação e corpo para testar qualquer rota.", { sample: { number: "5562999999999", text: "Teste personalizado" } }),
+ op("server-ok", "Servidor", "Verificar servidor", "GET", "/server/ok", "Confirma que o servidor HTTP está respondendo.", { auth: "none" }),
+
+ op("instance-create", "Instância", "Criar instância", "POST", "/instance/create", "Cria uma instância e define sua API key.", { auth: "admin", sample: { instanceId: "minha-instancia", name: "Minha instância", token: "CHAVE_DA_INSTANCIA", proxy: null, advancedSettings: null } }),
+ op("instance-all", "Instância", "Listar instâncias", "GET", "/instance/all", "Lista todas as instâncias.", { auth: "admin" }),
+ op("instance-info", "Instância", "Detalhes da instância", "GET", "/instance/info/:instanceId", "Consulta uma instância pelo ID.", { auth: "admin" }),
+ op("instance-delete", "Instância", "Excluir instância", "DELETE", "/instance/delete/:instanceId", "Exclui permanentemente uma instância.", { auth: "admin" }),
+ op("instance-proxy-set", "Instância", "Configurar proxy", "POST", "/instance/proxy/:instanceId", "Configura proxy da instância.", { auth: "admin", sample: { protocol: "http", host: "127.0.0.1", port: "8080", username: "usuario", password: "senha" } }),
+ op("instance-proxy-delete", "Instância", "Remover proxy", "DELETE", "/instance/proxy/:instanceId", "Remove a configuração de proxy.", { auth: "admin" }),
+ op("instance-force", "Instância", "Forçar reconexão", "POST", "/instance/forcereconnect/:instanceId", "Força a atualização e reconexão administrativa.", { auth: "admin", sample: { number: "5562999999999" } }),
+ op("instance-logs", "Instância", "Logs da instância", "GET", "/instance/logs/:instanceId", "Consulta logs; parâmetros de data, nível e limite podem ser adicionados na query string.", { auth: "admin" }),
+ op("instance-connect", "Instância", "Conectar", "POST", "/instance/connect", "Inicia a conexão da sessão.", { sample: { webhookUrl: "", subscribe: [], immediate: false, phone: "", rabbitmqEnable: "", webSocketEnable: "", natsEnable: "" } }),
+ op("instance-status", "Instância", "Status", "GET", "/instance/status", "Consulta estado conectado/logado."),
+ op("instance-qr", "Instância", "QR Code", "GET", "/instance/qr", "Obtém QR Code, código ou etapa de passkey."),
+ op("instance-pair", "Instância", "Parear por telefone", "POST", "/instance/pair", "Solicita código de pareamento.", { sample: { phone: "5562999999999", subscribe: [] } }),
+ op("instance-disconnect", "Instância", "Desconectar", "POST", "/instance/disconnect", "Desconecta sem apagar a sessão.", { sample: {} }),
+ op("instance-reconnect", "Instância", "Reconectar", "POST", "/instance/reconnect", "Reinicia a conexão preservando credenciais.", { sample: {} }),
+ op("instance-logout", "Instância", "Logout", "DELETE", "/instance/logout", "Desvincula o aparelho e remove a sessão."),
+ op("instance-advanced-get", "Instância", "Ler configurações avançadas", "GET", "/instance/:instanceId/advanced-settings", "Consulta configurações avançadas."),
+ op("instance-advanced-put", "Instância", "Atualizar configurações avançadas", "PUT", "/instance/:instanceId/advanced-settings", "Atualiza configurações avançadas com JSON livre.", { sample: {} }),
+
+ op("send-text", "Envio", "Texto comum", "POST", "/send/text", "Envia texto com menções, resposta, atraso e encaminhamento.", { sample: { number: "5562999999999", text: "Mensagem de teste", forwardingScore: 0, ...commonSend } }),
+ op("send-link", "Envio", "Link com prévia", "POST", "/send/link", "Envia link e gera metadados de prévia.", { sample: { number: "5562999999999", text: "Confira https://evolution-api.com", title: "", url: "", description: "", imgUrl: "", ...commonSend } }),
+ op("send-media-file", "Envio", "Mídia por arquivo", "POST", "/send/media", "Upload multipart de imagem, vídeo, áudio ou documento.", { bodyMode: "multipart", fileField: "file", sample: { number: "5562999999999", type: "image", caption: "Teste de mídia", filename: "arquivo.jpg", delay: 0, mentionAll: false } }),
+ op("send-media-url", "Envio", "Mídia por URL/base64", "POST", "/send/media", "Envia mídia por URL pública ou base64.", { sample: { number: "5562999999999", url: "https://picsum.photos/800/600", type: "image", caption: "Imagem de teste", filename: "imagem.jpg", forwardingScore: 0, ...commonSend } }),
+ op("send-poll", "Envio", "Enquete", "POST", "/send/poll", "Envia enquete com duas ou mais opções.", { sample: { number: "5562999999999", question: "Qual opção você prefere?", maxAnswer: 1, options: ["Opção A", "Opção B"], ...commonSend } }),
+ op("send-sticker", "Envio", "Figurinha", "POST", "/send/sticker", "Baixa uma imagem pública e envia como WebP.", { sample: { number: "5562999999999", sticker: "https://picsum.photos/512/512", ...commonSend } }),
+ op("send-location", "Envio", "Localização", "POST", "/send/location", "Envia latitude, longitude, nome e endereço.", { sample: { number: "5562999999999", name: "Local de teste", latitude: -16.6869, longitude: -49.2648, address: "Goiânia - GO", ...commonSend } }),
+ op("send-contact", "Envio", "Contato vCard", "POST", "/send/contact", "Envia um contato em formato vCard.", { sample: { number: "5562999999999", vcard: { fullName: "Contato Teste", phone: "5562888888888", organization: "Evolution GO" }, ...commonSend } }),
+ op("send-button", "Envio", "Botões", "POST", "/send/button", "Testa reply, copy, URL, call e PIX.", { sample: { number: "5562999999999", title: "Oferta especial", description: "Escolha uma opção", footer: "Evolution GO", buttons: [{ type: "reply", displayText: "Quero saber mais", id: "btn_info" }, { type: "reply", displayText: "Agora não", id: "btn_no" }], imageUrl: "", videoUrl: "", ...commonSend } }),
+ op("send-list", "Envio", "Lista interativa", "POST", "/send/list", "Envia uma lista de seleção única.", { sample: { number: "5562999999999", title: "Nossos planos", description: "Escolha uma opção", buttonText: "Abrir menu", footerText: "Evolution GO", sections: [{ title: "Planos", rows: [{ title: "Plano básico", description: "R$ 29,90/mês", rowId: "plan_basic" }] }], ...commonSend } }),
+ op("send-carousel", "Envio", "Carrossel", "POST", "/send/carousel", "Envia cartões interativos com mídia e botões.", { sample: { number: "5562999999999", body: "Confira nossas novidades", footer: "Evolution GO", delay: 0, formatJid: true, quoted, cards: [{ header: { title: "Oferta do dia", subtitle: "Somente hoje", imageUrl: "https://picsum.photos/seed/evolution/600/400", videoUrl: "" }, body: { text: "Card de demonstração" }, footer: "Por tempo limitado", buttons: [{ type: "REPLY", displayText: "Tenho interesse", id: "card_interest", copyCode: "" }] }] } }),
+ op("send-status-text", "Envio", "Status de texto", "POST", "/send/status/text", "Publica texto no status.", { sample: { text: "Status enviado pelo Evolution GO", id: "" } }),
+ op("send-status-media-file", "Envio", "Status com arquivo", "POST", "/send/status/media", "Publica imagem ou vídeo por multipart.", { bodyMode: "multipart", fileField: "file", sample: { type: "image", caption: "Status de teste", id: "" } }),
+ op("send-status-media-url", "Envio", "Status por URL", "POST", "/send/status/media", "Publica imagem ou vídeo por URL.", { sample: { type: "image", url: "https://picsum.photos/1080/1920", caption: "Status de teste", id: "" } }),
+
+ op("user-info", "Usuário", "Informações do usuário", "POST", "/user/info", "Consulta status, dispositivos, LID e nome verificado.", { sample: { number: ["5562999999999"] } }),
+ op("user-check", "Usuário", "Verificar números", "POST", "/user/check", "Verifica se números estão no WhatsApp.", { sample: { number: ["5562999999999"], formatJid: false } }),
+ op("user-avatar", "Usuário", "Avatar", "POST", "/user/avatar", "Consulta foto de perfil.", { sample: { number: "5562999999999", preview: true } }),
+ op("user-contacts", "Usuário", "Contatos", "GET", "/user/contacts", "Lista contatos sincronizados."),
+ op("user-privacy-get", "Usuário", "Ler privacidade", "GET", "/user/privacy", "Consulta as configurações de privacidade."),
+ op("user-privacy-set", "Usuário", "Atualizar privacidade", "POST", "/user/privacy", "Atualiza todas as opções de privacidade.", { sample: { groupAdd: "all", lastSeen: "all", status: "all", profile: "all", readReceipts: "all", callAdd: "all", online: "all" } }),
+ op("user-block", "Usuário", "Bloquear", "POST", "/user/block", "Bloqueia um contato.", { sample: { number: "5562999999999" } }),
+ op("user-unblock", "Usuário", "Desbloquear", "POST", "/user/unblock", "Desbloqueia um contato.", { sample: { number: "5562999999999" } }),
+ op("user-blocklist", "Usuário", "Lista de bloqueio", "GET", "/user/blocklist", "Lista contatos bloqueados."),
+ op("user-profile-picture", "Usuário", "Foto do perfil", "POST", "/user/profilePicture", "Atualiza foto por URL ou base64 conforme o backend.", { sample: { image: "BASE64_OU_URL" } }),
+ op("user-profile-name", "Usuário", "Nome do perfil", "POST", "/user/profileName", "Atualiza o nome do perfil.", { sample: { name: "Evolution GO" } }),
+ op("user-profile-status", "Usuário", "Recado do perfil", "POST", "/user/profileStatus", "Atualiza o recado do perfil.", { sample: { status: "Disponível" } }),
+
+ op("message-react", "Mensagem", "Reagir", "POST", "/message/react", "Adiciona ou remove reação.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM", emoji: "👍" } }),
+ op("message-presence", "Mensagem", "Presença no chat", "POST", "/message/presence", "Envia composing, paused ou recording.", { sample: { number: "5562999999999", presence: "composing", delay: 1000 } }),
+ op("message-read", "Mensagem", "Marcar como lida", "POST", "/message/markread", "Marca mensagem como lida.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM" } }),
+ op("message-played", "Mensagem", "Marcar como reproduzida", "POST", "/message/markplayed", "Marca mídia de áudio como reproduzida.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM" } }),
+ op("message-download", "Mensagem", "Baixar mídia", "POST", "/message/downloadmedia", "Baixa mídia usando os metadados da mensagem.", { sample: {} }),
+ op("message-status", "Mensagem", "Status da mensagem", "POST", "/message/status", "Consulta status por ID.", { sample: { messageId: "ID_DA_MENSAGEM" } }),
+ op("message-delete", "Mensagem", "Apagar para todos", "POST", "/message/delete", "Apaga uma mensagem enviada.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM" } }),
+ op("message-edit", "Mensagem", "Editar", "POST", "/message/edit", "Edita texto enviado.", { sample: { number: "5562999999999", messageId: "ID_DA_MENSAGEM", text: "Texto editado" } }),
+
+ op("chat-pin", "Chat", "Fixar", "POST", "/chat/pin", "Fixa uma conversa.", { sample: { number: "5562999999999" } }),
+ op("chat-unpin", "Chat", "Desafixar", "POST", "/chat/unpin", "Remove conversa dos fixados.", { sample: { number: "5562999999999" } }),
+ op("chat-archive", "Chat", "Arquivar", "POST", "/chat/archive", "Arquiva uma conversa.", { sample: { number: "5562999999999" } }),
+ op("chat-unarchive", "Chat", "Desarquivar", "POST", "/chat/unarchive", "Desarquiva uma conversa.", { sample: { number: "5562999999999" } }),
+ op("chat-mute", "Chat", "Silenciar", "POST", "/chat/mute", "Silencia uma conversa.", { sample: { number: "5562999999999", duration: 86400 } }),
+ op("chat-unmute", "Chat", "Remover silêncio", "POST", "/chat/unmute", "Remove o silêncio.", { sample: { number: "5562999999999" } }),
+ op("chat-history", "Chat", "Sincronizar histórico", "POST", "/chat/history-sync", "Solicita history sync.", { sample: {} }),
+
+ op("group-list", "Grupo", "Listar grupos", "GET", "/group/list", "Lista grupos conhecidos."),
+ op("group-info", "Grupo", "Informações", "POST", "/group/info", "Consulta metadados do grupo.", { sample: { number: "120363000000000000@g.us" } }),
+ op("group-invite", "Grupo", "Link de convite", "POST", "/group/invitelink", "Obtém link de convite.", { sample: { number: "120363000000000000@g.us" } }),
+ op("group-photo", "Grupo", "Foto", "POST", "/group/photo", "Atualiza foto do grupo.", { sample: { number: "120363000000000000@g.us", image: "BASE64_OU_URL" } }),
+ op("group-name", "Grupo", "Nome", "POST", "/group/name", "Altera nome do grupo.", { sample: { number: "120363000000000000@g.us", name: "Novo nome" } }),
+ op("group-description", "Grupo", "Descrição", "POST", "/group/description", "Altera descrição do grupo.", { sample: { number: "120363000000000000@g.us", description: "Descrição de teste" } }),
+ op("group-create", "Grupo", "Criar grupo", "POST", "/group/create", "Cria grupo com participantes.", { sample: { name: "Grupo de teste", participants: ["5562999999999"] } }),
+ op("group-participant", "Grupo", "Participantes", "POST", "/group/participant", "Adiciona, remove, promove ou rebaixa.", { sample: { number: "120363000000000000@g.us", participants: ["5562999999999"], action: "add" } }),
+ op("group-myall", "Grupo", "Meus grupos", "GET", "/group/myall", "Consulta grupos da conta."),
+ op("group-join", "Grupo", "Entrar por convite", "POST", "/group/join", "Entra por URL ou código.", { sample: { code: "CODIGO_DO_CONVITE" } }),
+ op("group-leave", "Grupo", "Sair", "POST", "/group/leave", "Sai de um grupo.", { sample: { number: "120363000000000000@g.us" } }),
+ op("group-settings", "Grupo", "Configurações", "POST", "/group/settings", "Atualiza configurações do grupo.", { sample: { number: "120363000000000000@g.us", action: "announcement" } }),
+
+ op("call-status", "Chamadas", "Status", "GET", "/call/status", "Lista chamadas da instância."),
+ op("call-start", "Chamadas", "Iniciar", "POST", "/call/start", "Inicia chamada de voz ou vídeo.", { sample: { number: "5562999999999", video: false } }),
+ op("call-accept", "Chamadas", "Aceitar", "POST", "/call/:callId/accept", "Aceita chamada recebida.", { sample: {} }),
+ op("call-webrtc-create", "Chamadas", "Criar WebRTC", "POST", "/call/:callId/webrtc", "Cria sessão WebRTC a partir de uma oferta SDP.", { sample: { offer: { type: "offer", sdp: "COLE_O_SDP" } } }),
+ op("call-webrtc-list", "Chamadas", "Listar WebRTC", "GET", "/call/:callId/webrtc", "Lista sessões WebRTC."),
+ op("call-webrtc-close", "Chamadas", "Fechar WebRTC", "DELETE", "/call/:callId/webrtc/:sessionId", "Fecha uma sessão WebRTC."),
+ op("call-terminate", "Chamadas", "Encerrar", "DELETE", "/call/:callId", "Encerra uma chamada."),
+ op("call-reject", "Chamadas", "Recusar", "POST", "/call/reject", "Recusa chamada recebida.", { sample: { number: "5562999999999", callCreator: "5562999999999@s.whatsapp.net", callId: "CALL_ID" } }),
+
+ op("community-create", "Comunidade", "Criar", "POST", "/community/create", "Cria comunidade.", { sample: { name: "Comunidade de teste", description: "Criada pelo API Lab" } }),
+ op("community-add", "Comunidade", "Adicionar grupo", "POST", "/community/add", "Adiciona grupo à comunidade.", { sample: { number: "120363000000000000@g.us", communityId: "120363000000000001@g.us" } }),
+ op("community-remove", "Comunidade", "Remover grupo", "POST", "/community/remove", "Remove grupo da comunidade.", { sample: { number: "120363000000000000@g.us", communityId: "120363000000000001@g.us" } }),
+
+ op("label-chat", "Labels", "Aplicar no chat", "POST", "/label/chat", "Aplica label à conversa.", { sample: { number: "5562999999999", labelId: "LABEL_ID" } }),
+ op("label-message", "Labels", "Aplicar na mensagem", "POST", "/label/message", "Aplica label à mensagem.", { sample: { messageId: "ID_DA_MENSAGEM", labelId: "LABEL_ID" } }),
+ op("label-edit", "Labels", "Criar ou editar", "POST", "/label/edit", "Cria ou edita label.", { sample: { id: "", name: "Cliente", color: 1, predefinedId: "" } }),
+ op("label-list", "Labels", "Listar", "GET", "/label/list", "Lista labels."),
+ op("unlabel-chat", "Labels", "Remover do chat", "POST", "/unlabel/chat", "Remove label da conversa.", { sample: { number: "5562999999999", labelId: "LABEL_ID" } }),
+ op("unlabel-message", "Labels", "Remover da mensagem", "POST", "/unlabel/message", "Remove label da mensagem.", { sample: { messageId: "ID_DA_MENSAGEM", labelId: "LABEL_ID" } }),
+
+ op("newsletter-create", "Newsletter", "Criar canal", "POST", "/newsletter/create", "Cria newsletter/canal.", { sample: { name: "Canal de teste", description: "Criado pelo Evolution GO" } }),
+ op("newsletter-list", "Newsletter", "Listar canais", "GET", "/newsletter/list", "Lista canais."),
+ op("newsletter-info", "Newsletter", "Informações", "POST", "/newsletter/info", "Consulta canal.", { sample: { newsletterId: "120363000000000000@newsletter" } }),
+ op("newsletter-link", "Newsletter", "Link", "POST", "/newsletter/link", "Obtém link do canal.", { sample: { newsletterId: "120363000000000000@newsletter" } }),
+ op("newsletter-subscribe", "Newsletter", "Inscrever-se", "POST", "/newsletter/subscribe", "Inscreve a conta no canal.", { sample: { newsletterId: "120363000000000000@newsletter" } }),
+ op("newsletter-messages", "Newsletter", "Mensagens", "POST", "/newsletter/messages", "Consulta mensagens recentes.", { sample: { newsletterId: "120363000000000000@newsletter", count: 20 } }),
+
+ op("poll-results", "Enquetes", "Resultados", "GET", "/polls/:pollMessageId/results", "Consulta votos de uma enquete."),
+];
diff --git a/manager-v2/src/api-lab-layout.css b/manager-v2/src/api-lab-layout.css
new file mode 100644
index 00000000..843546f6
--- /dev/null
+++ b/manager-v2/src/api-lab-layout.css
@@ -0,0 +1,4 @@
+.api-route-row.editable { grid-template-columns: 90px minmax(0, 1fr) 180px 130px; }
+.multipart-file-row { display: grid; grid-template-columns: minmax(150px, .4fr) minmax(0, 1fr); gap: 9px; }
+@media (max-width: 900px) { .api-route-row.editable { grid-template-columns: 90px minmax(0, 1fr); } }
+@media (max-width: 620px) { .api-route-row.editable, .multipart-file-row { grid-template-columns: 1fr; } }
diff --git a/manager-v2/src/api-lab.tsx b/manager-v2/src/api-lab.tsx
new file mode 100644
index 00000000..0a63b4de
--- /dev/null
+++ b/manager-v2/src/api-lab.tsx
@@ -0,0 +1,295 @@
+import { useMemo, useState } from "react";
+import { API_OPERATIONS, type ApiOperation, type BodyMode } from "./api-catalog";
+import type { ApiAuthMode, ApiExecutionResult, EvolutionApi, EvolutionConnection } from "./api";
+import { GuidedRequestEditor, supportsGuidedRequest, validateRequestDraft } from "./guided-request";
+import { RequestPresetPanel, type RequestPresetDraft } from "./request-presets";
+
+function replaceInstanceId(path: string, connection: EvolutionConnection): string {
+ return path.replaceAll(":instanceId", connection.instanceId || "INSTANCE_ID");
+}
+
+function stringifySample(sample: unknown): string {
+ return sample === undefined ? "" : JSON.stringify(sample, null, 2);
+}
+
+function appendFormValue(form: FormData, key: string, value: unknown): void {
+ if (value === undefined || value === null) return;
+ if (typeof value === "object") form.set(key, JSON.stringify(value));
+ else form.set(key, String(value));
+}
+
+function responseText(result: ApiExecutionResult | null): string {
+ if (!result) return "Execute uma operação para visualizar a resposta.";
+ if (typeof result.data === "string") return result.data;
+ return JSON.stringify(result.data, null, 2) ?? String(result.data);
+}
+
+function buildCurl(
+ connection: EvolutionConnection,
+ operationValue: Pick,
+ method: string,
+ bodyMode: BodyMode,
+ path: string,
+ body: string,
+): string {
+ const keyLabel = operationValue.auth === "admin" ? "SUA_CHAVE_GLOBAL" : operationValue.auth === "none" ? "" : "SUA_CHAVE_DA_INSTANCIA";
+ const parts = [`curl -X ${method} '${connection.baseUrl}${path}'`];
+ if (keyLabel) parts.push(`-H 'apikey: ${keyLabel}'`);
+ if (bodyMode === "json" && body.trim()) {
+ parts.push("-H 'Content-Type: application/json'");
+ parts.push(`--data '${body.replaceAll("'", "'\\''")}'`);
+ }
+ if (bodyMode === "multipart") {
+ try {
+ const parsed = JSON.parse(body || "{}") as Record;
+ Object.entries(parsed).forEach(([key, value]) => parts.push(`-F '${key}=${typeof value === "object" ? JSON.stringify(value) : String(value)}'`));
+ } catch {
+ // Keep cURL visible while the JSON editor is incomplete.
+ }
+ parts.push(`-F '${operationValue.fileField || "file"}=@/caminho/arquivo'`);
+ }
+ return parts.join(" \\\n ");
+}
+
+export function ApiLab({ api, connection }: { api: EvolutionApi | null; connection: EvolutionConnection }) {
+ const initial = API_OPERATIONS.find((item) => item.id === "send-text") ?? API_OPERATIONS[0];
+ const [query, setQuery] = useState("");
+ const [category, setCategory] = useState("Todos");
+ const [selectedId, setSelectedId] = useState(initial.id);
+ const [method, setMethod] = useState(initial.method);
+ const [path, setPath] = useState(() => replaceInstanceId(initial.path, connection));
+ const [bodyMode, setBodyMode] = useState(initial.bodyMode);
+ const [body, setBody] = useState(() => stringifySample(initial.sample));
+ const [auth, setAuth] = useState(initial.auth);
+ const [file, setFile] = useState(null);
+ const [fileField, setFileField] = useState(initial.fileField || "file");
+ const [editorMode, setEditorMode] = useState<"guided" | "json">(supportsGuidedRequest(initial.id) ? "guided" : "json");
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState("");
+ const [result, setResult] = useState(null);
+ const [history, setHistory] = useState>([]);
+
+ const selected = API_OPERATIONS.find((item) => item.id === selectedId) ?? initial;
+ const guidedAvailable = supportsGuidedRequest(selected.id);
+ const validation = useMemo(() => validateRequestDraft(selected.id, bodyMode, body, file), [body, bodyMode, file, selected.id]);
+ const categories = useMemo(() => ["Todos", ...Array.from(new Set(API_OPERATIONS.map((item) => item.category)))], []);
+ const visible = useMemo(() => {
+ const normalized = query.trim().toLocaleLowerCase("pt-BR");
+ return API_OPERATIONS.filter((item) => {
+ if (category !== "Todos" && item.category !== category) return false;
+ if (!normalized) return true;
+ return `${item.title} ${item.path} ${item.description}`.toLocaleLowerCase("pt-BR").includes(normalized);
+ });
+ }, [category, query]);
+
+ const choose = (item: ApiOperation) => {
+ setSelectedId(item.id);
+ setMethod(item.method);
+ setPath(replaceInstanceId(item.path, connection));
+ setBodyMode(item.bodyMode);
+ setBody(stringifySample(item.sample));
+ setAuth(item.auth);
+ setFile(null);
+ setFileField(item.fileField || "file");
+ setEditorMode(supportsGuidedRequest(item.id) ? "guided" : "json");
+ setError("");
+ setResult(null);
+ };
+
+ const applyPreset = (preset: RequestPresetDraft) => {
+ const operation = API_OPERATIONS.find((item) => item.id === preset.operationId);
+ if (!operation) {
+ setError(`A operação ${preset.operationId} não existe mais no catálogo.`);
+ return;
+ }
+ setSelectedId(operation.id);
+ setMethod(preset.method);
+ setPath(preset.path);
+ setBodyMode(preset.bodyMode);
+ setBody(preset.body);
+ setAuth(preset.auth);
+ setFile(null);
+ setFileField(preset.fileField || operation.fileField || "file");
+ setEditorMode(supportsGuidedRequest(operation.id) ? "guided" : "json");
+ setError("");
+ setResult(null);
+ };
+
+ const resetPayload = () => {
+ setBody(stringifySample(selected.sample));
+ setFile(null);
+ setFileField(selected.fileField || "file");
+ setError("");
+ };
+
+ const formatPayload = () => {
+ try {
+ setBody(JSON.stringify(JSON.parse(body || "{}") as unknown, null, 2));
+ setError("");
+ } catch (cause) {
+ setError(cause instanceof Error ? `JSON inválido: ${cause.message}` : "JSON inválido");
+ }
+ };
+
+ const run = async () => {
+ if (!api || running) return;
+ if (validation.errors.length) {
+ setError(validation.errors.join(" "));
+ return;
+ }
+ setRunning(true);
+ setError("");
+ try {
+ let requestBody: BodyInit | null | undefined;
+ if (bodyMode === "json" && body.trim()) {
+ requestBody = JSON.stringify(JSON.parse(body) as unknown);
+ } else if (bodyMode === "multipart") {
+ const form = new FormData();
+ const values = JSON.parse(body || "{}") as Record;
+ Object.entries(values).forEach(([key, value]) => appendFormValue(form, key, value));
+ if (file) form.set(fileField || "file", file, file.name);
+ requestBody = form;
+ }
+ const response = await api.execute({ method, path, auth, body: requestBody });
+ setResult(response);
+ setHistory((current) => [{ id: Date.now(), title: selected.title, status: response.status, duration: response.durationMs }, ...current].slice(0, 20));
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Falha ao executar a requisição");
+ } finally {
+ setRunning(false);
+ }
+ };
+
+ const curl = buildCurl(connection, { auth, fileField }, method, bodyMode, path, body);
+ const renderedResponse = responseText(result);
+ const presetDraft: RequestPresetDraft = {
+ operationId: selected.id,
+ operationTitle: selected.title,
+ method,
+ path,
+ auth,
+ bodyMode,
+ body,
+ fileField,
+ };
+
+ return (
+
+
+ Catálogo completo
{API_OPERATIONS.length} operações
+ setQuery(event.target.value)} placeholder="Buscar rota ou função" />
+
+ {categories.map((item) => setCategory(item)}>{item} )}
+
+
+ {visible.map((item) => (
+ choose(item)}>
+ {item.method}
+ {item.title} {item.path}
+
+ ))}
+
+
+
+
+
+
+
{selected.category}
{selected.title}
+
{method}
+
+ {selected.description}
+
+ setMethod(event.target.value)} aria-label="Método HTTP">
+ {(["GET", "POST", "PUT", "PATCH", "DELETE"] as const).map((item) => {item} )}
+
+ setPath(event.target.value)} aria-label="Caminho da API" />
+ setAuth(event.target.value as ApiAuthMode)} aria-label="Tipo de autenticação">
+ Chave da instância
+ Chave global
+ Sem autenticação
+
+ setBodyMode(event.target.value as BodyMode)} aria-label="Formato do corpo">
+ Sem corpo
+ JSON
+ Multipart
+
+
+
+ {bodyMode !== "none" && (
+ <>
+
+ {guidedAvailable && setEditorMode("guided")}>Formulário guiado }
+ setEditorMode("json")}>JSON avançado
+ {guidedAvailable ? "Os dois modos usam o mesmo payload." : "Esta operação usa o editor JSON livre."}
+
+
+ {guidedAvailable && editorMode === "guided" ? (
+
+ ) : (
+
+ {bodyMode === "multipart" ? "Campos multipart em JSON" : "Corpo JSON"}
+
+ Formatar JSON
+ Restaurar exemplo
+
+
+ )}
+
+
+ {validation.errors.map((item) =>
Erro: {item}
)}
+ {validation.warnings.map((item) =>
Atenção: {item}
)}
+
+ >
+ )}
+ {bodyMode === "multipart" && (
+
+ Arquivo
+
+ setFileField(event.target.value)} placeholder="Nome do campo: file" />
+ setFile(event.target.files?.[0] ?? null)} />
+
+ {file ? `${file.name} · ${Math.ceil(file.size / 1024)} KB` : "Selecione um arquivo quando a rota exigir upload."}
+
+ )}
+
+ {error && {error}
}
+
+ Restaurar exemplo
+ void navigator.clipboard.writeText(curl)}>Copiar cURL
+ 0} onClick={() => void run()}>{running ? "Executando…" : "Executar teste"}
+
+
+
+
+
+
Resposta
{result ? `${result.status} ${result.statusText}` : "Aguardando execução"}
+
+ {result && void navigator.clipboard.writeText(renderedResponse)}>Copiar resposta }
+ {result && {result.durationMs} ms }
+
+
+ {result && {result.url} {result.ok ? "Sucesso" : "Erro HTTP"}
}
+ {renderedResponse}
+
+
+
+ Reprodução
cURL equivalente
+ {curl}
+
+
+
+
+
+
+ {history.length === 0 ? Nenhuma requisição executada.
: history.map((item) => (
+
+ {item.title}
+ = 200 && item.status < 300 ? "ok" : "failed"}>{item.status}
+ {item.duration} ms
+
+ ))}
+
+
+ );
+}
diff --git a/manager-v2/src/api.ts b/manager-v2/src/api.ts
new file mode 100644
index 00000000..c7b365d2
--- /dev/null
+++ b/manager-v2/src/api.ts
@@ -0,0 +1,309 @@
+export interface EvolutionConnection {
+ baseUrl: string;
+ apiKey: string;
+ adminApiKey: string;
+ instanceId: string;
+ remember: boolean;
+}
+
+export type ApiAuthMode = "instance" | "admin" | "none";
+
+export interface ApiExecutionRequest {
+ method: string;
+ path: string;
+ auth?: ApiAuthMode;
+ body?: BodyInit | null;
+ headers?: HeadersInit;
+}
+
+export interface ApiExecutionResult {
+ ok: boolean;
+ status: number;
+ statusText: string;
+ durationMs: number;
+ url: string;
+ headers: Record;
+ data: unknown;
+ rawText: string;
+}
+
+export type CallDirection = "incoming" | "outgoing";
+export type CallState = "idle" | "ringing" | "connecting" | "active" | "ended" | "failed";
+
+export interface EvolutionCall {
+ id: string;
+ peer: string;
+ direction: CallDirection;
+ state: CallState;
+ video?: boolean;
+ endReason?: string;
+ createdAt?: string;
+ updatedAt?: string;
+}
+
+export interface CallStatusSnapshot {
+ instanceId?: string;
+ connected: boolean;
+ calls: EvolutionCall[];
+}
+
+export interface WebRTCSessionResponse {
+ sessionId: string;
+ answer: RTCSessionDescriptionInit;
+}
+
+export interface EvolutionContact {
+ Jid: string;
+ Found: boolean;
+ FirstName: string;
+ FullName: string;
+ PushName: string;
+ BusinessName: string;
+}
+
+export interface CheckedUser {
+ Query: string;
+ IsInWhatsapp: boolean;
+ JID: string;
+ RemoteJID: string;
+ LID?: string | null;
+ VerifiedName?: string;
+}
+
+export interface MessageSendResult {
+ id?: string;
+ messageId?: string;
+ timestamp?: number | string;
+ [key: string]: unknown;
+}
+
+interface ApiEnvelope {
+ message: string;
+ data: T;
+}
+
+interface CheckUserCollection {
+ Users?: CheckedUser[];
+ users?: CheckedUser[];
+}
+
+const PERSISTENT_KEY = "evolution.managerV2.connection.v2";
+const SESSION_KEY = "evolution.managerV2.connection.session.v2";
+const LEGACY_PERSISTENT_KEY = "evolution.managerV2.connection.v1";
+const LEGACY_SESSION_KEY = "evolution.managerV2.connection.session.v1";
+
+export function loadConnection(): EvolutionConnection {
+ const parse = (value: string | null): Partial | null => {
+ if (!value) return null;
+ try {
+ return JSON.parse(value) as Partial;
+ } catch {
+ return null;
+ }
+ };
+ const persistent = parse(localStorage.getItem(PERSISTENT_KEY)) ?? parse(localStorage.getItem(LEGACY_PERSISTENT_KEY));
+ const temporary = parse(sessionStorage.getItem(SESSION_KEY)) ?? parse(sessionStorage.getItem(LEGACY_SESSION_KEY));
+ const value = persistent ?? temporary ?? {};
+ return {
+ baseUrl: normalizeBaseUrl(value.baseUrl || window.location.origin),
+ apiKey: value.apiKey || "",
+ adminApiKey: value.adminApiKey || "",
+ instanceId: value.instanceId || "",
+ remember: Boolean(persistent),
+ };
+}
+
+export function saveConnection(connection: EvolutionConnection): void {
+ const normalized = { ...connection, baseUrl: normalizeBaseUrl(connection.baseUrl) };
+ if (connection.remember) {
+ localStorage.setItem(PERSISTENT_KEY, JSON.stringify(normalized));
+ sessionStorage.removeItem(SESSION_KEY);
+ } else {
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(normalized));
+ localStorage.removeItem(PERSISTENT_KEY);
+ }
+ localStorage.removeItem(LEGACY_PERSISTENT_KEY);
+ sessionStorage.removeItem(LEGACY_SESSION_KEY);
+}
+
+export function normalizeBaseUrl(value: string): string {
+ return (value.trim() || window.location.origin).replace(/\/+$/, "");
+}
+
+export function normalizePhone(value: string): string {
+ return value.replace(/\D/g, "");
+}
+
+export function displayPhone(value: string): string {
+ return String(value || "").replace(/:\d+@/, "@").split("@")[0] || "Número não identificado";
+}
+
+export class EvolutionApi {
+ private readonly baseUrl: string;
+ private readonly instanceApiKey: string;
+ private readonly adminApiKey: string;
+
+ constructor(connection: EvolutionConnection) {
+ this.baseUrl = normalizeBaseUrl(connection.baseUrl);
+ this.instanceApiKey = connection.apiKey.trim();
+ this.adminApiKey = connection.adminApiKey.trim();
+ }
+
+ async execute(request: ApiExecutionRequest): Promise {
+ const path = request.path.startsWith("/") ? request.path : `/${request.path}`;
+ const url = `${this.baseUrl}${path}`;
+ const headers = new Headers(request.headers);
+ const auth = request.auth ?? "instance";
+ const key = auth === "admin" ? (this.adminApiKey || this.instanceApiKey) : this.instanceApiKey;
+ if (auth !== "none") {
+ if (!key) throw new Error(auth === "admin" ? "Informe a API key global" : "Informe a API key da instância");
+ headers.set("apikey", key);
+ }
+ const isFormData = typeof FormData !== "undefined" && request.body instanceof FormData;
+ if (request.body !== undefined && request.body !== null && !isFormData && !headers.has("Content-Type")) {
+ headers.set("Content-Type", "application/json");
+ }
+
+ const startedAt = performance.now();
+ const response = await fetch(url, {
+ method: request.method.toUpperCase(),
+ headers,
+ body: ["GET", "HEAD"].includes(request.method.toUpperCase()) ? undefined : request.body,
+ });
+ const rawText = await response.text();
+ let data: unknown = rawText;
+ if (rawText) {
+ try {
+ data = JSON.parse(rawText) as unknown;
+ } catch {
+ data = rawText;
+ }
+ } else {
+ data = null;
+ }
+ return {
+ ok: response.ok,
+ status: response.status,
+ statusText: response.statusText,
+ durationMs: Math.round(performance.now() - startedAt),
+ url,
+ headers: Object.fromEntries(response.headers.entries()),
+ data,
+ rawText,
+ };
+ }
+
+ private async request(path: string, init: RequestInit = {}): Promise {
+ const result = await this.execute({
+ path,
+ method: init.method || "GET",
+ body: init.body,
+ headers: init.headers,
+ auth: "instance",
+ });
+ if (!result.ok) {
+ const body = result.data as Record | null;
+ const message = body && typeof body === "object" && typeof body.error === "string"
+ ? body.error
+ : body && typeof body === "object" && typeof body.message === "string"
+ ? body.message
+ : `HTTP ${result.status}`;
+ throw new Error(message);
+ }
+ return result.data as T;
+ }
+
+ callStatus(): Promise {
+ return this.request("/call/status");
+ }
+
+ startCall(number: string): Promise {
+ return this.request("/call/start", {
+ method: "POST",
+ body: JSON.stringify({ number: normalizePhone(number), video: false }),
+ });
+ }
+
+ acceptCall(callId: string): Promise {
+ return this.request(`/call/${encodeURIComponent(callId)}/accept`, { method: "POST" });
+ }
+
+ rejectCall(call: EvolutionCall): Promise {
+ return this.request("/call/reject", {
+ method: "POST",
+ body: JSON.stringify({ number: call.peer, callCreator: call.peer, callId: call.id }),
+ });
+ }
+
+ terminateCall(callId: string): Promise {
+ return this.request(`/call/${encodeURIComponent(callId)}`, { method: "DELETE" });
+ }
+
+ createWebRTC(callId: string, offer: RTCSessionDescriptionInit): Promise {
+ return this.request(`/call/${encodeURIComponent(callId)}/webrtc`, {
+ method: "POST",
+ body: JSON.stringify({ offer }),
+ });
+ }
+
+ closeWebRTC(callId: string, sessionId: string): Promise {
+ return this.request(
+ `/call/${encodeURIComponent(callId)}/webrtc/${encodeURIComponent(sessionId)}`,
+ { method: "DELETE" },
+ );
+ }
+
+ async contacts(): Promise {
+ const response = await this.request>("/user/contacts");
+ return Array.isArray(response.data) ? response.data : [];
+ }
+
+ async checkUser(number: string): Promise {
+ const normalized = normalizePhone(number);
+ if (!normalized) return null;
+ const response = await this.request>("/user/check", {
+ method: "POST",
+ body: JSON.stringify({ number: [normalized], formatJid: false }),
+ });
+ const users = response.data?.Users ?? response.data?.users ?? [];
+ return users.find((user) => user.IsInWhatsapp) ?? users[0] ?? null;
+ }
+
+ async sendText(number: string, text: string): Promise {
+ const response = await this.request>("/send/text", {
+ method: "POST",
+ body: JSON.stringify({
+ number,
+ text,
+ delay: 0,
+ mentionAll: false,
+ mentionedJid: [],
+ quoted: { messageId: "", participant: "" },
+ }),
+ });
+ return response.data ?? {};
+ }
+
+ async sendMedia(number: string, file: File, caption = ""): Promise {
+ const form = new FormData();
+ form.set("number", number);
+ form.set("type", mediaTypeForFile(file));
+ form.set("caption", caption);
+ form.set("filename", file.name);
+ form.set("delay", "0");
+ form.set("mentionAll", "false");
+ form.set("file", file, file.name);
+ const response = await this.request>("/send/media", {
+ method: "POST",
+ body: form,
+ });
+ return response.data ?? {};
+ }
+}
+
+function mediaTypeForFile(file: File): "image" | "video" | "audio" | "document" {
+ if (file.type.startsWith("image/")) return "image";
+ if (file.type.startsWith("video/")) return "video";
+ if (file.type.startsWith("audio/")) return "audio";
+ return "document";
+}
diff --git a/manager-v2/src/app.tsx b/manager-v2/src/app.tsx
new file mode 100644
index 00000000..6d076d83
--- /dev/null
+++ b/manager-v2/src/app.tsx
@@ -0,0 +1,88 @@
+import { useMemo, useState } from "react";
+import { ApiLab } from "./api-lab";
+import { EvolutionApi, loadConnection, saveConnection, type EvolutionConnection } from "./api";
+import { CallWorkspace } from "./call-workspace";
+import { ConnectionEditor, InstanceWorkspace } from "./instance";
+
+type View = "instance" | "api" | "calls" | "settings";
+
+const NAV_ITEMS: Array<{ id: View; icon: string; label: string }> = [
+ { id: "instance", icon: "◫", label: "Instância" },
+ { id: "api", icon: "⌘", label: "API Lab" },
+ { id: "calls", icon: "☎", label: "Chamadas" },
+ { id: "settings", icon: "⚙", label: "Configuração" },
+];
+
+function connectionHost(value: string): string {
+ try {
+ return new URL(value).host;
+ } catch {
+ return value || "URL não configurada";
+ }
+}
+
+export function App() {
+ const [view, setView] = useState("instance");
+ const [connection, setConnection] = useState(loadConnection);
+ const hasAnyKey = Boolean(connection.apiKey || connection.adminApiKey);
+ const api = useMemo(() => hasAnyKey ? new EvolutionApi(connection) : null, [connection, hasAnyKey]);
+
+ const updateConnection = (next: EvolutionConnection) => {
+ saveConnection(next);
+ setConnection(next);
+ };
+
+ const connectionLabel = connection.apiKey
+ ? "Chave da instância salva"
+ : connection.adminApiKey
+ ? "Chave global salva"
+ : "Configuração necessária";
+
+ return (
+
+
+
+
+
+
+
+ {view === "instance" &&
}
+ {view === "api" &&
}
+ {view === "calls" &&
}
+ {view === "settings" &&
}
+
+ {!hasAnyKey && !["instance", "settings"].includes(view) && (
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/manager-v2/src/call-workspace.tsx b/manager-v2/src/call-workspace.tsx
new file mode 100644
index 00000000..b10ce277
--- /dev/null
+++ b/manager-v2/src/call-workspace.tsx
@@ -0,0 +1,217 @@
+import { useEffect, useRef, useState } from "react";
+import { displayPhone, normalizePhone, type EvolutionApi, type EvolutionCall } from "./api";
+import { useCallDesk } from "./calls";
+import { EvolutionPcmBridge, type MediaStats, type MediaStatus } from "./pcm";
+
+interface LogEntry {
+ id: number;
+ timestamp: string;
+ message: string;
+ details?: string;
+}
+
+function stateLabel(state: EvolutionCall["state"]): string {
+ return {
+ idle: "Inativa",
+ ringing: "Chamando",
+ connecting: "Conectando",
+ active: "Ativa",
+ ended: "Encerrada",
+ failed: "Falhou",
+ }[state] || state;
+}
+
+export function CallWorkspace({ api }: { api: EvolutionApi | null }) {
+ const desk = useCallDesk(api);
+ const [number, setNumber] = useState("");
+ const [mediaStatus, setMediaStatus] = useState("idle");
+ const [stats, setStats] = useState({ sent: 0, received: 0, dropped: 0 });
+ const [muted, setMuted] = useState(false);
+ const [logs, setLogs] = useState([]);
+ const [autoConnectId, setAutoConnectId] = useState("");
+ const bridgeRef = useRef(null);
+
+ const log = (message: string, details?: unknown) => {
+ setLogs((current) => [...current.slice(-119), {
+ id: Date.now() + Math.random(),
+ timestamp: new Date().toLocaleTimeString(),
+ message,
+ details: details === undefined ? undefined : typeof details === "string" ? details : JSON.stringify(details),
+ }]);
+ };
+
+ useEffect(() => {
+ if (!api) {
+ void bridgeRef.current?.disconnect(false);
+ bridgeRef.current = null;
+ return;
+ }
+ const bridge = new EvolutionPcmBridge(api, {
+ onStatus: setMediaStatus,
+ onStats: setStats,
+ onLog: log,
+ });
+ bridgeRef.current = bridge;
+ return () => {
+ void bridge.disconnect();
+ bridgeRef.current = null;
+ };
+ }, [api]);
+
+ useEffect(() => {
+ const selected = desk.selectedCall;
+ const activeMediaCall = bridgeRef.current?.activeCallId;
+ if (activeMediaCall) {
+ const current = desk.snapshot.calls.find((call) => call.id === activeMediaCall);
+ if (!current || ["ended", "failed"].includes(current.state)) {
+ void bridgeRef.current?.disconnect(false);
+ }
+ }
+ if (selected?.id === autoConnectId && selected.state === "active" && mediaStatus === "idle") {
+ setAutoConnectId("");
+ void bridgeRef.current?.connect(selected.id).catch((cause) => {
+ log("Conexão automática do áudio falhou", cause instanceof Error ? cause.message : cause);
+ });
+ }
+ }, [autoConnectId, desk.selectedCall, desk.snapshot.calls, mediaStatus]);
+
+ const selected = desk.selectedCall;
+ const incoming = desk.snapshot.calls.filter((call) => call.direction === "incoming" && call.state === "ringing").length;
+ const live = desk.snapshot.calls.filter((call) => !["ended", "failed"].includes(call.state)).length;
+
+ const beginCall = async () => {
+ const normalized = normalizePhone(number);
+ if (normalized.length < 8 || normalized.length > 20) {
+ log("Número inválido", "Informe o número completo com DDI");
+ return;
+ }
+ try {
+ const call = await desk.start(normalized);
+ setNumber(normalized);
+ setAutoConnectId(call.id);
+ log("Chamada iniciada", { callId: call.id, peer: call.peer });
+ } catch {
+ // The hook exposes the error in the workspace.
+ }
+ };
+
+ const connectAudio = async () => {
+ if (!selected || selected.state !== "active") return;
+ try {
+ await bridgeRef.current?.connect(selected.id);
+ } catch (cause) {
+ log("Falha ao conectar áudio", cause instanceof Error ? cause.message : cause);
+ }
+ };
+
+ const terminate = async (call: EvolutionCall) => {
+ if (bridgeRef.current?.activeCallId === call.id) await bridgeRef.current.disconnect();
+ await desk.terminate(call).catch(() => undefined);
+ log("Chamada encerrada", call.id);
+ };
+
+ return (
+
+
+
+
+
Teste especializado
+
Telefonia WhatsApp no navegador
+
Validação de sinalização, WebRTC, microfone, relay e áudio recebido.
+
+
+
{live} em andamento
+
{incoming} tocando
+
{stats.received} frames recebidos
+
+
+
+
+
+
POST /call/start
Discador de teste
+
+ {desk.snapshot.connected ? "WhatsApp conectado" : "WhatsApp desconectado"}
+
+
+
+
DDI
+
setNumber(event.target.value)}
+ onKeyDown={(event) => { if (event.key === "Enter") void beginCall(); }}
+ />
+
void beginCall()}>☎ Ligar
+
+ {desk.error && {desk.error}
}
+
+
+ {selected ? (
+
+ {displayPhone(selected.peer).slice(-2)}
+
+
{selected.direction === "incoming" ? "Chamada recebida" : "Chamada realizada"}
+
{displayPhone(selected.peer)}
+
{stateLabel(selected.state)} ID {selected.id}
+
Áudio: {mediaStatus} ↑ {stats.sent} ↓ {stats.received} Descartados {stats.dropped}
+
+
+ {selected.direction === "incoming" && selected.state === "ringing" && (
+ <>
+ {
+ setAutoConnectId(selected.id);
+ void desk.accept(selected).then(() => log("Chamada aceita", selected.id)).catch(() => undefined);
+ }}>✓
+ void desk.reject(selected).then(() => log("Chamada recusada", selected.id)).catch(() => undefined)}>×
+ >
+ )}
+ {selected.state === "active" && mediaStatus === "idle" && void connectAudio()}>Conectar áudio }
+ {mediaStatus === "connected" && (
+ {
+ const next = !muted;
+ setMuted(next);
+ bridgeRef.current?.setMuted(next);
+ }}>{muted ? "Ativar microfone" : "Silenciar"}
+ )}
+ {!(["ended", "failed"] as string[]).includes(selected.state) && void terminate(selected)}>⌁ }
+
+
+ ) : (
+
☎
Nenhuma chamada selecionada Teste de voz pronto Inicie uma chamada ou aguarde uma ligação recebida.
+ )}
+
+
+ GET /call/status
Chamadas da sessão void desk.refresh(false)} disabled={desk.loading}>↻
+
+ {desk.snapshot.calls.length === 0 ?
Nenhuma chamada registrada.
: [...desk.snapshot.calls].reverse().map((call) => (
+
desk.setSelectedCallId(call.id)}>
+ {call.direction === "incoming" ? "↙" : "↗"}
+ {displayPhone(call.peer)} {call.id}
+ {call.direction === "incoming" ? "Recebida" : "Realizada"}
+ {stateLabel(call.state)}
+
+ ))}
+
+
+
+
+
+
+ Operação
Diagnóstico local
+
+ {logs.length === 0 ?
Nenhum evento local registrado.
: [...logs].reverse().map((entry) => (
+
{entry.timestamp} {entry.message} {entry.details && {entry.details} }
+ ))}
+
+
+
+ Qualidade da chamada
+ {stats.received > 0 ? "Fluxo bidirecional" : mediaStatus === "connected" ? "Aguardando retorno" : "Sem mídia ativa"}
+ 0 ? "active" : ""} /> 10 ? "active" : ""} />
+ Os contadores separam falhas do navegador, WebRTC, relay, SRTP e codec.
+
+
+
+ );
+}
diff --git a/manager-v2/src/calls.ts b/manager-v2/src/calls.ts
new file mode 100644
index 00000000..9b21c337
--- /dev/null
+++ b/manager-v2/src/calls.ts
@@ -0,0 +1,118 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type { EvolutionApi, EvolutionCall, CallStatusSnapshot } from "./api";
+
+const POLL_INTERVAL_MS = 1800;
+
+export interface CallDeskState {
+ snapshot: CallStatusSnapshot;
+ selectedCall: EvolutionCall | null;
+ selectedCallId: string;
+ loading: boolean;
+ error: string;
+ setSelectedCallId: (callId: string) => void;
+ refresh: (quiet?: boolean) => Promise;
+ start: (number: string) => Promise;
+ accept: (call: EvolutionCall) => Promise;
+ reject: (call: EvolutionCall) => Promise;
+ terminate: (call: EvolutionCall) => Promise;
+}
+
+const EMPTY_SNAPSHOT: CallStatusSnapshot = { connected: false, calls: [] };
+
+export function useCallDesk(api: EvolutionApi | null): CallDeskState {
+ const [snapshot, setSnapshot] = useState(EMPTY_SNAPSHOT);
+ const [selectedCallId, setSelectedCallId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+
+ const chooseCall = useCallback((next: CallStatusSnapshot, currentId: string): string => {
+ if (currentId && next.calls.some((call) => call.id === currentId)) return currentId;
+ const live = [...next.calls].reverse().find((call) => !["ended", "failed"].includes(call.state));
+ return live?.id || next.calls.at(-1)?.id || "";
+ }, []);
+
+ const refresh = useCallback(async (quiet = false) => {
+ if (!api) {
+ setSnapshot(EMPTY_SNAPSHOT);
+ return;
+ }
+ if (!quiet) setLoading(true);
+ try {
+ const next = await api.callStatus();
+ next.calls = Array.isArray(next.calls) ? next.calls : [];
+ setSnapshot(next);
+ setSelectedCallId((current) => chooseCall(next, current));
+ setError("");
+ } catch (cause) {
+ if (!quiet) setError(cause instanceof Error ? cause.message : "Falha ao consultar chamadas");
+ } finally {
+ if (!quiet) setLoading(false);
+ }
+ }, [api, chooseCall]);
+
+ useEffect(() => {
+ void refresh(false);
+ if (!api) return;
+ const timer = window.setInterval(() => void refresh(true), POLL_INTERVAL_MS);
+ return () => window.clearInterval(timer);
+ }, [api, refresh]);
+
+ const run = useCallback(async (operation: () => Promise): Promise => {
+ setLoading(true);
+ setError("");
+ try {
+ return await operation();
+ } catch (cause) {
+ const message = cause instanceof Error ? cause.message : "Operação não concluída";
+ setError(message);
+ throw cause;
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const start = useCallback(async (number: string) => {
+ if (!api) throw new Error("Configure a conexão da instância");
+ const call = await run(() => api.startCall(number));
+ setSelectedCallId(call.id);
+ await refresh(true);
+ return call;
+ }, [api, refresh, run]);
+
+ const accept = useCallback(async (call: EvolutionCall) => {
+ if (!api) throw new Error("Configure a conexão da instância");
+ await run(() => api.acceptCall(call.id));
+ await refresh(true);
+ }, [api, refresh, run]);
+
+ const reject = useCallback(async (call: EvolutionCall) => {
+ if (!api) throw new Error("Configure a conexão da instância");
+ await run(() => api.rejectCall(call));
+ await refresh(true);
+ }, [api, refresh, run]);
+
+ const terminate = useCallback(async (call: EvolutionCall) => {
+ if (!api) throw new Error("Configure a conexão da instância");
+ await run(() => api.terminateCall(call.id));
+ await refresh(true);
+ }, [api, refresh, run]);
+
+ const selectedCall = useMemo(
+ () => snapshot.calls.find((call) => call.id === selectedCallId) ?? null,
+ [selectedCallId, snapshot.calls],
+ );
+
+ return {
+ snapshot,
+ selectedCall,
+ selectedCallId,
+ loading,
+ error,
+ setSelectedCallId,
+ refresh,
+ start,
+ accept,
+ reject,
+ terminate,
+ };
+}
diff --git a/manager-v2/src/guided-request.css b/manager-v2/src/guided-request.css
new file mode 100644
index 00000000..8de41800
--- /dev/null
+++ b/manager-v2/src/guided-request.css
@@ -0,0 +1,40 @@
+.guided-request-layout { display: grid; grid-template-columns: minmax(0, 1fr) 280px; gap: 18px; align-items: start; }
+.guided-form { display: grid; gap: 14px; }
+.guided-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
+.guided-grid.compact { grid-template-columns: repeat(3, minmax(0, 1fr)); padding-top: 4px; }
+.guided-field { display: grid; gap: 7px; min-width: 0; }
+.guided-field > span { color: var(--muted); font-size: 12px; font-weight: 700; }
+.guided-field small { color: #66857c; font-size: 10px; line-height: 1.45; }
+.guided-field input, .guided-field select, .guided-field textarea { width: 100%; border: 1px solid var(--line); border-radius: 12px; background: rgba(2,12,9,.55); color: var(--text); padding: 11px 13px; outline: none; }
+.guided-field input:focus, .guided-field select:focus, .guided-field textarea:focus { border-color: rgba(40,209,124,.65); box-shadow: 0 0 0 3px rgba(40,209,124,.08); }
+.guided-field textarea { resize: vertical; min-height: 92px; line-height: 1.45; }
+.guided-json-error { color: #ffb2ba !important; font-size: 10px; overflow-wrap: anywhere; }
+.guided-check { min-height: 45px; display: flex; align-items: center; gap: 9px; border: 1px solid var(--line); border-radius: 12px; padding: 0 12px; background: rgba(2,12,9,.35); color: var(--muted); font-size: 12px; }
+.guided-check input { width: auto; min-height: auto; padding: 0; }
+.guided-preview { position: sticky; top: 92px; display: grid; gap: 12px; border: 1px solid var(--line); border-radius: 15px; padding: 14px; background: rgba(0,0,0,.13); }
+.guided-preview > p { margin: 0; color: var(--muted); font-size: 10px; line-height: 1.5; }
+.preview-phone { border-radius: 16px; overflow: hidden; border: 1px solid rgba(255,255,255,.08); background: #0b1714; }
+.preview-header { padding: 10px 12px; background: #10251e; color: #a8c0b8; font-size: 11px; font-weight: 800; }
+.preview-bubble { margin: 18px 12px 20px 30px; padding: 11px 12px 8px; border-radius: 13px 13px 4px 13px; background: #155c43; box-shadow: 0 8px 22px rgba(0,0,0,.2); }
+.preview-bubble strong { display: block; font-size: 13px; }
+.preview-bubble p { margin: 5px 0 8px; font-size: 12px; color: #e4f4ee; white-space: pre-wrap; overflow-wrap: anywhere; }
+.preview-bubble small { display: block; text-align: right; color: rgba(255,255,255,.58); font-size: 9px; margin-top: 7px; }
+.preview-bubble ul { margin: 8px 0; padding-left: 18px; color: #dcefe8; font-size: 11px; }
+.preview-buttons { display: grid; gap: 5px; margin-top: 9px; }
+.preview-buttons span { padding: 7px 8px; text-align: center; border-top: 1px solid rgba(255,255,255,.12); color: #9df0c3; font-size: 10px; }
+.preview-chip { display: inline-flex; width: fit-content; margin-top: 8px; padding: 5px 8px; border-radius: 99px; background: rgba(255,255,255,.1); font-size: 10px; }
+.guided-invalid { display: grid; gap: 5px; border: 1px solid rgba(255,92,108,.22); border-radius: 13px; padding: 14px; background: rgba(255,92,108,.07); color: #ffb3bb; }
+.guided-invalid span { font-size: 11px; overflow-wrap: anywhere; }
+.request-mode-row { display: flex; gap: 7px; align-items: center; flex-wrap: wrap; margin: 0 0 14px; }
+.request-mode-row button { border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; background: var(--panel-3); color: var(--muted); cursor: pointer; font-size: 11px; font-weight: 750; }
+.request-mode-row button.active { color: var(--accent-2); border-color: rgba(40,209,124,.35); background: rgba(40,209,124,.09); }
+.request-mode-row span { margin-left: auto; color: var(--muted); font-size: 10px; }
+.request-validation { display: grid; gap: 7px; margin-top: 13px; }
+.request-validation div { padding: 9px 11px; border-radius: 10px; font-size: 11px; }
+.request-validation .validation-error { color: #ffb2ba; background: rgba(255,92,108,.08); border: 1px solid rgba(255,92,108,.2); }
+.request-validation .validation-warning { color: #ffe0a2; background: rgba(246,200,95,.08); border: 1px solid rgba(246,200,95,.2); }
+.api-inline-actions { display: flex; gap: 7px; flex-wrap: wrap; margin-bottom: 10px; }
+.api-inline-actions button { border: 1px solid var(--line); border-radius: 9px; background: rgba(255,255,255,.025); color: var(--muted); padding: 6px 9px; font-size: 10px; cursor: pointer; }
+.api-inline-actions button:hover { color: var(--text); }
+@media (max-width: 1080px) { .guided-request-layout { grid-template-columns: 1fr; } .guided-preview { position: static; } }
+@media (max-width: 680px) { .guided-grid, .guided-grid.compact { grid-template-columns: 1fr; } .request-mode-row span { width: 100%; margin-left: 0; } }
diff --git a/manager-v2/src/guided-request.tsx b/manager-v2/src/guided-request.tsx
new file mode 100644
index 00000000..f9ff7475
--- /dev/null
+++ b/manager-v2/src/guided-request.tsx
@@ -0,0 +1,439 @@
+import { useEffect, useState, type ReactNode } from "react";
+import type { BodyMode } from "./api-catalog";
+import "./guided-request.css";
+
+type JsonObject = Record;
+
+type GuidedRequestProps = {
+ operationId: string;
+ body: string;
+ onChange: (body: string) => void;
+};
+
+export type RequestValidation = {
+ errors: string[];
+ warnings: string[];
+};
+
+const GUIDED_OPERATIONS = new Set([
+ "send-text",
+ "send-link",
+ "send-media-file",
+ "send-media-url",
+ "send-poll",
+ "send-sticker",
+ "send-location",
+ "send-contact",
+ "send-button",
+ "send-list",
+ "send-carousel",
+ "send-status-text",
+ "send-status-media-file",
+ "send-status-media-url",
+]);
+
+function parseObject(body: string): { value: JsonObject | null; error: string } {
+ try {
+ const parsed = JSON.parse(body || "{}") as unknown;
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
+ return { value: null, error: "O corpo precisa ser um objeto JSON." };
+ }
+ return { value: parsed as JsonObject, error: "" };
+ } catch (cause) {
+ return { value: null, error: cause instanceof Error ? cause.message : "JSON inválido" };
+ }
+}
+
+function objectValue(value: unknown): JsonObject {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as JsonObject : {};
+}
+
+function arrayValue(value: unknown): unknown[] {
+ return Array.isArray(value) ? value : [];
+}
+
+function stringValue(value: unknown): string {
+ return typeof value === "string" ? value : value === undefined || value === null ? "" : String(value);
+}
+
+function numberValue(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : Number(value) || 0;
+}
+
+function booleanValue(value: unknown): boolean {
+ return value === true;
+}
+
+function splitLines(value: string): string[] {
+ return value.split("\n").map((item) => item.trim()).filter(Boolean);
+}
+
+function rowsToText(rows: unknown): string {
+ return arrayValue(rows).map((row) => {
+ const item = objectValue(row);
+ return [stringValue(item.title), stringValue(item.description), stringValue(item.rowId)].join("|");
+ }).join("\n");
+}
+
+function textToRows(value: string): JsonObject[] {
+ return splitLines(value).map((line, index) => {
+ const [title = "", description = "", rowId = ""] = line.split("|").map((item) => item.trim());
+ return { title, description, rowId: rowId || `row_${index + 1}` };
+ });
+}
+
+function buttonPreset(buttonsValue: unknown): "reply" | "cta" | "pix" {
+ const buttons = arrayValue(buttonsValue).map(objectValue);
+ if (buttons.some((item) => stringValue(item.type).toLowerCase() === "pix")) return "pix";
+ if (buttons.some((item) => ["copy", "url", "call"].includes(stringValue(item.type).toLowerCase()))) return "cta";
+ return "reply";
+}
+
+function presetButtons(preset: "reply" | "cta" | "pix"): JsonObject[] {
+ if (preset === "pix") {
+ return [{ type: "pix", currency: "BRL", name: "Minha empresa", keyType: "random", key: "CHAVE_PIX" }];
+ }
+ if (preset === "cta") {
+ return [
+ { type: "copy", displayText: "Copiar cupom", id: "copy_coupon", copyCode: "PROMO2026" },
+ { type: "url", displayText: "Abrir site", url: "https://example.com" },
+ { type: "call", displayText: "Ligar", phoneNumber: "+5562999999999" },
+ ];
+ }
+ return [
+ { type: "reply", displayText: "Quero saber mais", id: "btn_info" },
+ { type: "reply", displayText: "Agora não", id: "btn_no" },
+ ];
+}
+
+function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+ {hint && {hint} }
+
+ );
+}
+
+function TextField({ label, value, onChange, placeholder, hint, type = "text" }: {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ hint?: string;
+ type?: "text" | "url";
+}) {
+ return onChange(event.target.value)} /> ;
+}
+
+function NumberField({ label, value, onChange, step }: { label: string; value: number; onChange: (value: number) => void; step?: string }) {
+ return onChange(Number(event.target.value))} /> ;
+}
+
+function TextAreaField({ label, value, onChange, placeholder, hint, rows = 4 }: {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ hint?: string;
+ rows?: number;
+}) {
+ return ;
+}
+
+function JsonArrayField({ label, value, onChange, hint, rows = 9 }: {
+ label: string;
+ value: unknown;
+ onChange: (value: unknown[]) => void;
+ hint?: string;
+ rows?: number;
+}) {
+ const serialized = JSON.stringify(arrayValue(value), null, 2);
+ const [draft, setDraft] = useState(serialized);
+ const [draftError, setDraftError] = useState("");
+
+ useEffect(() => {
+ setDraft(serialized);
+ setDraftError("");
+ }, [serialized]);
+
+ const updateDraft = (next: string) => {
+ setDraft(next);
+ try {
+ const parsed = JSON.parse(next) as unknown;
+ if (!Array.isArray(parsed)) throw new Error("Informe um array JSON.");
+ onChange(parsed);
+ setDraftError("");
+ } catch (cause) {
+ setDraftError(cause instanceof Error ? cause.message : "JSON inválido");
+ }
+ };
+
+ return (
+
+
+ );
+}
+
+function SelectField({ label, value, onChange, options }: {
+ label: string;
+ value: string;
+ onChange: (value: string) => void;
+ options: Array<{ value: string; label: string }>;
+}) {
+ return onChange(event.target.value)}>{options.map((item) => {item.label} )} ;
+}
+
+function CommonFields({ data, patch }: { data: JsonObject; patch: (key: string, value: unknown) => void }) {
+ return (
+
+ patch("delay", value)} />
+ patch("formatJid", event.target.checked)} />Formatar automaticamente
+ patch("mentionAll", event.target.checked)} />Mencionar todos
+
+ );
+}
+
+function Preview({ operationId, data }: { operationId: string; data: JsonObject }) {
+ const type = stringValue(data.type) || "mensagem";
+ let title = stringValue(data.title) || stringValue(data.question) || stringValue(data.name) || "Prévia da mensagem";
+ let content = stringValue(data.text) || stringValue(data.description) || stringValue(data.caption) || stringValue(data.address);
+ let details: ReactNode = null;
+
+ if (operationId === "send-poll") {
+ details = {arrayValue(data.options).map((item, index) => {stringValue(item)} )} ;
+ } else if (operationId === "send-button") {
+ details = {arrayValue(data.buttons).map((item, index) => {
+ const button = objectValue(item);
+ return {stringValue(button.displayText) || stringValue(button.name) || stringValue(button.type)} ;
+ })}
;
+ } else if (operationId === "send-list") {
+ const first = objectValue(arrayValue(data.sections)[0]);
+ details = {arrayValue(first.rows).map((item, index) => {stringValue(objectValue(item).title)} )} ;
+ } else if (operationId === "send-carousel") {
+ const card = objectValue(arrayValue(data.cards)[0]);
+ const header = objectValue(card.header);
+ title = stringValue(header.title) || title;
+ content = stringValue(objectValue(card.body).text) || content;
+ details = 1º card do carrossel ;
+ } else if (["send-media-file", "send-media-url", "send-status-media-file", "send-status-media-url"].includes(operationId)) {
+ details = {type} ;
+ } else if (operationId === "send-location") {
+ details = 📍 {numberValue(data.latitude)}, {numberValue(data.longitude)} ;
+ } else if (operationId === "send-contact") {
+ const vcard = objectValue(data.vcard);
+ title = stringValue(vcard.fullName) || "Contato";
+ content = stringValue(vcard.phone);
+ details = {stringValue(vcard.organization) || "vCard"} ;
+ }
+
+ return (
+
+ );
+}
+
+export function supportsGuidedRequest(operationId: string): boolean {
+ return GUIDED_OPERATIONS.has(operationId);
+}
+
+export function GuidedRequestEditor({ operationId, body, onChange }: GuidedRequestProps) {
+ const parsed = parseObject(body);
+ if (!parsed.value) {
+ return Não foi possível abrir o formulário guiado. {parsed.error}
;
+ }
+ const data = parsed.value;
+ const commit = (next: JsonObject) => onChange(JSON.stringify(next, null, 2));
+ const patch = (key: string, value: unknown) => commit({ ...data, [key]: value });
+ const patchNested = (parent: string, key: string, value: unknown) => commit({ ...data, [parent]: { ...objectValue(data[parent]), [key]: value } });
+ const patchObjectArrayItem = (key: string, index: number, update: (current: JsonObject) => JsonObject) => {
+ const items = arrayValue(data[key]).map(objectValue);
+ while (items.length <= index) items.push({});
+ const next = [...items];
+ next[index] = update(next[index]);
+ patch(key, next);
+ };
+ const numberField = !operationId.startsWith("send-status");
+
+ let fields: ReactNode;
+ switch (operationId) {
+ case "send-text":
+ fields = patch("text", value)} placeholder="Digite a mensagem de teste" rows={7} />;
+ break;
+ case "send-link":
+ fields = <>
+ patch("text", value)} />
+ patch("url", value)} /> patch("title", value)} /> patch("imgUrl", value)} />
+ patch("description", value)} />
+ >;
+ break;
+ case "send-media-file":
+ case "send-media-url":
+ case "send-status-media-file":
+ case "send-status-media-url":
+ fields = <>
+
+ patch("type", value)} options={[{ value: "image", label: "Imagem" }, { value: "video", label: "Vídeo" }, { value: "audio", label: "Áudio" }, { value: "document", label: "Documento" }]} />
+ {operationId.endsWith("url") && patch("url", value)} />}
+ {!operationId.startsWith("send-status") && patch("filename", value)} />}
+
+ patch("caption", value)} />
+ >;
+ break;
+ case "send-poll":
+ fields = <>
+ patch("question", value)} />
+ patch("options", splitLines(value))} hint="Uma opção por linha; mínimo de duas." />
+ patch("maxAnswer", value)} />
+ >;
+ break;
+ case "send-sticker":
+ fields = patch("sticker", value)} />;
+ break;
+ case "send-location":
+ fields = <>
+ patch("name", value)} /> patch("address", value)} />
+ patch("latitude", value)} /> patch("longitude", value)} />
+ >;
+ break;
+ case "send-contact": {
+ const vcard = objectValue(data.vcard);
+ fields = patchNested("vcard", "fullName", value)} /> patchNested("vcard", "phone", value)} /> patchNested("vcard", "organization", value)} />
;
+ break;
+ }
+ case "send-button": {
+ const preset = buttonPreset(data.buttons);
+ fields = <>
+ patch("title", value)} /> patch("footer", value)} />
+ patch("description", value)} />
+ patch("buttons", presetButtons(value as "reply" | "cta" | "pix"))} options={[{ value: "reply", label: "Até 3 respostas rápidas" }, { value: "cta", label: "Copiar + URL + ligar" }, { value: "pix", label: "PIX isolado" }]} />
+ patch("buttons", value)} hint="O rascunho pode ficar temporariamente inválido sem perder o texto digitado." />
+ >;
+ break;
+ }
+ case "send-list": {
+ const section = objectValue(arrayValue(data.sections)[0]);
+ fields = <>
+ patch("title", value)} /> patch("buttonText", value)} />
+ patch("description", value)} />
+ patchObjectArrayItem("sections", 0, (current) => ({ ...current, title: value }))} />
+ patchObjectArrayItem("sections", 0, (current) => ({ ...current, rows: textToRows(value) }))} hint="Uma linha por item: Título|Descrição|rowId" />
+ >;
+ break;
+ }
+ case "send-carousel": {
+ const card = objectValue(arrayValue(data.cards)[0]);
+ const header = objectValue(card.header);
+ const cardBody = objectValue(card.body);
+ fields = <>
+ patch("body", value)} />
+ patchObjectArrayItem("cards", 0, (current) => ({ ...current, header: { ...objectValue(current.header), title: value } }))} /> patchObjectArrayItem("cards", 0, (current) => ({ ...current, header: { ...objectValue(current.header), imageUrl: value } }))} />
+ patchObjectArrayItem("cards", 0, (current) => ({ ...current, body: { ...objectValue(current.body), text: value } }))} />
+ >;
+ break;
+ }
+ case "send-status-text":
+ fields = patch("text", value)} rows={7} />;
+ break;
+ default:
+ fields = null;
+ }
+
+ return (
+
+
+ {numberField && patch("number", value)} placeholder="5562999999999" hint="Informe DDI + DDD + número ou um JID completo." />}
+ {fields}
+ {!operationId.startsWith("send-status") && !["send-button", "send-list", "send-carousel"].includes(operationId) && }
+
+
+
+ );
+}
+
+function requiredString(data: JsonObject, key: string, label: string, errors: string[]) {
+ if (!stringValue(data[key]).trim()) errors.push(`${label} é obrigatório.`);
+}
+
+export function validateRequestDraft(operationId: string, bodyMode: BodyMode, body: string, file: File | null): RequestValidation {
+ const errors: string[] = [];
+ const warnings: string[] = [];
+ if (bodyMode === "none") return { errors, warnings };
+
+ const parsed = parseObject(body);
+ if (!parsed.value) return { errors: [`JSON inválido: ${parsed.error}`], warnings };
+ const data = parsed.value;
+
+ if (supportsGuidedRequest(operationId) && !operationId.startsWith("send-status")) {
+ requiredString(data, "number", "Destinatário", errors);
+ }
+ if (bodyMode === "multipart" && ["send-media-file", "send-status-media-file"].includes(operationId) && !file) {
+ errors.push("Selecione o arquivo do upload multipart.");
+ }
+
+ switch (operationId) {
+ case "send-text": requiredString(data, "text", "Texto", errors); break;
+ case "send-link": requiredString(data, "text", "Texto", errors); break;
+ case "send-media-url":
+ case "send-status-media-url": requiredString(data, "url", "URL/base64", errors); break;
+ case "send-poll":
+ requiredString(data, "question", "Pergunta", errors);
+ if (arrayValue(data.options).filter((item) => stringValue(item).trim()).length < 2) errors.push("A enquete precisa de pelo menos duas opções.");
+ break;
+ case "send-sticker": requiredString(data, "sticker", "Figurinha", errors); break;
+ case "send-location":
+ requiredString(data, "name", "Nome do local", errors);
+ requiredString(data, "address", "Endereço", errors);
+ if (!numberValue(data.latitude)) errors.push("Latitude diferente de zero é obrigatória.");
+ if (!numberValue(data.longitude)) errors.push("Longitude diferente de zero é obrigatória.");
+ break;
+ case "send-contact": {
+ const vcard = objectValue(data.vcard);
+ requiredString(vcard, "fullName", "Nome do contato", errors);
+ requiredString(vcard, "phone", "Telefone do contato", errors);
+ break;
+ }
+ case "send-button": {
+ requiredString(data, "title", "Título", errors);
+ requiredString(data, "description", "Descrição", errors);
+ requiredString(data, "footer", "Rodapé", errors);
+ const buttons = arrayValue(data.buttons).map(objectValue);
+ if (!buttons.length) errors.push("Adicione pelo menos um botão.");
+ const types = buttons.map((item) => stringValue(item.type).toLowerCase());
+ if (types.filter((item) => item === "reply").length > 3) errors.push("São permitidos no máximo três botões reply.");
+ if (types.includes("reply") && types.some((item) => item !== "reply")) errors.push("Botões reply não podem ser misturados com CTA.");
+ if (types.includes("pix") && buttons.length !== 1) errors.push("PIX precisa ser o único botão da mensagem.");
+ break;
+ }
+ case "send-list": {
+ requiredString(data, "title", "Título", errors);
+ requiredString(data, "description", "Descrição", errors);
+ requiredString(data, "buttonText", "Texto do botão", errors);
+ const sections = arrayValue(data.sections).map(objectValue);
+ if (!sections.some((section) => arrayValue(section.rows).length > 0)) errors.push("A lista precisa de pelo menos uma linha.");
+ break;
+ }
+ case "send-carousel":
+ if (!arrayValue(data.cards).length) errors.push("O carrossel precisa de pelo menos um card.");
+ break;
+ case "send-status-text": requiredString(data, "text", "Texto do status", errors); break;
+ }
+
+ if (stringValue(data.number).includes("9999999999")) warnings.push("O destinatário ainda parece ser o número de exemplo.");
+ if (body.includes("CHAVE_PIX") || body.includes("ID_DA_MENSAGEM")) warnings.push("O payload contém valores de exemplo que precisam ser substituídos.");
+ return { errors, warnings };
+}
diff --git a/manager-v2/src/instance.tsx b/manager-v2/src/instance.tsx
new file mode 100644
index 00000000..42fe6f2a
--- /dev/null
+++ b/manager-v2/src/instance.tsx
@@ -0,0 +1,236 @@
+import { useEffect, useMemo, useState } from "react";
+import {
+ normalizeBaseUrl,
+ type ApiAuthMode,
+ type ApiExecutionResult,
+ type EvolutionApi,
+ type EvolutionConnection,
+} from "./api";
+
+function pretty(value: unknown): string {
+ if (typeof value === "string") return value;
+ return JSON.stringify(value, null, 2);
+}
+
+function findQrImage(value: unknown): string {
+ const visit = (candidate: unknown): string => {
+ if (typeof candidate === "string") {
+ if (candidate.startsWith("data:image/")) return candidate;
+ if (candidate.length > 200 && /^[A-Za-z0-9+/=\r\n]+$/.test(candidate)) {
+ return `data:image/png;base64,${candidate.replace(/\s/g, "")}`;
+ }
+ return "";
+ }
+ if (Array.isArray(candidate)) {
+ for (const item of candidate) {
+ const found = visit(item);
+ if (found) return found;
+ }
+ return "";
+ }
+ if (candidate && typeof candidate === "object") {
+ const record = candidate as Record;
+ for (const key of ["qrcode", "qrCode", "base64", "image", "code"]) {
+ if (key in record) {
+ const found = visit(record[key]);
+ if (found) return found;
+ }
+ }
+ for (const item of Object.values(record)) {
+ const found = visit(item);
+ if (found) return found;
+ }
+ }
+ return "";
+ };
+ return visit(value);
+}
+
+export function ConnectionEditor({
+ value,
+ onSave,
+ compact = false,
+}: {
+ value: EvolutionConnection;
+ onSave: (connection: EvolutionConnection) => void;
+ compact?: boolean;
+}) {
+ const [draft, setDraft] = useState(value);
+ const [saved, setSaved] = useState(false);
+ useEffect(() => setDraft(value), [value]);
+
+ const save = () => {
+ const normalized = {
+ ...draft,
+ baseUrl: normalizeBaseUrl(draft.baseUrl),
+ apiKey: draft.apiKey.trim(),
+ adminApiKey: draft.adminApiKey.trim(),
+ instanceId: draft.instanceId.trim(),
+ };
+ onSave(normalized);
+ setDraft(normalized);
+ setSaved(true);
+ window.setTimeout(() => setSaved(false), 1800);
+ };
+
+ return (
+
+ );
+}
+
+type SessionAction = {
+ id: string;
+ title: string;
+ description: string;
+ method: string;
+ path: string;
+ auth?: ApiAuthMode;
+ danger?: boolean;
+};
+
+const SESSION_ACTIONS: SessionAction[] = [
+ { id: "create", title: "Criar instância", description: "Cria a instância usando o ID e a chave do perfil acima.", method: "POST", path: "/instance/create", auth: "admin" },
+ { id: "status", title: "Consultar status", description: "Verifica se a sessão está conectada.", method: "GET", path: "/instance/status" },
+ { id: "connect", title: "Conectar", description: "Inicia a conexão e prepara o QR Code.", method: "POST", path: "/instance/connect" },
+ { id: "qr", title: "Gerar QR Code", description: "Busca o QR Code para pareamento.", method: "GET", path: "/instance/qr" },
+ { id: "reconnect", title: "Reconectar", description: "Reinicia o cliente preservando a sessão.", method: "POST", path: "/instance/reconnect" },
+ { id: "disconnect", title: "Desconectar", description: "Desconecta sem apagar credenciais.", method: "POST", path: "/instance/disconnect" },
+ { id: "logout", title: "Fazer logout", description: "Remove a sessão vinculada ao WhatsApp.", method: "DELETE", path: "/instance/logout", danger: true },
+];
+
+export function InstanceWorkspace({
+ api,
+ connection,
+ onSave,
+}: {
+ api: EvolutionApi | null;
+ connection: EvolutionConnection;
+ onSave: (connection: EvolutionConnection) => void;
+}) {
+ const [running, setRunning] = useState("");
+ const [error, setError] = useState("");
+ const [result, setResult] = useState(null);
+ const qrImage = useMemo(() => findQrImage(result?.data), [result]);
+
+ const execute = async (action: SessionAction) => {
+ if (!api || running) return;
+ setRunning(action.id);
+ setError("");
+ try {
+ let body: BodyInit | undefined;
+ if (action.id === "create") {
+ if (!connection.instanceId.trim()) throw new Error("Informe e salve o ID da instância");
+ if (!connection.apiKey.trim()) throw new Error("Informe e salve a API key da instância");
+ body = JSON.stringify({
+ instanceId: connection.instanceId.trim(),
+ name: connection.instanceId.trim(),
+ token: connection.apiKey.trim(),
+ proxy: null,
+ advancedSettings: null,
+ });
+ } else if (action.method === "POST") {
+ body = JSON.stringify({});
+ }
+ const response = await api.execute({
+ method: action.method,
+ path: action.path,
+ auth: action.auth ?? "instance",
+ body,
+ });
+ setResult(response);
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "Falha ao executar a ação");
+ } finally {
+ setRunning("");
+ }
+ };
+
+ return (
+
+
+
+
Sessão WhatsApp
+
Conectar, salvar e testar
+
Este painel não é uma caixa de entrada. Ele cria ou conecta a instância e oferece ferramentas para validar todas as funções da API.
+
+
+
{connection.instanceId || "—"} instância
+
{connection.apiKey ? "OK" : "—"} chave local
+
{connection.adminApiKey ? "OK" : "—"} chave global
+
+
+
+
+
+
+ {SESSION_ACTIONS.map((action) => (
+
+ {action.method}
+ {action.title}
+ {action.description}
+ {action.path}
+ void execute(action)}
+ >
+ {running === action.id ? "Executando…" : action.title}
+
+
+ ))}
+
+
+ {(error || result) && (
+
+ {qrImage && (
+
+ Pareamento
+ QR Code da sessão
+
+ Abra o WhatsApp no celular e use “Aparelhos conectados”.
+
+ )}
+
+
+
Resposta da API
{result ? `${result.status} ${result.statusText}` : "Falha local"}
+ {result &&
{result.durationMs} ms }
+
+ {error ? {error}
: {pretty(result?.data)} }
+
+
+ )}
+
+ );
+}
diff --git a/manager-v2/src/lab.css b/manager-v2/src/lab.css
new file mode 100644
index 00000000..a451ba40
--- /dev/null
+++ b/manager-v2/src/lab.css
@@ -0,0 +1,85 @@
+textarea, select { font: inherit; color: var(--text); }
+textarea, select { border: 1px solid var(--line); border-radius: 13px; background: rgba(2,12,9,.55); outline: none; }
+textarea:focus, select:focus { border-color: rgba(40,209,124,.7); box-shadow: 0 0 0 3px rgba(40,209,124,.1); }
+textarea { width: 100%; padding: 14px; resize: vertical; line-height: 1.55; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; }
+select { min-height: 48px; padding: 0 12px; }
+pre { margin: 0; padding: 16px; overflow: auto; border: 1px solid var(--line); border-radius: 13px; background: rgba(0,0,0,.2); color: #c9e4da; white-space: pre-wrap; overflow-wrap: anywhere; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 12px; line-height: 1.55; }
+code { color: #9ccbbb; font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; font-size: 11px; overflow-wrap: anywhere; }
+
+.connection-card.compact { max-width: 820px; margin: 0 auto; }
+.connection-form-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+.save-feedback { align-self: center; margin-right: 12px; color: var(--accent-2); font-size: 12px; }
+.instance-workspace { display: grid; gap: 18px; }
+.instance-hero .hero-metrics strong { max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 17px; }
+.session-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
+.session-action-card { padding: 19px; display: flex; flex-direction: column; align-items: flex-start; gap: 10px; min-height: 220px; }
+.session-action-card h3 { margin: 4px 0 0; }
+.session-action-card p { margin: 0; color: var(--muted); line-height: 1.5; font-size: 13px; flex: 1; }
+.session-action-card .button { width: 100%; margin-top: 5px; }
+.danger-button { background: rgba(255,92,108,.12); color: #ff9aa5; border-color: rgba(255,92,108,.24); }
+.session-result-grid { display: grid; grid-template-columns: minmax(260px, .65fr) minmax(0, 1.35fr); gap: 18px; align-items: start; }
+.qr-card, .session-response { padding: 22px; }
+.qr-card h2 { margin: 5px 0 18px; }
+.qr-card img { display: block; width: min(100%, 320px); aspect-ratio: 1; object-fit: contain; margin: 0 auto; padding: 12px; border-radius: 17px; background: white; }
+.qr-card p { color: var(--muted); text-align: center; font-size: 12px; }
+.session-response pre { max-height: 500px; }
+
+.api-lab-layout { display: grid; grid-template-columns: 300px minmax(0, 1fr) 230px; gap: 18px; align-items: start; }
+.api-catalog, .api-history { position: sticky; top: 92px; padding: 18px; max-height: calc(100vh - 116px); overflow: hidden; }
+.api-catalog { display: flex; flex-direction: column; }
+.api-catalog > input { min-height: 42px; }
+.api-category-strip { display: flex; gap: 6px; overflow-x: auto; padding: 11px 0; }
+.api-category-strip button { flex: 0 0 auto; border: 1px solid var(--line); background: rgba(255,255,255,.025); color: var(--muted); border-radius: 99px; padding: 6px 9px; cursor: pointer; font-size: 10px; }
+.api-category-strip button.active { color: var(--accent-2); border-color: rgba(40,209,124,.35); background: rgba(40,209,124,.1); }
+.api-operation-list { display: grid; gap: 5px; overflow: auto; padding-right: 4px; }
+.api-operation-list > button { width: 100%; display: grid; grid-template-columns: 52px minmax(0, 1fr); align-items: center; gap: 9px; padding: 9px; border: 1px solid transparent; border-radius: 11px; background: transparent; text-align: left; cursor: pointer; }
+.api-operation-list > button:hover, .api-operation-list > button.selected { background: rgba(255,255,255,.035); border-color: var(--line); }
+.api-operation-list strong, .api-operation-list small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.api-operation-list strong { font-size: 12px; }
+.api-operation-list small { margin-top: 3px; color: var(--muted); font-family: monospace; font-size: 9px; }
+.http-method { display: inline-grid; place-items: center; width: fit-content; min-width: 46px; padding: 5px 7px; border-radius: 8px; font-family: monospace; font-size: 10px; font-weight: 900; letter-spacing: .04em; background: rgba(83,159,255,.12); color: #8cbcff; }
+.method-post { background: rgba(40,209,124,.12); color: var(--accent-2); }
+.method-put, .method-patch { background: rgba(246,200,95,.12); color: var(--warning); }
+.method-delete { background: rgba(255,92,108,.12); color: #ff9aa5; }
+.api-console { display: grid; gap: 18px; min-width: 0; }
+.api-request-card, .api-response-card, .api-curl-card { padding: 22px; }
+.api-description { color: var(--muted); line-height: 1.55; font-size: 13px; }
+.api-route-row { display: grid; grid-template-columns: 90px minmax(0, 1fr) 180px; gap: 9px; margin: 17px 0; }
+.api-editor-label > span, .api-file-field > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 12px; }
+.api-file-field { display: block; margin-top: 13px; padding: 13px; border: 1px dashed var(--line); border-radius: 13px; }
+.api-file-field input { min-height: 40px; padding: 8px; }
+.api-file-field small { display: block; color: var(--muted); margin-top: 7px; }
+.api-actions { display: flex; justify-content: flex-end; gap: 9px; margin-top: 16px; }
+.response-status { padding: 7px 10px; border-radius: 99px; font-size: 11px; border: 1px solid var(--line); }
+.response-status.ok, .api-history-item .ok { color: var(--accent-2); }
+.response-status.failed, .api-history-item .failed { color: #ff9aa5; }
+.api-response-meta { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 12px; color: var(--muted); font-size: 11px; overflow-wrap: anywhere; }
+.api-response-card pre { min-height: 220px; max-height: 560px; }
+.api-curl-card pre { color: #a8c9ff; }
+.api-history > p { color: var(--muted); font-size: 12px; }
+.api-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 8px; padding: 10px 0; border-bottom: 1px solid var(--line); }
+.api-history-item strong { font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.api-history-item span { font-size: 11px; font-weight: 900; }
+.api-history-item small { grid-column: 1 / -1; color: var(--muted); font-size: 9px; }
+
+@media (max-width: 1320px) {
+ .api-lab-layout { grid-template-columns: 280px minmax(0, 1fr); }
+ .api-history { position: static; max-height: none; grid-column: 1 / -1; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
+ .api-history .section-heading { grid-column: 1 / -1; }
+ .api-history-item { border: 1px solid var(--line); border-radius: 11px; padding: 10px; }
+}
+@media (max-width: 960px) {
+ .api-lab-layout { grid-template-columns: 1fr; }
+ .api-catalog { position: static; max-height: 430px; }
+ .api-history { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .session-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+}
+@media (max-width: 700px) {
+ .connection-form-grid, .session-result-grid { grid-template-columns: 1fr; }
+ .session-grid { grid-template-columns: 1fr; }
+ .api-route-row { grid-template-columns: 76px minmax(0, 1fr); }
+ .api-route-row select:last-child { grid-column: 1 / -1; }
+ .api-actions { flex-direction: column-reverse; }
+ .api-actions .button { width: 100%; }
+ .api-history { grid-template-columns: 1fr; }
+}
diff --git a/manager-v2/src/main.tsx b/manager-v2/src/main.tsx
new file mode 100644
index 00000000..ce88f37a
--- /dev/null
+++ b/manager-v2/src/main.tsx
@@ -0,0 +1,15 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./app";
+import "./styles.css";
+import "./lab.css";
+import "./api-lab-layout.css";
+
+const root = document.getElementById("root");
+if (!root) throw new Error("Manager V2 root element was not found");
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/manager-v2/src/pcm.ts b/manager-v2/src/pcm.ts
new file mode 100644
index 00000000..94fa4ff2
--- /dev/null
+++ b/manager-v2/src/pcm.ts
@@ -0,0 +1,350 @@
+import type { EvolutionApi } from "./api";
+
+const DATA_CHANNEL_LABEL = "evolution-call-pcm";
+const DATA_CHANNEL_PROTOCOL = "evcall.pcm.v1";
+const PCM_RATE = 16000;
+const PCM_FRAME_SAMPLES = 960;
+const HEADER_BYTES = 16;
+const MAX_BUFFERED_AMOUNT = 256 * 1024;
+
+export interface MediaStats {
+ sent: number;
+ received: number;
+ dropped: number;
+}
+
+export type MediaStatus = "idle" | "connecting" | "connected" | "failed";
+
+interface BridgeCallbacks {
+ onStatus: (status: MediaStatus) => void;
+ onStats: (stats: MediaStats) => void;
+ onLog: (message: string, details?: unknown) => void;
+}
+
+class StreamingLinearResampler {
+ private readonly step: number;
+ private position = 0;
+ private carry = new Float32Array(0);
+
+ constructor(inputRate: number, outputRate: number) {
+ this.step = inputRate / outputRate;
+ }
+
+ push(input: Float32Array): Float32Array {
+ if (!input.length) return new Float32Array(0);
+ const data = new Float32Array(this.carry.length + input.length);
+ data.set(this.carry);
+ data.set(input, this.carry.length);
+ const output: number[] = [];
+ let position = this.position;
+ while (position + 1 < data.length) {
+ const left = Math.floor(position);
+ const fraction = position - left;
+ output.push(data[left] + (data[left + 1] - data[left]) * fraction);
+ position += this.step;
+ }
+ const consumed = Math.floor(position);
+ this.carry = data.slice(Math.min(consumed, data.length));
+ this.position = position - consumed;
+ return Float32Array.from(output);
+ }
+}
+
+function encodePCM(samples: Float32Array): ArrayBuffer {
+ const buffer = new ArrayBuffer(HEADER_BYTES + samples.length * 4);
+ const bytes = new Uint8Array(buffer);
+ bytes.set([0x45, 0x56, 0x50, 0x43]);
+ const view = new DataView(buffer);
+ view.setUint8(4, 1);
+ view.setUint8(5, 1);
+ view.setUint16(6, 0, true);
+ view.setUint32(8, PCM_RATE, true);
+ view.setUint32(12, samples.length, true);
+ samples.forEach((value, index) => {
+ const sample = Number.isFinite(value) ? Math.max(-1, Math.min(1, value)) : 0;
+ view.setFloat32(HEADER_BYTES + index * 4, sample, true);
+ });
+ return buffer;
+}
+
+function decodePCM(buffer: ArrayBuffer): Float32Array {
+ if (buffer.byteLength < HEADER_BYTES) throw new Error("Frame PCM truncado");
+ const bytes = new Uint8Array(buffer, 0, 4);
+ if (bytes[0] !== 0x45 || bytes[1] !== 0x56 || bytes[2] !== 0x50 || bytes[3] !== 0x43) {
+ throw new Error("Cabeçalho PCM inválido");
+ }
+ const view = new DataView(buffer);
+ if (view.getUint8(4) !== 1 || view.getUint8(5) !== 1 || view.getUint16(6, true) !== 0) {
+ throw new Error("Versão PCM incompatível");
+ }
+ if (view.getUint32(8, true) !== PCM_RATE) throw new Error("Sample rate PCM incompatível");
+ const count = view.getUint32(12, true);
+ if (!count || count > PCM_FRAME_SAMPLES * 4 || buffer.byteLength !== HEADER_BYTES + count * 4) {
+ throw new Error("Tamanho PCM inválido");
+ }
+ const output = new Float32Array(count);
+ for (let index = 0; index < count; index++) {
+ output[index] = view.getFloat32(HEADER_BYTES + index * 4, true);
+ }
+ return output;
+}
+
+async function installWorklet(context: AudioContext): Promise {
+ const source = `
+ class EvolutionManagerV2PCM extends AudioWorkletProcessor {
+ constructor(options) {
+ super();
+ this.mode = options.processorOptions.mode;
+ this.queue = [];
+ this.offset = 0;
+ this.port.onmessage = event => {
+ if (this.mode === 'playback' && event.data instanceof Float32Array) this.queue.push(event.data);
+ };
+ }
+ process(inputs, outputs) {
+ if (this.mode === 'capture') {
+ const input = inputs[0] && inputs[0][0];
+ if (input && input.length) this.port.postMessage(new Float32Array(input));
+ } else {
+ const output = outputs[0] && outputs[0][0];
+ if (output) {
+ output.fill(0);
+ let written = 0;
+ while (written < output.length && this.queue.length) {
+ const chunk = this.queue[0];
+ const count = Math.min(output.length - written, chunk.length - this.offset);
+ output.set(chunk.subarray(this.offset, this.offset + count), written);
+ written += count;
+ this.offset += count;
+ if (this.offset >= chunk.length) { this.queue.shift(); this.offset = 0; }
+ }
+ }
+ }
+ return true;
+ }
+ }
+ registerProcessor('evolution-manager-v2-pcm', EvolutionManagerV2PCM);
+ `;
+ const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
+ try {
+ await context.audioWorklet.addModule(url);
+ } finally {
+ URL.revokeObjectURL(url);
+ }
+}
+
+async function gatherICE(connection: RTCPeerConnection): Promise {
+ if (connection.iceGatheringState === "complete") return;
+ await new Promise((resolve, reject) => {
+ const timeout = window.setTimeout(() => {
+ connection.removeEventListener("icegatheringstatechange", listener);
+ reject(new Error("Timeout ao coletar candidatos ICE"));
+ }, 15000);
+ const listener = () => {
+ if (connection.iceGatheringState === "complete") {
+ window.clearTimeout(timeout);
+ connection.removeEventListener("icegatheringstatechange", listener);
+ resolve();
+ }
+ };
+ connection.addEventListener("icegatheringstatechange", listener);
+ });
+}
+
+export class EvolutionPcmBridge {
+ private peer: RTCPeerConnection | null = null;
+ private channel: RTCDataChannel | null = null;
+ private sessionId = "";
+ private callId = "";
+ private audioContext: AudioContext | null = null;
+ private microphone: MediaStream | null = null;
+ private captureSource: MediaStreamAudioSourceNode | null = null;
+ private captureNode: AudioWorkletNode | null = null;
+ private playbackNode: AudioWorkletNode | null = null;
+ private captureResampler: StreamingLinearResampler | null = null;
+ private playbackResampler: StreamingLinearResampler | null = null;
+ private pending = new Float32Array(0);
+ private muted = false;
+ private stats: MediaStats = { sent: 0, received: 0, dropped: 0 };
+
+ constructor(
+ private readonly api: EvolutionApi,
+ private readonly callbacks: BridgeCallbacks,
+ ) {}
+
+ get activeCallId(): string {
+ return this.callId;
+ }
+
+ get isMuted(): boolean {
+ return this.muted;
+ }
+
+ setMuted(value: boolean): void {
+ this.muted = value;
+ this.callbacks.onLog(value ? "Microfone silenciado" : "Microfone ativado");
+ }
+
+ private publishStats(): void {
+ this.callbacks.onStats({ ...this.stats });
+ }
+
+ private appendCapture(samples: Float32Array): void {
+ const joined = new Float32Array(this.pending.length + samples.length);
+ joined.set(this.pending);
+ joined.set(samples, this.pending.length);
+ let offset = 0;
+ while (joined.length - offset >= PCM_FRAME_SAMPLES) {
+ const frame = joined.slice(offset, offset + PCM_FRAME_SAMPLES);
+ offset += PCM_FRAME_SAMPLES;
+ if (this.muted) continue;
+ if (this.channel?.readyState === "open" && this.channel.bufferedAmount <= MAX_BUFFERED_AMOUNT) {
+ this.channel.send(encodePCM(frame));
+ this.stats.sent++;
+ } else {
+ this.stats.dropped++;
+ }
+ }
+ this.pending = joined.slice(offset);
+ this.publishStats();
+ }
+
+ private async startAudio(): Promise {
+ if (!window.AudioContext || !window.AudioWorkletNode) {
+ throw new Error("Este navegador não suporta AudioWorklet");
+ }
+ this.audioContext = new AudioContext({ latencyHint: "interactive" });
+ await installWorklet(this.audioContext);
+ await this.audioContext.resume();
+ this.captureResampler = new StreamingLinearResampler(this.audioContext.sampleRate, PCM_RATE);
+ this.playbackResampler = new StreamingLinearResampler(PCM_RATE, this.audioContext.sampleRate);
+
+ this.playbackNode = new AudioWorkletNode(this.audioContext, "evolution-manager-v2-pcm", {
+ numberOfInputs: 0,
+ numberOfOutputs: 1,
+ outputChannelCount: [1],
+ processorOptions: { mode: "playback" },
+ });
+ this.playbackNode.connect(this.audioContext.destination);
+
+ this.microphone = await navigator.mediaDevices.getUserMedia({
+ audio: {
+ channelCount: 1,
+ echoCancellation: true,
+ noiseSuppression: true,
+ autoGainControl: true,
+ },
+ video: false,
+ });
+ this.captureSource = this.audioContext.createMediaStreamSource(this.microphone);
+ this.captureNode = new AudioWorkletNode(this.audioContext, "evolution-manager-v2-pcm", {
+ numberOfInputs: 1,
+ numberOfOutputs: 0,
+ processorOptions: { mode: "capture" },
+ });
+ this.captureNode.port.onmessage = (event: MessageEvent) => {
+ const resampled = this.captureResampler?.push(event.data) ?? new Float32Array(0);
+ this.appendCapture(resampled);
+ };
+ this.captureSource.connect(this.captureNode);
+ this.callbacks.onLog("Microfone e reprodução iniciados", { sampleRate: this.audioContext.sampleRate });
+ }
+
+ async connect(callId: string): Promise {
+ if (!window.isSecureContext && location.hostname !== "localhost") {
+ throw new Error("O microfone exige HTTPS");
+ }
+ if (this.peer) await this.disconnect();
+ this.callbacks.onStatus("connecting");
+ this.callId = callId;
+ this.stats = { sent: 0, received: 0, dropped: 0 };
+ this.pending = new Float32Array(0);
+ this.publishStats();
+
+ const peer = new RTCPeerConnection({ iceServers: [] });
+ const channel = peer.createDataChannel(DATA_CHANNEL_LABEL, {
+ ordered: true,
+ protocol: DATA_CHANNEL_PROTOCOL,
+ });
+ channel.binaryType = "arraybuffer";
+ channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2;
+ this.peer = peer;
+ this.channel = channel;
+
+ channel.onopen = () => {
+ void this.startAudio()
+ .then(() => this.callbacks.onStatus("connected"))
+ .catch(async (cause) => {
+ this.callbacks.onLog("Falha ao iniciar áudio", cause instanceof Error ? cause.message : cause);
+ this.callbacks.onStatus("failed");
+ await this.disconnect();
+ });
+ };
+ channel.onmessage = (event: MessageEvent) => {
+ try {
+ const pcm = decodePCM(event.data);
+ const playback = this.playbackResampler?.push(pcm) ?? new Float32Array(0);
+ if (playback.length) this.playbackNode?.port.postMessage(playback, [playback.buffer]);
+ this.stats.received++;
+ } catch (cause) {
+ this.stats.dropped++;
+ this.callbacks.onLog("Frame de áudio rejeitado", cause instanceof Error ? cause.message : cause);
+ }
+ this.publishStats();
+ };
+ channel.onerror = () => this.callbacks.onLog("Erro no DataChannel de áudio");
+ channel.onclose = () => {
+ if (this.callId === callId) void this.disconnect(false);
+ };
+ peer.onconnectionstatechange = () => {
+ this.callbacks.onLog("PeerConnection", peer.connectionState);
+ if (["failed", "closed"].includes(peer.connectionState)) void this.disconnect(false);
+ };
+
+ try {
+ await peer.setLocalDescription(await peer.createOffer());
+ await gatherICE(peer);
+ if (!peer.localDescription) throw new Error("Oferta WebRTC não foi criada");
+ const response = await this.api.createWebRTC(callId, {
+ type: "offer",
+ sdp: peer.localDescription.sdp,
+ });
+ this.sessionId = response.sessionId;
+ await peer.setRemoteDescription(response.answer);
+ this.callbacks.onLog("Sessão WebRTC criada", { callId, sessionId: response.sessionId });
+ } catch (cause) {
+ await this.disconnect(false);
+ this.callbacks.onStatus("failed");
+ throw cause;
+ }
+ }
+
+ async disconnect(notifyServer = true): Promise {
+ const callId = this.callId;
+ const sessionId = this.sessionId;
+ this.callId = "";
+ this.sessionId = "";
+ this.microphone?.getTracks().forEach((track) => track.stop());
+ this.microphone = null;
+ this.captureSource?.disconnect();
+ this.captureNode?.disconnect();
+ this.playbackNode?.disconnect();
+ this.captureSource = null;
+ this.captureNode = null;
+ this.playbackNode = null;
+ if (this.audioContext) await this.audioContext.close().catch(() => undefined);
+ this.audioContext = null;
+ this.channel?.close();
+ this.peer?.close();
+ this.channel = null;
+ this.peer = null;
+ this.captureResampler = null;
+ this.playbackResampler = null;
+ this.pending = new Float32Array(0);
+ this.callbacks.onStatus("idle");
+ if (notifyServer && callId && sessionId) {
+ await this.api.closeWebRTC(callId, sessionId).catch(() => undefined);
+ }
+ if (callId) this.callbacks.onLog("Áudio desconectado", { callId, ...this.stats });
+ }
+}
diff --git a/manager-v2/src/request-presets.css b/manager-v2/src/request-presets.css
new file mode 100644
index 00000000..0cec3749
--- /dev/null
+++ b/manager-v2/src/request-presets.css
@@ -0,0 +1,15 @@
+.request-presets { border-bottom: 1px solid var(--border); padding-bottom: 18px; margin-bottom: 18px; }
+.request-presets-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
+.request-presets-heading h3 { margin: 3px 0 0; font-size: 1rem; }
+.request-presets-heading > span { font-size: .72rem; opacity: .7; }
+.request-presets > p { margin: 8px 0 12px; font-size: .78rem; opacity: .72; line-height: 1.4; }
+.request-preset-save { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
+.request-preset-save button, .request-preset-tools button { white-space: nowrap; }
+.request-preset-tools { display: flex; gap: 7px; margin-top: 7px; }
+.request-preset-message { display: block; margin-top: 8px; }
+.request-preset-list { display: grid; gap: 7px; margin-top: 12px; max-height: 280px; overflow: auto; }
+.request-preset-item { display: grid; grid-template-columns: minmax(0, 1fr) 32px; gap: 5px; align-items: stretch; }
+.request-preset-apply { text-align: left; display: grid; gap: 3px; min-width: 0; }
+.request-preset-apply strong, .request-preset-apply small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.request-preset-delete { font-size: 1.15rem; padding: 0; }
+@media (max-width: 620px) { .request-preset-save { grid-template-columns: 1fr; } }
diff --git a/manager-v2/src/request-presets.tsx b/manager-v2/src/request-presets.tsx
new file mode 100644
index 00000000..0851e57c
--- /dev/null
+++ b/manager-v2/src/request-presets.tsx
@@ -0,0 +1,194 @@
+import { useMemo, useRef, useState } from "react";
+import type { ApiAuthMode } from "./api";
+import type { BodyMode } from "./api-catalog";
+import "./request-presets.css";
+
+export type RequestPresetDraft = {
+ operationId: string;
+ operationTitle: string;
+ method: string;
+ path: string;
+ auth: ApiAuthMode;
+ bodyMode: BodyMode;
+ body: string;
+ fileField: string;
+};
+
+type RequestPreset = RequestPresetDraft & {
+ id: string;
+ name: string;
+ updatedAt: string;
+};
+
+type RequestPresetPanelProps = {
+ current: RequestPresetDraft;
+ onApply: (preset: RequestPresetDraft) => void;
+};
+
+const STORAGE_KEY = "evolution-go.manager-v2.request-presets.v1";
+
+function isAuthMode(value: unknown): value is ApiAuthMode {
+ return value === "instance" || value === "admin" || value === "none";
+}
+
+function isBodyMode(value: unknown): value is BodyMode {
+ return value === "none" || value === "json" || value === "multipart";
+}
+
+function normalizePreset(value: unknown): RequestPreset | null {
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
+ const item = value as Record;
+ if (
+ typeof item.id !== "string" ||
+ typeof item.name !== "string" ||
+ typeof item.operationId !== "string" ||
+ typeof item.operationTitle !== "string" ||
+ typeof item.method !== "string" ||
+ typeof item.path !== "string" ||
+ !isAuthMode(item.auth) ||
+ !isBodyMode(item.bodyMode) ||
+ typeof item.body !== "string" ||
+ typeof item.fileField !== "string" ||
+ typeof item.updatedAt !== "string"
+ ) return null;
+
+ return {
+ id: item.id,
+ name: item.name,
+ operationId: item.operationId,
+ operationTitle: item.operationTitle,
+ method: item.method,
+ path: item.path,
+ auth: item.auth,
+ bodyMode: item.bodyMode,
+ body: item.body,
+ fileField: item.fileField,
+ updatedAt: item.updatedAt,
+ };
+}
+
+function loadPresets(): RequestPreset[] {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return [];
+ const parsed = JSON.parse(raw) as unknown;
+ if (!Array.isArray(parsed)) return [];
+ return parsed.map(normalizePreset).filter((item): item is RequestPreset => item !== null);
+ } catch {
+ return [];
+ }
+}
+
+function persistPresets(presets: RequestPreset[]): void {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(presets));
+}
+
+function downloadPresets(presets: RequestPreset[]): void {
+ const payload = JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), presets }, null, 2);
+ const url = URL.createObjectURL(new Blob([payload], { type: "application/json" }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `evolution-go-presets-${new Date().toISOString().slice(0, 10)}.json`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+function importedPresets(value: unknown): RequestPreset[] {
+ const root = value && typeof value === "object" && !Array.isArray(value) ? value as Record : {};
+ const source = Array.isArray(value) ? value : root.presets;
+ if (!Array.isArray(source)) throw new Error("O arquivo não contém uma lista de presets.");
+ const normalized = source.map(normalizePreset).filter((item): item is RequestPreset => item !== null);
+ if (!normalized.length && source.length) throw new Error("Nenhum preset válido foi encontrado.");
+ return normalized;
+}
+
+export function RequestPresetPanel({ current, onApply }: RequestPresetPanelProps) {
+ const [presets, setPresets] = useState(loadPresets);
+ const [name, setName] = useState("");
+ const [message, setMessage] = useState("");
+ const fileInput = useRef(null);
+
+ const operationPresets = useMemo(
+ () => presets.filter((item) => item.operationId === current.operationId),
+ [current.operationId, presets],
+ );
+
+ const commit = (next: RequestPreset[]) => {
+ setPresets(next);
+ persistPresets(next);
+ };
+
+ const save = () => {
+ const trimmed = name.trim() || `${current.operationTitle} ${operationPresets.length + 1}`;
+ const now = new Date().toISOString();
+ const existing = presets.find((item) => item.operationId === current.operationId && item.name.toLocaleLowerCase("pt-BR") === trimmed.toLocaleLowerCase("pt-BR"));
+ const preset: RequestPreset = {
+ ...current,
+ id: existing?.id ?? `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name: trimmed,
+ updatedAt: now,
+ };
+ const next = [preset, ...presets.filter((item) => item.id !== preset.id)].slice(0, 100);
+ commit(next);
+ setName("");
+ setMessage(existing ? "Preset atualizado." : "Preset salvo.");
+ };
+
+ const remove = (id: string) => {
+ commit(presets.filter((item) => item.id !== id));
+ setMessage("Preset removido.");
+ };
+
+ const importFile = async (file: File | null) => {
+ if (!file) return;
+ try {
+ const parsed = JSON.parse(await file.text()) as unknown;
+ const incoming = importedPresets(parsed);
+ const merged = [...incoming, ...presets].reduce((items, item) => {
+ if (items.some((existing) => existing.id === item.id)) return items;
+ items.push(item);
+ return items;
+ }, []).slice(0, 100);
+ commit(merged);
+ setMessage(`${incoming.length} preset(s) importado(s).`);
+ } catch (cause) {
+ setMessage(cause instanceof Error ? cause.message : "Falha ao importar presets.");
+ } finally {
+ if (fileInput.current) fileInput.current.value = "";
+ }
+ };
+
+ return (
+
+
+
+ Coleção local
+
Presets de teste
+
+
{presets.length}/100
+
+ Salva rota e payload sem armazenar chaves ou arquivos.
+
+ setName(event.target.value)} placeholder="Nome do preset" />
+ Salvar atual
+
+
+ downloadPresets(presets)}>Exportar
+ fileInput.current?.click()}>Importar
+ void importFile(event.target.files?.[0] ?? null)} />
+
+ {message && {message} }
+
+ {operationPresets.length === 0 ?
Nenhum preset salvo para esta operação. : operationPresets.map((item) => (
+
+ onApply(item)}>
+ {item.name}
+ {item.method} · {item.bodyMode} · {new Date(item.updatedAt).toLocaleDateString("pt-BR")}
+
+ remove(item.id)}>×
+
+ ))}
+
+
+ );
+}
diff --git a/manager-v2/src/styles.css b/manager-v2/src/styles.css
new file mode 100644
index 00000000..220d1edd
--- /dev/null
+++ b/manager-v2/src/styles.css
@@ -0,0 +1,182 @@
+:root {
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: #e9f2ef;
+ background: #07110f;
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ --bg: #07110f;
+ --panel: #0c1916;
+ --panel-2: #10211d;
+ --panel-3: #152a25;
+ --line: rgba(155, 199, 186, 0.14);
+ --muted: #83a49b;
+ --text: #e9f2ef;
+ --accent: #28d17c;
+ --accent-2: #75f0ac;
+ --danger: #ff5c6c;
+ --warning: #f6c85f;
+ color-scheme: dark;
+}
+
+* { box-sizing: border-box; }
+body { margin: 0; min-width: 320px; min-height: 100vh; background: radial-gradient(circle at 70% -20%, rgba(40, 209, 124, .14), transparent 36%), var(--bg); }
+button, input { font: inherit; }
+button { color: inherit; }
+
+.app-shell { min-height: 100vh; display: grid; grid-template-columns: 248px minmax(0, 1fr); }
+.sidebar { position: sticky; top: 0; height: 100vh; padding: 24px 18px; border-right: 1px solid var(--line); background: rgba(7, 17, 15, .94); display: flex; flex-direction: column; backdrop-filter: blur(22px); }
+.brand { display: flex; gap: 12px; align-items: center; padding: 4px 10px 30px; }
+.brand-mark { display: grid; place-items: center; width: 42px; height: 42px; border-radius: 13px; background: linear-gradient(145deg, var(--accent), #137a4c); color: #02130a; font-size: 23px; font-weight: 900; box-shadow: 0 12px 28px rgba(40, 209, 124, .22); }
+.brand strong, .brand small { display: block; }
+.brand small { color: var(--muted); margin-top: 2px; }
+.sidebar nav { display: grid; gap: 7px; }
+.sidebar nav button { border: 0; background: transparent; color: #9bb2ab; padding: 12px 14px; border-radius: 12px; display: flex; align-items: center; gap: 12px; cursor: pointer; text-align: left; }
+.sidebar nav button span { width: 22px; text-align: center; font-size: 17px; }
+.sidebar nav button:hover { background: rgba(255,255,255,.04); color: var(--text); }
+.sidebar nav button.active { background: linear-gradient(90deg, rgba(40,209,124,.18), rgba(40,209,124,.06)); color: var(--accent-2); box-shadow: inset 3px 0 var(--accent); }
+.sidebar-foot { margin-top: auto; padding: 15px 12px; border: 1px solid var(--line); border-radius: 14px; background: rgba(255,255,255,.025); display: flex; gap: 10px; align-items: center; min-width: 0; }
+.sidebar-foot strong, .sidebar-foot small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.sidebar-foot small { color: var(--muted); margin-top: 3px; font-size: 11px; }
+.status-dot { width: 9px; height: 9px; border-radius: 99px; background: #53635e; box-shadow: 0 0 0 4px rgba(83,99,94,.14); flex: 0 0 auto; }
+.status-dot.online { background: var(--accent); box-shadow: 0 0 0 4px rgba(40,209,124,.13), 0 0 18px rgba(40,209,124,.45); }
+
+main { min-width: 0; }
+.topbar { height: 74px; padding: 0 30px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--line); background: rgba(7,17,15,.72); backdrop-filter: blur(18px); position: sticky; top: 0; z-index: 20; }
+.breadcrumb { color: var(--muted); margin-right: 7px; }
+.top-actions { display: flex; align-items: center; gap: 12px; }
+.profile-button { border: 1px solid var(--line); width: 38px; height: 38px; border-radius: 12px; background: var(--panel-2); cursor: pointer; font-weight: 800; }
+.content { padding: 28px; max-width: 1600px; margin: 0 auto; position: relative; }
+
+.card { border: 1px solid var(--line); background: linear-gradient(145deg, rgba(16,33,29,.96), rgba(9,23,20,.96)); border-radius: 19px; box-shadow: 0 18px 50px rgba(0,0,0,.18); }
+.call-layout { display: grid; grid-template-columns: minmax(0, 1fr) 340px; gap: 22px; align-items: start; }
+.call-main, .call-aside { display: grid; gap: 18px; }
+.hero { padding: 28px; display: flex; justify-content: space-between; align-items: center; overflow: hidden; position: relative; }
+.hero::after { content: ""; position: absolute; width: 260px; height: 260px; right: -70px; top: -130px; border-radius: 50%; background: rgba(40,209,124,.09); border: 1px solid rgba(40,209,124,.18); }
+.hero h1 { margin: 5px 0 8px; font-size: clamp(25px, 3vw, 38px); line-height: 1.08; letter-spacing: -.04em; }
+.hero p { margin: 0; color: var(--muted); max-width: 600px; }
+.eyebrow { color: var(--accent-2); font-size: 11px; font-weight: 800; letter-spacing: .15em; text-transform: uppercase; }
+.hero-metrics { display: flex; gap: 12px; position: relative; z-index: 1; }
+.hero-metrics div { min-width: 96px; padding: 14px; border-radius: 15px; background: rgba(0,0,0,.18); border: 1px solid var(--line); }
+.hero-metrics strong, .hero-metrics span { display: block; }
+.hero-metrics strong { font-size: 24px; }
+.hero-metrics span { color: var(--muted); font-size: 11px; margin-top: 3px; }
+
+.dialer-card, .active-call-card, .call-history, .diagnostic-card, .quality-card, .connection-card { padding: 22px; }
+.section-heading { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 18px; }
+.section-heading h2 { margin: 3px 0 0; font-size: 18px; }
+.connection-pill { padding: 7px 10px; border-radius: 99px; font-size: 11px; border: 1px solid var(--line); color: var(--muted); }
+.connection-pill.connected { color: var(--accent-2); background: rgba(40,209,124,.08); border-color: rgba(40,209,124,.22); }
+.connection-pill.disconnected { color: #ff9aa5; background: rgba(255,92,108,.07); }
+.dial-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; }
+.country-code, input { min-height: 48px; border: 1px solid var(--line); border-radius: 13px; background: rgba(2,12,9,.55); color: var(--text); }
+.country-code { display: grid; place-items: center; padding: 0 15px; color: var(--muted); }
+input { width: 100%; padding: 0 15px; outline: none; }
+input:focus { border-color: rgba(40,209,124,.7); box-shadow: 0 0 0 3px rgba(40,209,124,.1); }
+.button { border: 1px solid transparent; border-radius: 13px; padding: 0 18px; min-height: 44px; cursor: pointer; font-weight: 750; }
+.button:disabled { opacity: .45; cursor: not-allowed; }
+.button.primary, .call-button { background: linear-gradient(145deg, var(--accent), #1ca965); color: #03150b; box-shadow: 0 10px 26px rgba(40,209,124,.18); }
+.button.secondary { background: var(--panel-3); border-color: var(--line); }
+.call-button { min-height: 48px; min-width: 120px; }
+.alert { margin-top: 12px; padding: 10px 13px; border-radius: 11px; font-size: 13px; }
+.alert.error { color: #ffb2ba; background: rgba(255,92,108,.08); border: 1px solid rgba(255,92,108,.2); }
+
+.active-call-card { display: flex; align-items: center; gap: 18px; min-height: 150px; }
+.call-avatar { width: 70px; height: 70px; border-radius: 22px; display: grid; place-items: center; background: linear-gradient(145deg, rgba(40,209,124,.22), rgba(40,209,124,.07)); border: 1px solid rgba(40,209,124,.24); color: var(--accent-2); font-size: 23px; font-weight: 900; }
+.active-call-content { min-width: 0; flex: 1; }
+.active-call-content h2 { margin: 4px 0 8px; font-size: 25px; }
+.call-meta, .media-strip { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 14px; color: var(--muted); font-size: 12px; }
+.media-strip { margin-top: 13px; padding-top: 12px; border-top: 1px solid var(--line); }
+.call-actions { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; justify-content: flex-end; }
+.round-action { width: 48px; height: 48px; border: 0; border-radius: 16px; cursor: pointer; font-size: 21px; font-weight: 800; }
+.round-action.accept { background: var(--accent); color: #04160c; }
+.round-action.danger { background: var(--danger); color: white; }
+.placeholder-call p { color: var(--muted); margin: 6px 0 0; }
+
+.state-badge { display: inline-flex; align-items: center; width: fit-content; padding: 5px 9px; border-radius: 99px; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; background: rgba(131,164,155,.12); color: #a7beb7; }
+.state-active { background: rgba(40,209,124,.12); color: var(--accent-2); }
+.state-ringing { background: rgba(246,200,95,.12); color: var(--warning); }
+.state-connecting { background: rgba(83,159,255,.12); color: #8cbcff; }
+.state-ended, .state-failed { background: rgba(255,92,108,.1); color: #ff9aa5; }
+.icon-button { width: 38px; height: 38px; border-radius: 11px; border: 1px solid var(--line); background: var(--panel-3); cursor: pointer; }
+.call-table { display: grid; gap: 7px; }
+.call-row { width: 100%; display: grid; grid-template-columns: 42px minmax(180px, 1fr) 110px 100px; gap: 12px; align-items: center; padding: 12px; border: 1px solid transparent; border-radius: 13px; background: transparent; cursor: pointer; text-align: left; }
+.call-row:hover, .call-row.selected { background: rgba(255,255,255,.027); border-color: var(--line); }
+.direction-icon { width: 34px; height: 34px; border-radius: 11px; display: grid; place-items: center; background: rgba(40,209,124,.1); color: var(--accent-2); }
+.direction-icon.incoming { background: rgba(83,159,255,.1); color: #8cbcff; }
+.call-person { min-width: 0; }
+.call-person strong, .call-person small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.call-person small { color: var(--muted); font-size: 10px; margin-top: 3px; }
+.table-empty { padding: 26px; text-align: center; color: var(--muted); border: 1px dashed var(--line); border-radius: 13px; }
+
+.diagnostic-card { min-height: 430px; }
+.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 15px var(--accent); }
+.log-list { max-height: 350px; overflow: auto; display: grid; gap: 9px; padding-right: 4px; }
+.log-list > p { color: var(--muted); }
+.log-entry { display: grid; grid-template-columns: 58px minmax(0, 1fr); gap: 4px 9px; padding: 10px; border: 1px solid var(--line); border-radius: 11px; background: rgba(0,0,0,.13); }
+.log-entry time { color: var(--muted); font-size: 10px; grid-row: 1 / 3; }
+.log-entry strong { font-size: 12px; }
+.log-entry span { color: var(--muted); font-size: 10px; overflow-wrap: anywhere; }
+.quality-card h2 { margin: 5px 0 12px; }
+.quality-card p { color: var(--muted); font-size: 13px; line-height: 1.5; }
+.quality-bars { display: flex; align-items: flex-end; height: 48px; gap: 7px; }
+.quality-bars i { width: 11px; height: 12px; border-radius: 4px; background: #263c36; }
+.quality-bars i:nth-child(2) { height: 22px; background: #33564b; }
+.quality-bars i:nth-child(3) { height: 34px; }
+.quality-bars i:nth-child(4) { height: 46px; }
+.quality-bars i.active { background: var(--accent); box-shadow: 0 0 12px rgba(40,209,124,.25); }
+
+.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
+.form-grid label > span { display: block; margin-bottom: 7px; color: var(--muted); font-size: 12px; }
+.check-row { display: flex; align-items: center; gap: 9px; color: var(--muted); margin-top: 14px; font-size: 13px; }
+.check-row input { width: auto; min-height: auto; }
+.button-row { display: flex; justify-content: flex-end; margin-top: 18px; }
+.empty-module { min-height: 430px; display: grid; place-items: center; align-content: center; text-align: center; padding: 40px; }
+.empty-module h2 { margin: 13px 0 7px; }
+.empty-module p { max-width: 520px; color: var(--muted); }
+.empty-module span { color: #5f7d74; font-size: 12px; }
+.empty-icon { width: 62px; height: 62px; border-radius: 20px; display: grid; place-items: center; border: 1px solid var(--line); background: var(--panel-2); color: var(--accent-2); font-size: 28px; }
+.setup-overlay { position: absolute; inset: 0; z-index: 10; padding: 80px 28px; background: rgba(7,17,15,.78); backdrop-filter: blur(10px); }
+.setup-overlay .connection-card { max-width: 760px; margin: 0 auto; }
+
+@media (max-width: 1120px) {
+ .call-layout { grid-template-columns: 1fr; }
+ .call-aside { grid-template-columns: 1fr 1fr; }
+ .diagnostic-card { min-height: 280px; }
+}
+@media (max-width: 820px) {
+ .app-shell { grid-template-columns: 76px minmax(0, 1fr); }
+ .sidebar { padding: 20px 10px; }
+ .brand { padding-inline: 7px; justify-content: center; }
+ .brand > div, .sidebar nav button:not(.active)::after, .sidebar nav button { font-size: 0; }
+ .sidebar nav button { justify-content: center; padding: 12px; }
+ .sidebar nav button span { font-size: 18px; }
+ .sidebar-foot div { display: none; }
+ .sidebar-foot { justify-content: center; }
+ .content { padding: 18px; }
+ .topbar { padding: 0 18px; }
+ .hero { align-items: flex-start; flex-direction: column; gap: 20px; }
+ .call-row { grid-template-columns: 42px minmax(130px, 1fr) 90px; }
+ .call-row > :last-child { display: none; }
+}
+@media (max-width: 600px) {
+ .app-shell { display: block; }
+ .sidebar { position: fixed; inset: auto 0 0; width: 100%; height: auto; padding: 8px; z-index: 50; border-right: 0; border-top: 1px solid var(--line); }
+ .brand, .sidebar-foot { display: none; }
+ .sidebar nav { display: flex; justify-content: space-around; }
+ .sidebar nav button { flex: 1; }
+ .sidebar nav button:nth-child(n+5) { display: none; }
+ main { padding-bottom: 72px; }
+ .topbar { height: 62px; }
+ .connection-pill { display: none; }
+ .content { padding: 12px; }
+ .hero, .dialer-card, .active-call-card, .call-history, .diagnostic-card, .quality-card, .connection-card { padding: 17px; border-radius: 16px; }
+ .hero-metrics { width: 100%; overflow-x: auto; }
+ .dial-row { grid-template-columns: 68px minmax(0,1fr); }
+ .call-button { grid-column: 1 / -1; }
+ .active-call-card { align-items: flex-start; flex-wrap: wrap; }
+ .call-actions { width: 100%; justify-content: flex-start; }
+ .call-aside { grid-template-columns: 1fr; }
+ .form-grid { grid-template-columns: 1fr; }
+ .call-row { grid-template-columns: 38px minmax(0,1fr); }
+ .call-row > :nth-child(n+3) { display: none; }
+}
diff --git a/manager-v2/tsconfig.json b/manager-v2/tsconfig.json
new file mode 100644
index 00000000..02c957f9
--- /dev/null
+++ b/manager-v2/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "allowJs": false,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "strict": true,
+ "forceConsistentCasingInFileNames": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "types": ["vite/client"]
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/manager-v2/vite.config.ts b/manager-v2/vite.config.ts
new file mode 100644
index 00000000..86c9538d
--- /dev/null
+++ b/manager-v2/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ base: "/manager-v2/",
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ sourcemap: true,
+ },
+});
diff --git a/manager/dist/assets/call-manager.css b/manager/dist/assets/call-manager.css
new file mode 100644
index 00000000..71e699a5
--- /dev/null
+++ b/manager/dist/assets/call-manager.css
@@ -0,0 +1,446 @@
+#evcall-root,
+#evcall-root * {
+ box-sizing: border-box;
+}
+
+#evcall-root {
+ --evcall-bg: #ffffff;
+ --evcall-surface: #f7f8fa;
+ --evcall-border: #dfe3e8;
+ --evcall-text: #17212b;
+ --evcall-muted: #68727d;
+ --evcall-primary: #168a57;
+ --evcall-primary-hover: #117247;
+ --evcall-danger: #d14343;
+ --evcall-danger-hover: #b93636;
+ --evcall-shadow: 0 20px 55px rgba(17, 24, 39, 0.24);
+ color: var(--evcall-text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ position: fixed;
+ right: 20px;
+ bottom: 20px;
+ z-index: 2147483000;
+}
+
+@media (prefers-color-scheme: dark) {
+ #evcall-root {
+ --evcall-bg: #15191e;
+ --evcall-surface: #20262d;
+ --evcall-border: #343d47;
+ --evcall-text: #f5f7f9;
+ --evcall-muted: #a9b2bc;
+ --evcall-shadow: 0 20px 55px rgba(0, 0, 0, 0.55);
+ }
+}
+
+.evcall-launcher {
+ align-items: center;
+ background: var(--evcall-primary);
+ border: 0;
+ border-radius: 50%;
+ bottom: 0;
+ box-shadow: 0 10px 28px rgba(22, 138, 87, 0.38);
+ color: #fff;
+ cursor: pointer;
+ display: flex;
+ font-size: 25px;
+ height: 58px;
+ justify-content: center;
+ position: absolute;
+ right: 0;
+ transition: transform 0.16s ease, background 0.16s ease;
+ width: 58px;
+}
+
+.evcall-launcher:hover,
+.evcall-launcher.active {
+ background: var(--evcall-primary-hover);
+ transform: translateY(-2px);
+}
+
+.evcall-launcher:focus-visible,
+.evcall-panel button:focus-visible,
+.evcall-panel input:focus-visible,
+.evcall-panel summary:focus-visible {
+ outline: 3px solid rgba(45, 145, 255, 0.5);
+ outline-offset: 2px;
+}
+
+.evcall-badge {
+ align-items: center;
+ background: var(--evcall-danger);
+ border: 2px solid #fff;
+ border-radius: 999px;
+ color: #fff;
+ display: flex;
+ font-size: 11px;
+ font-weight: 700;
+ height: 22px;
+ justify-content: center;
+ min-width: 22px;
+ padding: 0 5px;
+ position: absolute;
+ right: -4px;
+ top: -5px;
+}
+
+.evcall-panel {
+ background: var(--evcall-bg);
+ border: 1px solid var(--evcall-border);
+ border-radius: 16px;
+ bottom: 72px;
+ box-shadow: var(--evcall-shadow);
+ max-height: min(760px, calc(100vh - 110px));
+ overflow: hidden;
+ position: absolute;
+ right: 0;
+ width: min(420px, calc(100vw - 32px));
+}
+
+.evcall-panel[hidden],
+.evcall-current[hidden],
+.evcall-panel button[hidden],
+.evcall-badge[hidden] {
+ display: none !important;
+}
+
+.evcall-header {
+ align-items: center;
+ background: var(--evcall-bg);
+ border-bottom: 1px solid var(--evcall-border);
+ display: flex;
+ justify-content: space-between;
+ padding: 15px 17px;
+}
+
+.evcall-header > div {
+ display: grid;
+ gap: 3px;
+}
+
+.evcall-header strong {
+ font-size: 15px;
+}
+
+.evcall-header small,
+.evcall-empty,
+.evcall-media-state,
+.evcall-list-item small {
+ color: var(--evcall-muted);
+ font-size: 12px;
+}
+
+.evcall-body {
+ display: grid;
+ gap: 14px;
+ max-height: calc(min(760px, 100vh - 110px) - 62px);
+ overflow-y: auto;
+ padding: 14px;
+}
+
+.evcall-icon {
+ align-items: center;
+ background: transparent;
+ border: 0;
+ border-radius: 8px;
+ color: var(--evcall-muted);
+ cursor: pointer;
+ display: inline-flex;
+ font-size: 21px;
+ height: 34px;
+ justify-content: center;
+ width: 34px;
+}
+
+.evcall-icon:hover {
+ background: var(--evcall-surface);
+ color: var(--evcall-text);
+}
+
+.evcall-settings {
+ background: var(--evcall-surface);
+ border: 1px solid var(--evcall-border);
+ border-radius: 12px;
+ padding: 10px 12px;
+}
+
+.evcall-settings summary,
+.evcall-log-wrap summary {
+ cursor: pointer;
+ font-size: 13px;
+ font-weight: 700;
+ user-select: none;
+}
+
+.evcall-grid {
+ display: grid;
+ gap: 10px;
+ padding-top: 11px;
+}
+
+.evcall-panel label {
+ color: var(--evcall-muted);
+ display: grid;
+ font-size: 12px;
+ gap: 5px;
+}
+
+.evcall-panel input {
+ background: var(--evcall-bg);
+ border: 1px solid var(--evcall-border);
+ border-radius: 9px;
+ color: var(--evcall-text);
+ font: inherit;
+ min-height: 40px;
+ padding: 9px 11px;
+ width: 100%;
+}
+
+.evcall-panel input::placeholder {
+ color: var(--evcall-muted);
+ opacity: 0.7;
+}
+
+.evcall-check {
+ align-items: center;
+ display: flex !important;
+ gap: 8px !important;
+}
+
+.evcall-check input {
+ min-height: 0;
+ width: auto;
+}
+
+.evcall-dialer {
+ align-items: end;
+ display: grid;
+ gap: 9px;
+ grid-template-columns: 1fr auto;
+}
+
+.evcall-panel button:not(.evcall-icon):not(.evcall-launcher) {
+ border: 0;
+ border-radius: 9px;
+ cursor: pointer;
+ font: inherit;
+ font-size: 13px;
+ font-weight: 700;
+ min-height: 40px;
+ padding: 9px 13px;
+ transition: filter 0.14s ease, transform 0.08s ease;
+}
+
+.evcall-panel button:not(.evcall-icon):not(.evcall-launcher):active {
+ transform: scale(0.98);
+}
+
+.evcall-panel button:disabled {
+ cursor: wait;
+ opacity: 0.55;
+}
+
+.evcall-primary {
+ background: var(--evcall-primary);
+ color: #fff;
+}
+
+.evcall-primary:hover {
+ background: var(--evcall-primary-hover);
+}
+
+.evcall-danger {
+ background: var(--evcall-danger);
+ color: #fff;
+}
+
+.evcall-danger:hover {
+ background: var(--evcall-danger-hover);
+}
+
+.evcall-secondary {
+ background: var(--evcall-surface);
+ color: var(--evcall-text);
+ outline: 1px solid var(--evcall-border);
+}
+
+.evcall-secondary:hover {
+ filter: brightness(0.97);
+}
+
+.evcall-current {
+ background: var(--evcall-surface);
+ border: 1px solid var(--evcall-border);
+ border-radius: 13px;
+ display: grid;
+ gap: 11px;
+ padding: 13px;
+}
+
+.evcall-current-main {
+ display: grid;
+ gap: 3px;
+}
+
+.evcall-direction {
+ color: var(--evcall-muted);
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.evcall-peer {
+ font-size: 18px;
+ overflow-wrap: anywhere;
+}
+
+.evcall-state,
+.evcall-pill {
+ border-radius: 999px;
+ display: inline-flex;
+ font-size: 11px;
+ font-weight: 700;
+ justify-self: start;
+ padding: 4px 8px;
+}
+
+.state-ringing {
+ background: #fff0c2;
+ color: #8a5a00;
+}
+
+.state-connecting {
+ background: #d9ecff;
+ color: #075a9c;
+}
+
+.state-active {
+ background: #d8f5e5;
+ color: #11653e;
+}
+
+.state-ended,
+.state-idle {
+ background: #e9edf1;
+ color: #5c6670;
+}
+
+.state-failed,
+.state-unknown {
+ background: #ffe0e0;
+ color: #9f2525;
+}
+
+.evcall-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.evcall-media-state {
+ line-height: 1.45;
+}
+
+.evcall-section-head {
+ align-items: center;
+ display: flex;
+ justify-content: space-between;
+}
+
+.evcall-section-head strong {
+ font-size: 13px;
+}
+
+.evcall-list {
+ display: grid;
+ gap: 8px;
+}
+
+.evcall-empty {
+ border: 1px dashed var(--evcall-border);
+ border-radius: 10px;
+ margin: 0;
+ padding: 16px;
+ text-align: center;
+}
+
+.evcall-list-item {
+ background: var(--evcall-bg) !important;
+ border: 1px solid var(--evcall-border) !important;
+ color: var(--evcall-text) !important;
+ display: grid;
+ gap: 5px;
+ min-height: 0 !important;
+ padding: 10px 11px !important;
+ text-align: left;
+ width: 100%;
+}
+
+.evcall-list-item:hover,
+.evcall-list-item.selected {
+ border-color: var(--evcall-primary) !important;
+}
+
+.evcall-list-item.selected {
+ box-shadow: inset 3px 0 0 var(--evcall-primary);
+}
+
+.evcall-list-top {
+ align-items: center;
+ display: flex;
+ gap: 9px;
+ justify-content: space-between;
+}
+
+.evcall-list-top strong {
+ font-size: 13px;
+ overflow-wrap: anywhere;
+}
+
+.evcall-list-item small {
+ display: block;
+ font-weight: 400;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.evcall-log-wrap {
+ border-top: 1px solid var(--evcall-border);
+ padding-top: 11px;
+}
+
+.evcall-log {
+ background: #10151b;
+ border-radius: 9px;
+ color: #d8e4ef;
+ font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ margin: 9px 0 0;
+ max-height: 180px;
+ min-height: 80px;
+ overflow: auto;
+ padding: 10px;
+ white-space: pre-wrap;
+}
+
+@media (max-width: 520px) {
+ #evcall-root {
+ bottom: 12px;
+ right: 12px;
+ }
+
+ .evcall-panel {
+ bottom: 68px;
+ max-height: calc(100vh - 96px);
+ width: calc(100vw - 24px);
+ }
+
+ .evcall-body {
+ max-height: calc(100vh - 158px);
+ }
+
+ .evcall-dialer {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/manager/dist/assets/call-manager.js b/manager/dist/assets/call-manager.js
new file mode 100644
index 00000000..4bdb5090
--- /dev/null
+++ b/manager/dist/assets/call-manager.js
@@ -0,0 +1,707 @@
+(() => {
+ "use strict";
+
+ if (window.__evolutionCallManagerLoaded) return;
+ window.__evolutionCallManagerLoaded = true;
+
+ const DATA_CHANNEL_LABEL = "evolution-call-pcm";
+ const DATA_CHANNEL_PROTOCOL = "evcall.pcm.v1";
+ const PCM_RATE = 16000;
+ const PCM_FRAME_SAMPLES = 960;
+ const MAX_BUFFERED_AMOUNT = 256 * 1024;
+ const HEADER_BYTES = 16;
+ const STORAGE_KEY = "evolution.callManager.config.v1";
+ const SESSION_KEY = "evolution.callManager.session.v1";
+ const POLL_INTERVAL_MS = 1800;
+
+ const state = {
+ open: false,
+ loading: false,
+ calls: [],
+ selectedCallId: "",
+ autoConnectCallId: "",
+ pollTimer: null,
+ peer: null,
+ channel: null,
+ sessionId: "",
+ mediaCallId: "",
+ audioContext: null,
+ microphoneStream: null,
+ captureSource: null,
+ captureNode: null,
+ playbackNode: null,
+ captureResampler: null,
+ playbackResampler: null,
+ capturePending: new Float32Array(0),
+ muted: false,
+ sentFrames: 0,
+ receivedFrames: 0,
+ droppedFrames: 0,
+ };
+
+ const root = document.createElement("div");
+ root.id = "evcall-root";
+ root.innerHTML = `
+
+ ☎
+ 0
+
+
+
+
+
+
+ Conexão da instância
+
+ URL da API
+
+
+ API key da instância
+
+
+
+
+ Salvar chave neste navegador
+
+ Salvar e consultar
+
+
+
+
+ Número com DDI
+
+
+ Ligar
+
+
+
+
+
+
+
+
+
+ Atender
+ Recusar
+ Conectar áudio
+ Silenciar
+ Encerrar
+
+
+
+
+
+ Chamadas da instância
+ ↻
+
+
Informe a API key para consultar.
+
+
+ Diagnóstico
+
+
+
+
+ `;
+ document.body.appendChild(root);
+
+ const ui = {
+ launcher: root.querySelector(".evcall-launcher"),
+ badge: root.querySelector(".evcall-badge"),
+ panel: root.querySelector(".evcall-panel"),
+ close: root.querySelector(".evcall-close"),
+ runtime: root.querySelector(".evcall-runtime"),
+ settings: root.querySelector(".evcall-settings"),
+ baseUrl: root.querySelector(".evcall-base-url"),
+ apiKey: root.querySelector(".evcall-api-key"),
+ remember: root.querySelector(".evcall-remember"),
+ save: root.querySelector(".evcall-save"),
+ number: root.querySelector(".evcall-number"),
+ start: root.querySelector(".evcall-start"),
+ current: root.querySelector(".evcall-current"),
+ direction: root.querySelector(".evcall-direction"),
+ peer: root.querySelector(".evcall-peer"),
+ callState: root.querySelector(".evcall-state"),
+ accept: root.querySelector(".evcall-accept"),
+ reject: root.querySelector(".evcall-reject"),
+ connect: root.querySelector(".evcall-connect"),
+ mute: root.querySelector(".evcall-mute"),
+ hangup: root.querySelector(".evcall-hangup"),
+ mediaState: root.querySelector(".evcall-media-state"),
+ refresh: root.querySelector(".evcall-refresh"),
+ list: root.querySelector(".evcall-list"),
+ log: root.querySelector(".evcall-log"),
+ };
+
+ class StreamingLinearResampler {
+ constructor(inputRate, outputRate) {
+ this.step = inputRate / outputRate;
+ this.position = 0;
+ this.carry = new Float32Array(0);
+ }
+
+ push(input) {
+ if (!(input instanceof Float32Array) || input.length === 0) return new Float32Array(0);
+ const data = new Float32Array(this.carry.length + input.length);
+ data.set(this.carry);
+ data.set(input, this.carry.length);
+ const output = [];
+ let position = this.position;
+ while (position + 1 < data.length) {
+ const left = Math.floor(position);
+ const fraction = position - left;
+ output.push(data[left] + (data[left + 1] - data[left]) * fraction);
+ position += this.step;
+ }
+ const consumed = Math.floor(position);
+ this.carry = data.slice(Math.min(consumed, data.length));
+ this.position = position - consumed;
+ return Float32Array.from(output);
+ }
+ }
+
+ function safeJSON(value, fallback = null) {
+ try { return JSON.parse(value); } catch (_) { return fallback; }
+ }
+
+ function loadConfig() {
+ const persistent = safeJSON(localStorage.getItem(STORAGE_KEY), null);
+ const temporary = safeJSON(sessionStorage.getItem(SESSION_KEY), null);
+ const config = persistent || temporary || {};
+ ui.baseUrl.value = config.baseUrl || window.location.origin;
+ ui.apiKey.value = config.apiKey || "";
+ ui.remember.checked = Boolean(persistent);
+ if (config.number) ui.number.value = config.number;
+ }
+
+ function saveConfig() {
+ const config = {
+ baseUrl: normalizedBaseURL(),
+ apiKey: ui.apiKey.value.trim(),
+ number: normalizeNumber(ui.number.value),
+ };
+ if (ui.remember.checked) {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
+ sessionStorage.removeItem(SESSION_KEY);
+ } else {
+ sessionStorage.setItem(SESSION_KEY, JSON.stringify(config));
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ }
+
+ function normalizedBaseURL() {
+ return (ui.baseUrl.value.trim() || window.location.origin).replace(/\/+$/, "");
+ }
+
+ function normalizeNumber(value) {
+ return String(value || "").replace(/\D/g, "");
+ }
+
+ function callByID(callId) {
+ return state.calls.find(call => call.id === callId) || null;
+ }
+
+ function selectedCall() {
+ return callByID(state.selectedCallId);
+ }
+
+ function isTerminal(call) {
+ return !call || call.state === "ended" || call.state === "failed";
+ }
+
+ function formatPeer(peer) {
+ const value = String(peer || "");
+ return value.replace(/:\d+@/, "@").split("@")[0] || "Contato desconhecido";
+ }
+
+ function stateLabel(value) {
+ return ({
+ ringing: "Chamando",
+ connecting: "Conectando",
+ active: "Ativa",
+ ended: "Encerrada",
+ failed: "Falhou",
+ idle: "Inativa",
+ })[value] || value || "Desconhecido";
+ }
+
+ function log(message, details) {
+ const suffix = details === undefined ? "" : ` ${typeof details === "string" ? details : JSON.stringify(details)}`;
+ ui.log.textContent += `[${new Date().toLocaleTimeString()}] ${message}${suffix}\n`;
+ const lines = ui.log.textContent.split("\n");
+ if (lines.length > 180) ui.log.textContent = lines.slice(-160).join("\n");
+ ui.log.scrollTop = ui.log.scrollHeight;
+ }
+
+ function setBusy(busy) {
+ state.loading = busy;
+ [ui.save, ui.start, ui.accept, ui.reject, ui.connect, ui.hangup, ui.refresh].forEach(button => {
+ button.disabled = busy;
+ });
+ }
+
+ async function api(path, options = {}) {
+ const key = ui.apiKey.value.trim();
+ if (!key) throw new Error("Informe a API key da instância");
+ const headers = new Headers(options.headers || {});
+ headers.set("apikey", key);
+ if (options.body !== undefined && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
+ const response = await fetch(`${normalizedBaseURL()}${path}`, { ...options, headers });
+ const body = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(body.error || body.message || `HTTP ${response.status}`);
+ return body;
+ }
+
+ function chooseDefaultCall() {
+ if (state.selectedCallId && callByID(state.selectedCallId)) return;
+ const live = [...state.calls].reverse().find(call => !isTerminal(call));
+ const latest = state.calls[state.calls.length - 1];
+ state.selectedCallId = live?.id || latest?.id || "";
+ }
+
+ function render() {
+ const incoming = state.calls.filter(call => call.direction === "incoming" && call.state === "ringing");
+ ui.badge.hidden = incoming.length === 0;
+ ui.badge.textContent = String(incoming.length);
+
+ ui.list.replaceChildren();
+ if (state.calls.length === 0) {
+ const empty = document.createElement("p");
+ empty.className = "evcall-empty";
+ empty.textContent = ui.apiKey.value.trim() ? "Nenhuma chamada registrada." : "Informe a API key para consultar.";
+ ui.list.appendChild(empty);
+ } else {
+ [...state.calls].reverse().forEach(call => {
+ const button = document.createElement("button");
+ button.type = "button";
+ button.className = `evcall-list-item${call.id === state.selectedCallId ? " selected" : ""}`;
+ const top = document.createElement("span");
+ top.className = "evcall-list-top";
+ const peer = document.createElement("strong");
+ peer.textContent = formatPeer(call.peer);
+ const status = document.createElement("span");
+ status.className = `evcall-pill state-${call.state || "unknown"}`;
+ status.textContent = stateLabel(call.state);
+ top.append(peer, status);
+ const meta = document.createElement("small");
+ meta.textContent = `${call.direction === "incoming" ? "Recebida" : "Realizada"} · ${call.video ? "vídeo" : "voz"} · ${call.id}`;
+ button.append(top, meta);
+ button.addEventListener("click", () => {
+ state.selectedCallId = call.id;
+ render();
+ });
+ ui.list.appendChild(button);
+ });
+ }
+
+ const call = selectedCall();
+ ui.current.hidden = !call;
+ if (!call) return;
+
+ ui.direction.textContent = call.direction === "incoming" ? "Chamada recebida" : "Chamada realizada";
+ ui.peer.textContent = formatPeer(call.peer);
+ ui.callState.textContent = stateLabel(call.state);
+ ui.callState.className = `evcall-state state-${call.state || "unknown"}`;
+
+ const incomingRinging = call.direction === "incoming" && call.state === "ringing";
+ const canTerminate = !isTerminal(call);
+ const mediaConnected = state.mediaCallId === call.id && Boolean(state.peer);
+ ui.accept.hidden = !incomingRinging;
+ ui.reject.hidden = !incomingRinging;
+ ui.connect.hidden = call.state !== "active" || mediaConnected;
+ ui.mute.hidden = !mediaConnected;
+ ui.hangup.hidden = !canTerminate;
+ ui.mute.textContent = state.muted ? "Ativar microfone" : "Silenciar";
+
+ if (mediaConnected) {
+ ui.mediaState.textContent = `Áudio conectado · enviados ${state.sentFrames} · recebidos ${state.receivedFrames} · descartados ${state.droppedFrames}`;
+ } else if (call.state === "active") {
+ ui.mediaState.textContent = "Chamada ativa. Conecte o microfone e o alto-falante.";
+ } else {
+ ui.mediaState.textContent = "O áudio ficará disponível quando a chamada estiver ativa.";
+ }
+ }
+
+ async function refreshStatus({ quiet = false } = {}) {
+ if (!ui.apiKey.value.trim()) {
+ ui.runtime.textContent = "Configuração necessária";
+ render();
+ return;
+ }
+ try {
+ const snapshot = await api("/call/status");
+ state.calls = Array.isArray(snapshot.calls) ? snapshot.calls : [];
+ chooseDefaultCall();
+ ui.runtime.textContent = `${snapshot.connected ? "WhatsApp conectado" : "WhatsApp desconectado"}${snapshot.instanceId ? ` · ${snapshot.instanceId}` : ""}`;
+ ui.settings.open = !snapshot.connected;
+ const mediaCall = callByID(state.mediaCallId);
+ if (state.mediaCallId && (!mediaCall || isTerminal(mediaCall))) await disconnectMedia({ notifyServer: false });
+ const autoCall = callByID(state.autoConnectCallId);
+ if (autoCall?.state === "active" && state.mediaCallId !== autoCall.id) {
+ state.autoConnectCallId = "";
+ connectMedia(autoCall.id).catch(error => log("Conexão automática do áudio falhou", error.message));
+ }
+ render();
+ } catch (error) {
+ ui.runtime.textContent = "Falha ao consultar instância";
+ if (!quiet) log("Falha ao consultar chamadas", error.message);
+ }
+ }
+
+ async function startCall() {
+ const number = normalizeNumber(ui.number.value);
+ if (number.length < 8 || number.length > 20) throw new Error("Informe o número completo com DDI");
+ ui.number.value = number;
+ saveConfig();
+ const call = await api("/call/start", {
+ method: "POST",
+ body: JSON.stringify({ number, video: false }),
+ });
+ state.selectedCallId = call.id;
+ state.autoConnectCallId = call.id;
+ log("Chamada iniciada", { callId: call.id, peer: call.peer });
+ await refreshStatus({ quiet: true });
+ }
+
+ async function acceptCall() {
+ const call = selectedCall();
+ if (!call) throw new Error("Selecione uma chamada");
+ await api(`/call/${encodeURIComponent(call.id)}/accept`, { method: "POST" });
+ state.autoConnectCallId = call.id;
+ log("Chamada aceita", call.id);
+ await refreshStatus({ quiet: true });
+ }
+
+ async function rejectCall() {
+ const call = selectedCall();
+ if (!call) throw new Error("Selecione uma chamada");
+ await api("/call/reject", {
+ method: "POST",
+ body: JSON.stringify({ callCreator: call.peer, callId: call.id }),
+ });
+ state.autoConnectCallId = "";
+ log("Chamada recusada", call.id);
+ await refreshStatus({ quiet: true });
+ }
+
+ async function terminateCall() {
+ const call = selectedCall();
+ if (!call) throw new Error("Selecione uma chamada");
+ if (state.mediaCallId === call.id) await disconnectMedia();
+ await api(`/call/${encodeURIComponent(call.id)}`, { method: "DELETE" });
+ state.autoConnectCallId = "";
+ log("Chamada encerrada", call.id);
+ await refreshStatus({ quiet: true });
+ }
+
+ function encodePCM(samples) {
+ const buffer = new ArrayBuffer(HEADER_BYTES + samples.length * 4);
+ const bytes = new Uint8Array(buffer);
+ bytes.set([0x45, 0x56, 0x50, 0x43], 0);
+ const view = new DataView(buffer);
+ view.setUint8(4, 1);
+ view.setUint8(5, 1);
+ view.setUint16(6, 0, true);
+ view.setUint32(8, PCM_RATE, true);
+ view.setUint32(12, samples.length, true);
+ for (let index = 0; index < samples.length; index++) {
+ const sample = Number.isFinite(samples[index]) ? Math.max(-1, Math.min(1, samples[index])) : 0;
+ view.setFloat32(HEADER_BYTES + index * 4, sample, true);
+ }
+ return buffer;
+ }
+
+ function decodePCM(buffer) {
+ if (!(buffer instanceof ArrayBuffer) || buffer.byteLength < HEADER_BYTES) throw new Error("frame PCM truncado");
+ const bytes = new Uint8Array(buffer, 0, 4);
+ if (bytes[0] !== 0x45 || bytes[1] !== 0x56 || bytes[2] !== 0x50 || bytes[3] !== 0x43) throw new Error("magic PCM inválido");
+ const view = new DataView(buffer);
+ if (view.getUint8(4) !== 1 || view.getUint8(5) !== 1 || view.getUint16(6, true) !== 0) throw new Error("versão PCM incompatível");
+ if (view.getUint32(8, true) !== PCM_RATE) throw new Error("sample rate PCM incompatível");
+ const count = view.getUint32(12, true);
+ if (!count || count > PCM_FRAME_SAMPLES * 4 || buffer.byteLength !== HEADER_BYTES + count * 4) throw new Error("tamanho PCM inválido");
+ const output = new Float32Array(count);
+ for (let index = 0; index < count; index++) output[index] = view.getFloat32(HEADER_BYTES + index * 4, true);
+ return output;
+ }
+
+ async function installAudioWorklet(context) {
+ const source = `
+ class EvolutionManagerPCMProcessor extends AudioWorkletProcessor {
+ constructor(options) {
+ super();
+ this.mode = options.processorOptions.mode;
+ this.queue = [];
+ this.offset = 0;
+ this.port.onmessage = event => {
+ if (this.mode === 'playback' && event.data instanceof Float32Array) this.queue.push(event.data);
+ };
+ }
+ process(inputs, outputs) {
+ if (this.mode === 'capture') {
+ const input = inputs[0] && inputs[0][0];
+ if (input && input.length) this.port.postMessage(new Float32Array(input));
+ } else {
+ const output = outputs[0] && outputs[0][0];
+ if (output) {
+ output.fill(0);
+ let written = 0;
+ while (written < output.length && this.queue.length) {
+ const chunk = this.queue[0];
+ const count = Math.min(output.length - written, chunk.length - this.offset);
+ output.set(chunk.subarray(this.offset, this.offset + count), written);
+ written += count;
+ this.offset += count;
+ if (this.offset >= chunk.length) { this.queue.shift(); this.offset = 0; }
+ }
+ }
+ }
+ return true;
+ }
+ }
+ registerProcessor('evolution-manager-pcm', EvolutionManagerPCMProcessor);
+ `;
+ const url = URL.createObjectURL(new Blob([source], { type: "text/javascript" }));
+ try { await context.audioWorklet.addModule(url); } finally { URL.revokeObjectURL(url); }
+ }
+
+ function gatherComplete(connection) {
+ if (connection.iceGatheringState === "complete") return Promise.resolve();
+ return new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ connection.removeEventListener("icegatheringstatechange", listener);
+ reject(new Error("timeout ao coletar candidatos ICE"));
+ }, 15000);
+ const listener = () => {
+ if (connection.iceGatheringState === "complete") {
+ clearTimeout(timeout);
+ connection.removeEventListener("icegatheringstatechange", listener);
+ resolve();
+ }
+ };
+ connection.addEventListener("icegatheringstatechange", listener);
+ });
+ }
+
+ function appendCapture(samples) {
+ const joined = new Float32Array(state.capturePending.length + samples.length);
+ joined.set(state.capturePending);
+ joined.set(samples, state.capturePending.length);
+ let offset = 0;
+ while (joined.length - offset >= PCM_FRAME_SAMPLES) {
+ const frame = joined.slice(offset, offset + PCM_FRAME_SAMPLES);
+ offset += PCM_FRAME_SAMPLES;
+ if (!state.muted && state.channel?.readyState === "open" && state.channel.bufferedAmount <= MAX_BUFFERED_AMOUNT) {
+ state.channel.send(encodePCM(frame));
+ state.sentFrames++;
+ } else if (!state.muted) {
+ state.droppedFrames++;
+ }
+ }
+ state.capturePending = joined.slice(offset);
+ }
+
+ async function startAudio() {
+ const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
+ if (!AudioContextCtor || !window.AudioWorkletNode) throw new Error("Este navegador não suporta AudioWorklet");
+ state.audioContext = new AudioContextCtor({ latencyHint: "interactive" });
+ await installAudioWorklet(state.audioContext);
+ await state.audioContext.resume();
+ state.captureResampler = new StreamingLinearResampler(state.audioContext.sampleRate, PCM_RATE);
+ state.playbackResampler = new StreamingLinearResampler(PCM_RATE, state.audioContext.sampleRate);
+
+ state.playbackNode = new AudioWorkletNode(state.audioContext, "evolution-manager-pcm", {
+ numberOfInputs: 0,
+ numberOfOutputs: 1,
+ outputChannelCount: [1],
+ processorOptions: { mode: "playback" },
+ });
+ state.playbackNode.connect(state.audioContext.destination);
+
+ state.microphoneStream = await navigator.mediaDevices.getUserMedia({
+ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
+ video: false,
+ });
+ state.captureSource = state.audioContext.createMediaStreamSource(state.microphoneStream);
+ state.captureNode = new AudioWorkletNode(state.audioContext, "evolution-manager-pcm", {
+ numberOfInputs: 1,
+ numberOfOutputs: 0,
+ processorOptions: { mode: "capture" },
+ });
+ state.captureNode.port.onmessage = event => appendCapture(state.captureResampler.push(event.data));
+ state.captureSource.connect(state.captureNode);
+ log("Microfone e reprodução iniciados", { sampleRate: state.audioContext.sampleRate });
+ }
+
+ async function connectMedia(callId) {
+ const call = callByID(callId);
+ if (!call || call.state !== "active") throw new Error("A chamada precisa estar ativa");
+ if (!window.isSecureContext && location.hostname !== "localhost") throw new Error("O microfone exige HTTPS");
+ if (state.peer) await disconnectMedia();
+
+ state.sentFrames = 0;
+ state.receivedFrames = 0;
+ state.droppedFrames = 0;
+ state.capturePending = new Float32Array(0);
+ state.mediaCallId = callId;
+ state.peer = new RTCPeerConnection({ iceServers: [] });
+ state.channel = state.peer.createDataChannel(DATA_CHANNEL_LABEL, { ordered: true, protocol: DATA_CHANNEL_PROTOCOL });
+ state.channel.binaryType = "arraybuffer";
+ state.channel.bufferedAmountLowThreshold = MAX_BUFFERED_AMOUNT / 2;
+
+ state.channel.onopen = async () => {
+ log("Canal de áudio aberto", callId);
+ try {
+ await startAudio();
+ render();
+ } catch (error) {
+ log("Falha ao iniciar áudio", error.message);
+ await disconnectMedia();
+ }
+ };
+ state.channel.onclose = () => {
+ log("Canal de áudio fechado", callId);
+ if (state.mediaCallId === callId) disconnectMedia({ notifyServer: false }).catch(() => {});
+ };
+ state.channel.onerror = event => log("Erro no canal de áudio", event?.message || "DataChannel");
+ state.channel.onmessage = event => {
+ try {
+ const pcm16k = decodePCM(event.data);
+ const playback = state.playbackResampler?.push(pcm16k) || new Float32Array(0);
+ if (playback.length) state.playbackNode?.port.postMessage(playback, [playback.buffer]);
+ state.receivedFrames++;
+ if (state.receivedFrames % 10 === 0) render();
+ } catch (error) {
+ state.droppedFrames++;
+ log("Frame de áudio recebido rejeitado", error.message);
+ }
+ };
+ state.peer.onconnectionstatechange = () => {
+ log("PeerConnection", state.peer?.connectionState || "closed");
+ if (["failed", "closed"].includes(state.peer?.connectionState)) disconnectMedia({ notifyServer: false }).catch(() => {});
+ };
+
+ try {
+ await state.peer.setLocalDescription(await state.peer.createOffer());
+ await gatherComplete(state.peer);
+ const body = await api(`/call/${encodeURIComponent(callId)}/webrtc`, {
+ method: "POST",
+ body: JSON.stringify({ offer: { type: "offer", sdp: state.peer.localDescription.sdp } }),
+ });
+ state.sessionId = body.sessionId;
+ await state.peer.setRemoteDescription(body.answer);
+ log("Sessão WebRTC criada", { callId, sessionId: state.sessionId });
+ render();
+ } catch (error) {
+ await disconnectMedia({ notifyServer: false });
+ throw error;
+ }
+ }
+
+ async function disconnectMedia({ notifyServer = true } = {}) {
+ const closingSession = state.sessionId;
+ const closingCall = state.mediaCallId;
+ state.sessionId = "";
+ state.mediaCallId = "";
+
+ state.microphoneStream?.getTracks().forEach(track => track.stop());
+ state.microphoneStream = null;
+ state.captureSource?.disconnect();
+ state.captureNode?.disconnect();
+ state.playbackNode?.disconnect();
+ state.captureSource = null;
+ state.captureNode = null;
+ state.playbackNode = null;
+ if (state.audioContext) await state.audioContext.close().catch(() => {});
+ state.audioContext = null;
+ state.channel?.close();
+ state.peer?.close();
+ state.channel = null;
+ state.peer = null;
+ state.capturePending = new Float32Array(0);
+
+ if (notifyServer && closingSession && closingCall && ui.apiKey.value.trim()) {
+ await api(`/call/${encodeURIComponent(closingCall)}/webrtc/${encodeURIComponent(closingSession)}`, {
+ method: "DELETE",
+ }).catch(() => {});
+ }
+ if (closingCall) log("Áudio desconectado", { callId: closingCall, sent: state.sentFrames, received: state.receivedFrames, dropped: state.droppedFrames });
+ render();
+ }
+
+ async function runAction(action) {
+ if (state.loading) return;
+ setBusy(true);
+ try { await action(); } catch (error) { log("Operação falhou", error.message); }
+ finally { setBusy(false); render(); }
+ }
+
+ function startPolling() {
+ if (state.pollTimer) return;
+ state.pollTimer = window.setInterval(() => refreshStatus({ quiet: true }), POLL_INTERVAL_MS);
+ }
+
+ function stopPolling() {
+ if (!state.pollTimer) return;
+ clearInterval(state.pollTimer);
+ state.pollTimer = null;
+ }
+
+ function togglePanel(force) {
+ state.open = force ?? !state.open;
+ ui.panel.hidden = !state.open;
+ ui.launcher.classList.toggle("active", state.open);
+ if (state.open) {
+ startPolling();
+ refreshStatus({ quiet: false });
+ setTimeout(() => ui.apiKey.value ? ui.number.focus() : ui.apiKey.focus(), 50);
+ } else {
+ stopPolling();
+ }
+ }
+
+ ui.launcher.addEventListener("click", () => togglePanel());
+ ui.close.addEventListener("click", () => togglePanel(false));
+ ui.save.addEventListener("click", () => runAction(async () => {
+ saveConfig();
+ log("Configuração salva", { baseUrl: normalizedBaseURL(), persistent: ui.remember.checked });
+ await refreshStatus();
+ }));
+ ui.refresh.addEventListener("click", () => runAction(() => refreshStatus()));
+ ui.start.addEventListener("click", () => runAction(startCall));
+ ui.accept.addEventListener("click", () => runAction(acceptCall));
+ ui.reject.addEventListener("click", () => runAction(rejectCall));
+ ui.connect.addEventListener("click", () => runAction(() => connectMedia(selectedCall()?.id)));
+ ui.mute.addEventListener("click", () => {
+ state.muted = !state.muted;
+ log(state.muted ? "Microfone silenciado" : "Microfone ativado");
+ render();
+ });
+ ui.hangup.addEventListener("click", () => runAction(terminateCall));
+ ui.number.addEventListener("keydown", event => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ runAction(startCall);
+ }
+ });
+ ui.apiKey.addEventListener("keydown", event => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ runAction(async () => { saveConfig(); await refreshStatus(); });
+ }
+ });
+ window.addEventListener("beforeunload", () => {
+ state.microphoneStream?.getTracks().forEach(track => track.stop());
+ state.peer?.close();
+ });
+
+ loadConfig();
+ render();
+ if (ui.apiKey.value.trim()) refreshStatus({ quiet: true });
+})();
diff --git a/manager/dist/index.html b/manager/dist/index.html
index 55588c66..1d21fd1a 100644
--- a/manager/dist/index.html
+++ b/manager/dist/index.html
@@ -7,6 +7,8 @@
Evolution GO Manager
+
+
diff --git a/pkg/call/handler/call_handler.go b/pkg/call/handler/call_handler.go
index 550c3390..812c8376 100644
--- a/pkg/call/handler/call_handler.go
+++ b/pkg/call/handler/call_handler.go
@@ -1,21 +1,135 @@
package call_handler
import (
+ "context"
+ "errors"
"net/http"
+ "time"
call_service "github.com/evolution-foundation/evolution-go/pkg/call/service"
+ call_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser"
instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model"
"github.com/gin-gonic/gin"
)
type CallHandler interface {
+ StartCall(ctx *gin.Context)
+ AcceptCall(ctx *gin.Context)
+ TerminateCall(ctx *gin.Context)
RejectCall(ctx *gin.Context)
+ Status(ctx *gin.Context)
+ CreateWebRTC(ctx *gin.Context)
+ ListWebRTC(ctx *gin.Context)
+ CloseWebRTC(ctx *gin.Context)
}
type callHandler struct {
callService call_service.CallService
}
+func instanceFromContext(ctx *gin.Context) (*instance_model.Instance, bool) {
+ value, exists := ctx.Get("instance")
+ if !exists {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"})
+ return nil, false
+ }
+ instance, ok := value.(*instance_model.Instance)
+ if !ok {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"})
+ return nil, false
+ }
+ return instance, true
+}
+
+// Start call
+// @Summary Start an experimental WhatsApp call
+// @Description Sends a real WhatsApp call offer and prepares the experimental media pipeline.
+// @Tags Call
+// @Accept json
+// @Produce json
+// @Param message body call_service.StartCallStruct true "Call data"
+// @Success 201 {object} gin.H "Call created"
+// @Failure 400 {object} gin.H "Invalid request"
+// @Failure 500 {object} gin.H "Call signaling failed"
+// @Router /call/start [post]
+func (g *callHandler) StartCall(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+
+ var data call_service.StartCallStruct
+ if err := ctx.ShouldBindJSON(&data); err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ call, err := g.callService.StartCall(&data, instance)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusCreated, call)
+}
+
+// Accept call
+// @Summary Accept an incoming WhatsApp call
+// @Description Sends preaccept and accept signaling for a prepared incoming call.
+// @Tags Call
+// @Produce json
+// @Param callId path string true "Call ID"
+// @Success 200 {object} gin.H "Call accepted"
+// @Failure 400 {object} gin.H "Invalid request"
+// @Failure 500 {object} gin.H "Call signaling failed"
+// @Router /call/{callId}/accept [post]
+func (g *callHandler) AcceptCall(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+ callID := ctx.Param("callId")
+ if callID == "" {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"})
+ return
+ }
+
+ call, err := g.callService.AcceptCall(callID, instance)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusOK, call)
+}
+
+// Terminate call
+// @Summary Terminate a WhatsApp call
+// @Description Sends a terminate stanza for a call tracked by this instance.
+// @Tags Call
+// @Produce json
+// @Param callId path string true "Call ID"
+// @Success 200 {object} gin.H "Call terminated"
+// @Failure 404 {object} gin.H "Call not found"
+// @Failure 500 {object} gin.H "Call signaling failed"
+// @Router /call/{callId} [delete]
+func (g *callHandler) TerminateCall(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+ callID := ctx.Param("callId")
+ if callID == "" {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"})
+ return
+ }
+
+ call, err := g.callService.TerminateCall(callID, instance)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusOK, call)
+}
+
// Reject call
// @Summary Reject call
// @Description Reject call
@@ -27,23 +141,18 @@ type callHandler struct {
// @Failure 500 {object} gin.H "Internal server error"
// @Router /call/reject [post]
func (g *callHandler) RejectCall(ctx *gin.Context) {
- getInstance := ctx.MustGet("instance")
-
- instance, ok := getInstance.(*instance_model.Instance)
+ instance, ok := instanceFromContext(ctx)
if !ok {
- ctx.JSON(http.StatusInternalServerError, gin.H{"error": "instance not found"})
return
}
- var data *call_service.RejectCallStruct
- err := ctx.ShouldBindBodyWithJSON(&data)
- if err != nil {
+ var data call_service.RejectCallStruct
+ if err := ctx.ShouldBindJSON(&data); err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
- err = g.callService.RejectCall(data, instance)
- if err != nil {
+ if err := g.callService.RejectCall(&data, instance); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -51,10 +160,121 @@ func (g *callHandler) RejectCall(ctx *gin.Context) {
ctx.JSON(http.StatusOK, gin.H{"message": "success"})
}
-func NewCallHandler(
- callService call_service.CallService,
-) CallHandler {
- return &callHandler{
- callService: callService,
+// Runtime status
+// @Summary Get VoIP runtime status
+// @Description Returns the VoIP runtime attached to the authenticated Evolution instance
+// @Tags Call
+// @Produce json
+// @Success 200 {object} gin.H "VoIP runtime status"
+// @Failure 500 {object} gin.H "Internal server error"
+// @Router /call/status [get]
+func (g *callHandler) Status(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+
+ status, err := g.callService.RuntimeStatus(instance)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
}
+
+ ctx.JSON(http.StatusOK, status)
+}
+
+func browserHTTPStatus(err error) int {
+ switch {
+ case errors.Is(err, call_browser.ErrWebRTCDisabled):
+ return http.StatusNotImplemented
+ case errors.Is(err, call_browser.ErrInvalidOffer), errors.Is(err, call_browser.ErrInvalidPCMMessage):
+ return http.StatusBadRequest
+ case errors.Is(err, call_browser.ErrSessionNotFound):
+ return http.StatusNotFound
+ case errors.Is(err, call_browser.ErrSessionLimit), errors.Is(err, call_service.ErrCallNotActive):
+ return http.StatusConflict
+ case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled):
+ return http.StatusGatewayTimeout
+ default:
+ return http.StatusInternalServerError
+ }
+}
+
+// Create browser WebRTC PCM session
+// @Summary Create an experimental browser PCM bridge
+// @Description Exchanges a complete SDP offer and answer. Requires the voip_pion build and an active WhatsApp call.
+// @Tags Call
+// @Accept json
+// @Produce json
+// @Param callId path string true "Call ID"
+// @Param offer body call_browser.CreateRequest true "Browser SDP offer"
+// @Success 201 {object} call_browser.CreateResponse
+// @Router /call/{callId}/webrtc [post]
+func (g *callHandler) CreateWebRTC(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+ callID := ctx.Param("callId")
+ if callID == "" {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "callId is required"})
+ return
+ }
+ var request call_browser.CreateRequest
+ if err := ctx.ShouldBindJSON(&request); err != nil {
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ requestContext, cancel := context.WithTimeout(ctx.Request.Context(), 30*time.Second)
+ defer cancel()
+ response, err := g.callService.CreateWebRTC(requestContext, callID, request, instance)
+ if err != nil {
+ ctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusCreated, response)
+}
+
+// List browser WebRTC PCM sessions
+// @Summary List browser PCM bridge sessions
+// @Tags Call
+// @Produce json
+// @Param callId path string true "Call ID"
+// @Success 200 {object} gin.H
+// @Router /call/{callId}/webrtc [get]
+func (g *callHandler) ListWebRTC(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+ sessions, err := g.callService.WebRTCSessions(ctx.Param("callId"), instance)
+ if err != nil {
+ ctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusOK, gin.H{"sessions": sessions})
+}
+
+// Close browser WebRTC PCM session
+// @Summary Close a browser PCM bridge session
+// @Tags Call
+// @Produce json
+// @Param callId path string true "Call ID"
+// @Param sessionId path string true "WebRTC session ID"
+// @Success 200 {object} gin.H
+// @Router /call/{callId}/webrtc/{sessionId} [delete]
+func (g *callHandler) CloseWebRTC(ctx *gin.Context) {
+ instance, ok := instanceFromContext(ctx)
+ if !ok {
+ return
+ }
+ if err := g.callService.CloseWebRTC(ctx.Param("callId"), ctx.Param("sessionId"), instance); err != nil {
+ ctx.JSON(browserHTTPStatus(err), gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusOK, gin.H{"message": "browser media session closed"})
+}
+
+func NewCallHandler(callService call_service.CallService) CallHandler {
+ return &callHandler{callService: callService}
}
diff --git a/pkg/call/lifecycle/coordinator.go b/pkg/call/lifecycle/coordinator.go
new file mode 100644
index 00000000..f810d840
--- /dev/null
+++ b/pkg/call/lifecycle/coordinator.go
@@ -0,0 +1,400 @@
+// Package lifecycle coordinates call state, private negotiation material and
+// experimental media relays for each Evolution WhatsApp client.
+package lifecycle
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+ "sync"
+
+ call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ call_incoming "github.com/evolution-foundation/evolution-go/pkg/call/voip/incoming"
+ call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/types"
+)
+
+// Coordinator owns the call registries shared by the WhatsApp lifecycle and
+// the HTTP call service. It is safe for concurrent use.
+type Coordinator struct {
+ mu sync.RWMutex
+
+ runtimes *call_runtime.Registry
+ incoming *call_incoming.Registry
+ relays *call_media.RelayRegistry
+ packets *call_media.PacketRegistry
+ audio *call_media.AudioRegistry
+
+ onRTP func(instanceID, callID string, packet *call_media.RTPPacket)
+ onPCM func(instanceID, callID string, pcm []float32)
+ browserPCM func(instanceID, callID string, pcm []float32)
+ onCallMediaCleanup func(instanceID, callID string)
+ onInstanceMediaCleanup func(instanceID string)
+
+ incomingEnabled map[string]bool
+ mediaErrorReported map[string]bool
+}
+
+func NewCoordinator() *Coordinator {
+ incoming := call_incoming.NewRegistry()
+ packets := call_media.NewPacketRegistry(incoming)
+ coordinator := &Coordinator{
+ runtimes: call_runtime.NewRegistry(),
+ incoming: incoming,
+ packets: packets,
+ incomingEnabled: make(map[string]bool),
+ mediaErrorReported: make(map[string]bool),
+ }
+ coordinator.audio = call_media.NewAudioRegistry(func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error {
+ return coordinator.SendOpus(instanceID, callID, payload, durationSamples, marker)
+ }, nil)
+ coordinator.audio.SetOnPCM(coordinator.dispatchPCM)
+ coordinator.relays = call_media.NewRelayRegistry(incoming, nil, nil)
+ coordinator.relays.SetOnRemoved(func(instanceID, callID string) {
+ coordinator.audio.Remove(instanceID, callID)
+ coordinator.packets.Remove(instanceID, callID)
+ coordinator.clearMediaError(instanceID, callID)
+ coordinator.notifyCallMediaCleanup(instanceID, callID)
+ })
+ coordinator.relays.SetOnCleanup(func(instanceID string) {
+ coordinator.audio.Close(instanceID)
+ coordinator.packets.Close(instanceID)
+ coordinator.clearInstanceMediaErrors(instanceID)
+ coordinator.notifyInstanceMediaCleanup(instanceID)
+ })
+ coordinator.relays.SetOnConnected(func(instanceID, callID string) {
+ if err := coordinator.packets.Prepare(instanceID, callID); err != nil {
+ coordinator.reportMediaError(instanceID, callID, "prepare RTP/SRTP session", err)
+ return
+ }
+ if err := coordinator.audio.Prepare(instanceID, callID); err != nil {
+ coordinator.reportMediaError(instanceID, callID, "prepare audio codec", err)
+ coordinator.packets.Remove(instanceID, callID)
+ return
+ }
+ if runtime, ok := coordinator.runtimes.Get(instanceID); ok {
+ runtime.Transition(callID, "", "", call_runtime.StateActive, nil, "")
+ }
+ slog.Info("WhatsApp call media active", "instance", instanceID, "call_id", callID)
+ })
+ coordinator.packets.SetOnPeerSSRC(func(instanceID, callID string, previous, actual uint32) {
+ if err := coordinator.relays.UpdatePeerSSRC(instanceID, callID, actual); err != nil {
+ coordinator.reportMediaError(instanceID, callID, "refresh relay peer SSRC", err)
+ return
+ }
+ slog.Info("WhatsApp peer SSRC adopted",
+ "instance", instanceID,
+ "call_id", callID,
+ "predicted_ssrc", previous,
+ "actual_ssrc", actual,
+ )
+ })
+ coordinator.packets.SetOnRTP(func(instanceID, callID string, packet *call_media.RTPPacket) {
+ if err := coordinator.audio.HandleRTP(instanceID, callID, packet); err != nil {
+ coordinator.reportMediaError(instanceID, callID, "decode WhatsApp audio", err)
+ return
+ }
+ coordinator.mu.RLock()
+ callback := coordinator.onRTP
+ coordinator.mu.RUnlock()
+ if callback != nil {
+ callback(instanceID, callID, packet)
+ }
+ })
+ coordinator.relays.SetOnPacket(func(instanceID, callID string, packet []byte) {
+ err := coordinator.packets.Handle(instanceID, callID, packet)
+ if err == nil || errors.Is(err, call_media.ErrNonRTPFrame) || errors.Is(err, call_media.ErrPacketSessionNotReady) || errors.Is(err, call_media.ErrSelfRTPFrame) {
+ return
+ }
+ coordinator.reportMediaError(instanceID, callID, "receive WhatsApp SRTP", err)
+ })
+ return coordinator
+}
+
+func mediaErrorKey(instanceID, callID string) string {
+ return instanceID + "\x00" + callID
+}
+
+func (c *Coordinator) reportMediaError(instanceID, callID, stage string, err error) {
+ if c == nil || err == nil {
+ return
+ }
+ key := mediaErrorKey(instanceID, callID)
+ c.mu.Lock()
+ if c.mediaErrorReported[key] {
+ c.mu.Unlock()
+ return
+ }
+ c.mediaErrorReported[key] = true
+ c.mu.Unlock()
+ slog.Warn("WhatsApp inbound media failed", "instance", instanceID, "call_id", callID, "stage", stage, "err", err)
+}
+
+func (c *Coordinator) clearMediaError(instanceID, callID string) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ delete(c.mediaErrorReported, mediaErrorKey(instanceID, callID))
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) clearInstanceMediaErrors(instanceID string) {
+ if c == nil {
+ return
+ }
+ prefix := instanceID + "\x00"
+ c.mu.Lock()
+ for key := range c.mediaErrorReported {
+ if len(key) >= len(prefix) && key[:len(prefix)] == prefix {
+ delete(c.mediaErrorReported, key)
+ }
+ }
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) configureRuntime(runtime *call_runtime.Runtime) {
+ if c == nil || runtime == nil {
+ return
+ }
+ runtime.SetOnTimeout(func(instanceID, callID string) {
+ slog.Warn("WhatsApp call negotiation timed out", "instance", instanceID, "call_id", callID)
+ c.RemovePrivate(instanceID, callID)
+ })
+}
+
+// AttachClient is called by the WhatsApp client lifecycle. Public call state is
+// always monitored. Private outgoing negotiation remains available even when
+// incoming offer preparation is disabled by automatic rejection settings.
+func (c *Coordinator) AttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) {
+ if c == nil || instanceID == "" || client == nil {
+ return
+ }
+
+ c.mu.Lock()
+ c.incomingEnabled[instanceID] = prepareIncoming
+ c.mu.Unlock()
+
+ runtime := c.runtimes.Attach(instanceID, client)
+ c.configureRuntime(runtime)
+ c.incoming.Attach(instanceID, client, prepareIncoming)
+ c.packets.Attach(instanceID, client)
+ c.relays.Attach(instanceID, client)
+}
+
+// DetachClient removes handlers, relay connections, codec sessions, packet
+// contexts, browser media, configuration and private call keys before the
+// client is discarded.
+func (c *Coordinator) DetachClient(instanceID string) {
+ if c == nil || instanceID == "" {
+ return
+ }
+ c.mu.Lock()
+ delete(c.incomingEnabled, instanceID)
+ c.mu.Unlock()
+ c.relays.Close(instanceID)
+ c.audio.Close(instanceID)
+ c.packets.Close(instanceID)
+ c.clearInstanceMediaErrors(instanceID)
+ c.notifyInstanceMediaCleanup(instanceID)
+ c.runtimes.Remove(instanceID)
+ c.incoming.Close(instanceID)
+}
+
+// Attach keeps call-service operations idempotent without overriding the
+// automatic-rejection policy configured by AttachClient.
+func (c *Coordinator) Attach(instanceID string, client *whatsmeow.Client) {
+ if c == nil || instanceID == "" || client == nil {
+ return
+ }
+ runtime := c.runtimes.Attach(instanceID, client)
+ c.configureRuntime(runtime)
+
+ c.mu.RLock()
+ prepareIncoming, configured := c.incomingEnabled[instanceID]
+ c.mu.RUnlock()
+ if !configured {
+ prepareIncoming = true
+ }
+ c.incoming.Attach(instanceID, client, prepareIncoming)
+ c.packets.Attach(instanceID, client)
+ c.relays.Attach(instanceID, client)
+}
+
+func (c *Coordinator) Detach(instanceID string) {
+ c.DetachClient(instanceID)
+}
+
+func (c *Coordinator) Runtime(instanceID string) (*call_runtime.Runtime, bool) {
+ if c == nil {
+ return nil, false
+ }
+ return c.runtimes.Get(instanceID)
+}
+
+func (c *Coordinator) RuntimeFor(instanceID string, client *whatsmeow.Client) *call_runtime.Runtime {
+ if c == nil || instanceID == "" {
+ return nil
+ }
+ c.Attach(instanceID, client)
+ runtime, _ := c.runtimes.Get(instanceID)
+ return runtime
+}
+
+func (c *Coordinator) StoreOutgoing(instanceID, callID string, callKey []byte, peer, creator types.JID, video bool, relayData *core.RelayData) error {
+ if c == nil {
+ return nil
+ }
+ return c.incoming.StoreOutgoing(instanceID, callID, callKey, peer, creator, video, relayData)
+}
+
+// RelayData returns a defensive copy. The caller owns the copy and must call
+// core.ZeroRelayData after use.
+func (c *Coordinator) RelayData(instanceID, callID string) (*core.RelayData, bool) {
+ if c == nil {
+ return nil, false
+ }
+ return c.incoming.RelayData(instanceID, callID)
+}
+
+func (c *Coordinator) AcceptIncoming(ctx context.Context, instanceID, callID string) error {
+ if err := c.incoming.Accept(ctx, instanceID, callID); err != nil {
+ return err
+ }
+ go func() {
+ if err := c.relays.Start(instanceID, callID); err != nil {
+ c.reportMediaError(instanceID, callID, "start incoming relay", err)
+ }
+ }()
+ return nil
+}
+
+func (c *Coordinator) TerminateIncoming(ctx context.Context, instanceID, callID string) error {
+ if err := c.incoming.Terminate(ctx, instanceID, callID); err != nil {
+ return err
+ }
+ c.relays.Remove(instanceID, callID)
+ c.audio.Remove(instanceID, callID)
+ c.packets.Remove(instanceID, callID)
+ c.clearMediaError(instanceID, callID)
+ c.notifyCallMediaCleanup(instanceID, callID)
+ return nil
+}
+
+// FeedPCM accepts mono float PCM at 16 kHz. Samples may arrive in arbitrary
+// chunk sizes; the audio registry accumulates complete 960-sample MLow frames.
+func (c *Coordinator) FeedPCM(instanceID, callID string, pcm []float32) error {
+ if c == nil {
+ return call_media.ErrAudioSessionNotReady
+ }
+ return c.audio.FeedPCM(instanceID, callID, pcm)
+}
+
+// SetOnPCM registers an optional external decoded-audio observer. Browser
+// media keeps a separate internal sink so neither callback replaces the other.
+func (c *Coordinator) SetOnPCM(callback func(instanceID, callID string, pcm []float32)) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ c.onPCM = callback
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) SetBrowserPCM(callback func(instanceID, callID string, pcm []float32)) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ c.browserPCM = callback
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) SetMediaCleanupHooks(onCall func(instanceID, callID string), onInstance func(instanceID string)) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ c.onCallMediaCleanup = onCall
+ c.onInstanceMediaCleanup = onInstance
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) dispatchPCM(instanceID, callID string, pcm []float32) {
+ c.mu.RLock()
+ browserCallback := c.browserPCM
+ externalCallback := c.onPCM
+ c.mu.RUnlock()
+ if browserCallback != nil {
+ browserCallback(instanceID, callID, append([]float32(nil), pcm...))
+ }
+ if externalCallback != nil {
+ externalCallback(instanceID, callID, append([]float32(nil), pcm...))
+ }
+}
+
+func (c *Coordinator) notifyCallMediaCleanup(instanceID, callID string) {
+ c.mu.RLock()
+ callback := c.onCallMediaCleanup
+ c.mu.RUnlock()
+ if callback != nil {
+ callback(instanceID, callID)
+ }
+}
+
+func (c *Coordinator) notifyInstanceMediaCleanup(instanceID string) {
+ c.mu.RLock()
+ callback := c.onInstanceMediaCleanup
+ c.mu.RUnlock()
+ if callback != nil {
+ callback(instanceID)
+ }
+}
+
+// SendOpus protects one encoded MLow/Opus-compatible frame as SRTP and
+// broadcasts it through the currently connected WhatsApp relays.
+func (c *Coordinator) SendOpus(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error {
+ if c == nil {
+ return call_media.ErrPacketSessionNotReady
+ }
+ protected, err := c.packets.ProtectOpus(instanceID, callID, payload, durationSamples, marker)
+ if err != nil {
+ return err
+ }
+ defer wipe(protected)
+ return c.relays.Broadcast(instanceID, callID, protected)
+}
+
+// SetOnRTP keeps the low-level authenticated RTP observation hook while the
+// internal decoder remains permanently connected.
+func (c *Coordinator) SetOnRTP(callback func(instanceID, callID string, packet *call_media.RTPPacket)) {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ c.onRTP = callback
+ c.mu.Unlock()
+}
+
+func (c *Coordinator) RemovePrivate(instanceID, callID string) {
+ c.relays.Remove(instanceID, callID)
+ c.audio.Remove(instanceID, callID)
+ c.packets.Remove(instanceID, callID)
+ c.incoming.Remove(instanceID, callID)
+ c.clearMediaError(instanceID, callID)
+ c.notifyCallMediaCleanup(instanceID, callID)
+}
+
+// RemoveIncoming is kept as a compatibility alias while call-service code is
+// migrated to direction-neutral private negotiation storage.
+func (c *Coordinator) RemoveIncoming(instanceID, callID string) {
+ c.RemovePrivate(instanceID, callID)
+}
+
+func wipe(value []byte) {
+ for index := range value {
+ value[index] = 0
+ }
+}
diff --git a/pkg/call/runtime/runtime.go b/pkg/call/runtime/runtime.go
new file mode 100644
index 00000000..c65338c8
--- /dev/null
+++ b/pkg/call/runtime/runtime.go
@@ -0,0 +1,490 @@
+package call_runtime
+
+import (
+ "context"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ call_wa "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+// State represents the lifecycle state of a WhatsApp call.
+type State string
+
+const (
+ StateIdle State = "idle"
+ StateRinging State = "ringing"
+ StateConnecting State = "connecting"
+ StateActive State = "active"
+ StateEnded State = "ended"
+ StateFailed State = "failed"
+)
+
+// Direction identifies whether a call was created locally or received from a peer.
+type Direction string
+
+const (
+ DirectionIncoming Direction = "incoming"
+ DirectionOutgoing Direction = "outgoing"
+)
+
+// Call contains transport-independent call state.
+type Call struct {
+ ID string `json:"id"`
+ Peer string `json:"peer"`
+ Direction Direction `json:"direction"`
+ State State `json:"state"`
+ Video bool `json:"video"`
+ EndReason string `json:"endReason,omitempty"`
+ Error string `json:"error,omitempty"`
+ CreatedAt time.Time `json:"createdAt"`
+ UpdatedAt time.Time `json:"updatedAt"`
+}
+
+// Snapshot is a safe, serializable view of one instance runtime.
+type Snapshot struct {
+ InstanceID string `json:"instanceId"`
+ Connected bool `json:"connected"`
+ Calls []Call `json:"calls"`
+}
+
+// Runtime owns the VoIP state associated with exactly one Evolution instance.
+type Runtime struct {
+ mu sync.RWMutex
+ instanceID string
+ client *whatsmeow.Client
+ eventHandlerID uint32
+ calls map[string]Call
+}
+
+func New(instanceID string, client *whatsmeow.Client) *Runtime {
+ runtime := &Runtime{
+ instanceID: instanceID,
+ calls: make(map[string]Call),
+ }
+ runtime.AttachClient(client)
+ return runtime
+}
+
+func (r *Runtime) InstanceID() string {
+ return r.instanceID
+}
+
+// AttachClient replaces the client after an Evolution instance reconnects and
+// registers an isolated call event handler on the same authenticated session.
+func (r *Runtime) AttachClient(client *whatsmeow.Client) {
+ r.mu.Lock()
+ if r.client == client && (client == nil || r.eventHandlerID != 0) {
+ r.mu.Unlock()
+ return
+ }
+
+ previousClient := r.client
+ previousHandlerID := r.eventHandlerID
+ r.client = client
+ r.eventHandlerID = 0
+ r.mu.Unlock()
+
+ if previousClient != nil && previousHandlerID != 0 {
+ previousClient.RemoveEventHandler(previousHandlerID)
+ }
+ if client == nil {
+ return
+ }
+
+ handlerID := client.AddEventHandler(r.handleEvent)
+ r.mu.Lock()
+ if r.client == client {
+ r.eventHandlerID = handlerID
+ r.mu.Unlock()
+ return
+ }
+ r.mu.Unlock()
+ client.RemoveEventHandler(handlerID)
+}
+
+func (r *Runtime) Client() *whatsmeow.Client {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.client
+}
+
+func (r *Runtime) Close() {
+ r.mu.Lock()
+ client := r.client
+ handlerID := r.eventHandlerID
+ r.client = nil
+ r.eventHandlerID = 0
+ r.mu.Unlock()
+
+ if client != nil && handlerID != 0 {
+ client.RemoveEventHandler(handlerID)
+ }
+ r.closeWatchdogs()
+}
+
+// UpsertCall creates or updates a call while preserving its creation time.
+func (r *Runtime) UpsertCall(call Call) {
+ r.mu.Lock()
+ now := time.Now().UTC()
+ if current, ok := r.calls[call.ID]; ok {
+ if call.CreatedAt.IsZero() {
+ call.CreatedAt = current.CreatedAt
+ }
+ if call.Peer == "" {
+ call.Peer = current.Peer
+ }
+ } else if call.CreatedAt.IsZero() {
+ call.CreatedAt = now
+ }
+
+ if call.UpdatedAt.IsZero() {
+ call.UpdatedAt = now
+ }
+ r.calls[call.ID] = call
+ r.mu.Unlock()
+ r.syncWatchdog(call)
+}
+
+// Transition applies a partial lifecycle update without erasing metadata that
+// was captured by an earlier call event.
+func (r *Runtime) Transition(callID, peer string, direction Direction, state State, video *bool, endReason string) {
+ if callID == "" {
+ return
+ }
+
+ r.mu.Lock()
+ now := time.Now().UTC()
+ call, exists := r.calls[callID]
+ if !exists {
+ call = Call{
+ ID: callID,
+ CreatedAt: now,
+ }
+ }
+ if shouldReplacePeer(call.Peer, peer) {
+ call.Peer = peer
+ }
+ if direction != "" && call.Direction == "" {
+ call.Direction = direction
+ }
+ if state != "" {
+ call.State = state
+ }
+ if video != nil {
+ call.Video = *video
+ }
+ if endReason != "" {
+ call.EndReason = endReason
+ }
+ call.UpdatedAt = now
+ r.calls[callID] = call
+ r.mu.Unlock()
+ r.syncWatchdog(call)
+}
+
+func shouldReplacePeer(current, candidate string) bool {
+ if candidate == "" {
+ return false
+ }
+ if current == "" {
+ return true
+ }
+ currentJID, currentErr := types.ParseJID(current)
+ candidateJID, candidateErr := types.ParseJID(candidate)
+ return currentErr == nil && candidateErr == nil &&
+ currentJID.Server == types.HiddenUserServer && candidateJID.Server == types.DefaultUserServer
+}
+
+func (r *Runtime) Call(callID string) (Call, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ call, ok := r.calls[callID]
+ return call, ok
+}
+
+func (r *Runtime) RemoveCall(callID string) {
+ r.mu.Lock()
+ delete(r.calls, callID)
+ r.mu.Unlock()
+ r.cancelWatchdog(callID)
+}
+
+func (r *Runtime) Snapshot() Snapshot {
+ r.mu.RLock()
+ calls := make([]Call, 0, len(r.calls))
+ for _, call := range r.calls {
+ calls = append(calls, call)
+ }
+ client := r.client
+ instanceID := r.instanceID
+ connected := client != nil && client.IsConnected()
+ r.mu.RUnlock()
+
+ for index := range calls {
+ calls[index].Peer = resolveDisplayPeer(client, calls[index].Peer)
+ }
+ sort.Slice(calls, func(i, j int) bool {
+ return calls[i].CreatedAt.Before(calls[j].CreatedAt)
+ })
+
+ return Snapshot{
+ InstanceID: instanceID,
+ Connected: connected,
+ Calls: calls,
+ }
+}
+
+func (r *Runtime) handleEvent(rawEvent interface{}) {
+ switch event := rawEvent.(type) {
+ case *events.CallOffer:
+ video := callNodeContainsVideo(event.Data)
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.CallCreator, event.From),
+ DirectionIncoming,
+ StateRinging,
+ &video,
+ "",
+ )
+ case *events.CallOfferNotice:
+ video := strings.EqualFold(event.Media, "video") || callNodeContainsVideo(event.Data)
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.CallCreator, event.From),
+ DirectionIncoming,
+ StateRinging,
+ &video,
+ "",
+ )
+ case *events.CallPreAccept:
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.From, event.CallCreator),
+ DirectionOutgoing,
+ StateConnecting,
+ nil,
+ "",
+ )
+ case *events.CallAccept:
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.From, event.CallCreator),
+ DirectionOutgoing,
+ StateConnecting,
+ nil,
+ "",
+ )
+ case *events.CallTransport:
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.From, event.CallCreator),
+ "",
+ StateConnecting,
+ nil,
+ "",
+ )
+ case *events.CallReject:
+ call, exists := r.Call(event.CallID)
+ reason := "rejected"
+ direction := DirectionOutgoing
+ if exists {
+ direction = call.Direction
+ if call.Direction == DirectionIncoming {
+ if call.State == StateRinging {
+ reason = "caller_cancelled"
+ } else {
+ reason = "peer_ended"
+ }
+ } else if call.State != StateRinging {
+ reason = "peer_ended"
+ }
+ }
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.CallCreator, event.From),
+ direction,
+ StateEnded,
+ nil,
+ reason,
+ )
+ case *events.CallTerminate:
+ r.Transition(
+ event.CallID,
+ r.eventPeer(event.CallCreator, event.From),
+ "",
+ StateEnded,
+ nil,
+ event.Reason,
+ )
+ case *events.Disconnected:
+ r.failOpenCalls("whatsapp client disconnected")
+ case *events.LoggedOut:
+ r.failOpenCalls("whatsapp client logged out")
+ }
+}
+
+func (r *Runtime) eventPeer(primary, secondary types.JID) string {
+ r.mu.RLock()
+ client := r.client
+ r.mu.RUnlock()
+
+ candidates := []types.JID{primary, secondary}
+ for _, candidate := range candidates {
+ if candidate.IsEmpty() || isOwnJID(client, candidate) {
+ continue
+ }
+ resolved := resolveDisplayJID(client, candidate)
+ if resolved.Server == types.DefaultUserServer {
+ return resolved.String()
+ }
+ }
+ for _, candidate := range candidates {
+ if !candidate.IsEmpty() && !isOwnJID(client, candidate) {
+ return candidate.ToNonAD().String()
+ }
+ }
+ return ""
+}
+
+func resolveDisplayPeer(client *whatsmeow.Client, peer string) string {
+ jid, err := types.ParseJID(peer)
+ if err != nil || jid.IsEmpty() {
+ return peer
+ }
+ return resolveDisplayJID(client, jid).String()
+}
+
+func resolveDisplayJID(client *whatsmeow.Client, jid types.JID) types.JID {
+ jid = jid.ToNonAD()
+ if client == nil || jid.Server != types.HiddenUserServer {
+ return jid
+ }
+ return call_wa.NewSocket(client).ResolvePNForLID(context.Background(), jid).ToNonAD()
+}
+
+func isOwnJID(client *whatsmeow.Client, jid types.JID) bool {
+ if client == nil || client.Store == nil || jid.IsEmpty() {
+ return false
+ }
+ jid = jid.ToNonAD()
+ if client.Store.ID != nil {
+ ownPN := client.Store.ID.ToNonAD()
+ if jid.User == ownPN.User && jid.Server == ownPN.Server {
+ return true
+ }
+ }
+ ownLID := client.Store.LID.ToNonAD()
+ return !ownLID.IsEmpty() && jid.User == ownLID.User && jid.Server == ownLID.Server
+}
+
+func (r *Runtime) failOpenCalls(reason string) {
+ r.mu.Lock()
+ now := time.Now().UTC()
+ failedCallIDs := make([]string, 0)
+ for callID, call := range r.calls {
+ if call.State == StateEnded || call.State == StateFailed {
+ continue
+ }
+ call.State = StateFailed
+ call.Error = reason
+ call.UpdatedAt = now
+ r.calls[callID] = call
+ failedCallIDs = append(failedCallIDs, callID)
+ }
+ r.mu.Unlock()
+ for _, callID := range failedCallIDs {
+ r.cancelWatchdog(callID)
+ }
+}
+
+func callNodeContainsVideo(node *waBinary.Node) bool {
+ if node == nil {
+ return false
+ }
+ if strings.EqualFold(node.Tag, "video") {
+ return true
+ }
+ for key, value := range node.Attrs {
+ keyLower := strings.ToLower(key)
+ valueString := strings.ToLower(strings.TrimSpace(valueToString(value)))
+ if (keyLower == "media" || keyLower == "type") && valueString == "video" {
+ return true
+ }
+ }
+
+ switch content := node.Content.(type) {
+ case []waBinary.Node:
+ for index := range content {
+ if callNodeContainsVideo(&content[index]) {
+ return true
+ }
+ }
+ case *waBinary.Node:
+ return callNodeContainsVideo(content)
+ }
+ return false
+}
+
+func valueToString(value interface{}) string {
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case []byte:
+ return string(typed)
+ default:
+ return ""
+ }
+}
+
+// Registry stores one Runtime per Evolution instance.
+type Registry struct {
+ mu sync.RWMutex
+ runtimes map[string]*Runtime
+}
+
+func NewRegistry() *Registry {
+ return &Registry{runtimes: make(map[string]*Runtime)}
+}
+
+// Attach returns the existing runtime or creates it. On reconnect it updates the
+// runtime to point at the newly-created whatsmeow client.
+func (r *Registry) Attach(instanceID string, client *whatsmeow.Client) *Runtime {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+
+ if runtime, ok := r.runtimes[instanceID]; ok {
+ runtime.AttachClient(client)
+ return runtime
+ }
+
+ runtime := New(instanceID, client)
+ r.runtimes[instanceID] = runtime
+ return runtime
+}
+
+func (r *Registry) Get(instanceID string) (*Runtime, bool) {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ runtime, ok := r.runtimes[instanceID]
+ return runtime, ok
+}
+
+func (r *Registry) Remove(instanceID string) {
+ r.mu.Lock()
+ runtime, ok := r.runtimes[instanceID]
+ if ok {
+ delete(r.runtimes, instanceID)
+ }
+ r.mu.Unlock()
+
+ if ok {
+ runtime.Close()
+ }
+}
diff --git a/pkg/call/runtime/runtime_peer_test.go b/pkg/call/runtime/runtime_peer_test.go
new file mode 100644
index 00000000..3a03cb91
--- /dev/null
+++ b/pkg/call/runtime/runtime_peer_test.go
@@ -0,0 +1,52 @@
+package call_runtime
+
+import (
+ "testing"
+
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+func TestTransitionPreservesKnownPhoneNumber(t *testing.T) {
+ runtime := New("instance", nil)
+ runtime.Transition(
+ "call",
+ "556298612492@s.whatsapp.net",
+ DirectionOutgoing,
+ StateRinging,
+ nil,
+ "",
+ )
+ runtime.Transition(
+ "call",
+ "75741748277476:3@lid",
+ DirectionOutgoing,
+ StateConnecting,
+ nil,
+ "",
+ )
+ call, _ := runtime.Call("call")
+ if call.Peer != "556298612492@s.whatsapp.net" {
+ t.Fatalf("phone number was replaced by LID: %s", call.Peer)
+ }
+}
+
+func TestIncomingCallRejectAfterAcceptMeansPeerEnded(t *testing.T) {
+ runtime := New("instance", nil)
+ runtime.Transition(
+ "call",
+ "556298612492@s.whatsapp.net",
+ DirectionIncoming,
+ StateConnecting,
+ nil,
+ "",
+ )
+ runtime.handleEvent(&events.CallReject{BasicCallMeta: types.BasicCallMeta{
+ CallID: "call",
+ CallCreator: types.NewJID("66155398054068", types.HiddenUserServer),
+ }})
+ call, _ := runtime.Call("call")
+ if call.State != StateEnded || call.EndReason != "peer_ended" {
+ t.Fatalf("unexpected reject classification: state=%s reason=%s", call.State, call.EndReason)
+ }
+}
diff --git a/pkg/call/runtime/runtime_test.go b/pkg/call/runtime/runtime_test.go
new file mode 100644
index 00000000..28d5bb49
--- /dev/null
+++ b/pkg/call/runtime/runtime_test.go
@@ -0,0 +1,209 @@
+package call_runtime
+
+import (
+ "testing"
+ "time"
+
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+func TestRegistryAttachReusesRuntime(t *testing.T) {
+ registry := NewRegistry()
+
+ first := registry.Attach("instance-1", nil)
+ second := registry.Attach("instance-1", nil)
+
+ if first != second {
+ t.Fatal("expected registry to reuse the runtime for the same instance")
+ }
+ if first.InstanceID() != "instance-1" {
+ t.Fatalf("unexpected instance id: %s", first.InstanceID())
+ }
+}
+
+func TestRuntimeUpsertPreservesCreatedAt(t *testing.T) {
+ runtime := New("instance-1", nil)
+ createdAt := time.Date(2026, time.July, 31, 12, 0, 0, 0, time.UTC)
+
+ runtime.UpsertCall(Call{
+ ID: "call-1",
+ Peer: "5511999999999@s.whatsapp.net",
+ Direction: DirectionOutgoing,
+ State: StateRinging,
+ CreatedAt: createdAt,
+ })
+ runtime.UpsertCall(Call{
+ ID: "call-1",
+ Peer: "5511999999999@s.whatsapp.net",
+ Direction: DirectionOutgoing,
+ State: StateActive,
+ })
+
+ call, ok := runtime.Call("call-1")
+ if !ok {
+ t.Fatal("expected call to exist")
+ }
+ if !call.CreatedAt.Equal(createdAt) {
+ t.Fatalf("createdAt changed: got %s want %s", call.CreatedAt, createdAt)
+ }
+ if call.State != StateActive {
+ t.Fatalf("unexpected state: %s", call.State)
+ }
+ if call.UpdatedAt.IsZero() {
+ t.Fatal("expected updatedAt to be populated")
+ }
+}
+
+func TestRuntimeTransitionPreservesCapturedMetadata(t *testing.T) {
+ runtime := New("instance-1", nil)
+ video := true
+
+ runtime.Transition(
+ "call-1",
+ "5511999999999@s.whatsapp.net",
+ DirectionIncoming,
+ StateRinging,
+ &video,
+ "",
+ )
+ runtime.Transition("call-1", "", DirectionOutgoing, StateActive, nil, "")
+
+ call, ok := runtime.Call("call-1")
+ if !ok {
+ t.Fatal("expected call to exist")
+ }
+ if call.Peer != "5511999999999@s.whatsapp.net" {
+ t.Fatalf("peer was erased: %s", call.Peer)
+ }
+ if call.Direction != DirectionIncoming {
+ t.Fatalf("direction was overwritten: %s", call.Direction)
+ }
+ if !call.Video {
+ t.Fatal("video metadata was erased")
+ }
+ if call.State != StateActive {
+ t.Fatalf("unexpected state: %s", call.State)
+ }
+}
+
+func TestRuntimeTracksWhatsmeowCallLifecycle(t *testing.T) {
+ runtime := New("instance-1", nil)
+ creator := types.NewJID("5511999999999", types.DefaultUserServer)
+
+ runtime.handleEvent(&events.CallOffer{
+ BasicCallMeta: types.BasicCallMeta{
+ From: creator,
+ CallCreator: creator,
+ CallID: "call-1",
+ },
+ Data: &waBinary.Node{
+ Tag: "offer",
+ Content: []waBinary.Node{
+ {Tag: "video"},
+ },
+ },
+ })
+
+ call, ok := runtime.Call("call-1")
+ if !ok {
+ t.Fatal("expected call offer to create a runtime call")
+ }
+ if call.State != StateRinging || call.Direction != DirectionIncoming {
+ t.Fatalf("unexpected offered call: %+v", call)
+ }
+ if !call.Video {
+ t.Fatal("expected video call metadata")
+ }
+
+ runtime.handleEvent(&events.CallAccept{
+ BasicCallMeta: types.BasicCallMeta{
+ From: creator,
+ CallCreator: creator,
+ CallID: "call-1",
+ },
+ })
+
+ call, _ = runtime.Call("call-1")
+ if call.State != StateConnecting {
+ t.Fatalf("expected connecting state before media, got %s", call.State)
+ }
+ if call.Direction != DirectionIncoming {
+ t.Fatalf("incoming direction must be preserved, got %s", call.Direction)
+ }
+
+ runtime.handleEvent(&events.CallTerminate{
+ BasicCallMeta: types.BasicCallMeta{
+ From: creator,
+ CallCreator: creator,
+ CallID: "call-1",
+ },
+ Reason: "peer_hangup",
+ })
+
+ call, _ = runtime.Call("call-1")
+ if call.State != StateEnded {
+ t.Fatalf("expected ended state, got %s", call.State)
+ }
+ if call.EndReason != "peer_hangup" {
+ t.Fatalf("unexpected end reason: %s", call.EndReason)
+ }
+}
+
+func TestRuntimeMarksOpenCallsFailedOnDisconnect(t *testing.T) {
+ runtime := New("instance-1", nil)
+ runtime.Transition("call-1", "peer", DirectionOutgoing, StateConnecting, nil, "")
+ runtime.Transition("call-2", "peer", DirectionIncoming, StateEnded, nil, "completed")
+
+ runtime.handleEvent(&events.Disconnected{})
+
+ openCall, _ := runtime.Call("call-1")
+ if openCall.State != StateFailed {
+ t.Fatalf("expected open call to fail, got %s", openCall.State)
+ }
+ if openCall.Error == "" {
+ t.Fatal("expected disconnect error")
+ }
+
+ endedCall, _ := runtime.Call("call-2")
+ if endedCall.State != StateEnded {
+ t.Fatalf("ended call must not change, got %s", endedCall.State)
+ }
+}
+
+func TestRuntimeSnapshotIsSortedAndIndependent(t *testing.T) {
+ runtime := New("instance-1", nil)
+ later := time.Date(2026, time.July, 31, 13, 0, 0, 0, time.UTC)
+ earlier := later.Add(-time.Hour)
+
+ runtime.UpsertCall(Call{ID: "later", CreatedAt: later, State: StateRinging})
+ runtime.UpsertCall(Call{ID: "earlier", CreatedAt: earlier, State: StateEnded})
+
+ snapshot := runtime.Snapshot()
+ if snapshot.Connected {
+ t.Fatal("nil client must be reported as disconnected")
+ }
+ if len(snapshot.Calls) != 2 {
+ t.Fatalf("unexpected call count: %d", len(snapshot.Calls))
+ }
+ if snapshot.Calls[0].ID != "earlier" || snapshot.Calls[1].ID != "later" {
+ t.Fatalf("calls are not sorted by creation time: %+v", snapshot.Calls)
+ }
+
+ snapshot.Calls[0].State = StateFailed
+ stored, _ := runtime.Call("earlier")
+ if stored.State != StateEnded {
+ t.Fatal("snapshot mutation changed runtime state")
+ }
+}
+
+func TestRegistryRemove(t *testing.T) {
+ registry := NewRegistry()
+ registry.Attach("instance-1", nil)
+ registry.Remove("instance-1")
+
+ if _, ok := registry.Get("instance-1"); ok {
+ t.Fatal("expected runtime to be removed")
+ }
+}
diff --git a/pkg/call/runtime/watchdog.go b/pkg/call/runtime/watchdog.go
new file mode 100644
index 00000000..f977f636
--- /dev/null
+++ b/pkg/call/runtime/watchdog.go
@@ -0,0 +1,173 @@
+package call_runtime
+
+import (
+ "sync"
+ "time"
+)
+
+var (
+ ringingWatchdogTimeout = 90 * time.Second
+ connectingWatchdogTimeout = 45 * time.Second
+)
+
+type runtimeWatchdogEntry struct {
+ timer *time.Timer
+ generation uint64
+ state State
+}
+
+type runtimeWatchdogState struct {
+ onTimeout func(instanceID, callID string)
+ entries map[string]runtimeWatchdogEntry
+ generation uint64
+}
+
+var runtimeWatchdogs = struct {
+ sync.Mutex
+ states map[*Runtime]*runtimeWatchdogState
+}{states: make(map[*Runtime]*runtimeWatchdogState)}
+
+// SetOnTimeout registers the cleanup callback invoked after a ringing or media
+// negotiation timeout. The runtime updates the public call to StateFailed
+// before invoking this callback.
+func (r *Runtime) SetOnTimeout(callback func(instanceID, callID string)) {
+ if r == nil {
+ return
+ }
+ runtimeWatchdogs.Lock()
+ state := runtimeWatchdogs.states[r]
+ if state == nil {
+ state = &runtimeWatchdogState{entries: make(map[string]runtimeWatchdogEntry)}
+ runtimeWatchdogs.states[r] = state
+ }
+ state.onTimeout = callback
+ runtimeWatchdogs.Unlock()
+}
+
+func (r *Runtime) syncWatchdog(call Call) {
+ if r == nil || call.ID == "" {
+ return
+ }
+
+ timeout, reason := watchdogTimeout(call.State)
+ if timeout <= 0 {
+ r.cancelWatchdog(call.ID)
+ return
+ }
+
+ runtimeWatchdogs.Lock()
+ state := runtimeWatchdogs.states[r]
+ if state == nil {
+ state = &runtimeWatchdogState{entries: make(map[string]runtimeWatchdogEntry)}
+ runtimeWatchdogs.states[r] = state
+ }
+ if previous, ok := state.entries[call.ID]; ok {
+ // Duplicate CallAccept/CallTransport/CallOffer events must not extend the
+ // negotiation deadline indefinitely. Only a real state transition gets a
+ // new timer.
+ if previous.state == call.State {
+ runtimeWatchdogs.Unlock()
+ return
+ }
+ if previous.timer != nil {
+ previous.timer.Stop()
+ }
+ }
+ state.generation++
+ generation := state.generation
+ entry := runtimeWatchdogEntry{
+ generation: generation,
+ state: call.State,
+ }
+ entry.timer = time.AfterFunc(timeout, func() {
+ r.expireWatchdog(call.ID, generation, call.State, reason)
+ })
+ state.entries[call.ID] = entry
+ runtimeWatchdogs.Unlock()
+}
+
+func watchdogTimeout(state State) (time.Duration, string) {
+ switch state {
+ case StateRinging:
+ return ringingWatchdogTimeout, "call ringing timed out"
+ case StateConnecting:
+ return connectingWatchdogTimeout, "call media negotiation timed out"
+ default:
+ return 0, ""
+ }
+}
+
+func (r *Runtime) expireWatchdog(callID string, generation uint64, expectedState State, reason string) {
+ if r == nil || callID == "" {
+ return
+ }
+
+ runtimeWatchdogs.Lock()
+ watchdogState := runtimeWatchdogs.states[r]
+ if watchdogState == nil {
+ runtimeWatchdogs.Unlock()
+ return
+ }
+ entry, ok := watchdogState.entries[callID]
+ if !ok || entry.generation != generation {
+ runtimeWatchdogs.Unlock()
+ return
+ }
+ delete(watchdogState.entries, callID)
+ callback := watchdogState.onTimeout
+ runtimeWatchdogs.Unlock()
+
+ r.mu.Lock()
+ call, ok := r.calls[callID]
+ if !ok || call.State != expectedState {
+ r.mu.Unlock()
+ return
+ }
+ call.State = StateFailed
+ call.Error = reason
+ call.EndReason = "timeout"
+ call.UpdatedAt = time.Now().UTC()
+ r.calls[callID] = call
+ instanceID := r.instanceID
+ r.mu.Unlock()
+
+ if callback != nil {
+ callback(instanceID, callID)
+ }
+}
+
+func (r *Runtime) cancelWatchdog(callID string) {
+ if r == nil || callID == "" {
+ return
+ }
+ runtimeWatchdogs.Lock()
+ state := runtimeWatchdogs.states[r]
+ if state != nil {
+ if entry, ok := state.entries[callID]; ok {
+ if entry.timer != nil {
+ entry.timer.Stop()
+ }
+ delete(state.entries, callID)
+ }
+ }
+ runtimeWatchdogs.Unlock()
+}
+
+func (r *Runtime) closeWatchdogs() {
+ if r == nil {
+ return
+ }
+ runtimeWatchdogs.Lock()
+ state := runtimeWatchdogs.states[r]
+ delete(runtimeWatchdogs.states, r)
+ if state != nil {
+ for callID, entry := range state.entries {
+ if entry.timer != nil {
+ entry.timer.Stop()
+ }
+ delete(state.entries, callID)
+ }
+ state.onTimeout = nil
+ }
+ runtimeWatchdogs.Unlock()
+}
diff --git a/pkg/call/runtime/watchdog_test.go b/pkg/call/runtime/watchdog_test.go
new file mode 100644
index 00000000..dab0f721
--- /dev/null
+++ b/pkg/call/runtime/watchdog_test.go
@@ -0,0 +1,155 @@
+package call_runtime
+
+import (
+ "testing"
+ "time"
+)
+
+func installWatchdogTimeouts(t *testing.T, ringing, connecting time.Duration) {
+ t.Helper()
+ previousRinging := ringingWatchdogTimeout
+ previousConnecting := connectingWatchdogTimeout
+ ringingWatchdogTimeout = ringing
+ connectingWatchdogTimeout = connecting
+ t.Cleanup(func() {
+ ringingWatchdogTimeout = previousRinging
+ connectingWatchdogTimeout = previousConnecting
+ })
+}
+
+func waitForTimeout(t *testing.T, timedOut <-chan string) string {
+ t.Helper()
+ select {
+ case callID := <-timedOut:
+ return callID
+ case <-time.After(2 * time.Second):
+ t.Fatal("watchdog callback did not run")
+ return ""
+ }
+}
+
+func TestRuntimeWatchdogFailsStuckRingingCall(t *testing.T) {
+ installWatchdogTimeouts(t, 20*time.Millisecond, time.Second)
+ runtime := New("watchdog-instance", nil)
+ defer runtime.Close()
+
+ timedOut := make(chan string, 1)
+ runtime.SetOnTimeout(func(instanceID, callID string) {
+ if instanceID != "watchdog-instance" {
+ t.Errorf("unexpected instance ID: %s", instanceID)
+ }
+ timedOut <- callID
+ })
+ runtime.Transition("ringing-call", "peer", DirectionOutgoing, StateRinging, nil, "")
+
+ if got := waitForTimeout(t, timedOut); got != "ringing-call" {
+ t.Fatalf("unexpected timed out call: %s", got)
+ }
+ call, ok := runtime.Call("ringing-call")
+ if !ok {
+ t.Fatal("timed out call was removed from public runtime")
+ }
+ if call.State != StateFailed || call.Error != "call ringing timed out" || call.EndReason != "timeout" {
+ t.Fatalf("unexpected timeout state: %+v", call)
+ }
+}
+
+func TestRuntimeWatchdogFailsStuckConnectingCall(t *testing.T) {
+ installWatchdogTimeouts(t, time.Second, 20*time.Millisecond)
+ runtime := New("watchdog-instance", nil)
+ defer runtime.Close()
+
+ timedOut := make(chan string, 1)
+ runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID })
+ runtime.Transition("connecting-call", "peer", DirectionIncoming, StateConnecting, nil, "")
+
+ if got := waitForTimeout(t, timedOut); got != "connecting-call" {
+ t.Fatalf("unexpected timed out call: %s", got)
+ }
+ call, _ := runtime.Call("connecting-call")
+ if call.State != StateFailed || call.Error != "call media negotiation timed out" {
+ t.Fatalf("unexpected connecting timeout state: %+v", call)
+ }
+}
+
+func TestRuntimeWatchdogIsCancelledWhenMediaBecomesActive(t *testing.T) {
+ installWatchdogTimeouts(t, time.Second, 30*time.Millisecond)
+ runtime := New("watchdog-instance", nil)
+ defer runtime.Close()
+
+ timedOut := make(chan string, 1)
+ runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID })
+ runtime.Transition("active-call", "peer", DirectionOutgoing, StateConnecting, nil, "")
+ runtime.Transition("active-call", "", "", StateActive, nil, "")
+
+ time.Sleep(90 * time.Millisecond)
+ select {
+ case callID := <-timedOut:
+ t.Fatalf("active call timed out: %s", callID)
+ default:
+ }
+ call, _ := runtime.Call("active-call")
+ if call.State != StateActive {
+ t.Fatalf("unexpected active call state: %+v", call)
+ }
+}
+
+func TestRuntimeWatchdogIgnoresReplacedTimer(t *testing.T) {
+ installWatchdogTimeouts(t, 30*time.Millisecond, 80*time.Millisecond)
+ runtime := New("watchdog-instance", nil)
+ defer runtime.Close()
+
+ timedOut := make(chan string, 2)
+ runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID })
+ runtime.Transition("progressing-call", "peer", DirectionOutgoing, StateRinging, nil, "")
+ time.Sleep(10 * time.Millisecond)
+ runtime.Transition("progressing-call", "", "", StateConnecting, nil, "")
+
+ // The old ringing timer must not fail the newer connecting state.
+ time.Sleep(45 * time.Millisecond)
+ select {
+ case callID := <-timedOut:
+ t.Fatalf("stale watchdog timer fired for %s", callID)
+ default:
+ }
+
+ if got := waitForTimeout(t, timedOut); got != "progressing-call" {
+ t.Fatalf("unexpected connecting timeout: %s", got)
+ }
+}
+
+func TestRuntimeWatchdogDuplicateStateKeepsOriginalDeadline(t *testing.T) {
+ installWatchdogTimeouts(t, time.Second, time.Second)
+ runtime := New("watchdog-instance", nil)
+ defer runtime.Close()
+
+ runtime.Transition("duplicate-call", "peer", DirectionOutgoing, StateConnecting, nil, "")
+ runtimeWatchdogs.Lock()
+ first := runtimeWatchdogs.states[runtime].entries["duplicate-call"]
+ runtimeWatchdogs.Unlock()
+
+ runtime.Transition("duplicate-call", "peer", DirectionOutgoing, StateConnecting, nil, "")
+ runtimeWatchdogs.Lock()
+ second := runtimeWatchdogs.states[runtime].entries["duplicate-call"]
+ runtimeWatchdogs.Unlock()
+
+ if first.generation != second.generation || first.timer != second.timer {
+ t.Fatalf("duplicate state replaced watchdog deadline: first=%d second=%d", first.generation, second.generation)
+ }
+}
+
+func TestRuntimeCloseCancelsWatchdogs(t *testing.T) {
+ installWatchdogTimeouts(t, 30*time.Millisecond, 30*time.Millisecond)
+ runtime := New("watchdog-instance", nil)
+ timedOut := make(chan string, 1)
+ runtime.SetOnTimeout(func(_, callID string) { timedOut <- callID })
+ runtime.Transition("closed-call", "peer", DirectionOutgoing, StateRinging, nil, "")
+ runtime.Close()
+
+ time.Sleep(90 * time.Millisecond)
+ select {
+ case callID := <-timedOut:
+ t.Fatalf("closed runtime watchdog fired for %s", callID)
+ default:
+ }
+}
diff --git a/pkg/call/service/call_service.go b/pkg/call/service/call_service.go
index 3c14a948..32f05ca0 100644
--- a/pkg/call/service/call_service.go
+++ b/pkg/call/service/call_service.go
@@ -3,93 +3,299 @@ package call_service
import (
"context"
"errors"
+ "fmt"
"time"
+ call_lifecycle "github.com/evolution-foundation/evolution-go/pkg/call/lifecycle"
+ call_runtime "github.com/evolution-foundation/evolution-go/pkg/call/runtime"
+ call_browser "github.com/evolution-foundation/evolution-go/pkg/call/voip/browser"
+ call_driver "github.com/evolution-foundation/evolution-go/pkg/call/voip/driver"
instance_model "github.com/evolution-foundation/evolution-go/pkg/instance/model"
logger_wrapper "github.com/evolution-foundation/evolution-go/pkg/logger"
+ "github.com/evolution-foundation/evolution-go/pkg/utils"
whatsmeow_service "github.com/evolution-foundation/evolution-go/pkg/whatsmeow/service"
"github.com/gomessguii/logger"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/types"
)
+const signalingTimeout = 30 * time.Second
+
+var ErrCallNotActive = errors.New("call media is not active")
+
type CallService interface {
+ StartCall(data *StartCallStruct, instance *instance_model.Instance) (call_runtime.Call, error)
+ AcceptCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error)
+ TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error)
RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error
+ RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error)
+ CreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error)
+ WebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error)
+ CloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error
}
type callService struct {
clientPointer map[string]*whatsmeow.Client
whatsmeowService whatsmeow_service.WhatsmeowService
loggerWrapper *logger_wrapper.LoggerManager
+ coordinator *call_lifecycle.Coordinator
+ browser call_browser.Manager
+}
+
+type StartCallStruct struct {
+ Number string `json:"number" binding:"required"`
+ Video bool `json:"video"`
}
type RejectCallStruct struct {
CallCreator types.JID `json:"callCreator"`
- CallID string `json:"callId"`
+ CallID string `json:"callId" binding:"required"`
}
-func (c *callService) ensureClientConnected(instanceId string) (*whatsmeow.Client, error) {
- client := c.clientPointer[instanceId]
- c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Checking client connection status - Client exists: %v", instanceId, client != nil)
+func (c *callService) ensureClientConnected(instanceID string) (*whatsmeow.Client, error) {
+ client := c.clientPointer[instanceID]
+ c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Checking client connection status - Client exists: %v", instanceID, client != nil)
if client == nil {
- c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] No client found, attempting to start new instance", instanceId)
- err := c.whatsmeowService.StartInstance(instanceId)
- if err != nil {
- c.loggerWrapper.GetLogger(instanceId).LogError("[%s] Failed to start instance: %v", instanceId, err)
+ c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] No client found, attempting to start new instance", instanceID)
+ if err := c.whatsmeowService.StartInstance(instanceID); err != nil {
+ c.loggerWrapper.GetLogger(instanceID).LogError("[%s] Failed to start instance: %v", instanceID, err)
return nil, errors.New("no active session found")
}
- c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Instance started, waiting 2 seconds...", instanceId)
+ c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Instance started, waiting 2 seconds...", instanceID)
time.Sleep(2 * time.Second)
- client = c.clientPointer[instanceId]
- c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Checking new client - Exists: %v, Connected: %v",
- instanceId,
+ client = c.clientPointer[instanceID]
+ c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Checking new client - Exists: %v, Connected: %v",
+ instanceID,
client != nil,
client != nil && client.IsConnected())
if client == nil || !client.IsConnected() {
- c.loggerWrapper.GetLogger(instanceId).LogError("[%s] New client validation failed - Exists: %v, Connected: %v",
- instanceId,
+ c.loggerWrapper.GetLogger(instanceID).LogError("[%s] New client validation failed - Exists: %v, Connected: %v",
+ instanceID,
client != nil,
client != nil && client.IsConnected())
return nil, errors.New("no active session found")
}
} else if !client.IsConnected() {
- c.loggerWrapper.GetLogger(instanceId).LogError("[%s] Existing client is disconnected - Connected status: %v",
- instanceId,
+ c.loggerWrapper.GetLogger(instanceID).LogError("[%s] Existing client is disconnected - Connected status: %v",
+ instanceID,
client.IsConnected())
return nil, errors.New("client disconnected")
}
- c.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Client successfully validated - Connected: %v", instanceId, client.IsConnected())
+ c.coordinator.Attach(instanceID, client)
+ c.loggerWrapper.GetLogger(instanceID).LogInfo("[%s] Client successfully validated - Connected: %v", instanceID, client.IsConnected())
return client, nil
}
+func (c *callService) StartCall(data *StartCallStruct, instance *instance_model.Instance) (call_runtime.Call, error) {
+ client, err := c.ensureClientConnected(instance.Id)
+ if err != nil {
+ return call_runtime.Call{}, err
+ }
+
+ peer, ok := utils.ParseJID(data.Number)
+ if !ok {
+ return call_runtime.Call{}, fmt.Errorf("invalid WhatsApp number: %s", data.Number)
+ }
+ peer = utils.CanonicalJID(peer)
+ if peer.Server != types.DefaultUserServer && peer.Server != types.HiddenUserServer {
+ return call_runtime.Call{}, fmt.Errorf("calls only support individual WhatsApp users")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout)
+ defer cancel()
+
+ result, err := call_driver.NewSignalingDriver(client).Start(ctx, peer, data.Video)
+ if err != nil {
+ return call_runtime.Call{}, err
+ }
+ defer result.Wipe()
+
+ if err := c.coordinator.StoreOutgoing(
+ instance.Id,
+ result.CallID,
+ result.CallKey,
+ result.Peer,
+ result.Creator,
+ data.Video,
+ result.RelayData,
+ ); err != nil {
+ return call_runtime.Call{}, err
+ }
+
+ runtime := c.coordinator.RuntimeFor(instance.Id, client)
+ video := data.Video
+ runtime.Transition(
+ result.CallID,
+ peer.ToNonAD().String(),
+ call_runtime.DirectionOutgoing,
+ call_runtime.StateRinging,
+ &video,
+ "",
+ )
+ call, _ := runtime.Call(result.CallID)
+ relayCount := 0
+ if result.RelayData != nil {
+ relayCount = len(result.RelayData.Endpoints)
+ }
+ c.loggerWrapper.GetLogger(instance.Id).LogInfo(
+ "[%s] Call offer sent - CallID: %s, Peer: %s, Video: %v, Relays: %d",
+ instance.Id,
+ result.CallID,
+ result.Peer.String(),
+ data.Video,
+ relayCount,
+ )
+ return call, nil
+}
+
+func (c *callService) AcceptCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) {
+ client, err := c.ensureClientConnected(instance.Id)
+ if err != nil {
+ return call_runtime.Call{}, err
+ }
+
+ runtime := c.coordinator.RuntimeFor(instance.Id, client)
+ call, ok := runtime.Call(callID)
+ if !ok {
+ return call_runtime.Call{}, fmt.Errorf("call %s not found", callID)
+ }
+ if call.Direction != call_runtime.DirectionIncoming {
+ return call_runtime.Call{}, fmt.Errorf("call %s is not incoming", callID)
+ }
+ if call.State == call_runtime.StateEnded || call.State == call_runtime.StateFailed {
+ return call_runtime.Call{}, fmt.Errorf("call %s cannot be accepted in state %s", callID, call.State)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout)
+ defer cancel()
+ if err := c.coordinator.AcceptIncoming(ctx, instance.Id, callID); err != nil {
+ return call_runtime.Call{}, err
+ }
+
+ runtime.Transition(callID, "", call_runtime.DirectionIncoming, call_runtime.StateConnecting, nil, "")
+ call, _ = runtime.Call(callID)
+ c.loggerWrapper.GetLogger(instance.Id).LogInfo("[%s] Incoming call accepted - CallID: %s", instance.Id, callID)
+ return call, nil
+}
+
+func (c *callService) TerminateCall(callID string, instance *instance_model.Instance) (call_runtime.Call, error) {
+ client, err := c.ensureClientConnected(instance.Id)
+ if err != nil {
+ return call_runtime.Call{}, err
+ }
+
+ runtime := c.coordinator.RuntimeFor(instance.Id, client)
+ call, ok := runtime.Call(callID)
+ if !ok {
+ return call_runtime.Call{}, fmt.Errorf("call %s not found", callID)
+ }
+ if call.State == call_runtime.StateEnded || call.State == call_runtime.StateFailed {
+ c.coordinator.RemovePrivate(instance.Id, callID)
+ return call, nil
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), signalingTimeout)
+ defer cancel()
+
+ if call.Direction == call_runtime.DirectionIncoming {
+ if err := c.coordinator.TerminateIncoming(ctx, instance.Id, callID); err != nil {
+ return call_runtime.Call{}, err
+ }
+ } else {
+ peer, parseErr := types.ParseJID(call.Peer)
+ if parseErr != nil || peer.IsEmpty() {
+ return call_runtime.Call{}, fmt.Errorf("invalid call peer: %s", call.Peer)
+ }
+ if err := call_driver.NewSignalingDriver(client).EndOutgoing(ctx, callID, peer); err != nil {
+ return call_runtime.Call{}, err
+ }
+ }
+
+ c.coordinator.RemovePrivate(instance.Id, callID)
+ runtime.Transition(callID, "", "", call_runtime.StateEnded, nil, "user_ended")
+ call, _ = runtime.Call(callID)
+ return call, nil
+}
+
func (c *callService) RejectCall(data *RejectCallStruct, instance *instance_model.Instance) error {
client, err := c.ensureClientConnected(instance.Id)
if err != nil {
return err
}
- err = client.RejectCall(context.Background(), data.CallCreator, data.CallID)
- if err != nil {
+ if err = client.RejectCall(context.Background(), data.CallCreator, data.CallID); err != nil {
logger.LogError("[%s] error reject call: %v", instance.Id, err)
return err
}
+ c.coordinator.RemovePrivate(instance.Id, data.CallID)
+ runtime := c.coordinator.RuntimeFor(instance.Id, client)
+ runtime.Transition(data.CallID, data.CallCreator.String(), call_runtime.DirectionIncoming, call_runtime.StateEnded, nil, "rejected")
return nil
}
+func (c *callService) RuntimeStatus(instance *instance_model.Instance) (call_runtime.Snapshot, error) {
+ client, err := c.ensureClientConnected(instance.Id)
+ if err != nil {
+ return call_runtime.Snapshot{InstanceID: instance.Id}, err
+ }
+
+ runtime := c.coordinator.RuntimeFor(instance.Id, client)
+ return runtime.Snapshot(), nil
+}
+
+func (c *callService) CreateWebRTC(ctx context.Context, callID string, request call_browser.CreateRequest, instance *instance_model.Instance) (call_browser.CreateResponse, error) {
+ client, err := c.ensureClientConnected(instance.Id)
+ if err != nil {
+ return call_browser.CreateResponse{}, err
+ }
+ runtime := c.coordinator.RuntimeFor(instance.Id, client)
+ call, ok := runtime.Call(callID)
+ if !ok {
+ return call_browser.CreateResponse{}, fmt.Errorf("call %s not found", callID)
+ }
+ if call.State != call_runtime.StateActive {
+ return call_browser.CreateResponse{}, fmt.Errorf("%w: call %s is %s", ErrCallNotActive, callID, call.State)
+ }
+ return c.browser.Create(ctx, instance.Id, callID, request)
+}
+
+func (c *callService) WebRTCSessions(callID string, instance *instance_model.Instance) ([]call_browser.SessionInfo, error) {
+ if callID == "" {
+ return nil, fmt.Errorf("callId is required")
+ }
+ return c.browser.Sessions(instance.Id, callID)
+}
+
+func (c *callService) CloseWebRTC(callID, sessionID string, instance *instance_model.Instance) error {
+ if callID == "" || sessionID == "" {
+ return call_browser.ErrSessionNotFound
+ }
+ return c.browser.CloseSession(instance.Id, callID, sessionID)
+}
+
func NewCallService(
clientPointer map[string]*whatsmeow.Client,
whatsmeowService whatsmeow_service.WhatsmeowService,
loggerWrapper *logger_wrapper.LoggerManager,
+ coordinator *call_lifecycle.Coordinator,
) CallService {
- return &callService{
+ if coordinator == nil {
+ coordinator = call_lifecycle.NewCoordinator()
+ }
+ service := &callService{
clientPointer: clientPointer,
whatsmeowService: whatsmeowService,
loggerWrapper: loggerWrapper,
+ coordinator: coordinator,
}
+ service.browser = call_browser.NewManager(coordinator.FeedPCM)
+ coordinator.SetBrowserPCM(service.browser.HandlePCM)
+ coordinator.SetMediaCleanupHooks(service.browser.CloseCall, service.browser.CloseInstance)
+ return service
}
diff --git a/pkg/call/voip/LICENSE-WACALLS b/pkg/call/voip/LICENSE-WACALLS
new file mode 100644
index 00000000..ae408422
--- /dev/null
+++ b/pkg/call/voip/LICENSE-WACALLS
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 jotadev66
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/pkg/call/voip/browser/frame.go b/pkg/call/voip/browser/frame.go
new file mode 100644
index 00000000..3b041fdf
--- /dev/null
+++ b/pkg/call/voip/browser/frame.go
@@ -0,0 +1,77 @@
+package browser
+
+import (
+ "encoding/binary"
+ "fmt"
+ "math"
+)
+
+const (
+ pcmHeaderSize = 16
+ pcmVersion = 1
+ pcmKind = 1
+ maxPCMSamples = PCMFrameSamples * 4
+)
+
+var pcmMagic = [4]byte{'E', 'V', 'P', 'C'}
+
+func EncodePCMFrame(pcm []float32) ([]byte, error) {
+ if len(pcm) == 0 || len(pcm) > maxPCMSamples {
+ return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, len(pcm))
+ }
+ output := make([]byte, pcmHeaderSize+len(pcm)*4)
+ copy(output[:4], pcmMagic[:])
+ output[4] = pcmVersion
+ output[5] = pcmKind
+ binary.LittleEndian.PutUint16(output[6:8], 0)
+ binary.LittleEndian.PutUint32(output[8:12], PCMSampleRate)
+ binary.LittleEndian.PutUint32(output[12:16], uint32(len(pcm)))
+ offset := pcmHeaderSize
+ for _, sample := range pcm {
+ binary.LittleEndian.PutUint32(output[offset:offset+4], math.Float32bits(sample))
+ offset += 4
+ }
+ return output, nil
+}
+
+func DecodePCMFrame(frame []byte) ([]float32, error) {
+ if len(frame) < pcmHeaderSize {
+ return nil, fmt.Errorf("%w: frame has %d bytes", ErrInvalidPCMMessage, len(frame))
+ }
+ if string(frame[:4]) != string(pcmMagic[:]) || frame[4] != pcmVersion || frame[5] != pcmKind {
+ return nil, fmt.Errorf("%w: unsupported framing", ErrInvalidPCMMessage)
+ }
+ if binary.LittleEndian.Uint16(frame[6:8]) != 0 {
+ return nil, fmt.Errorf("%w: unsupported flags", ErrInvalidPCMMessage)
+ }
+ if binary.LittleEndian.Uint32(frame[8:12]) != PCMSampleRate {
+ return nil, fmt.Errorf("%w: sample rate must be %d", ErrInvalidPCMMessage, PCMSampleRate)
+ }
+ sampleCount := int(binary.LittleEndian.Uint32(frame[12:16]))
+ if sampleCount <= 0 || sampleCount > maxPCMSamples {
+ return nil, fmt.Errorf("%w: sample count %d", ErrInvalidPCMMessage, sampleCount)
+ }
+ expected := pcmHeaderSize + sampleCount*4
+ if len(frame) != expected {
+ return nil, fmt.Errorf("%w: frame has %d bytes, want %d", ErrInvalidPCMMessage, len(frame), expected)
+ }
+ pcm := make([]float32, sampleCount)
+ offset := pcmHeaderSize
+ for index := range pcm {
+ pcm[index] = math.Float32frombits(binary.LittleEndian.Uint32(frame[offset : offset+4]))
+ offset += 4
+ }
+ return pcm, nil
+}
+
+func zeroPCM(values []float32) {
+ for index := range values {
+ values[index] = 0
+ }
+}
+
+func zeroFrame(value []byte) {
+ for index := range value {
+ value[index] = 0
+ }
+}
diff --git a/pkg/call/voip/browser/frame_test.go b/pkg/call/voip/browser/frame_test.go
new file mode 100644
index 00000000..03109d12
--- /dev/null
+++ b/pkg/call/voip/browser/frame_test.go
@@ -0,0 +1,54 @@
+package browser
+
+import (
+ "errors"
+ "math"
+ "testing"
+)
+
+func TestPCMFrameRoundTrip(t *testing.T) {
+ input := []float32{-1, -0.25, 0, 0.5, 1}
+ frame, err := EncodePCMFrame(input)
+ if err != nil {
+ t.Fatal(err)
+ }
+ output, err := DecodePCMFrame(frame)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(output) != len(input) {
+ t.Fatalf("decoded %d samples, want %d", len(output), len(input))
+ }
+ for index := range input {
+ if math.Float32bits(output[index]) != math.Float32bits(input[index]) {
+ t.Fatalf("sample %d=%v, want %v", index, output[index], input[index])
+ }
+ }
+}
+
+func TestPCMFrameRejectsMalformedInput(t *testing.T) {
+ frame, err := EncodePCMFrame(make([]float32, PCMFrameSamples))
+ if err != nil {
+ t.Fatal(err)
+ }
+ cases := [][]byte{
+ nil,
+ frame[:10],
+ append([]byte(nil), frame[:len(frame)-1]...),
+ append([]byte("BAD!"), frame[4:]...),
+ }
+ for _, value := range cases {
+ if _, err = DecodePCMFrame(value); !errors.Is(err, ErrInvalidPCMMessage) {
+ t.Fatalf("expected invalid PCM error, got %v", err)
+ }
+ }
+}
+
+func TestPCMFrameLimitsSamples(t *testing.T) {
+ if _, err := EncodePCMFrame(nil); !errors.Is(err, ErrInvalidPCMMessage) {
+ t.Fatalf("expected empty frame error, got %v", err)
+ }
+ if _, err := EncodePCMFrame(make([]float32, maxPCMSamples+1)); !errors.Is(err, ErrInvalidPCMMessage) {
+ t.Fatalf("expected oversized frame error, got %v", err)
+ }
+}
diff --git a/pkg/call/voip/browser/manager_default.go b/pkg/call/voip/browser/manager_default.go
new file mode 100644
index 00000000..e948e7ba
--- /dev/null
+++ b/pkg/call/voip/browser/manager_default.go
@@ -0,0 +1,27 @@
+//go:build !voip_pion
+
+package browser
+
+import "context"
+
+type disabledManager struct{}
+
+func NewManager(PCMFeeder) Manager {
+ return &disabledManager{}
+}
+
+func (*disabledManager) Create(context.Context, string, string, CreateRequest) (CreateResponse, error) {
+ return CreateResponse{}, ErrWebRTCDisabled
+}
+
+func (*disabledManager) Sessions(string, string) ([]SessionInfo, error) {
+ return nil, ErrWebRTCDisabled
+}
+
+func (*disabledManager) CloseSession(string, string, string) error {
+ return ErrWebRTCDisabled
+}
+
+func (*disabledManager) CloseCall(string, string) {}
+func (*disabledManager) CloseInstance(string) {}
+func (*disabledManager) HandlePCM(string, string, []float32) {}
diff --git a/pkg/call/voip/browser/manager_default_test.go b/pkg/call/voip/browser/manager_default_test.go
new file mode 100644
index 00000000..cb608757
--- /dev/null
+++ b/pkg/call/voip/browser/manager_default_test.go
@@ -0,0 +1,17 @@
+//go:build !voip_pion
+
+package browser
+
+import (
+ "context"
+ "errors"
+ "testing"
+)
+
+func TestDefaultManagerIsDisabled(t *testing.T) {
+ manager := NewManager(nil)
+ _, err := manager.Create(context.Background(), "instance", "call", CreateRequest{})
+ if !errors.Is(err, ErrWebRTCDisabled) {
+ t.Fatalf("expected disabled error, got %v", err)
+ }
+}
diff --git a/pkg/call/voip/browser/manager_pion.go b/pkg/call/voip/browser/manager_pion.go
new file mode 100644
index 00000000..0cd52fb7
--- /dev/null
+++ b/pkg/call/voip/browser/manager_pion.go
@@ -0,0 +1,483 @@
+//go:build voip_pion
+
+package browser
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/pion/webrtc/v4"
+)
+
+const (
+ maxSessionsPerCall = 4
+ maxOfferBytes = 256 * 1024
+ maxBufferedAmount = 512 * 1024
+ mediaQueueDepth = 8
+)
+
+type pionManager struct {
+ mu sync.RWMutex
+ feeder PCMFeeder
+ sessions map[string]map[string]map[string]*pionSession
+}
+
+type pionSession struct {
+ manager *pionManager
+ instanceID string
+ callID string
+ id string
+ createdAt time.Time
+ pc *webrtc.PeerConnection
+
+ mu sync.RWMutex
+ channel *webrtc.DataChannel
+ state SessionState
+ inputFrames uint64
+ outputFrames uint64
+ droppedFrames uint64
+
+ incoming chan []float32
+ outgoing chan []byte
+ stopCh chan struct{}
+ stopOnce sync.Once
+ wg sync.WaitGroup
+}
+
+func NewManager(feeder PCMFeeder) Manager {
+ return &pionManager{
+ feeder: feeder,
+ sessions: make(map[string]map[string]map[string]*pionSession),
+ }
+}
+
+func (m *pionManager) Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error) {
+ if m == nil || instanceID == "" || callID == "" {
+ return CreateResponse{}, ErrInvalidOffer
+ }
+ offerType := strings.ToLower(strings.TrimSpace(request.Offer.Type))
+ if offerType != "offer" || request.Offer.SDP == "" || len(request.Offer.SDP) > maxOfferBytes {
+ return CreateResponse{}, ErrInvalidOffer
+ }
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ m.mu.Lock()
+ calls := m.sessions[instanceID]
+ if calls == nil {
+ calls = make(map[string]map[string]*pionSession)
+ m.sessions[instanceID] = calls
+ }
+ callSessions := calls[callID]
+ if callSessions == nil {
+ callSessions = make(map[string]*pionSession)
+ calls[callID] = callSessions
+ }
+ if len(callSessions) >= maxSessionsPerCall {
+ m.mu.Unlock()
+ return CreateResponse{}, ErrSessionLimit
+ }
+ sessionID := uuid.NewString()
+ m.mu.Unlock()
+
+ pc, err := newBrowserPeerConnection()
+ if err != nil {
+ return CreateResponse{}, fmt.Errorf("create browser peer connection: %w", err)
+ }
+ session := &pionSession{
+ manager: m,
+ instanceID: instanceID,
+ callID: callID,
+ id: sessionID,
+ createdAt: time.Now().UTC(),
+ pc: pc,
+ state: SessionStateConnecting,
+ incoming: make(chan []float32, mediaQueueDepth),
+ outgoing: make(chan []byte, mediaQueueDepth),
+ stopCh: make(chan struct{}),
+ }
+ session.wg.Add(2)
+ go session.inputLoop()
+ go session.outputLoop()
+
+ m.mu.Lock()
+ calls = m.sessions[instanceID]
+ if calls == nil {
+ calls = make(map[string]map[string]*pionSession)
+ m.sessions[instanceID] = calls
+ }
+ callSessions = calls[callID]
+ if callSessions == nil {
+ callSessions = make(map[string]*pionSession)
+ calls[callID] = callSessions
+ }
+ if len(callSessions) >= maxSessionsPerCall {
+ m.mu.Unlock()
+ session.close()
+ return CreateResponse{}, ErrSessionLimit
+ }
+ callSessions[sessionID] = session
+ m.mu.Unlock()
+
+ fail := func(cause error) (CreateResponse, error) {
+ _ = m.CloseSession(instanceID, callID, sessionID)
+ return CreateResponse{}, cause
+ }
+
+ pc.OnDataChannel(func(channel *webrtc.DataChannel) {
+ session.attachDataChannel(channel)
+ })
+ pc.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
+ switch state {
+ case webrtc.PeerConnectionStateFailed:
+ session.setState(SessionStateFailed)
+ go func() { _ = m.CloseSession(instanceID, callID, sessionID) }()
+ case webrtc.PeerConnectionStateClosed:
+ go func() { _ = m.CloseSession(instanceID, callID, sessionID) }()
+ }
+ })
+
+ remote := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: request.Offer.SDP}
+ if err = pc.SetRemoteDescription(remote); err != nil {
+ return fail(fmt.Errorf("set browser remote description: %w", err))
+ }
+ answer, err := pc.CreateAnswer(nil)
+ if err != nil {
+ return fail(fmt.Errorf("create browser SDP answer: %w", err))
+ }
+ gatheringComplete := webrtc.GatheringCompletePromise(pc)
+ if err = pc.SetLocalDescription(answer); err != nil {
+ return fail(fmt.Errorf("set browser local description: %w", err))
+ }
+ select {
+ case <-gatheringComplete:
+ case <-ctx.Done():
+ return fail(fmt.Errorf("gather browser ICE candidates: %w", ctx.Err()))
+ }
+ local := pc.LocalDescription()
+ if local == nil || local.SDP == "" {
+ return fail(fmt.Errorf("create browser SDP answer: empty local description"))
+ }
+
+ return CreateResponse{
+ SessionID: sessionID,
+ Answer: SDPDescription{Type: "answer", SDP: local.SDP},
+ Audio: DefaultProtocolInfo(),
+ }, nil
+}
+
+func (m *pionManager) Sessions(instanceID, callID string) ([]SessionInfo, error) {
+ if m == nil {
+ return nil, ErrSessionNotFound
+ }
+ sessions := m.snapshot(instanceID, callID)
+ result := make([]SessionInfo, 0, len(sessions))
+ for _, session := range sessions {
+ result = append(result, session.info())
+ }
+ sort.Slice(result, func(i, j int) bool { return result[i].CreatedAt.Before(result[j].CreatedAt) })
+ return result, nil
+}
+
+func (m *pionManager) CloseSession(instanceID, callID, sessionID string) error {
+ if m == nil || sessionID == "" {
+ return ErrSessionNotFound
+ }
+ m.mu.Lock()
+ calls := m.sessions[instanceID]
+ callSessions := calls[callID]
+ session := callSessions[sessionID]
+ if session != nil {
+ delete(callSessions, sessionID)
+ if len(callSessions) == 0 {
+ delete(calls, callID)
+ }
+ if len(calls) == 0 {
+ delete(m.sessions, instanceID)
+ }
+ }
+ m.mu.Unlock()
+ if session == nil {
+ return ErrSessionNotFound
+ }
+ session.close()
+ return nil
+}
+
+func (m *pionManager) CloseCall(instanceID, callID string) {
+ for _, session := range m.takeCall(instanceID, callID) {
+ session.close()
+ }
+}
+
+func (m *pionManager) CloseInstance(instanceID string) {
+ if m == nil {
+ return
+ }
+ m.mu.Lock()
+ calls := m.sessions[instanceID]
+ delete(m.sessions, instanceID)
+ m.mu.Unlock()
+ for _, callSessions := range calls {
+ for _, session := range callSessions {
+ session.close()
+ }
+ }
+}
+
+func (m *pionManager) HandlePCM(instanceID, callID string, pcm []float32) {
+ if len(pcm) == 0 {
+ return
+ }
+ frame, err := EncodePCMFrame(pcm)
+ if err != nil {
+ return
+ }
+ defer zeroFrame(frame)
+ for _, session := range m.snapshot(instanceID, callID) {
+ session.enqueueOutgoing(append([]byte(nil), frame...))
+ }
+}
+
+func (m *pionManager) snapshot(instanceID, callID string) []*pionSession {
+ if m == nil {
+ return nil
+ }
+ m.mu.RLock()
+ callSessions := m.sessions[instanceID][callID]
+ result := make([]*pionSession, 0, len(callSessions))
+ for _, session := range callSessions {
+ result = append(result, session)
+ }
+ m.mu.RUnlock()
+ return result
+}
+
+func (m *pionManager) takeCall(instanceID, callID string) []*pionSession {
+ if m == nil {
+ return nil
+ }
+ m.mu.Lock()
+ calls := m.sessions[instanceID]
+ callSessions := calls[callID]
+ delete(calls, callID)
+ if len(calls) == 0 {
+ delete(m.sessions, instanceID)
+ }
+ result := make([]*pionSession, 0, len(callSessions))
+ for _, session := range callSessions {
+ result = append(result, session)
+ }
+ m.mu.Unlock()
+ return result
+}
+
+func (s *pionSession) attachDataChannel(channel *webrtc.DataChannel) {
+ if channel == nil || channel.Label() != DataChannelLabel {
+ if channel != nil {
+ _ = channel.Close()
+ }
+ s.incrementDropped()
+ go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }()
+ return
+ }
+ if protocol := channel.Protocol(); protocol != "" && protocol != DataChannelProtocol {
+ _ = channel.Close()
+ s.incrementDropped()
+ go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }()
+ return
+ }
+
+ s.mu.Lock()
+ if s.channel != nil || s.state == SessionStateClosed || s.state == SessionStateFailed {
+ s.mu.Unlock()
+ _ = channel.Close()
+ return
+ }
+ s.channel = channel
+ s.mu.Unlock()
+
+ channel.SetBufferedAmountLowThreshold(maxBufferedAmount / 2)
+ channel.OnOpen(func() { s.setState(SessionStateOpen) })
+ channel.OnClose(func() {
+ go func() { _ = s.manager.CloseSession(s.instanceID, s.callID, s.id) }()
+ })
+ channel.OnMessage(func(message webrtc.DataChannelMessage) {
+ if message.IsString {
+ s.incrementDropped()
+ return
+ }
+ pcm, err := DecodePCMFrame(message.Data)
+ if err != nil {
+ s.incrementDropped()
+ return
+ }
+ s.enqueueIncoming(pcm)
+ })
+}
+
+func (s *pionSession) enqueueIncoming(pcm []float32) {
+ select {
+ case <-s.stopCh:
+ zeroPCM(pcm)
+ case s.incoming <- pcm:
+ default:
+ zeroPCM(pcm)
+ s.incrementDropped()
+ }
+}
+
+func (s *pionSession) enqueueOutgoing(frame []byte) {
+ if !s.isOpen() {
+ zeroFrame(frame)
+ s.incrementDropped()
+ return
+ }
+ select {
+ case <-s.stopCh:
+ zeroFrame(frame)
+ case s.outgoing <- frame:
+ default:
+ zeroFrame(frame)
+ s.incrementDropped()
+ }
+}
+
+func (s *pionSession) inputLoop() {
+ defer s.wg.Done()
+ for {
+ select {
+ case <-s.stopCh:
+ return
+ case pcm := <-s.incoming:
+ if s.manager.feeder != nil {
+ if err := s.manager.feeder(s.instanceID, s.callID, pcm); err != nil {
+ s.incrementDropped()
+ } else {
+ s.mu.Lock()
+ s.inputFrames++
+ s.mu.Unlock()
+ }
+ } else {
+ s.incrementDropped()
+ }
+ zeroPCM(pcm)
+ }
+ }
+}
+
+func (s *pionSession) outputLoop() {
+ defer s.wg.Done()
+ for {
+ select {
+ case <-s.stopCh:
+ return
+ case frame := <-s.outgoing:
+ s.sendFrame(frame)
+ zeroFrame(frame)
+ }
+ }
+}
+
+func (s *pionSession) sendFrame(frame []byte) {
+ s.mu.RLock()
+ channel := s.channel
+ open := s.state == SessionStateOpen && channel != nil
+ s.mu.RUnlock()
+ if !open || channel.BufferedAmount() > maxBufferedAmount {
+ s.incrementDropped()
+ return
+ }
+ if err := channel.Send(frame); err != nil {
+ s.incrementDropped()
+ return
+ }
+ s.mu.Lock()
+ s.outputFrames++
+ s.mu.Unlock()
+}
+
+func (s *pionSession) setState(state SessionState) {
+ s.mu.Lock()
+ if s.state != SessionStateClosed {
+ s.state = state
+ }
+ s.mu.Unlock()
+}
+
+func (s *pionSession) isOpen() bool {
+ s.mu.RLock()
+ open := s.state == SessionStateOpen && s.channel != nil
+ s.mu.RUnlock()
+ return open
+}
+
+func (s *pionSession) incrementDropped() {
+ s.mu.Lock()
+ s.droppedFrames++
+ s.mu.Unlock()
+}
+
+func (s *pionSession) info() SessionInfo {
+ s.mu.RLock()
+ info := SessionInfo{
+ SessionID: s.id,
+ CallID: s.callID,
+ State: s.state,
+ ChannelOpen: s.state == SessionStateOpen && s.channel != nil,
+ CreatedAt: s.createdAt,
+ InputFrames: s.inputFrames,
+ OutputFrames: s.outputFrames,
+ DroppedFrames: s.droppedFrames,
+ }
+ s.mu.RUnlock()
+ return info
+}
+
+func (s *pionSession) close() {
+ if s == nil {
+ return
+ }
+ s.stopOnce.Do(func() {
+ close(s.stopCh)
+ s.mu.Lock()
+ s.state = SessionStateClosed
+ channel := s.channel
+ s.channel = nil
+ pc := s.pc
+ s.pc = nil
+ s.mu.Unlock()
+ if channel != nil {
+ _ = channel.Close()
+ }
+ if pc != nil {
+ _ = pc.Close()
+ }
+ s.wg.Wait()
+ for {
+ select {
+ case pcm := <-s.incoming:
+ zeroPCM(pcm)
+ default:
+ goto outgoing
+ }
+ }
+ outgoing:
+ for {
+ select {
+ case frame := <-s.outgoing:
+ zeroFrame(frame)
+ default:
+ return
+ }
+ }
+ })
+}
+
+var _ Manager = (*pionManager)(nil)
diff --git a/pkg/call/voip/browser/manager_pion_test.go b/pkg/call/voip/browser/manager_pion_test.go
new file mode 100644
index 00000000..ebfdce3a
--- /dev/null
+++ b/pkg/call/voip/browser/manager_pion_test.go
@@ -0,0 +1,116 @@
+//go:build voip_pion
+
+package browser
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/pion/webrtc/v4"
+)
+
+func TestPionManagerBridgesPCMOverDataChannel(t *testing.T) {
+ fed := make(chan []float32, 2)
+ manager := NewManager(func(_, _ string, pcm []float32) error {
+ fed <- append([]float32(nil), pcm...)
+ return nil
+ })
+ defer manager.CloseInstance("instance")
+
+ client, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ protocol := DataChannelProtocol
+ channel, err := client.CreateDataChannel(DataChannelLabel, &webrtc.DataChannelInit{Protocol: &protocol})
+ if err != nil {
+ t.Fatal(err)
+ }
+ opened := make(chan struct{})
+ received := make(chan []float32, 2)
+ channel.OnOpen(func() { close(opened) })
+ channel.OnMessage(func(message webrtc.DataChannelMessage) {
+ pcm, decodeErr := DecodePCMFrame(message.Data)
+ if decodeErr != nil {
+ t.Errorf("decode server PCM: %v", decodeErr)
+ return
+ }
+ received <- pcm
+ })
+
+ offer, err := client.CreateOffer(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ gather := webrtc.GatheringCompletePromise(client)
+ if err = client.SetLocalDescription(offer); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-gather:
+ case <-time.After(10 * time.Second):
+ t.Fatal("client ICE gathering timed out")
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ response, err := manager.Create(ctx, "instance", "call", CreateRequest{Offer: SDPDescription{
+ Type: "offer",
+ SDP: client.LocalDescription().SDP,
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = client.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: response.Answer.SDP}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-opened:
+ case <-time.After(10 * time.Second):
+ t.Fatal("PCM data channel did not open")
+ }
+
+ browserPCM := make([]float32, PCMFrameSamples)
+ browserPCM[0] = 0.25
+ frame, err := EncodePCMFrame(browserPCM)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err = channel.Send(frame); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case got := <-fed:
+ if len(got) != PCMFrameSamples || got[0] != 0.25 {
+ t.Fatalf("unexpected fed PCM: len=%d first=%v", len(got), got[0])
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("server did not receive browser PCM")
+ }
+
+ serverPCM := make([]float32, PCMFrameSamples)
+ serverPCM[0] = -0.5
+ manager.HandlePCM("instance", "call", serverPCM)
+ select {
+ case got := <-received:
+ if len(got) != PCMFrameSamples || got[0] != -0.5 {
+ t.Fatalf("unexpected browser PCM: len=%d first=%v", len(got), got[0])
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("browser did not receive server PCM")
+ }
+
+ sessions, err := manager.Sessions("instance", "call")
+ if err != nil || len(sessions) != 1 || !sessions[0].ChannelOpen {
+ t.Fatalf("unexpected sessions: %+v err=%v", sessions, err)
+ }
+ if err = manager.CloseSession("instance", "call", response.SessionID); err != nil {
+ t.Fatal(err)
+ }
+ sessions, err = manager.Sessions("instance", "call")
+ if err != nil || len(sessions) != 0 {
+ t.Fatalf("session was not removed: %+v err=%v", sessions, err)
+ }
+}
diff --git a/pkg/call/voip/browser/network_pion.go b/pkg/call/voip/browser/network_pion.go
new file mode 100644
index 00000000..1a733e0d
--- /dev/null
+++ b/pkg/call/voip/browser/network_pion.go
@@ -0,0 +1,167 @@
+//go:build voip_pion
+
+package browser
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "strconv"
+ "strings"
+ "sync"
+
+ "github.com/pion/webrtc/v4"
+)
+
+const (
+ browserPublicIPEnv = "CALL_WEBRTC_PUBLIC_IP"
+ browserMediaPortEnv = "CALL_WEBRTC_MEDIA_PORT"
+)
+
+type browserNetworkConfig struct {
+ enabled bool
+ publicIP string
+ mediaPort int
+}
+
+type publicIPDetector func() string
+
+type environmentReader func(string) string
+
+var browserAPISingleton struct {
+ once sync.Once
+ api *webrtc.API
+ err error
+}
+
+// newBrowserPeerConnection uses one process-wide Pion API so every browser
+// session can share the configured UDP/TCP ICE muxes and fixed media port.
+func newBrowserPeerConnection() (*webrtc.PeerConnection, error) {
+ api, err := configuredBrowserAPI()
+ if err != nil {
+ return nil, err
+ }
+ return api.NewPeerConnection(webrtc.Configuration{})
+}
+
+func configuredBrowserAPI() (*webrtc.API, error) {
+ browserAPISingleton.once.Do(func() {
+ config, err := readBrowserNetworkConfig(os.Getenv, detectPublicIPv4)
+ if err != nil {
+ browserAPISingleton.err = err
+ return
+ }
+ api, _, actualPort, err := buildBrowserAPI(config)
+ if err != nil {
+ browserAPISingleton.err = err
+ return
+ }
+ browserAPISingleton.api = api
+ if config.enabled {
+ slog.Info("browser WebRTC fixed ICE endpoint enabled",
+ "public_ip", config.publicIP,
+ "media_port", actualPort,
+ "udp", true,
+ "ice_tcp", true,
+ )
+ }
+ })
+ if browserAPISingleton.err != nil {
+ return nil, browserAPISingleton.err
+ }
+ if browserAPISingleton.api == nil {
+ return nil, fmt.Errorf("browser WebRTC API is not initialized")
+ }
+ return browserAPISingleton.api, nil
+}
+
+func readBrowserNetworkConfig(getenv environmentReader, detect publicIPDetector) (browserNetworkConfig, error) {
+ if getenv == nil {
+ getenv = os.Getenv
+ }
+ publicIP := strings.TrimSpace(getenv(browserPublicIPEnv))
+ portValue := strings.TrimSpace(getenv(browserMediaPortEnv))
+ if publicIP == "" && portValue == "" {
+ return browserNetworkConfig{}, nil
+ }
+ if publicIP == "" || portValue == "" {
+ return browserNetworkConfig{}, fmt.Errorf("%s and %s must be configured together", browserPublicIPEnv, browserMediaPortEnv)
+ }
+ if strings.EqualFold(publicIP, "auto") {
+ if detect == nil {
+ return browserNetworkConfig{}, fmt.Errorf("detect public IPv4 address: detector is unavailable")
+ }
+ publicIP = strings.TrimSpace(detect())
+ if publicIP == "" {
+ return browserNetworkConfig{}, fmt.Errorf("detect public IPv4 address for %s=auto", browserPublicIPEnv)
+ }
+ }
+ parsedIP := net.ParseIP(publicIP)
+ if parsedIP == nil || parsedIP.To4() == nil {
+ return browserNetworkConfig{}, fmt.Errorf("%s must be an IPv4 address or auto", browserPublicIPEnv)
+ }
+ mediaPort, err := strconv.Atoi(portValue)
+ if err != nil || mediaPort < 1 || mediaPort > 65535 {
+ return browserNetworkConfig{}, fmt.Errorf("%s must be an integer between 1 and 65535", browserMediaPortEnv)
+ }
+ return browserNetworkConfig{
+ enabled: true,
+ publicIP: parsedIP.To4().String(),
+ mediaPort: mediaPort,
+ }, nil
+}
+
+// buildBrowserAPI binds UDP and TCP on the same port. A zero mediaPort is
+// accepted only for tests; environment parsing always requires a fixed port.
+func buildBrowserAPI(config browserNetworkConfig) (*webrtc.API, []io.Closer, int, error) {
+ if !config.enabled {
+ return webrtc.NewAPI(), nil, 0, nil
+ }
+ if parsedIP := net.ParseIP(config.publicIP); parsedIP == nil || parsedIP.To4() == nil {
+ return nil, nil, 0, fmt.Errorf("invalid browser WebRTC advertised IPv4 address %q", config.publicIP)
+ }
+ if config.mediaPort < 0 || config.mediaPort > 65535 {
+ return nil, nil, 0, fmt.Errorf("invalid browser WebRTC media port %d", config.mediaPort)
+ }
+
+ udpConn, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: config.mediaPort})
+ if err != nil {
+ return nil, nil, 0, fmt.Errorf("bind browser WebRTC UDP port %d: %w", config.mediaPort, err)
+ }
+ actualPort := udpConn.LocalAddr().(*net.UDPAddr).Port
+ tcpListener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: net.IPv4zero, Port: actualPort})
+ if err != nil {
+ _ = udpConn.Close()
+ return nil, nil, 0, fmt.Errorf("bind browser WebRTC ICE-TCP port %d: %w", actualPort, err)
+ }
+
+ settingEngine := webrtc.SettingEngine{}
+ settingEngine.SetNAT1To1IPs([]string{config.publicIP}, webrtc.ICECandidateTypeHost)
+ settingEngine.SetNetworkTypes([]webrtc.NetworkType{
+ webrtc.NetworkTypeUDP4,
+ webrtc.NetworkTypeTCP4,
+ })
+ settingEngine.SetICEUDPMux(webrtc.NewICEUDPMux(nil, udpConn))
+ settingEngine.SetICETCPMux(webrtc.NewICETCPMux(nil, tcpListener, 8))
+
+ api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
+ return api, []io.Closer{tcpListener, udpConn}, actualPort, nil
+}
+
+// detectPublicIPv4 resolves the local IPv4 selected by the default route. On a
+// host-networked VPS this is normally the public address. Behind NAT, set the
+// externally routed address explicitly instead of using auto.
+func detectPublicIPv4() string {
+ connection, err := net.Dial("udp4", "8.8.8.8:80")
+ if err != nil {
+ return ""
+ }
+ defer connection.Close()
+ address, ok := connection.LocalAddr().(*net.UDPAddr)
+ if !ok || address.IP == nil {
+ return ""
+ }
+ return address.IP.To4().String()
+}
diff --git a/pkg/call/voip/browser/network_pion_test.go b/pkg/call/voip/browser/network_pion_test.go
new file mode 100644
index 00000000..c4d24074
--- /dev/null
+++ b/pkg/call/voip/browser/network_pion_test.go
@@ -0,0 +1,166 @@
+//go:build voip_pion
+
+package browser
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pion/webrtc/v4"
+)
+
+func TestReadBrowserNetworkConfig(t *testing.T) {
+ tests := []struct {
+ name string
+ environment map[string]string
+ detected string
+ want browserNetworkConfig
+ wantError bool
+ }{
+ {name: "disabled", environment: map[string]string{}},
+ {
+ name: "explicit endpoint",
+ environment: map[string]string{
+ browserPublicIPEnv: "203.0.113.20",
+ browserMediaPortEnv: "50000",
+ },
+ want: browserNetworkConfig{enabled: true, publicIP: "203.0.113.20", mediaPort: 50000},
+ },
+ {
+ name: "automatic address",
+ environment: map[string]string{
+ browserPublicIPEnv: "auto",
+ browserMediaPortEnv: "40000",
+ },
+ detected: "198.51.100.8",
+ want: browserNetworkConfig{enabled: true, publicIP: "198.51.100.8", mediaPort: 40000},
+ },
+ {
+ name: "missing port",
+ environment: map[string]string{browserPublicIPEnv: "203.0.113.20"},
+ wantError: true,
+ },
+ {
+ name: "invalid address",
+ environment: map[string]string{
+ browserPublicIPEnv: "not-an-ip",
+ browserMediaPortEnv: "50000",
+ },
+ wantError: true,
+ },
+ {
+ name: "invalid port",
+ environment: map[string]string{
+ browserPublicIPEnv: "203.0.113.20",
+ browserMediaPortEnv: "70000",
+ },
+ wantError: true,
+ },
+ {
+ name: "automatic detection failed",
+ environment: map[string]string{
+ browserPublicIPEnv: "auto",
+ browserMediaPortEnv: "50000",
+ },
+ wantError: true,
+ },
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ getenv := func(key string) string { return test.environment[key] }
+ config, err := readBrowserNetworkConfig(getenv, func() string { return test.detected })
+ if test.wantError {
+ if err == nil {
+ t.Fatalf("expected configuration error, got %+v", config)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatal(err)
+ }
+ if config != test.want {
+ t.Fatalf("unexpected configuration: got %+v want %+v", config, test.want)
+ }
+ })
+ }
+}
+
+func TestBrowserAPIAdvertisesFixedUDPAndTCPPort(t *testing.T) {
+ api, closers, mediaPort, err := buildBrowserAPI(browserNetworkConfig{
+ enabled: true,
+ publicIP: "127.0.0.1",
+ mediaPort: 0,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ for _, closer := range closers {
+ _ = closer.Close()
+ }
+ }()
+
+ server, err := api.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ client, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ if _, err = client.CreateDataChannel(DataChannelLabel, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ offer, err := client.CreateOffer(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ clientGathering := webrtc.GatheringCompletePromise(client)
+ if err = client.SetLocalDescription(offer); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-clientGathering:
+ case <-time.After(10 * time.Second):
+ t.Fatal("client ICE gathering timed out")
+ }
+
+ if err = server.SetRemoteDescription(*client.LocalDescription()); err != nil {
+ t.Fatal(err)
+ }
+ answer, err := server.CreateAnswer(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ serverGathering := webrtc.GatheringCompletePromise(server)
+ if err = server.SetLocalDescription(answer); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ select {
+ case <-serverGathering:
+ case <-ctx.Done():
+ t.Fatal("server ICE gathering timed out")
+ }
+
+ sdp := server.LocalDescription().SDP
+ endpoint := fmt.Sprintf(" 127.0.0.1 %d typ host", mediaPort)
+ if !strings.Contains(sdp, endpoint) {
+ t.Fatalf("SDP does not advertise fixed endpoint %q:\n%s", endpoint, sdp)
+ }
+ lowerSDP := strings.ToLower(sdp)
+ if !strings.Contains(lowerSDP, " udp ") {
+ t.Fatalf("SDP does not contain a UDP candidate:\n%s", sdp)
+ }
+ if !strings.Contains(lowerSDP, " tcp ") || !strings.Contains(lowerSDP, "tcptype passive") {
+ t.Fatalf("SDP does not contain a passive ICE-TCP candidate:\n%s", sdp)
+ }
+}
diff --git a/pkg/call/voip/browser/types.go b/pkg/call/voip/browser/types.go
new file mode 100644
index 00000000..2858e3aa
--- /dev/null
+++ b/pkg/call/voip/browser/types.go
@@ -0,0 +1,89 @@
+package browser
+
+import (
+ "context"
+ "errors"
+ "time"
+)
+
+const (
+ DataChannelLabel = "evolution-call-pcm"
+ DataChannelProtocol = "evcall.pcm.v1"
+ PCMSampleRate = 16000
+ PCMChannels = 1
+ PCMFrameSamples = 960
+)
+
+var (
+ ErrWebRTCDisabled = errors.New("browser WebRTC bridge requires the voip_pion build tag")
+ ErrInvalidOffer = errors.New("invalid WebRTC SDP offer")
+ ErrSessionNotFound = errors.New("browser WebRTC session not found")
+ ErrSessionLimit = errors.New("browser WebRTC session limit reached")
+ ErrInvalidPCMMessage = errors.New("invalid browser PCM message")
+)
+
+type SDPDescription struct {
+ Type string `json:"type" binding:"required"`
+ SDP string `json:"sdp" binding:"required"`
+}
+
+type CreateRequest struct {
+ Offer SDPDescription `json:"offer" binding:"required"`
+}
+
+type ProtocolInfo struct {
+ DataChannel string `json:"dataChannel"`
+ Protocol string `json:"protocol"`
+ Format string `json:"format"`
+ SampleRate int `json:"sampleRate"`
+ Channels int `json:"channels"`
+ FrameSamples int `json:"frameSamples"`
+}
+
+type CreateResponse struct {
+ SessionID string `json:"sessionId"`
+ Answer SDPDescription `json:"answer"`
+ Audio ProtocolInfo `json:"audio"`
+}
+
+type SessionState string
+
+const (
+ SessionStateConnecting SessionState = "connecting"
+ SessionStateOpen SessionState = "open"
+ SessionStateClosed SessionState = "closed"
+ SessionStateFailed SessionState = "failed"
+)
+
+type SessionInfo struct {
+ SessionID string `json:"sessionId"`
+ CallID string `json:"callId"`
+ State SessionState `json:"state"`
+ ChannelOpen bool `json:"channelOpen"`
+ CreatedAt time.Time `json:"createdAt"`
+ InputFrames uint64 `json:"inputFrames"`
+ OutputFrames uint64 `json:"outputFrames"`
+ DroppedFrames uint64 `json:"droppedFrames"`
+}
+
+type PCMFeeder func(instanceID, callID string, pcm []float32) error
+
+type Manager interface {
+ Create(ctx context.Context, instanceID, callID string, request CreateRequest) (CreateResponse, error)
+ Sessions(instanceID, callID string) ([]SessionInfo, error)
+ CloseSession(instanceID, callID, sessionID string) error
+ CloseCall(instanceID, callID string)
+ CloseInstance(instanceID string)
+ HandlePCM(instanceID, callID string, pcm []float32)
+}
+
+func DefaultProtocolInfo() ProtocolInfo {
+ return ProtocolInfo{
+ DataChannel: DataChannelLabel,
+ Protocol: DataChannelProtocol,
+ Format: "f32le",
+ SampleRate: PCMSampleRate,
+ Channels: PCMChannels,
+ FrameSamples: PCMFrameSamples,
+ }
+}
diff --git a/pkg/call/voip/call/state.go b/pkg/call/voip/call/state.go
new file mode 100644
index 00000000..c2d8ee15
--- /dev/null
+++ b/pkg/call/voip/call/state.go
@@ -0,0 +1,207 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package call
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+type StateData struct {
+ State core.CallState
+ ConnectedAt *time.Time
+ AcceptedAt *time.Time
+ EndedAt *time.Time
+ AudioMuted bool
+ VideoOff bool
+ Silenced bool
+ EndReason core.EndCallReason
+ DurationSecs int
+}
+
+type Info struct {
+ CallID string
+ PeerJID string
+ CallCreator string
+ Direction core.CallDirection
+ MediaType core.CallMediaType
+ StateData StateData
+ CreatedAt time.Time
+}
+
+func NewOutgoing(callID, peerJID, creator string, mediaType core.CallMediaType) *Info {
+ return &Info{
+ CallID: callID,
+ PeerJID: peerJID,
+ CallCreator: creator,
+ Direction: core.CallDirectionOutgoing,
+ MediaType: mediaType,
+ CreatedAt: time.Now().UTC(),
+ StateData: StateData{
+ State: core.CallStateInitiating,
+ VideoOff: mediaType != core.CallMediaTypeVideo,
+ AudioMuted: false,
+ },
+ }
+}
+
+func NewIncoming(callID, peerJID, creator string, mediaType core.CallMediaType) *Info {
+ return &Info{
+ CallID: callID,
+ PeerJID: peerJID,
+ CallCreator: creator,
+ Direction: core.CallDirectionIncoming,
+ MediaType: mediaType,
+ CreatedAt: time.Now().UTC(),
+ StateData: StateData{
+ State: core.CallStateIncomingRinging,
+ VideoOff: mediaType != core.CallMediaTypeVideo,
+ AudioMuted: false,
+ },
+ }
+}
+
+func (c *Info) IsInitiator() bool { return c != nil && c.Direction == core.CallDirectionOutgoing }
+func (c *Info) IsActive() bool { return c != nil && c.StateData.State == core.CallStateActive }
+func (c *Info) IsEnded() bool { return c != nil && c.StateData.State == core.CallStateEnded }
+func (c *Info) CanAccept() bool {
+ return c != nil && c.StateData.State == core.CallStateIncomingRinging
+}
+
+func (c *Info) Clone() *Info {
+ if c == nil {
+ return nil
+ }
+ clone := *c
+ clone.StateData.ConnectedAt = cloneTime(c.StateData.ConnectedAt)
+ clone.StateData.AcceptedAt = cloneTime(c.StateData.AcceptedAt)
+ clone.StateData.EndedAt = cloneTime(c.StateData.EndedAt)
+ return &clone
+}
+
+type TransitionType string
+
+const (
+ TransitionOfferSent TransitionType = "offer_sent"
+ TransitionRemoteAccepted TransitionType = "remote_accepted"
+ TransitionLocalAccepted TransitionType = "local_accepted"
+ TransitionRemoteRejected TransitionType = "remote_rejected"
+ TransitionLocalRejected TransitionType = "local_rejected"
+ TransitionMediaConnected TransitionType = "media_connected"
+ TransitionTerminated TransitionType = "terminated"
+ TransitionHold TransitionType = "hold"
+ TransitionResume TransitionType = "resume"
+ TransitionAudioMuteChanged TransitionType = "audio_mute_changed"
+ TransitionVideoStateChanged TransitionType = "video_state_changed"
+)
+
+type Transition struct {
+ Type TransitionType
+ Reason core.EndCallReason
+ Muted bool
+ Off bool
+}
+
+type InvalidTransition struct {
+ CurrentState core.CallState
+ Attempted TransitionType
+}
+
+func (e *InvalidTransition) Error() string {
+ return fmt.Sprintf("invalid transition %q in state %q", e.Attempted, e.CurrentState)
+}
+
+func (c *Info) Apply(transition Transition) error {
+ if c == nil {
+ return fmt.Errorf("nil call state")
+ }
+ state := &c.StateData
+ now := time.Now().UTC()
+
+ switch transition.Type {
+ case TransitionOfferSent:
+ if state.State != core.CallStateInitiating {
+ return invalid(state.State, transition.Type)
+ }
+ state.State = core.CallStateRinging
+ case TransitionRemoteAccepted:
+ if state.State != core.CallStateRinging {
+ return invalid(state.State, transition.Type)
+ }
+ state.State = core.CallStateConnecting
+ state.AcceptedAt = &now
+ case TransitionLocalAccepted:
+ if state.State != core.CallStateIncomingRinging {
+ return invalid(state.State, transition.Type)
+ }
+ state.State = core.CallStateConnecting
+ state.AcceptedAt = &now
+ case TransitionRemoteRejected:
+ if state.State != core.CallStateRinging {
+ return invalid(state.State, transition.Type)
+ }
+ endState(state, now, transition.Reason)
+ case TransitionLocalRejected:
+ if state.State != core.CallStateIncomingRinging {
+ return invalid(state.State, transition.Type)
+ }
+ endState(state, now, transition.Reason)
+ case TransitionMediaConnected:
+ if state.State != core.CallStateConnecting {
+ return invalid(state.State, transition.Type)
+ }
+ state.State = core.CallStateActive
+ state.ConnectedAt = &now
+ state.VideoOff = c.MediaType != core.CallMediaTypeVideo
+ case TransitionTerminated:
+ if state.State == core.CallStateEnded {
+ return invalid(state.State, transition.Type)
+ }
+ if (state.State == core.CallStateActive || state.State == core.CallStateOnHold) && state.ConnectedAt != nil {
+ state.DurationSecs = int(now.Sub(*state.ConnectedAt).Seconds())
+ }
+ endState(state, now, transition.Reason)
+ case TransitionHold:
+ if state.State != core.CallStateActive {
+ return invalid(state.State, transition.Type)
+ }
+ state.State = core.CallStateOnHold
+ case TransitionResume:
+ if state.State != core.CallStateOnHold {
+ return invalid(state.State, transition.Type)
+ }
+ state.State = core.CallStateActive
+ case TransitionAudioMuteChanged:
+ if state.State != core.CallStateActive {
+ return invalid(state.State, transition.Type)
+ }
+ state.AudioMuted = transition.Muted
+ case TransitionVideoStateChanged:
+ if state.State != core.CallStateActive {
+ return invalid(state.State, transition.Type)
+ }
+ state.VideoOff = transition.Off
+ default:
+ return invalid(state.State, transition.Type)
+ }
+ return nil
+}
+
+func invalid(state core.CallState, transition TransitionType) error {
+ return &InvalidTransition{CurrentState: state, Attempted: transition}
+}
+
+func endState(state *StateData, now time.Time, reason core.EndCallReason) {
+ state.State = core.CallStateEnded
+ state.EndedAt = &now
+ state.EndReason = reason
+}
+
+func cloneTime(value *time.Time) *time.Time {
+ if value == nil {
+ return nil
+ }
+ clone := *value
+ return &clone
+}
diff --git a/pkg/call/voip/call/state_test.go b/pkg/call/voip/call/state_test.go
new file mode 100644
index 00000000..9b70572d
--- /dev/null
+++ b/pkg/call/voip/call/state_test.go
@@ -0,0 +1,78 @@
+package call
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+func TestOutgoingCallLifecycle(t *testing.T) {
+ call := NewOutgoing("call-1", "peer@s.whatsapp.net", "self@s.whatsapp.net", core.CallMediaTypeAudio)
+ steps := []Transition{
+ {Type: TransitionOfferSent},
+ {Type: TransitionRemoteAccepted},
+ {Type: TransitionMediaConnected},
+ {Type: TransitionAudioMuteChanged, Muted: true},
+ {Type: TransitionHold},
+ {Type: TransitionResume},
+ {Type: TransitionTerminated, Reason: core.EndCallReasonUserEnded},
+ }
+ for _, step := range steps {
+ if err := call.Apply(step); err != nil {
+ t.Fatalf("Apply(%s) error = %v", step.Type, err)
+ }
+ }
+ if !call.IsEnded() || call.StateData.EndReason != core.EndCallReasonUserEnded {
+ t.Fatalf("unexpected final state: %+v", call.StateData)
+ }
+ if !call.StateData.AudioMuted {
+ t.Fatal("audio mute state was not preserved")
+ }
+}
+
+func TestIncomingAcceptLifecycle(t *testing.T) {
+ call := NewIncoming("call-2", "peer@s.whatsapp.net", "peer@s.whatsapp.net", core.CallMediaTypeVideo)
+ if !call.CanAccept() {
+ t.Fatal("incoming ringing call should be acceptable")
+ }
+ if err := call.Apply(Transition{Type: TransitionLocalAccepted}); err != nil {
+ t.Fatalf("local accept error = %v", err)
+ }
+ if err := call.Apply(Transition{Type: TransitionMediaConnected}); err != nil {
+ t.Fatalf("media connected error = %v", err)
+ }
+ if call.StateData.State != core.CallStateActive || call.StateData.VideoOff {
+ t.Fatalf("unexpected active video state: %+v", call.StateData)
+ }
+}
+
+func TestInvalidTransitionDoesNotMutateState(t *testing.T) {
+ call := NewOutgoing("call-3", "peer@s.whatsapp.net", "self@s.whatsapp.net", core.CallMediaTypeAudio)
+ err := call.Apply(Transition{Type: TransitionMediaConnected})
+ var invalidTransition *InvalidTransition
+ if !errors.As(err, &invalidTransition) {
+ t.Fatalf("expected InvalidTransition, got %v", err)
+ }
+ if call.StateData.State != core.CallStateInitiating {
+ t.Fatalf("invalid transition mutated state to %s", call.StateData.State)
+ }
+}
+
+func TestCloneIsIndependent(t *testing.T) {
+ call := NewOutgoing("call-4", "peer@s.whatsapp.net", "self@s.whatsapp.net", core.CallMediaTypeAudio)
+ if err := call.Apply(Transition{Type: TransitionOfferSent}); err != nil {
+ t.Fatal(err)
+ }
+ if err := call.Apply(Transition{Type: TransitionRemoteAccepted}); err != nil {
+ t.Fatal(err)
+ }
+ clone := call.Clone()
+ clone.StateData.State = core.CallStateEnded
+ if clone.StateData.AcceptedAt != nil {
+ clone.StateData.AcceptedAt = nil
+ }
+ if call.StateData.State != core.CallStateConnecting || call.StateData.AcceptedAt == nil {
+ t.Fatal("mutating clone changed original")
+ }
+}
diff --git a/pkg/call/voip/core/relay.go b/pkg/call/voip/core/relay.go
new file mode 100644
index 00000000..b39b0139
--- /dev/null
+++ b/pkg/call/voip/core/relay.go
@@ -0,0 +1,109 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package core
+
+const WARelayPort = 3480
+
+// RelayEndpoint describes one WhatsApp media relay candidate. Raw token fields
+// are kept for the future SCTP transport while the base64 forms remain useful
+// for diagnostics that do not expose the original byte slices by reference.
+type RelayEndpoint struct {
+ IP string
+ Port int
+ Token string
+ AuthToken string
+ RawToken []byte
+ RawAuthToken []byte
+ Key string
+ RelayID int
+ Protocol int
+ C2RRtt *int
+ RelayName string
+ AddressBytes []byte
+ AuthTokenID string
+}
+
+// RelayData is the transport metadata associated with a call. It intentionally
+// remains outside public runtime snapshots until a redacted API representation
+// is designed.
+type RelayData struct {
+ Endpoints []RelayEndpoint
+ ParticipantJIDs []string
+ UUID string
+ SelfPID *int
+ PeerPID *int
+ HBHKey []byte
+}
+
+// CloneRelayData makes a defensive deep copy of all relay metadata. This keeps
+// private call state independent from buffers owned by incoming protocol nodes.
+func CloneRelayData(data *RelayData) *RelayData {
+ if data == nil {
+ return nil
+ }
+
+ clone := &RelayData{
+ ParticipantJIDs: append([]string(nil), data.ParticipantJIDs...),
+ UUID: data.UUID,
+ SelfPID: cloneInt(data.SelfPID),
+ PeerPID: cloneInt(data.PeerPID),
+ HBHKey: append([]byte(nil), data.HBHKey...),
+ }
+ clone.Endpoints = make([]RelayEndpoint, len(data.Endpoints))
+ for index, endpoint := range data.Endpoints {
+ clone.Endpoints[index] = endpoint
+ clone.Endpoints[index].RawToken = append([]byte(nil), endpoint.RawToken...)
+ clone.Endpoints[index].RawAuthToken = append([]byte(nil), endpoint.RawAuthToken...)
+ clone.Endpoints[index].AddressBytes = append([]byte(nil), endpoint.AddressBytes...)
+ clone.Endpoints[index].C2RRtt = cloneInt(endpoint.C2RRtt)
+ }
+ return clone
+}
+
+// ZeroRelayData overwrites byte material and clears references before private
+// relay state is discarded. Strings cannot be overwritten in place in Go, so
+// their references are dropped immediately.
+func ZeroRelayData(data *RelayData) {
+ if data == nil {
+ return
+ }
+
+ zeroBytes(data.HBHKey)
+ for index := range data.Endpoints {
+ endpoint := &data.Endpoints[index]
+ zeroBytes(endpoint.RawToken)
+ zeroBytes(endpoint.RawAuthToken)
+ zeroBytes(endpoint.AddressBytes)
+ endpoint.Token = ""
+ endpoint.AuthToken = ""
+ endpoint.Key = ""
+ endpoint.RelayName = ""
+ endpoint.AuthTokenID = ""
+ endpoint.RawToken = nil
+ endpoint.RawAuthToken = nil
+ endpoint.AddressBytes = nil
+ endpoint.C2RRtt = nil
+ }
+ for index := range data.ParticipantJIDs {
+ data.ParticipantJIDs[index] = ""
+ }
+ data.Endpoints = nil
+ data.ParticipantJIDs = nil
+ data.UUID = ""
+ data.SelfPID = nil
+ data.PeerPID = nil
+ data.HBHKey = nil
+}
+
+func cloneInt(value *int) *int {
+ if value == nil {
+ return nil
+ }
+ clone := *value
+ return &clone
+}
+
+func zeroBytes(value []byte) {
+ for index := range value {
+ value[index] = 0
+ }
+}
diff --git a/pkg/call/voip/core/relay_constants.go b/pkg/call/voip/core/relay_constants.go
new file mode 100644
index 00000000..ce346d25
--- /dev/null
+++ b/pkg/call/voip/core/relay_constants.go
@@ -0,0 +1,6 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package core
+
+// WADTLSFingerprint is the fingerprint advertised by WhatsApp media relays.
+// It is used only by the experimental Pion SCTP transport.
+const WADTLSFingerprint = "sha-256 F9:CA:0C:98:A3:CC:71:D6:42:CE:5A:E2:53:D2:15:20:D3:1B:BA:D8:57:A4:F0:AF:BE:0B:FB:F3:6B:0C:A0:68"
diff --git a/pkg/call/voip/core/relay_test.go b/pkg/call/voip/core/relay_test.go
new file mode 100644
index 00000000..e66dbeb0
--- /dev/null
+++ b/pkg/call/voip/core/relay_test.go
@@ -0,0 +1,71 @@
+package core
+
+import "testing"
+
+func TestCloneRelayDataIsDeepCopy(t *testing.T) {
+ rtt := 18
+ selfPID := 4
+ original := &RelayData{
+ Endpoints: []RelayEndpoint{{
+ IP: "1.2.3.4",
+ RawToken: []byte{1, 2, 3},
+ RawAuthToken: []byte{4, 5, 6},
+ AddressBytes: []byte{1, 2, 3, 4, 13, 152},
+ C2RRtt: &rtt,
+ }},
+ ParticipantJIDs: []string{"device@s.whatsapp.net"},
+ UUID: "relay-uuid",
+ SelfPID: &selfPID,
+ HBHKey: []byte{7, 8, 9},
+ }
+
+ clone := CloneRelayData(original)
+ clone.Endpoints[0].RawToken[0] = 99
+ clone.Endpoints[0].RawAuthToken[0] = 99
+ clone.Endpoints[0].AddressBytes[0] = 99
+ *clone.Endpoints[0].C2RRtt = 99
+ clone.ParticipantJIDs[0] = "changed"
+ clone.HBHKey[0] = 99
+ *clone.SelfPID = 99
+
+ if original.Endpoints[0].RawToken[0] != 1 || original.Endpoints[0].RawAuthToken[0] != 4 {
+ t.Fatal("clone shares token buffers with original")
+ }
+ if original.Endpoints[0].AddressBytes[0] != 1 || *original.Endpoints[0].C2RRtt != 18 {
+ t.Fatal("clone shares endpoint metadata with original")
+ }
+ if original.ParticipantJIDs[0] != "device@s.whatsapp.net" || original.HBHKey[0] != 7 || *original.SelfPID != 4 {
+ t.Fatal("clone shares relay metadata with original")
+ }
+}
+
+func TestZeroRelayDataOverwritesBuffers(t *testing.T) {
+ rawToken := []byte{1, 2, 3}
+ rawAuth := []byte{4, 5, 6}
+ address := []byte{7, 8, 9, 10, 13, 152}
+ hbh := []byte{11, 12, 13}
+ data := &RelayData{
+ Endpoints: []RelayEndpoint{{
+ Token: "token",
+ AuthToken: "auth",
+ RawToken: rawToken,
+ RawAuthToken: rawAuth,
+ AddressBytes: address,
+ Key: "key",
+ }},
+ ParticipantJIDs: []string{"device@s.whatsapp.net"},
+ HBHKey: hbh,
+ }
+
+ ZeroRelayData(data)
+ for _, buffer := range [][]byte{rawToken, rawAuth, address, hbh} {
+ for _, value := range buffer {
+ if value != 0 {
+ t.Fatalf("private relay buffer was not overwritten: %v", buffer)
+ }
+ }
+ }
+ if data.Endpoints != nil || data.ParticipantJIDs != nil || data.HBHKey != nil {
+ t.Fatal("relay references were not cleared")
+ }
+}
diff --git a/pkg/call/voip/core/srtp.go b/pkg/call/voip/core/srtp.go
new file mode 100644
index 00000000..028396c6
--- /dev/null
+++ b/pkg/call/voip/core/srtp.go
@@ -0,0 +1,31 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package core
+
+const (
+ PayloadTypeWhatsAppOpus uint8 = 120
+
+ SRTPSendAuthTagLen = 4
+ SRTPRecvAuthTagLen = 4
+ SRTPAuthTagLen = 4
+
+ SRTPLabelEncryption byte = 0x00
+ SRTPLabelAuth byte = 0x01
+ SRTPLabelSalt byte = 0x02
+)
+
+// SRTPKeyingMaterial contains the RFC 3711 master key and master salt.
+// Callers own these buffers and must call Wipe after the material is consumed.
+type SRTPKeyingMaterial struct {
+ MasterKey []byte
+ MasterSalt []byte
+}
+
+func (m *SRTPKeyingMaterial) Wipe() {
+ if m == nil {
+ return
+ }
+ zeroBytes(m.MasterKey)
+ zeroBytes(m.MasterSalt)
+ m.MasterKey = nil
+ m.MasterSalt = nil
+}
diff --git a/pkg/call/voip/core/types.go b/pkg/call/voip/core/types.go
new file mode 100644
index 00000000..03e2a5aa
--- /dev/null
+++ b/pkg/call/voip/core/types.go
@@ -0,0 +1,42 @@
+// Package core contains the transport-independent WhatsApp VoIP domain types.
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package core
+
+type CallState string
+
+const (
+ CallStateInitiating CallState = "initiating"
+ CallStateRinging CallState = "ringing"
+ CallStateIncomingRinging CallState = "incoming_ringing"
+ CallStateConnecting CallState = "connecting"
+ CallStateActive CallState = "active"
+ CallStateOnHold CallState = "on_hold"
+ CallStateEnded CallState = "ended"
+)
+
+type CallDirection string
+
+const (
+ CallDirectionOutgoing CallDirection = "outgoing"
+ CallDirectionIncoming CallDirection = "incoming"
+)
+
+type CallMediaType string
+
+const (
+ CallMediaTypeAudio CallMediaType = "audio"
+ CallMediaTypeVideo CallMediaType = "video"
+)
+
+type EndCallReason string
+
+const (
+ EndCallReasonUserEnded EndCallReason = "user_ended"
+ EndCallReasonDeclined EndCallReason = "declined"
+ EndCallReasonTimeout EndCallReason = "timeout"
+ EndCallReasonBusy EndCallReason = "busy"
+ EndCallReasonCancelled EndCallReason = "cancelled"
+ EndCallReasonFailed EndCallReason = "failed"
+ EndCallReasonDoNotDisturb EndCallReason = "do_not_disturb"
+ EndCallReasonUnknown EndCallReason = "unknown"
+)
diff --git a/pkg/call/voip/core/voipsocket.go b/pkg/call/voip/core/voipsocket.go
new file mode 100644
index 00000000..fe3ff5b4
--- /dev/null
+++ b/pkg/call/voip/core/voipsocket.go
@@ -0,0 +1,25 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package core
+
+import (
+ "context"
+
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+// VoipSocket isolates call signaling from the Evolution instance lifecycle.
+// Implementations must wrap the same whatsmeow client used by messaging.
+type VoipSocket interface {
+ OwnPN() types.JID
+ OwnLID() types.JID
+ AccountDeviceIdentityNode() (waBinary.Node, bool)
+ SendNode(ctx context.Context, node waBinary.Node) error
+ Query(ctx context.Context, node waBinary.Node) (*waBinary.Node, error)
+ GetUSyncDevices(ctx context.Context, jids []types.JID) ([]types.JID, error)
+ AssertSessions(ctx context.Context, jids []types.JID, force bool) error
+ CreateParticipantNodes(ctx context.Context, devices []types.JID, callKey []byte, encAttrs waBinary.Attrs) ([]waBinary.Node, bool, error)
+ DecryptCallKey(ctx context.Context, from types.JID, encChild *waBinary.Node) ([]byte, error)
+ GetTCToken(ctx context.Context, jid types.JID) ([]byte, error)
+ ResolveLIDForPN(ctx context.Context, pn types.JID) types.JID
+}
diff --git a/pkg/call/voip/driver/signaling.go b/pkg/call/voip/driver/signaling.go
new file mode 100644
index 00000000..7d3a2cd5
--- /dev/null
+++ b/pkg/call/voip/driver/signaling.go
@@ -0,0 +1,128 @@
+// Package driver coordinates VoIP protocol operations without owning Evolution sessions.
+package driver
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/types"
+)
+
+// StartResult contains private negotiation material produced while starting a
+// call. Callers must copy it into their private registry and then call Wipe.
+type StartResult struct {
+ CallID string
+ Peer types.JID
+ Creator types.JID
+ CallKey []byte
+ RelayData *core.RelayData
+}
+
+// Wipe removes private material from this transient result after it has been
+// copied into the per-instance call registry.
+func (r *StartResult) Wipe() {
+ if r == nil {
+ return
+ }
+ for index := range r.CallKey {
+ r.CallKey[index] = 0
+ }
+ core.ZeroRelayData(r.RelayData)
+ r.CallKey = nil
+ r.RelayData = nil
+}
+
+// SignalingDriver sends real WhatsApp call stanzas.
+type SignalingDriver struct {
+ socket core.VoipSocket
+}
+
+func NewSignalingDriver(client *whatsmeow.Client) *SignalingDriver {
+ return &SignalingDriver{socket: wa.NewSocket(client)}
+}
+
+func (d *SignalingDriver) Start(ctx context.Context, peer types.JID, video bool) (*StartResult, error) {
+ if peer.IsEmpty() {
+ return nil, fmt.Errorf("peer JID is empty")
+ }
+
+ creator := d.socket.OwnLID()
+ if creator.IsEmpty() {
+ creator = d.socket.OwnPN()
+ }
+ if creator.IsEmpty() {
+ return nil, fmt.Errorf("whatsapp client has no own JID")
+ }
+
+ callID := signaling.GenerateCallID()
+ callKey, err := signaling.GenerateCallKey()
+ if err != nil {
+ return nil, err
+ }
+ wipeOnError := func() {
+ for index := range callKey {
+ callKey[index] = 0
+ }
+ }
+
+ resolvedPeer := d.socket.ResolveLIDForPN(ctx, peer)
+ offer, err := signaling.BuildOfferStanza(ctx, d.socket, callID, callKey, resolvedPeer, video)
+ if err != nil {
+ wipeOnError()
+ return nil, err
+ }
+
+ ack, err := d.socket.Query(ctx, offer)
+ if err != nil {
+ wipeOnError()
+ return nil, fmt.Errorf("send call offer: %w", err)
+ }
+
+ var relayData *core.RelayData
+ if ack != nil {
+ if ackError := wanode.AttrString(ack.Attrs, "error"); ackError != "" {
+ wipeOnError()
+ return nil, fmt.Errorf("call offer rejected by WhatsApp: %s", ackError)
+ }
+ parsed := signaling.ParseRelayFromAck(ack)
+ if len(parsed.Relays) > 0 || parsed.UUID != "" || len(parsed.HBHKey) > 0 {
+ relayData = &core.RelayData{
+ Endpoints: parsed.Relays,
+ ParticipantJIDs: parsed.ParticipantJIDs,
+ UUID: parsed.UUID,
+ SelfPID: parsed.SelfPID,
+ PeerPID: parsed.PeerPID,
+ HBHKey: parsed.HBHKey,
+ }
+ }
+ }
+
+ return &StartResult{
+ CallID: callID,
+ Peer: resolvedPeer,
+ Creator: creator,
+ CallKey: callKey,
+ RelayData: relayData,
+ }, nil
+}
+
+func (d *SignalingDriver) EndOutgoing(ctx context.Context, callID string, peer types.JID) error {
+ creator := d.socket.OwnLID()
+ if creator.IsEmpty() {
+ creator = d.socket.OwnPN()
+ }
+ if creator.IsEmpty() {
+ return fmt.Errorf("whatsapp client has no own JID")
+ }
+ resolvedPeer := d.socket.ResolveLIDForPN(ctx, peer)
+ node := signaling.BuildTerminateStanza(resolvedPeer, callID, creator)
+ if err := d.socket.SendNode(ctx, node); err != nil {
+ return fmt.Errorf("send call terminate: %w", err)
+ }
+ return nil
+}
diff --git a/pkg/call/voip/incoming/registry.go b/pkg/call/voip/incoming/registry.go
new file mode 100644
index 00000000..ec313362
--- /dev/null
+++ b/pkg/call/voip/incoming/registry.go
@@ -0,0 +1,518 @@
+// Package incoming keeps private material required by WhatsApp call negotiation.
+// Call keys, relay tokens and device metadata are intentionally separated from
+// public runtime snapshots and are never serialized.
+package incoming
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+const prepareTimeout = 30 * time.Second
+
+type callMaterial struct {
+ callKey []byte
+ peer types.JID
+ creator types.JID
+ video bool
+ relayData *core.RelayData
+ state *call_state.Info
+}
+
+type session struct {
+ mu sync.RWMutex
+ client *whatsmeow.Client
+ handlerID uint32
+ prepareIncoming bool
+ materials map[string]*callMaterial
+}
+
+func newSession(client *whatsmeow.Client, prepareIncoming ...bool) *session {
+ enabled := true
+ if len(prepareIncoming) > 0 {
+ enabled = prepareIncoming[0]
+ }
+ s := &session{
+ client: client,
+ prepareIncoming: enabled,
+ materials: make(map[string]*callMaterial),
+ }
+ if client != nil {
+ s.handlerID = client.AddEventHandler(s.handleEvent)
+ }
+ return s
+}
+
+func (s *session) usesClient(client *whatsmeow.Client) bool {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.client == client && client != nil
+}
+
+func (s *session) setPrepareIncoming(enabled bool) {
+ s.mu.Lock()
+ s.prepareIncoming = enabled
+ s.mu.Unlock()
+}
+
+func (s *session) handleEvent(rawEvent interface{}) {
+ switch event := rawEvent.(type) {
+ case *events.CallOffer:
+ s.mu.RLock()
+ prepareIncoming := s.prepareIncoming
+ s.mu.RUnlock()
+ if prepareIncoming {
+ go s.prepareOffer(event)
+ }
+ case *events.CallAccept:
+ _ = s.transition(event.CallID, call_state.Transition{Type: call_state.TransitionRemoteAccepted})
+ s.captureRelays(event.CallID, event.Data)
+ case *events.CallTransport:
+ s.captureRelays(event.CallID, event.Data)
+ case *events.CallReject:
+ _ = s.transition(event.CallID, call_state.Transition{
+ Type: call_state.TransitionRemoteRejected,
+ Reason: core.EndCallReasonDeclined,
+ })
+ s.remove(event.CallID)
+ case *events.CallTerminate:
+ reason := core.EndCallReason(event.Reason)
+ if reason == "" {
+ reason = core.EndCallReasonUnknown
+ }
+ _ = s.transition(event.CallID, call_state.Transition{Type: call_state.TransitionTerminated, Reason: reason})
+ s.remove(event.CallID)
+ case *events.Disconnected:
+ s.clear()
+ case *events.LoggedOut:
+ s.clear()
+ }
+}
+
+func (s *session) prepareOffer(event *events.CallOffer) {
+ if event == nil || event.CallID == "" || event.Data == nil {
+ return
+ }
+
+ s.mu.RLock()
+ client := s.client
+ prepareIncoming := s.prepareIncoming
+ s.mu.RUnlock()
+ if client == nil || !prepareIncoming {
+ return
+ }
+
+ peer := event.From
+ creator := event.CallCreator
+ if creator.IsEmpty() {
+ creator = peer
+ }
+ if peer.IsEmpty() {
+ peer = creator
+ }
+ if peer.IsEmpty() || creator.IsEmpty() {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), prepareTimeout)
+ defer cancel()
+
+ socket := wa.NewSocket(client)
+ callKey, err := signaling.DecryptCallKeyInNode(ctx, socket, event.Data, peer)
+ if err != nil || len(callKey) != 32 {
+ return
+ }
+ if !s.usesClient(client) {
+ zeroBytes(callKey)
+ return
+ }
+
+ video := signaling.NodeContainsVideo(event.Data)
+ mediaType := core.CallMediaTypeAudio
+ if video {
+ mediaType = core.CallMediaTypeVideo
+ }
+ material := &callMaterial{
+ callKey: append([]byte(nil), callKey...),
+ peer: peer,
+ creator: creator,
+ video: video,
+ relayData: relayDataFromNode(event.Data),
+ state: call_state.NewIncoming(event.CallID, peer.String(), creator.String(), mediaType),
+ }
+ zeroBytes(callKey)
+ s.store(event.CallID, material)
+
+ _ = socket.SendNode(ctx, signaling.BuildPreacceptStanza(peer, event.CallID, creator))
+}
+
+func (s *session) storeOutgoing(callID string, callKey []byte, peer, creator types.JID, video bool, relayData *core.RelayData) {
+ if callID == "" || len(callKey) == 0 || peer.IsEmpty() || creator.IsEmpty() {
+ return
+ }
+ mediaType := core.CallMediaTypeAudio
+ if video {
+ mediaType = core.CallMediaTypeVideo
+ }
+ state := call_state.NewOutgoing(callID, peer.String(), creator.String(), mediaType)
+ _ = state.Apply(call_state.Transition{Type: call_state.TransitionOfferSent})
+ s.store(callID, &callMaterial{
+ callKey: append([]byte(nil), callKey...),
+ peer: peer,
+ creator: creator,
+ video: video,
+ relayData: core.CloneRelayData(relayData),
+ state: state,
+ })
+}
+
+func (s *session) captureRelays(callID string, node *waBinary.Node) {
+ if callID == "" || node == nil {
+ return
+ }
+ relayData := relayDataFromNode(node)
+ if relayData == nil {
+ return
+ }
+
+ s.mu.Lock()
+ material := s.materials[callID]
+ if material == nil {
+ material = &callMaterial{}
+ s.materials[callID] = material
+ }
+ core.ZeroRelayData(material.relayData)
+ material.relayData = relayData
+ s.mu.Unlock()
+}
+
+func (s *session) accept(ctx context.Context, callID string) error {
+ material, ok := s.copyMaterial(callID)
+ if !ok || len(material.callKey) == 0 {
+ return fmt.Errorf("incoming call %s is not ready to accept", callID)
+ }
+ defer zeroMaterial(material)
+ if material.state == nil || !material.state.CanAccept() {
+ return fmt.Errorf("incoming call %s cannot be accepted in its current state", callID)
+ }
+
+ s.mu.RLock()
+ client := s.client
+ s.mu.RUnlock()
+ if client == nil {
+ return fmt.Errorf("incoming call session is detached")
+ }
+
+ socket := wa.NewSocket(client)
+ node, err := signaling.BuildAcceptStanza(
+ ctx,
+ socket,
+ callID,
+ material.callKey,
+ material.peer,
+ material.creator,
+ material.video,
+ )
+ if err != nil {
+ return fmt.Errorf("build call accept: %w", err)
+ }
+ if err := socket.SendNode(ctx, node); err != nil {
+ return fmt.Errorf("send call accept: %w", err)
+ }
+ return s.transition(callID, call_state.Transition{Type: call_state.TransitionLocalAccepted})
+}
+
+func (s *session) terminate(ctx context.Context, callID string) error {
+ material, ok := s.copyMaterial(callID)
+ if !ok || material.peer.IsEmpty() || material.creator.IsEmpty() {
+ return fmt.Errorf("call %s has no private signaling material", callID)
+ }
+ defer zeroMaterial(material)
+
+ s.mu.RLock()
+ client := s.client
+ s.mu.RUnlock()
+ if client == nil {
+ return fmt.Errorf("call session is detached")
+ }
+
+ node := signaling.BuildTerminateStanza(material.peer, callID, material.creator)
+ if err := wa.NewSocket(client).SendNode(ctx, node); err != nil {
+ return fmt.Errorf("send call terminate: %w", err)
+ }
+ _ = s.transition(callID, call_state.Transition{
+ Type: call_state.TransitionTerminated,
+ Reason: core.EndCallReasonUserEnded,
+ })
+ s.remove(callID)
+ return nil
+}
+
+func (s *session) transition(callID string, transition call_state.Transition) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ material := s.materials[callID]
+ if material == nil || material.state == nil {
+ return fmt.Errorf("call %s has no private state", callID)
+ }
+ return material.state.Apply(transition)
+}
+
+func (s *session) store(callID string, material *callMaterial) {
+ if callID == "" || material == nil {
+ return
+ }
+ s.mu.Lock()
+ if previous := s.materials[callID]; previous != nil {
+ if material.relayData == nil {
+ material.relayData = core.CloneRelayData(previous.relayData)
+ }
+ if material.state == nil {
+ material.state = previous.state.Clone()
+ }
+ zeroMaterial(previous)
+ }
+ s.materials[callID] = material
+ s.mu.Unlock()
+}
+
+func (s *session) copyMaterial(callID string) (*callMaterial, bool) {
+ s.mu.RLock()
+ material, ok := s.materials[callID]
+ if !ok || material == nil {
+ s.mu.RUnlock()
+ return nil, false
+ }
+ copyValue := &callMaterial{
+ callKey: append([]byte(nil), material.callKey...),
+ peer: material.peer,
+ creator: material.creator,
+ video: material.video,
+ relayData: core.CloneRelayData(material.relayData),
+ state: material.state.Clone(),
+ }
+ s.mu.RUnlock()
+ return copyValue, true
+}
+
+func (s *session) relayData(callID string) (*core.RelayData, bool) {
+ s.mu.RLock()
+ material := s.materials[callID]
+ if material == nil || material.relayData == nil {
+ s.mu.RUnlock()
+ return nil, false
+ }
+ data := core.CloneRelayData(material.relayData)
+ s.mu.RUnlock()
+ return data, true
+}
+
+func (s *session) state(callID string) (*call_state.Info, bool) {
+ s.mu.RLock()
+ material := s.materials[callID]
+ if material == nil || material.state == nil {
+ s.mu.RUnlock()
+ return nil, false
+ }
+ state := material.state.Clone()
+ s.mu.RUnlock()
+ return state, true
+}
+
+func (s *session) remove(callID string) {
+ s.mu.Lock()
+ if material := s.materials[callID]; material != nil {
+ zeroMaterial(material)
+ }
+ delete(s.materials, callID)
+ s.mu.Unlock()
+}
+
+func (s *session) clear() {
+ s.mu.Lock()
+ for callID, material := range s.materials {
+ if material != nil {
+ zeroMaterial(material)
+ }
+ delete(s.materials, callID)
+ }
+ s.mu.Unlock()
+}
+
+func (s *session) close() {
+ s.mu.Lock()
+ client := s.client
+ handlerID := s.handlerID
+ s.client = nil
+ s.handlerID = 0
+ s.mu.Unlock()
+
+ if client != nil && handlerID != 0 {
+ client.RemoveEventHandler(handlerID)
+ }
+ s.clear()
+}
+
+func relayDataFromNode(node *waBinary.Node) *core.RelayData {
+ if node == nil {
+ return nil
+ }
+
+ endpoints := signaling.ExtractRelayEndpoints(node)
+ parsed := signaling.ParseRelayFromAck(node)
+ if len(endpoints) == 0 {
+ endpoints = parsed.Relays
+ }
+ if len(endpoints) == 0 && parsed.UUID == "" && len(parsed.HBHKey) == 0 &&
+ len(parsed.ParticipantJIDs) == 0 && parsed.SelfPID == nil && parsed.PeerPID == nil {
+ return nil
+ }
+ return &core.RelayData{
+ Endpoints: endpoints,
+ ParticipantJIDs: parsed.ParticipantJIDs,
+ UUID: parsed.UUID,
+ SelfPID: parsed.SelfPID,
+ PeerPID: parsed.PeerPID,
+ HBHKey: parsed.HBHKey,
+ }
+}
+
+func zeroMaterial(material *callMaterial) {
+ if material == nil {
+ return
+ }
+ zeroBytes(material.callKey)
+ core.ZeroRelayData(material.relayData)
+ material.callKey = nil
+ material.peer = types.JID{}
+ material.creator = types.JID{}
+ material.relayData = nil
+ material.state = nil
+}
+
+func zeroBytes(value []byte) {
+ for index := range value {
+ value[index] = 0
+ }
+}
+
+// Registry stores one private call-negotiation session per Evolution instance.
+type Registry struct {
+ mu sync.RWMutex
+ sessions map[string]*session
+}
+
+func NewRegistry() *Registry {
+ return &Registry{sessions: make(map[string]*session)}
+}
+
+func (r *Registry) Attach(instanceID string, client *whatsmeow.Client, prepareIncoming ...bool) {
+ if instanceID == "" || client == nil {
+ return
+ }
+ enabled := true
+ if len(prepareIncoming) > 0 {
+ enabled = prepareIncoming[0]
+ }
+
+ r.mu.RLock()
+ current := r.sessions[instanceID]
+ if current != nil && current.usesClient(client) {
+ current.setPrepareIncoming(enabled)
+ r.mu.RUnlock()
+ return
+ }
+ r.mu.RUnlock()
+
+ candidate := newSession(client, enabled)
+
+ r.mu.Lock()
+ previous := r.sessions[instanceID]
+ r.sessions[instanceID] = candidate
+ r.mu.Unlock()
+
+ if previous != nil {
+ previous.close()
+ }
+}
+
+func (r *Registry) StoreOutgoing(instanceID, callID string, callKey []byte, peer, creator types.JID, video bool, relayData *core.RelayData) error {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s == nil {
+ return fmt.Errorf("call runtime is not attached for instance %s", instanceID)
+ }
+ s.storeOutgoing(callID, callKey, peer, creator, video, relayData)
+ return nil
+}
+
+func (r *Registry) RelayData(instanceID, callID string) (*core.RelayData, bool) {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s == nil {
+ return nil, false
+ }
+ return s.relayData(callID)
+}
+
+func (r *Registry) State(instanceID, callID string) (*call_state.Info, bool) {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s == nil {
+ return nil, false
+ }
+ return s.state(callID)
+}
+
+func (r *Registry) Accept(ctx context.Context, instanceID, callID string) error {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s == nil {
+ return fmt.Errorf("incoming call runtime is not attached for instance %s", instanceID)
+ }
+ return s.accept(ctx, callID)
+}
+
+func (r *Registry) Terminate(ctx context.Context, instanceID, callID string) error {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s == nil {
+ return fmt.Errorf("call runtime is not attached for instance %s", instanceID)
+ }
+ return s.terminate(ctx, callID)
+}
+
+func (r *Registry) Remove(instanceID, callID string) {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s != nil {
+ s.remove(callID)
+ }
+}
+
+func (r *Registry) Close(instanceID string) {
+ r.mu.Lock()
+ s := r.sessions[instanceID]
+ delete(r.sessions, instanceID)
+ r.mu.Unlock()
+ if s != nil {
+ s.close()
+ }
+}
diff --git a/pkg/call/voip/incoming/registry_test.go b/pkg/call/voip/incoming/registry_test.go
new file mode 100644
index 00000000..196f9d24
--- /dev/null
+++ b/pkg/call/voip/incoming/registry_test.go
@@ -0,0 +1,157 @@
+package incoming
+
+import (
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func newTestSession() *session {
+ return &session{materials: make(map[string]*callMaterial), prepareIncoming: true}
+}
+
+func TestMaterialCopyIsIndependent(t *testing.T) {
+ s := newTestSession()
+ key := make([]byte, 32)
+ for index := range key {
+ key[index] = byte(index + 1)
+ }
+ originalToken := []byte{7, 8, 9}
+ s.store("call-1", &callMaterial{
+ callKey: key,
+ peer: types.NewJID("5511999999999", types.DefaultUserServer),
+ creator: types.NewJID("5511999999999", types.HiddenUserServer),
+ relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{
+ IP: "1.2.3.4",
+ RawToken: originalToken,
+ }}},
+ })
+
+ copyValue, ok := s.copyMaterial("call-1")
+ if !ok {
+ t.Fatal("expected material copy")
+ }
+ copyValue.callKey[0] = 99
+ copyValue.relayData.Endpoints[0].RawToken[0] = 99
+ stored, _ := s.copyMaterial("call-1")
+ if stored.callKey[0] != 1 {
+ t.Fatal("mutating a material copy changed the stored key")
+ }
+ if stored.relayData.Endpoints[0].RawToken[0] != 7 {
+ t.Fatal("mutating a relay copy changed the stored token")
+ }
+ zeroMaterial(copyValue)
+ zeroMaterial(stored)
+}
+
+func TestRemoveZeroesStoredMaterial(t *testing.T) {
+ s := newTestSession()
+ key := make([]byte, 32)
+ for index := range key {
+ key[index] = 7
+ }
+ token := []byte{4, 5, 6}
+ s.store("call-1", &callMaterial{
+ callKey: key,
+ relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{
+ RawToken: token,
+ }}},
+ })
+ s.remove("call-1")
+
+ if _, ok := s.copyMaterial("call-1"); ok {
+ t.Fatal("material was not removed")
+ }
+ for index, value := range key {
+ if value != 0 {
+ t.Fatalf("key byte %d was not zeroed: %d", index, value)
+ }
+ }
+ for index, value := range token {
+ if value != 0 {
+ t.Fatalf("token byte %d was not zeroed: %d", index, value)
+ }
+ }
+}
+
+func TestClearZeroesAllKeys(t *testing.T) {
+ s := newTestSession()
+ first := []byte{1, 2, 3}
+ second := []byte{4, 5, 6}
+ s.store("first", &callMaterial{callKey: first})
+ s.store("second", &callMaterial{callKey: second})
+ s.clear()
+
+ for _, key := range [][]byte{first, second} {
+ for _, value := range key {
+ if value != 0 {
+ t.Fatalf("key was not zeroed: %v", key)
+ }
+ }
+ }
+}
+
+func TestTransportRelayUpdatePreservesCallKey(t *testing.T) {
+ s := newTestSession()
+ key := make([]byte, 32)
+ for index := range key {
+ key[index] = byte(index + 1)
+ }
+ s.store("call-1", &callMaterial{callKey: key})
+
+ node := &waBinary.Node{Content: []waBinary.Node{{
+ Tag: "relay",
+ Attrs: waBinary.Attrs{
+ "ip": "10.0.0.8",
+ "port": "3480",
+ "token": "relay-token",
+ },
+ }}}
+ s.captureRelays("call-1", node)
+
+ stored, ok := s.copyMaterial("call-1")
+ if !ok {
+ t.Fatal("expected stored material")
+ }
+ defer zeroMaterial(stored)
+ if len(stored.callKey) != 32 || stored.callKey[0] != 1 {
+ t.Fatal("relay update erased the call key")
+ }
+ if stored.relayData == nil || len(stored.relayData.Endpoints) != 1 {
+ t.Fatal("relay update was not stored")
+ }
+ if stored.relayData.Endpoints[0].IP != "10.0.0.8" {
+ t.Fatalf("unexpected relay IP: %s", stored.relayData.Endpoints[0].IP)
+ }
+}
+
+func TestStoreMergesRelayCapturedBeforeKey(t *testing.T) {
+ s := newTestSession()
+ node := &waBinary.Node{Content: []waBinary.Node{{
+ Tag: "relay",
+ Attrs: waBinary.Attrs{
+ "ip": "10.0.0.9",
+ "port": "3480",
+ "token": "relay-token",
+ },
+ }}}
+ s.captureRelays("call-1", node)
+
+ key := make([]byte, 32)
+ key[0] = 42
+ s.store("call-1", &callMaterial{callKey: key})
+
+ stored, ok := s.copyMaterial("call-1")
+ if !ok {
+ t.Fatal("expected stored material")
+ }
+ defer zeroMaterial(stored)
+ if stored.callKey[0] != 42 {
+ t.Fatal("call key was not stored")
+ }
+ if stored.relayData == nil || stored.relayData.Endpoints[0].IP != "10.0.0.9" {
+ t.Fatal("relay captured before key was not preserved")
+ }
+}
diff --git a/pkg/call/voip/incoming/relay_bridge.go b/pkg/call/voip/incoming/relay_bridge.go
new file mode 100644
index 00000000..57921028
--- /dev/null
+++ b/pkg/call/voip/incoming/relay_bridge.go
@@ -0,0 +1,64 @@
+package incoming
+
+import (
+ "fmt"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ waBinary "go.mau.fi/whatsmeow/binary"
+)
+
+// CaptureRelayNode merges relay metadata into private call material. Nothing is
+// copied into the public runtime snapshot.
+func (r *Registry) CaptureRelayNode(instanceID, callID string, node *waBinary.Node) {
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session != nil {
+ session.captureRelays(callID, node)
+ }
+}
+
+// EnsureRemoteAccepted idempotently advances an outgoing call to connecting.
+func (r *Registry) EnsureRemoteAccepted(instanceID, callID string) error {
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session == nil {
+ return fmt.Errorf("call runtime is not attached for instance %s", instanceID)
+ }
+ state, ok := session.state(callID)
+ if !ok {
+ return fmt.Errorf("call %s has no private state", callID)
+ }
+ switch state.StateData.State {
+ case core.CallStateConnecting, core.CallStateActive, core.CallStateOnHold:
+ return nil
+ case core.CallStateRinging:
+ return session.transition(callID, call_state.Transition{Type: call_state.TransitionRemoteAccepted})
+ default:
+ return fmt.Errorf("call %s cannot accept a remote answer in state %s", callID, state.StateData.State)
+ }
+}
+
+// MarkMediaConnected idempotently advances a negotiated call to active.
+func (r *Registry) MarkMediaConnected(instanceID, callID string) error {
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session == nil {
+ return fmt.Errorf("call runtime is not attached for instance %s", instanceID)
+ }
+ state, ok := session.state(callID)
+ if !ok {
+ return fmt.Errorf("call %s has no private state", callID)
+ }
+ switch state.StateData.State {
+ case core.CallStateActive, core.CallStateOnHold:
+ return nil
+ case core.CallStateConnecting:
+ return session.transition(callID, call_state.Transition{Type: call_state.TransitionMediaConnected})
+ default:
+ return fmt.Errorf("call %s cannot connect media in state %s", callID, state.StateData.State)
+ }
+}
diff --git a/pkg/call/voip/incoming/srtp_bridge.go b/pkg/call/voip/incoming/srtp_bridge.go
new file mode 100644
index 00000000..f35b3f96
--- /dev/null
+++ b/pkg/call/voip/incoming/srtp_bridge.go
@@ -0,0 +1,51 @@
+package incoming
+
+import (
+ "fmt"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media"
+)
+
+func (s *session) deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) {
+ if callID == "" {
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call ID is empty")
+ }
+ if selfDeviceJID == "" || peerDeviceJID == "" {
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("SRTP device JIDs are incomplete")
+ }
+
+ s.mu.RLock()
+ material := s.materials[callID]
+ if material == nil || len(material.callKey) == 0 {
+ s.mu.RUnlock()
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call %s has no private encryption key", callID)
+ }
+ callKey := append([]byte(nil), material.callKey...)
+ s.mu.RUnlock()
+ defer zeroBytes(callKey)
+
+ sendKeying, err := call_media.DerivePerJIDSRTPKey(callKey, selfDeviceJID)
+ if err != nil {
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive send SRTP keying: %w", err)
+ }
+ receiveKeying, err := call_media.DerivePerJIDSRTPKey(callKey, peerDeviceJID)
+ if err != nil {
+ sendKeying.Wipe()
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("derive receive SRTP keying for %s: %w", peerDeviceJID, err)
+ }
+ return sendKeying, receiveKeying, nil
+}
+
+// SRTPKeying derives per-device keying material without exposing the private
+// WhatsApp call key outside the negotiation registry. The caller owns both
+// returned values and must wipe them after constructing the SRTP session.
+func (r *Registry) SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) {
+ r.mu.RLock()
+ s := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if s == nil {
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, fmt.Errorf("call runtime is not attached for instance %s", instanceID)
+ }
+ return s.deriveSRTPKeying(callID, selfDeviceJID, peerDeviceJID)
+}
diff --git a/pkg/call/voip/incoming/srtp_bridge_test.go b/pkg/call/voip/incoming/srtp_bridge_test.go
new file mode 100644
index 00000000..eda149e6
--- /dev/null
+++ b/pkg/call/voip/incoming/srtp_bridge_test.go
@@ -0,0 +1,66 @@
+package incoming
+
+import (
+ "bytes"
+ "testing"
+
+ call_media "github.com/evolution-foundation/evolution-go/pkg/call/voip/media"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestSessionDerivesSRTPWithoutExposingCallKey(t *testing.T) {
+ callKey := bytes.Repeat([]byte{0x42}, 32)
+ session := newSession(nil)
+ peer := types.NewJID("5511999999999", types.HiddenUserServer)
+ creator := types.NewJID("5511000000000", types.HiddenUserServer)
+ session.storeOutgoing("call-1", callKey, peer, creator, false, nil)
+ defer session.clear()
+
+ const receiveCandidate = "5511999999999:99@hosted.lid"
+ send, receive, err := session.deriveSRTPKeying("call-1", "self:1@lid", receiveCandidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer send.Wipe()
+ defer receive.Wipe()
+
+ wantSend, err := call_media.DerivePerJIDSRTPKey(callKey, "self:1@lid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer wantSend.Wipe()
+ wantReceive, err := call_media.DerivePerJIDSRTPKey(callKey, receiveCandidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer wantReceive.Wipe()
+
+ if !bytes.Equal(send.MasterKey, wantSend.MasterKey) || !bytes.Equal(send.MasterSalt, wantSend.MasterSalt) {
+ t.Fatal("send SRTP keying mismatch")
+ }
+ if !bytes.Equal(receive.MasterKey, wantReceive.MasterKey) || !bytes.Equal(receive.MasterSalt, wantReceive.MasterSalt) {
+ t.Fatal("receive SRTP keying mismatch")
+ }
+
+ zeroBytes(send.MasterKey)
+ zeroBytes(send.MasterSalt)
+ again, againReceive, err := session.deriveSRTPKeying("call-1", "self:1@lid", receiveCandidate)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer again.Wipe()
+ defer againReceive.Wipe()
+ if bytes.Equal(again.MasterKey, make([]byte, len(again.MasterKey))) {
+ t.Fatal("wiping returned material modified the stored call key")
+ }
+}
+
+func TestSessionRejectsMissingSRTPMaterial(t *testing.T) {
+ session := newSession(nil)
+ if _, _, err := session.deriveSRTPKeying("missing", "self@lid", "peer@lid"); err == nil {
+ t.Fatal("expected missing call-key error")
+ }
+ if _, _, err := session.deriveSRTPKeying("", "self@lid", "peer@lid"); err == nil {
+ t.Fatal("expected empty call ID error")
+ }
+}
diff --git a/pkg/call/voip/incoming/state_integration_test.go b/pkg/call/voip/incoming/state_integration_test.go
new file mode 100644
index 00000000..ae7ce807
--- /dev/null
+++ b/pkg/call/voip/incoming/state_integration_test.go
@@ -0,0 +1,72 @@
+package incoming
+
+import (
+ "testing"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestOutgoingMaterialStartsRingingAndAcceptsRemote(t *testing.T) {
+ s := newTestSession()
+ peer := types.NewJID("5511999999999", types.HiddenUserServer)
+ creator := types.NewJID("5511000000000", types.DefaultUserServer)
+ key := make([]byte, 32)
+ key[0] = 9
+
+ s.storeOutgoing("call-out", key, peer, creator, false, &core.RelayData{
+ Endpoints: []core.RelayEndpoint{{IP: "10.0.0.1"}},
+ })
+ state, ok := s.state("call-out")
+ if !ok || state.StateData.State != core.CallStateRinging {
+ t.Fatalf("unexpected initial outgoing state: %+v", state)
+ }
+ if err := s.transition("call-out", call_state.Transition{Type: call_state.TransitionRemoteAccepted}); err != nil {
+ t.Fatalf("remote accept transition failed: %v", err)
+ }
+ state, _ = s.state("call-out")
+ if state.StateData.State != core.CallStateConnecting || state.StateData.AcceptedAt == nil {
+ t.Fatalf("unexpected accepted state: %+v", state.StateData)
+ }
+}
+
+func TestIncomingMaterialRejectsRemoteAcceptTransition(t *testing.T) {
+ s := newTestSession()
+ state := call_state.NewIncoming(
+ "call-in",
+ "peer@s.whatsapp.net",
+ "peer@s.whatsapp.net",
+ core.CallMediaTypeAudio,
+ )
+ s.store("call-in", &callMaterial{state: state})
+
+ if err := s.transition("call-in", call_state.Transition{Type: call_state.TransitionRemoteAccepted}); err == nil {
+ t.Fatal("incoming call unexpectedly accepted a remote-accepted transition")
+ }
+ stored, _ := s.state("call-in")
+ if stored.StateData.State != core.CallStateIncomingRinging {
+ t.Fatalf("invalid transition mutated state: %s", stored.StateData.State)
+ }
+}
+
+func TestStateCopyIsIndependent(t *testing.T) {
+ s := newTestSession()
+ state := call_state.NewIncoming(
+ "call-in",
+ "peer@s.whatsapp.net",
+ "peer@s.whatsapp.net",
+ core.CallMediaTypeAudio,
+ )
+ s.store("call-in", &callMaterial{state: state})
+
+ copyValue, ok := s.state("call-in")
+ if !ok {
+ t.Fatal("expected private state")
+ }
+ copyValue.StateData.State = core.CallStateEnded
+ stored, _ := s.state("call-in")
+ if stored.StateData.State != core.CallStateIncomingRinging {
+ t.Fatal("mutating state copy changed private state")
+ }
+}
diff --git a/pkg/call/voip/media/audio_jitter_integration_test.go b/pkg/call/voip/media/audio_jitter_integration_test.go
new file mode 100644
index 00000000..b77fdd64
--- /dev/null
+++ b/pkg/call/voip/media/audio_jitter_integration_test.go
@@ -0,0 +1,144 @@
+package media
+
+import (
+ "sync"
+ "testing"
+ "time"
+)
+
+type jitterIntegrationCodec struct {
+ mu sync.Mutex
+ decodedPayloads [][]byte
+ closed bool
+}
+
+func (c *jitterIntegrationCodec) Encode(pcm []float32) ([]byte, error) {
+ return []byte{1}, nil
+}
+
+func (c *jitterIntegrationCodec) Decode(payload []byte) ([]float32, error) {
+ c.mu.Lock()
+ c.decodedPayloads = append(c.decodedPayloads, append([]byte(nil), payload...))
+ c.mu.Unlock()
+ value := float32(-1)
+ if len(payload) > 0 {
+ value = float32(payload[0])
+ }
+ pcm := make([]float32, MLowFrameSize)
+ for index := range pcm {
+ pcm[index] = value
+ }
+ return pcm, nil
+}
+
+func (c *jitterIntegrationCodec) FrameSize() int { return MLowFrameSize }
+func (c *jitterIntegrationCodec) SampleRate() int { return MLowSampleRate }
+func (c *jitterIntegrationCodec) Close() {
+ c.mu.Lock()
+ c.closed = true
+ c.mu.Unlock()
+}
+
+func TestAudioRegistryReordersAndConcealsBeforePCM(t *testing.T) {
+ codec := &jitterIntegrationCodec{}
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ DisableSilence: true,
+ Jitter: JitterBufferOptions{
+ FrameDuration: 3 * time.Millisecond,
+ InitialDelayPackets: 2,
+ MaxPackets: 8,
+ MaxConcealmentPackets: 2,
+ },
+ }
+ registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options)
+ defer registry.Close("instance")
+
+ pcm := make(chan float32, 4)
+ registry.SetOnPCM(func(_, _ string, samples []float32) {
+ pcm <- samples[0]
+ })
+
+ if err := registry.HandleRTP("instance", "call", jitterPacket(102, 2920, 3)); err != nil {
+ t.Fatal(err)
+ }
+ if err := registry.HandleRTP("instance", "call", jitterPacket(100, 1000, 1)); err != nil {
+ t.Fatal(err)
+ }
+
+ want := []float32{1, -1, 3}
+ for index, expected := range want {
+ select {
+ case actual := <-pcm:
+ if actual != expected {
+ t.Fatalf("frame %d decoded %v, want %v", index, actual, expected)
+ }
+ case <-time.After(time.Second):
+ t.Fatalf("timed out waiting for PCM frame %d", index)
+ }
+ }
+
+ stats, ok := registry.JitterStats("instance", "call")
+ if !ok || stats.Delivered != 2 || stats.Concealed != 1 {
+ t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats)
+ }
+}
+
+func TestAudioRegistryIgnoresDuplicateAndLatePackets(t *testing.T) {
+ codec := &jitterIntegrationCodec{}
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ DisableSilence: true,
+ Jitter: JitterBufferOptions{
+ FrameDuration: 3 * time.Millisecond,
+ InitialDelayPackets: 1,
+ MaxPackets: 8,
+ MaxConcealmentPackets: 1,
+ },
+ }
+ registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options)
+ defer registry.Close("instance")
+
+ packet := jitterPacket(200, 1000, 1)
+ if err := registry.HandleRTP("instance", "call", packet); err != nil {
+ t.Fatal(err)
+ }
+ if err := registry.HandleRTP("instance", "call", packet); err != nil {
+ t.Fatalf("duplicate should be ignored, got %v", err)
+ }
+ time.Sleep(15 * time.Millisecond)
+ if err := registry.HandleRTP("instance", "call", packet); err != nil {
+ t.Fatalf("late packet should be ignored, got %v", err)
+ }
+
+ stats, ok := registry.JitterStats("instance", "call")
+ if !ok || stats.Duplicate != 1 || stats.Late != 1 {
+ t.Fatalf("unexpected jitter stats: ok=%v stats=%+v", ok, stats)
+ }
+}
+
+func TestAudioRegistryCloseStopsPlayoutBeforeCodecClose(t *testing.T) {
+ codec := &jitterIntegrationCodec{}
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ DisableSilence: true,
+ Jitter: JitterBufferOptions{
+ FrameDuration: time.Millisecond,
+ InitialDelayPackets: 1,
+ MaxPackets: 8,
+ MaxConcealmentPackets: 2,
+ },
+ }
+ registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options)
+ if err := registry.HandleRTP("instance", "call", jitterPacket(300, 1000, 1)); err != nil {
+ t.Fatal(err)
+ }
+ registry.Remove("instance", "call")
+
+ codec.mu.Lock()
+ closed := codec.closed
+ codec.mu.Unlock()
+ if !closed {
+ t.Fatal("codec was not closed after jitter playout stopped")
+ }
+}
diff --git a/pkg/call/voip/media/audio_pipeline.go b/pkg/call/voip/media/audio_pipeline.go
new file mode 100644
index 00000000..5dad4e13
--- /dev/null
+++ b/pkg/call/voip/media/audio_pipeline.go
@@ -0,0 +1,462 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "errors"
+ "fmt"
+ "math"
+ "sync"
+ "time"
+)
+
+var (
+ ErrAudioSessionNotReady = errors.New("audio codec session is not ready")
+ ErrAudioSenderUnavailable = errors.New("encoded audio sender is unavailable")
+)
+
+type CodecFactory func(options CodecOptions) (Codec, error)
+
+type EncodedAudioSender func(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) error
+
+type PCMCallback func(instanceID, callID string, pcm []float32)
+
+type AudioRegistryOptions struct {
+ CodecFactory CodecFactory
+ CodecOptions CodecOptions
+ SilenceTick time.Duration
+ SilenceAfter time.Duration
+ DisableSilence bool
+ Jitter JitterBufferOptions
+}
+
+func DefaultAudioRegistryOptions() AudioRegistryOptions {
+ return AudioRegistryOptions{
+ CodecFactory: NewMLowCodec,
+ CodecOptions: DefaultCodecOptions,
+ SilenceTick: 60 * time.Millisecond,
+ SilenceAfter: 120 * time.Millisecond,
+ Jitter: DefaultJitterBufferOptions(),
+ }
+}
+
+type audioSession struct {
+ mu sync.Mutex
+
+ instanceID string
+ callID string
+ codec Codec
+ sender EncodedAudioSender
+ onPCM func([]float32)
+ jitter *JitterBuffer
+
+ encodeBuffer []float32
+ encodePos int
+ marker bool
+ lastCapture time.Time
+ closed bool
+
+ silenceTick time.Duration
+ silenceAfter time.Duration
+ stopCh chan struct{}
+ doneCh chan struct{}
+ stopOnce sync.Once
+}
+
+func newAudioSession(instanceID, callID string, codec Codec, sender EncodedAudioSender, onPCM func([]float32), options AudioRegistryOptions) *audioSession {
+ session := &audioSession{
+ instanceID: instanceID,
+ callID: callID,
+ codec: codec,
+ sender: sender,
+ onPCM: onPCM,
+ encodeBuffer: make([]float32, codec.FrameSize()),
+ marker: true,
+ lastCapture: time.Now(),
+ silenceTick: options.SilenceTick,
+ silenceAfter: options.SilenceAfter,
+ stopCh: make(chan struct{}),
+ doneCh: make(chan struct{}),
+ }
+ session.jitter = NewJitterBuffer(&options.Jitter, session.handleJitterFrame)
+ if options.DisableSilence || options.SilenceTick <= 0 || options.SilenceAfter <= 0 {
+ close(session.doneCh)
+ } else {
+ go session.silenceLoop()
+ }
+ return session
+}
+
+func (s *audioSession) feedPCM(pcm []float32) error {
+ if s == nil {
+ return ErrAudioSessionNotReady
+ }
+ if len(pcm) == 0 {
+ return nil
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.closed || s.codec == nil {
+ return ErrAudioSessionNotReady
+ }
+ if s.sender == nil {
+ return ErrAudioSenderUnavailable
+ }
+
+ s.lastCapture = time.Now()
+ offset := 0
+ for offset < len(pcm) {
+ remaining := s.codec.FrameSize() - s.encodePos
+ count := min(remaining, len(pcm)-offset)
+ for index := 0; index < count; index++ {
+ s.encodeBuffer[s.encodePos+index] = sanitizePCMSample(pcm[offset+index])
+ }
+ s.encodePos += count
+ offset += count
+ if s.encodePos != s.codec.FrameSize() {
+ continue
+ }
+ if err := s.encodeAndSendLocked(s.encodeBuffer); err != nil {
+ return err
+ }
+ zeroFloat32(s.encodeBuffer)
+ s.encodePos = 0
+ }
+ return nil
+}
+
+func (s *audioSession) handleRTP(packet *RTPPacket) error {
+ if s == nil || packet == nil || packet.Header == nil {
+ return ErrAudioSessionNotReady
+ }
+ s.mu.Lock()
+ if s.closed || s.codec == nil || s.jitter == nil {
+ s.mu.Unlock()
+ return ErrAudioSessionNotReady
+ }
+ jitter := s.jitter
+ s.mu.Unlock()
+ return jitter.Push(packet)
+}
+
+func (s *audioSession) handleJitterFrame(frame JitterFrame) {
+ if s == nil {
+ return
+ }
+ s.mu.Lock()
+ if s.closed || s.codec == nil {
+ s.mu.Unlock()
+ return
+ }
+ payload := frame.Payload
+ if frame.Concealed {
+ payload = nil
+ }
+ decoded, err := s.codec.Decode(payload)
+ if err != nil {
+ s.mu.Unlock()
+ return
+ }
+ pcm := NormalizeFrame(decoded, s.codec.FrameSize())
+ callback := s.onPCM
+ s.mu.Unlock()
+ defer zeroFloat32(pcm)
+ if callback != nil {
+ callback(append([]float32(nil), pcm...))
+ }
+}
+
+func (s *audioSession) jitterStats() JitterBufferStats {
+ if s == nil {
+ return JitterBufferStats{}
+ }
+ s.mu.Lock()
+ jitter := s.jitter
+ s.mu.Unlock()
+ if jitter == nil {
+ return JitterBufferStats{}
+ }
+ return jitter.Stats()
+}
+
+func (s *audioSession) encodeAndSendLocked(frame []float32) error {
+ encoded, err := s.codec.Encode(frame)
+ if err != nil {
+ return fmt.Errorf("encode PCM frame: %w", err)
+ }
+ if len(encoded) == 0 {
+ return nil
+ }
+ defer zeroBytes(encoded)
+ if err = s.sender(s.instanceID, s.callID, encoded, uint32(s.codec.FrameSize()), s.marker); err != nil {
+ return fmt.Errorf("send encoded audio frame: %w", err)
+ }
+ s.marker = false
+ return nil
+}
+
+func (s *audioSession) silenceLoop() {
+ defer close(s.doneCh)
+ ticker := time.NewTicker(s.silenceTick)
+ defer ticker.Stop()
+ silence := make([]float32, s.codec.FrameSize())
+ defer zeroFloat32(silence)
+
+ for {
+ select {
+ case <-s.stopCh:
+ return
+ case <-ticker.C:
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ idle := time.Since(s.lastCapture) >= s.silenceAfter
+ ready := s.codec != nil && s.sender != nil
+ if idle && ready {
+ _ = s.encodeAndSendLocked(silence)
+ }
+ s.mu.Unlock()
+ }
+ }
+}
+
+func (s *audioSession) close() {
+ if s == nil {
+ return
+ }
+ s.stopOnce.Do(func() { close(s.stopCh) })
+ <-s.doneCh
+
+ s.mu.Lock()
+ if s.closed {
+ s.mu.Unlock()
+ return
+ }
+ s.closed = true
+ jitter := s.jitter
+ s.jitter = nil
+ s.mu.Unlock()
+
+ if jitter != nil {
+ jitter.Close()
+ }
+
+ s.mu.Lock()
+ if s.codec != nil {
+ s.codec.Close()
+ }
+ zeroFloat32(s.encodeBuffer)
+ s.codec = nil
+ s.sender = nil
+ s.onPCM = nil
+ s.encodeBuffer = nil
+ s.encodePos = 0
+ s.marker = false
+ s.lastCapture = time.Time{}
+ s.mu.Unlock()
+}
+
+func sanitizePCMSample(sample float32) float32 {
+ value := float64(sample)
+ if math.IsNaN(value) || math.IsInf(value, 0) {
+ return 0
+ }
+ if sample > 1 {
+ return 1
+ }
+ if sample < -1 {
+ return -1
+ }
+ return sample
+}
+
+// AudioRegistry owns one codec, jitter buffer and PCM accumulator per call. It
+// is independent from HTTP and device APIs so browser, native and test bridges
+// can reuse it.
+type AudioRegistry struct {
+ mu sync.RWMutex
+
+ options AudioRegistryOptions
+ sender EncodedAudioSender
+ sessions map[string]map[string]*audioSession
+ onPCM PCMCallback
+}
+
+func NewAudioRegistry(sender EncodedAudioSender, options *AudioRegistryOptions) *AudioRegistry {
+ resolved := DefaultAudioRegistryOptions()
+ if options != nil {
+ resolved = *options
+ if resolved.CodecFactory == nil {
+ resolved.CodecFactory = NewMLowCodec
+ }
+ if resolved.SilenceTick == 0 {
+ resolved.SilenceTick = 60 * time.Millisecond
+ }
+ if resolved.SilenceAfter == 0 {
+ resolved.SilenceAfter = 120 * time.Millisecond
+ }
+ jitterDefaults := DefaultJitterBufferOptions()
+ if resolved.Jitter.FrameDuration <= 0 {
+ resolved.Jitter.FrameDuration = jitterDefaults.FrameDuration
+ }
+ if resolved.Jitter.InitialDelayPackets <= 0 {
+ resolved.Jitter.InitialDelayPackets = jitterDefaults.InitialDelayPackets
+ }
+ if resolved.Jitter.MaxPackets <= 0 {
+ resolved.Jitter.MaxPackets = jitterDefaults.MaxPackets
+ }
+ if resolved.Jitter.MaxConcealmentPackets <= 0 {
+ resolved.Jitter.MaxConcealmentPackets = jitterDefaults.MaxConcealmentPackets
+ }
+ }
+ return &AudioRegistry{
+ options: resolved,
+ sender: sender,
+ sessions: make(map[string]map[string]*audioSession),
+ }
+}
+
+func (r *AudioRegistry) SetOnPCM(callback PCMCallback) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ r.onPCM = callback
+ r.mu.Unlock()
+}
+
+func (r *AudioRegistry) Prepare(instanceID, callID string) error {
+ if r == nil || instanceID == "" || callID == "" {
+ return ErrAudioSessionNotReady
+ }
+ r.mu.RLock()
+ if calls := r.sessions[instanceID]; calls != nil && calls[callID] != nil {
+ r.mu.RUnlock()
+ return nil
+ }
+ factory := r.options.CodecFactory
+ r.mu.RUnlock()
+ if factory == nil {
+ return ErrAudioSessionNotReady
+ }
+
+ codec, err := factory(r.options.CodecOptions)
+ if err != nil {
+ return fmt.Errorf("create MLow codec: %w", err)
+ }
+ candidate := newAudioSession(instanceID, callID, codec, r.sender, func(pcm []float32) {
+ r.emitPCM(instanceID, callID, pcm)
+ }, r.options)
+
+ r.mu.Lock()
+ calls := r.sessions[instanceID]
+ if calls == nil {
+ calls = make(map[string]*audioSession)
+ r.sessions[instanceID] = calls
+ }
+ if existing := calls[callID]; existing != nil {
+ r.mu.Unlock()
+ candidate.close()
+ return nil
+ }
+ calls[callID] = candidate
+ r.mu.Unlock()
+ return nil
+}
+
+func (r *AudioRegistry) FeedPCM(instanceID, callID string, pcm []float32) error {
+ session, err := r.session(instanceID, callID, true)
+ if err != nil {
+ return err
+ }
+ return session.feedPCM(pcm)
+}
+
+func (r *AudioRegistry) HandleRTP(instanceID, callID string, packet *RTPPacket) error {
+ session, err := r.session(instanceID, callID, true)
+ if err != nil {
+ return err
+ }
+ err = session.handleRTP(packet)
+ if errors.Is(err, ErrJitterDuplicatePacket) || errors.Is(err, ErrJitterLatePacket) {
+ return nil
+ }
+ return err
+}
+
+func (r *AudioRegistry) emitPCM(instanceID, callID string, pcm []float32) {
+ r.mu.RLock()
+ callback := r.onPCM
+ r.mu.RUnlock()
+ if callback != nil {
+ callback(instanceID, callID, append([]float32(nil), pcm...))
+ }
+}
+
+func (r *AudioRegistry) JitterStats(instanceID, callID string) (JitterBufferStats, bool) {
+ session, err := r.session(instanceID, callID, false)
+ if err != nil {
+ return JitterBufferStats{}, false
+ }
+ return session.jitterStats(), true
+}
+
+func (r *AudioRegistry) session(instanceID, callID string, lazy bool) (*audioSession, error) {
+ if r == nil {
+ return nil, ErrAudioSessionNotReady
+ }
+ r.mu.RLock()
+ calls := r.sessions[instanceID]
+ session := calls[callID]
+ r.mu.RUnlock()
+ if session != nil {
+ return session, nil
+ }
+ if lazy {
+ if err := r.Prepare(instanceID, callID); err != nil {
+ return nil, err
+ }
+ r.mu.RLock()
+ session = r.sessions[instanceID][callID]
+ r.mu.RUnlock()
+ if session != nil {
+ return session, nil
+ }
+ }
+ return nil, ErrAudioSessionNotReady
+}
+
+func (r *AudioRegistry) Remove(instanceID, callID string) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ calls := r.sessions[instanceID]
+ session := calls[callID]
+ delete(calls, callID)
+ if len(calls) == 0 {
+ delete(r.sessions, instanceID)
+ }
+ r.mu.Unlock()
+ if session != nil {
+ session.close()
+ }
+}
+
+func (r *AudioRegistry) Close(instanceID string) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ sessions := r.sessions[instanceID]
+ delete(r.sessions, instanceID)
+ r.mu.Unlock()
+ for callID, session := range sessions {
+ if session != nil {
+ session.close()
+ }
+ delete(sessions, callID)
+ }
+}
diff --git a/pkg/call/voip/media/audio_pipeline_test.go b/pkg/call/voip/media/audio_pipeline_test.go
new file mode 100644
index 00000000..2b2f2ba5
--- /dev/null
+++ b/pkg/call/voip/media/audio_pipeline_test.go
@@ -0,0 +1,211 @@
+package media
+
+import (
+ "math"
+ "sync"
+ "testing"
+ "time"
+)
+
+type fakeAudioCodec struct {
+ mu sync.Mutex
+ frames [][]float32
+ closed bool
+ decoded float32
+}
+
+func (c *fakeAudioCodec) Encode(pcm []float32) ([]byte, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ frame := append([]float32(nil), pcm...)
+ c.frames = append(c.frames, frame)
+ return []byte{byte(len(c.frames)), 0x7f}, nil
+}
+
+func (c *fakeAudioCodec) Decode(frame []byte) ([]float32, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ pcm := make([]float32, MLowFrameSize)
+ for index := range pcm {
+ pcm[index] = c.decoded
+ }
+ return pcm, nil
+}
+
+func (c *fakeAudioCodec) FrameSize() int { return MLowFrameSize }
+func (c *fakeAudioCodec) SampleRate() int { return MLowSampleRate }
+func (c *fakeAudioCodec) Close() {
+ c.mu.Lock()
+ c.closed = true
+ c.mu.Unlock()
+}
+
+type sentAudioFrame struct {
+ payload []byte
+ duration uint32
+ marker bool
+}
+
+func TestAudioRegistryBuffersSanitizesAndSendsPCM(t *testing.T) {
+ codec := &fakeAudioCodec{}
+ var mu sync.Mutex
+ var sent []sentAudioFrame
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ DisableSilence: true,
+ }
+ registry := NewAudioRegistry(func(_ string, _ string, payload []byte, duration uint32, marker bool) error {
+ mu.Lock()
+ sent = append(sent, sentAudioFrame{payload: append([]byte(nil), payload...), duration: duration, marker: marker})
+ mu.Unlock()
+ return nil
+ }, options)
+ defer registry.Close("instance")
+
+ first := make([]float32, MLowFrameSize/2)
+ first[0] = float32(math.NaN())
+ first[1] = 2
+ if err := registry.FeedPCM("instance", "call", first); err != nil {
+ t.Fatal(err)
+ }
+ mu.Lock()
+ if len(sent) != 0 {
+ t.Fatalf("partial PCM unexpectedly sent %d frames", len(sent))
+ }
+ mu.Unlock()
+
+ second := make([]float32, MLowFrameSize/2)
+ second[0] = -2
+ if err := registry.FeedPCM("instance", "call", second); err != nil {
+ t.Fatal(err)
+ }
+ if err := registry.FeedPCM("instance", "call", make([]float32, MLowFrameSize)); err != nil {
+ t.Fatal(err)
+ }
+
+ mu.Lock()
+ defer mu.Unlock()
+ if len(sent) != 2 {
+ t.Fatalf("sent %d frames, want 2", len(sent))
+ }
+ if !sent[0].marker || sent[1].marker {
+ t.Fatalf("unexpected marker sequence: %+v", sent)
+ }
+ if sent[0].duration != MLowFrameSize {
+ t.Fatalf("duration=%d, want %d", sent[0].duration, MLowFrameSize)
+ }
+
+ codec.mu.Lock()
+ defer codec.mu.Unlock()
+ if len(codec.frames) != 2 {
+ t.Fatalf("codec received %d frames", len(codec.frames))
+ }
+ if codec.frames[0][0] != 0 || codec.frames[0][1] != 1 || codec.frames[0][MLowFrameSize/2] != -1 {
+ t.Fatalf("PCM sanitization failed: %v %v %v", codec.frames[0][0], codec.frames[0][1], codec.frames[0][MLowFrameSize/2])
+ }
+}
+
+func TestAudioRegistryDecodesRTPToPCMCallback(t *testing.T) {
+ codec := &fakeAudioCodec{decoded: 0.25}
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ DisableSilence: true,
+ }
+ registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error { return nil }, options)
+ defer registry.Close("instance")
+
+ called := make(chan []float32, 1)
+ registry.SetOnPCM(func(instanceID, callID string, pcm []float32) {
+ if instanceID != "instance" || callID != "call" {
+ t.Errorf("unexpected callback identity %s/%s", instanceID, callID)
+ }
+ called <- append([]float32(nil), pcm...)
+ })
+ packet := &RTPPacket{Header: &RTPHeader{Version: 2}, Payload: []byte{1, 2, 3}}
+ if err := registry.HandleRTP("instance", "call", packet); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case pcm := <-called:
+ if len(pcm) != MLowFrameSize || pcm[0] != 0.25 {
+ t.Fatalf("unexpected decoded PCM: len=%d first=%v", len(pcm), pcm[0])
+ }
+ case <-time.After(time.Second):
+ t.Fatal("PCM callback was not invoked")
+ }
+}
+
+func TestAudioRegistrySendsSilenceWhileIdle(t *testing.T) {
+ codec := &fakeAudioCodec{}
+ sent := make(chan sentAudioFrame, 4)
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ SilenceTick: 5 * time.Millisecond,
+ SilenceAfter: 5 * time.Millisecond,
+ }
+ registry := NewAudioRegistry(func(_ string, _ string, payload []byte, duration uint32, marker bool) error {
+ sent <- sentAudioFrame{payload: append([]byte(nil), payload...), duration: duration, marker: marker}
+ return nil
+ }, options)
+ if err := registry.Prepare("instance", "call"); err != nil {
+ t.Fatal(err)
+ }
+ defer registry.Close("instance")
+
+ select {
+ case frame := <-sent:
+ if !frame.marker || frame.duration != MLowFrameSize {
+ t.Fatalf("unexpected silence frame: %+v", frame)
+ }
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("silence keepalive was not sent")
+ }
+}
+
+func TestAudioRegistryRemoveWaitsForInFlightSend(t *testing.T) {
+ codec := &fakeAudioCodec{}
+ entered := make(chan struct{})
+ release := make(chan struct{})
+ options := &AudioRegistryOptions{
+ CodecFactory: func(CodecOptions) (Codec, error) { return codec, nil },
+ DisableSilence: true,
+ }
+ registry := NewAudioRegistry(func(string, string, []byte, uint32, bool) error {
+ close(entered)
+ <-release
+ return nil
+ }, options)
+
+ feedDone := make(chan error, 1)
+ go func() {
+ feedDone <- registry.FeedPCM("instance", "call", make([]float32, MLowFrameSize))
+ }()
+ <-entered
+ removeDone := make(chan struct{})
+ go func() {
+ registry.Remove("instance", "call")
+ close(removeDone)
+ }()
+
+ select {
+ case <-removeDone:
+ t.Fatal("Remove returned while the sender was still in flight")
+ case <-time.After(20 * time.Millisecond):
+ }
+ close(release)
+ if err := <-feedDone; err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case <-removeDone:
+ case <-time.After(time.Second):
+ t.Fatal("Remove did not finish after the sender returned")
+ }
+
+ codec.mu.Lock()
+ closed := codec.closed
+ codec.mu.Unlock()
+ if !closed {
+ t.Fatal("codec was not closed")
+ }
+}
diff --git a/pkg/call/voip/media/codec.go b/pkg/call/voip/media/codec.go
new file mode 100644
index 00000000..ec33a0fc
--- /dev/null
+++ b/pkg/call/voip/media/codec.go
@@ -0,0 +1,42 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import "errors"
+
+const (
+ MLowSampleRate = 16000
+ MLowFrameSize = 960
+)
+
+var (
+ ErrCodecClosed = errors.New("audio codec is closed")
+ ErrInvalidPCMFrame = errors.New("invalid PCM frame size")
+)
+
+type Codec interface {
+ Encode(pcm []float32) ([]byte, error)
+ Decode(frame []byte) ([]float32, error)
+ FrameSize() int
+ SampleRate() int
+ Close()
+}
+
+type CodecOptions struct {
+ Bitrate int
+ Complexity int
+ FEC bool
+}
+
+var DefaultCodecOptions = CodecOptions{Bitrate: 6000, Complexity: 5, FEC: false}
+
+func NormalizeFrame(pcm []float32, samples int) []float32 {
+ if samples <= 0 {
+ return nil
+ }
+ if len(pcm) == samples {
+ return append([]float32(nil), pcm...)
+ }
+ normalized := make([]float32, samples)
+ copy(normalized, pcm)
+ return normalized
+}
diff --git a/pkg/call/voip/media/device_jid.go b/pkg/call/voip/media/device_jid.go
new file mode 100644
index 00000000..897d839c
--- /dev/null
+++ b/pkg/call/voip/media/device_jid.go
@@ -0,0 +1,29 @@
+package media
+
+import (
+ "strings"
+
+ "go.mau.fi/whatsmeow/types"
+)
+
+// ensureDeviceJIDString normalizes account-level JIDs to the device form used
+// by WhatsApp's SSRC and per-JID SRTP derivation. Relay participant entries
+// normally include a device number, while call accept events may only expose
+// the account-level LID/PN.
+func ensureDeviceJIDString(value string) string {
+ at := strings.IndexByte(value, '@')
+ if at <= 0 {
+ return value
+ }
+ if colon := strings.IndexByte(value[:at], ':'); colon >= 0 {
+ return value
+ }
+ return value[:at] + ":0" + value[at:]
+}
+
+func sameJIDAccount(left, right types.JID) bool {
+ if left.IsEmpty() || right.IsEmpty() {
+ return false
+ }
+ return left.User == right.User
+}
diff --git a/pkg/call/voip/media/device_selection.go b/pkg/call/voip/media/device_selection.go
new file mode 100644
index 00000000..fdc71000
--- /dev/null
+++ b/pkg/call/voip/media/device_selection.go
@@ -0,0 +1,48 @@
+package media
+
+import "go.mau.fi/whatsmeow/types"
+
+// selectCallDeviceJIDs chooses concrete device JIDs from the relay participant
+// list. The account-level peer LID and the call creator may use different user
+// identifiers, so the remote creator device is preferred for incoming calls.
+func selectCallDeviceJIDs(participants []string, ownJID, peerJID, creatorJID types.JID) (string, string) {
+ selfDevice := ensureDeviceJIDString(ownJID.String())
+ peerDevice := ensureDeviceJIDString(peerJID.String())
+ creatorIsRemote := !creatorJID.IsEmpty() && !sameJIDAccount(creatorJID, ownJID)
+
+ var exactPeer string
+ var creatorPeer string
+ var fallbackPeer string
+ for _, participant := range participants {
+ jid, err := types.ParseJID(participant)
+ if err != nil || jid.IsEmpty() {
+ continue
+ }
+ device := ensureDeviceJIDString(jid.String())
+ if sameJIDAccount(jid, ownJID) {
+ selfDevice = device
+ continue
+ }
+ if fallbackPeer == "" {
+ fallbackPeer = device
+ }
+ if sameJIDAccount(jid, peerJID) {
+ exactPeer = device
+ }
+ if creatorIsRemote && sameJIDAccount(jid, creatorJID) {
+ creatorPeer = device
+ }
+ }
+
+ switch {
+ case creatorPeer != "":
+ peerDevice = creatorPeer
+ case exactPeer != "":
+ peerDevice = exactPeer
+ case fallbackPeer != "":
+ peerDevice = fallbackPeer
+ case creatorIsRemote:
+ peerDevice = ensureDeviceJIDString(creatorJID.String())
+ }
+ return selfDevice, peerDevice
+}
diff --git a/pkg/call/voip/media/device_selection_test.go b/pkg/call/voip/media/device_selection_test.go
new file mode 100644
index 00000000..244fef12
--- /dev/null
+++ b/pkg/call/voip/media/device_selection_test.go
@@ -0,0 +1,37 @@
+package media
+
+import (
+ "testing"
+
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestSelectCallDeviceJIDsUsesConcreteRemoteParticipant(t *testing.T) {
+ self, peer := selectCallDeviceJIDs(
+ []string{
+ "15509143740569:3@lid",
+ "66155398054068:2@lid",
+ },
+ types.NewJID("15509143740569", types.HiddenUserServer),
+ types.NewJID("75741748277476", types.HiddenUserServer),
+ types.NewJID("66155398054068", types.HiddenUserServer),
+ )
+ if self != "15509143740569:3@lid" {
+ t.Fatalf("unexpected self device: %s", self)
+ }
+ if peer != "66155398054068:2@lid" {
+ t.Fatalf("expected creator participant as peer, got %s", peer)
+ }
+}
+
+func TestSelectCallDeviceJIDsNormalizesAccountFallback(t *testing.T) {
+ self, peer := selectCallDeviceJIDs(
+ nil,
+ types.NewJID("self", types.HiddenUserServer),
+ types.NewJID("peer", types.HiddenUserServer),
+ types.JID{},
+ )
+ if self != "self:0@lid" || peer != "peer:0@lid" {
+ t.Fatalf("unexpected normalized fallbacks: self=%s peer=%s", self, peer)
+ }
+}
diff --git a/pkg/call/voip/media/encryption.go b/pkg/call/voip/media/encryption.go
new file mode 100644
index 00000000..9bc9a2ce
--- /dev/null
+++ b/pkg/call/voip/media/encryption.go
@@ -0,0 +1,46 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "crypto/hkdf"
+ "crypto/sha256"
+ "fmt"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+const (
+ whatsAppCallKeyLength = 32
+ srtpHKDFOutputLength = 46
+ srtpMasterKeyLength = 16
+ srtpMasterSaltLength = 14
+)
+
+// DerivePerJIDSRTPKey derives the SRTP master key and salt bound to one
+// WhatsApp device JID. The caller owns the returned buffers and must wipe them.
+func DerivePerJIDSRTPKey(callKey []byte, deviceJID string) (core.SRTPKeyingMaterial, error) {
+ if len(callKey) != whatsAppCallKeyLength {
+ return core.SRTPKeyingMaterial{}, fmt.Errorf("invalid WhatsApp call key length: %d", len(callKey))
+ }
+ if deviceJID == "" {
+ return core.SRTPKeyingMaterial{}, fmt.Errorf("device JID is empty")
+ }
+
+ output, err := hkdf.Key(sha256.New, callKey, nil, deviceJID, srtpHKDFOutputLength)
+ if err != nil {
+ return core.SRTPKeyingMaterial{}, fmt.Errorf("derive SRTP key for device: %w", err)
+ }
+ defer zeroBytes(output)
+
+ material := core.SRTPKeyingMaterial{
+ MasterKey: append([]byte(nil), output[:srtpMasterKeyLength]...),
+ MasterSalt: append([]byte(nil), output[srtpMasterKeyLength:srtpMasterKeyLength+srtpMasterSaltLength]...),
+ }
+ return material, nil
+}
+
+func zeroBytes(value []byte) {
+ for index := range value {
+ value[index] = 0
+ }
+}
diff --git a/pkg/call/voip/media/jitter_buffer.go b/pkg/call/voip/media/jitter_buffer.go
new file mode 100644
index 00000000..bf7c5f7b
--- /dev/null
+++ b/pkg/call/voip/media/jitter_buffer.go
@@ -0,0 +1,341 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+ "time"
+)
+
+var (
+ ErrJitterBufferClosed = errors.New("jitter buffer is closed")
+ ErrJitterDuplicatePacket = errors.New("duplicate RTP packet")
+ ErrJitterLatePacket = errors.New("RTP packet arrived after its playout deadline")
+ ErrJitterBufferFull = errors.New("jitter buffer is full")
+)
+
+type JitterBufferOptions struct {
+ FrameDuration time.Duration
+ InitialDelayPackets int
+ MaxPackets int
+ MaxConcealmentPackets int
+}
+
+func DefaultJitterBufferOptions() JitterBufferOptions {
+ return JitterBufferOptions{
+ FrameDuration: 60 * time.Millisecond,
+ InitialDelayPackets: 2,
+ MaxPackets: 64,
+ MaxConcealmentPackets: 5,
+ }
+}
+
+type JitterFrame struct {
+ SequenceNumber uint16
+ Timestamp uint32
+ Marker bool
+ Payload []byte
+ Concealed bool
+}
+
+type JitterBufferStats struct {
+ Received uint64
+ Delivered uint64
+ Concealed uint64
+ Duplicate uint64
+ Late uint64
+ Overflow uint64
+}
+
+type bufferedRTP struct {
+ extendedSequence uint64
+ sequenceNumber uint16
+ timestamp uint32
+ marker bool
+ payload []byte
+}
+
+type JitterBuffer struct {
+ mu sync.Mutex
+
+ options JitterBufferOptions
+ onFrame func(JitterFrame)
+ packets map[uint64]*bufferedRTP
+
+ initialized bool
+ started bool
+ highestSequence uint64
+ nextSequence uint64
+ lastTimestamp uint32
+ hasTimestamp bool
+ firstArrival time.Time
+ consecutiveMissing int
+ closed bool
+ stats JitterBufferStats
+
+ stopCh chan struct{}
+ doneCh chan struct{}
+ stopOnce sync.Once
+}
+
+func NewJitterBuffer(options *JitterBufferOptions, onFrame func(JitterFrame)) *JitterBuffer {
+ resolved := DefaultJitterBufferOptions()
+ if options != nil {
+ resolved = *options
+ }
+ if resolved.FrameDuration <= 0 {
+ resolved.FrameDuration = 60 * time.Millisecond
+ }
+ if resolved.InitialDelayPackets <= 0 {
+ resolved.InitialDelayPackets = 1
+ }
+ if resolved.MaxPackets <= 0 {
+ resolved.MaxPackets = 64
+ }
+ if resolved.MaxConcealmentPackets <= 0 {
+ resolved.MaxConcealmentPackets = 1
+ }
+
+ buffer := &JitterBuffer{
+ options: resolved,
+ onFrame: onFrame,
+ packets: make(map[uint64]*bufferedRTP),
+ stopCh: make(chan struct{}),
+ doneCh: make(chan struct{}),
+ }
+ go buffer.playoutLoop()
+ return buffer
+}
+
+func (b *JitterBuffer) Push(packet *RTPPacket) error {
+ if b == nil {
+ return ErrJitterBufferClosed
+ }
+ if packet == nil || packet.Header == nil {
+ return fmt.Errorf("push jitter packet: RTP packet or header is nil")
+ }
+
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.closed {
+ return ErrJitterBufferClosed
+ }
+
+ extended := b.extendSequenceLocked(packet.Header.SequenceNumber)
+ if b.started && extended < b.nextSequence {
+ b.stats.Late++
+ return ErrJitterLatePacket
+ }
+ if _, exists := b.packets[extended]; exists {
+ b.stats.Duplicate++
+ return ErrJitterDuplicatePacket
+ }
+ if len(b.packets) >= b.options.MaxPackets {
+ b.stats.Overflow++
+ return ErrJitterBufferFull
+ }
+
+ if !b.initialized {
+ b.initialized = true
+ b.highestSequence = extended
+ b.firstArrival = time.Now()
+ } else if extended > b.highestSequence {
+ b.highestSequence = extended
+ }
+
+ b.packets[extended] = &bufferedRTP{
+ extendedSequence: extended,
+ sequenceNumber: packet.Header.SequenceNumber,
+ timestamp: packet.Header.Timestamp,
+ marker: packet.Header.Marker,
+ payload: append([]byte(nil), packet.Payload...),
+ }
+ b.stats.Received++
+
+ if !b.started && len(b.packets) >= b.options.InitialDelayPackets {
+ b.startLocked()
+ }
+ return nil
+}
+
+func (b *JitterBuffer) Stats() JitterBufferStats {
+ if b == nil {
+ return JitterBufferStats{}
+ }
+ b.mu.Lock()
+ stats := b.stats
+ b.mu.Unlock()
+ return stats
+}
+
+func (b *JitterBuffer) Buffered() int {
+ if b == nil {
+ return 0
+ }
+ b.mu.Lock()
+ count := len(b.packets)
+ b.mu.Unlock()
+ return count
+}
+
+func (b *JitterBuffer) Close() {
+ if b == nil {
+ return
+ }
+ b.stopOnce.Do(func() { close(b.stopCh) })
+ <-b.doneCh
+}
+
+func (b *JitterBuffer) playoutLoop() {
+ defer close(b.doneCh)
+ ticker := time.NewTicker(b.options.FrameDuration)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-b.stopCh:
+ b.mu.Lock()
+ b.closed = true
+ b.clearPacketsLocked()
+ b.mu.Unlock()
+ return
+ case now := <-ticker.C:
+ frame, ok := b.dequeue(now)
+ if !ok {
+ continue
+ }
+ if b.onFrame != nil {
+ b.onFrame(frame)
+ }
+ zeroBytes(frame.Payload)
+ }
+ }
+}
+
+func (b *JitterBuffer) dequeue(now time.Time) (JitterFrame, bool) {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+ if b.closed || !b.initialized {
+ return JitterFrame{}, false
+ }
+ if !b.started {
+ startupDelay := time.Duration(b.options.InitialDelayPackets) * b.options.FrameDuration
+ if len(b.packets) < b.options.InitialDelayPackets && now.Sub(b.firstArrival) < startupDelay {
+ return JitterFrame{}, false
+ }
+ b.startLocked()
+ }
+
+ if packet := b.packets[b.nextSequence]; packet != nil {
+ delete(b.packets, b.nextSequence)
+ frame := JitterFrame{
+ SequenceNumber: packet.sequenceNumber,
+ Timestamp: packet.timestamp,
+ Marker: packet.marker,
+ Payload: packet.payload,
+ }
+ packet.payload = nil
+ b.nextSequence++
+ b.lastTimestamp = frame.Timestamp
+ b.hasTimestamp = true
+ b.consecutiveMissing = 0
+ b.stats.Delivered++
+ return frame, true
+ }
+
+ // Only conceal a gap when a later packet proves that an expected sequence
+ // number is missing. At end-of-stream there is no future packet, so playout
+ // pauses instead of fabricating trailing audio or blocking teardown callbacks.
+ if len(b.packets) == 0 || b.highestSequence < b.nextSequence {
+ return JitterFrame{}, false
+ }
+
+ sequence := uint16(b.nextSequence)
+ timestamp := b.estimatedTimestampLocked()
+ b.nextSequence++
+ b.lastTimestamp = timestamp
+ b.hasTimestamp = true
+ b.consecutiveMissing++
+ b.stats.Concealed++
+ frame := JitterFrame{SequenceNumber: sequence, Timestamp: timestamp, Concealed: true}
+
+ if b.consecutiveMissing >= b.options.MaxConcealmentPackets {
+ b.resynchronizeLocked()
+ }
+ return frame, true
+}
+
+func (b *JitterBuffer) startLocked() {
+ if len(b.packets) == 0 {
+ return
+ }
+ b.nextSequence = b.minimumSequenceLocked()
+ b.started = true
+ b.consecutiveMissing = 0
+}
+
+func (b *JitterBuffer) resynchronizeLocked() {
+ b.consecutiveMissing = 0
+ if len(b.packets) == 0 {
+ b.initialized = false
+ b.started = false
+ b.highestSequence = 0
+ b.nextSequence = 0
+ b.lastTimestamp = 0
+ b.hasTimestamp = false
+ b.firstArrival = time.Time{}
+ return
+ }
+ b.nextSequence = b.minimumSequenceLocked()
+}
+
+func (b *JitterBuffer) extendSequenceLocked(sequence uint16) uint64 {
+ if !b.initialized {
+ // Start in epoch one so an out-of-order packet from the previous epoch can
+ // still be represented when sequence zero arrives before 65535.
+ return 1<<16 | uint64(sequence)
+ }
+ rollover := b.highestSequence >> 16
+ highestLow := uint16(b.highestSequence)
+ candidate := rollover<<16 | uint64(sequence)
+
+ if sequence < highestLow && highestLow-sequence > 0x8000 {
+ candidate += 1 << 16
+ } else if sequence > highestLow && sequence-highestLow > 0x8000 && rollover > 0 {
+ candidate -= 1 << 16
+ }
+ return candidate
+}
+
+func (b *JitterBuffer) minimumSequenceLocked() uint64 {
+ var minimum uint64
+ first := true
+ for sequence := range b.packets {
+ if first || sequence < minimum {
+ minimum = sequence
+ first = false
+ }
+ }
+ return minimum
+}
+
+func (b *JitterBuffer) estimatedTimestampLocked() uint32 {
+ if b.hasTimestamp {
+ return b.lastTimestamp + uint32(MLowFrameSize)
+ }
+ if next := b.packets[b.nextSequence+1]; next != nil {
+ return next.timestamp - uint32(MLowFrameSize)
+ }
+ return 0
+}
+
+func (b *JitterBuffer) clearPacketsLocked() {
+ for sequence, packet := range b.packets {
+ if packet != nil {
+ zeroBytes(packet.payload)
+ packet.payload = nil
+ }
+ delete(b.packets, sequence)
+ }
+}
diff --git a/pkg/call/voip/media/jitter_buffer_test.go b/pkg/call/voip/media/jitter_buffer_test.go
new file mode 100644
index 00000000..6fc806c1
--- /dev/null
+++ b/pkg/call/voip/media/jitter_buffer_test.go
@@ -0,0 +1,214 @@
+package media
+
+import (
+ "errors"
+ "reflect"
+ "testing"
+ "time"
+)
+
+func testJitterOptions() JitterBufferOptions {
+ return JitterBufferOptions{
+ FrameDuration: 3 * time.Millisecond,
+ InitialDelayPackets: 2,
+ MaxPackets: 8,
+ MaxConcealmentPackets: 2,
+ }
+}
+
+func jitterPacket(sequence uint16, timestamp uint32, value byte) *RTPPacket {
+ return &RTPPacket{
+ Header: &RTPHeader{
+ Version: 2,
+ SequenceNumber: sequence,
+ Timestamp: timestamp,
+ },
+ Payload: []byte{value},
+ }
+}
+
+func readJitterFrames(t *testing.T, frames <-chan JitterFrame, count int) []JitterFrame {
+ t.Helper()
+ result := make([]JitterFrame, 0, count)
+ deadline := time.After(time.Second)
+ for len(result) < count {
+ select {
+ case frame := <-frames:
+ result = append(result, frame)
+ case <-deadline:
+ t.Fatalf("timed out after %d/%d jitter frames", len(result), count)
+ }
+ }
+ return result
+}
+
+func TestJitterBufferReordersPackets(t *testing.T) {
+ options := testJitterOptions()
+ frames := make(chan JitterFrame, 4)
+ buffer := NewJitterBuffer(&options, func(frame JitterFrame) {
+ frame.Payload = append([]byte(nil), frame.Payload...)
+ frames <- frame
+ })
+ defer buffer.Close()
+
+ if err := buffer.Push(jitterPacket(11, 1960, 11)); err != nil {
+ t.Fatal(err)
+ }
+ if err := buffer.Push(jitterPacket(10, 1000, 10)); err != nil {
+ t.Fatal(err)
+ }
+
+ got := readJitterFrames(t, frames, 2)
+ if got[0].SequenceNumber != 10 || got[1].SequenceNumber != 11 {
+ t.Fatalf("unexpected order: %d, %d", got[0].SequenceNumber, got[1].SequenceNumber)
+ }
+ if !reflect.DeepEqual(got[0].Payload, []byte{10}) || !reflect.DeepEqual(got[1].Payload, []byte{11}) {
+ t.Fatalf("unexpected payloads: %v %v", got[0].Payload, got[1].Payload)
+ }
+}
+
+func TestJitterBufferConcealsGapThenContinues(t *testing.T) {
+ options := testJitterOptions()
+ frames := make(chan JitterFrame, 6)
+ buffer := NewJitterBuffer(&options, func(frame JitterFrame) {
+ frame.Payload = append([]byte(nil), frame.Payload...)
+ frames <- frame
+ })
+ defer buffer.Close()
+
+ if err := buffer.Push(jitterPacket(20, 1000, 20)); err != nil {
+ t.Fatal(err)
+ }
+ if err := buffer.Push(jitterPacket(22, 2920, 22)); err != nil {
+ t.Fatal(err)
+ }
+
+ got := readJitterFrames(t, frames, 3)
+ if got[0].SequenceNumber != 20 || got[0].Concealed {
+ t.Fatalf("unexpected first frame: %+v", got[0])
+ }
+ if got[1].SequenceNumber != 21 || !got[1].Concealed || len(got[1].Payload) != 0 {
+ t.Fatalf("missing packet was not concealed: %+v", got[1])
+ }
+ if got[1].Timestamp != 1960 {
+ t.Fatalf("concealed timestamp=%d, want 1960", got[1].Timestamp)
+ }
+ if got[2].SequenceNumber != 22 || got[2].Concealed {
+ t.Fatalf("unexpected recovery frame: %+v", got[2])
+ }
+
+ stats := buffer.Stats()
+ if stats.Delivered != 2 || stats.Concealed != 1 {
+ t.Fatalf("unexpected stats: %+v", stats)
+ }
+}
+
+func TestJitterBufferHandlesSequenceRollover(t *testing.T) {
+ options := testJitterOptions()
+ frames := make(chan JitterFrame, 4)
+ buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame })
+ defer buffer.Close()
+
+ if err := buffer.Push(jitterPacket(0, 1960, 0)); err != nil {
+ t.Fatal(err)
+ }
+ if err := buffer.Push(jitterPacket(65535, 1000, 1)); err != nil {
+ t.Fatal(err)
+ }
+
+ got := readJitterFrames(t, frames, 2)
+ if got[0].SequenceNumber != 65535 || got[1].SequenceNumber != 0 {
+ t.Fatalf("rollover order is %d, %d", got[0].SequenceNumber, got[1].SequenceNumber)
+ }
+}
+
+func TestJitterBufferRejectsDuplicateLateAndOverflow(t *testing.T) {
+ options := testJitterOptions()
+ options.MaxPackets = 2
+ frames := make(chan JitterFrame, 4)
+ buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame })
+ defer buffer.Close()
+
+ packet := jitterPacket(30, 1000, 1)
+ if err := buffer.Push(packet); err != nil {
+ t.Fatal(err)
+ }
+ if err := buffer.Push(packet); !errors.Is(err, ErrJitterDuplicatePacket) {
+ t.Fatalf("expected duplicate error, got %v", err)
+ }
+ if err := buffer.Push(jitterPacket(31, 1960, 2)); err != nil {
+ t.Fatal(err)
+ }
+ if err := buffer.Push(jitterPacket(32, 2920, 3)); !errors.Is(err, ErrJitterBufferFull) {
+ t.Fatalf("expected full error, got %v", err)
+ }
+
+ _ = readJitterFrames(t, frames, 2)
+ if err := buffer.Push(jitterPacket(30, 1000, 1)); !errors.Is(err, ErrJitterLatePacket) {
+ t.Fatalf("expected late error, got %v", err)
+ }
+
+ stats := buffer.Stats()
+ if stats.Duplicate != 1 || stats.Overflow != 1 || stats.Late != 1 {
+ t.Fatalf("unexpected stats: %+v", stats)
+ }
+}
+
+func TestJitterBufferStopsAfterBoundedConcealment(t *testing.T) {
+ options := testJitterOptions()
+ frames := make(chan JitterFrame, 8)
+ buffer := NewJitterBuffer(&options, func(frame JitterFrame) { frames <- frame })
+ defer buffer.Close()
+
+ if err := buffer.Push(jitterPacket(40, 1000, 1)); err != nil {
+ t.Fatal(err)
+ }
+ if err := buffer.Push(jitterPacket(45, 5800, 5)); err != nil {
+ t.Fatal(err)
+ }
+ got := readJitterFrames(t, frames, 4)
+ if got[0].SequenceNumber != 40 || got[0].Concealed {
+ t.Fatalf("unexpected first frame: %+v", got[0])
+ }
+ if got[1].SequenceNumber != 41 || !got[1].Concealed || got[2].SequenceNumber != 42 || !got[2].Concealed {
+ t.Fatalf("concealment was not bounded: %+v", got)
+ }
+ if got[3].SequenceNumber != 45 || got[3].Concealed {
+ t.Fatalf("buffer did not resynchronize to future packet: %+v", got[3])
+ }
+
+ select {
+ case extra := <-frames:
+ t.Fatalf("unbounded concealment produced extra frame: %+v", extra)
+ case <-time.After(25 * time.Millisecond):
+ }
+ stats := buffer.Stats()
+ if stats.Delivered != 2 || stats.Concealed != 2 {
+ t.Fatalf("unexpected bounded-concealment stats: %+v", stats)
+ }
+}
+
+func TestJitterBufferCopiesAndWipesOwnedPayload(t *testing.T) {
+ options := testJitterOptions()
+ options.InitialDelayPackets = 1
+ frames := make(chan JitterFrame, 1)
+ buffer := NewJitterBuffer(&options, func(frame JitterFrame) {
+ frame.Payload = append([]byte(nil), frame.Payload...)
+ frames <- frame
+ })
+ payload := []byte{1, 2, 3}
+ packet := jitterPacket(50, 1000, 0)
+ packet.Payload = payload
+ if err := buffer.Push(packet); err != nil {
+ t.Fatal(err)
+ }
+ payload[0] = 9
+ got := readJitterFrames(t, frames, 1)
+ if !reflect.DeepEqual(got[0].Payload, []byte{1, 2, 3}) {
+ t.Fatalf("jitter buffer retained caller payload: %v", got[0].Payload)
+ }
+ buffer.Close()
+ if err := buffer.Push(packet); !errors.Is(err, ErrJitterBufferClosed) {
+ t.Fatalf("expected closed error, got %v", err)
+ }
+}
diff --git a/pkg/call/voip/media/mlow/analysis.go b/pkg/call/voip/media/mlow/analysis.go
new file mode 100644
index 00000000..37729afc
--- /dev/null
+++ b/pkg/call/voip/media/mlow/analysis.go
@@ -0,0 +1,862 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math"
+
+// MLow encoder analysis — faithful port of analysis.rs (datasheets/mlow-encoder.md):
+// PCM → SmplFrameParams. Per internal frame: LPC front-end (window → FFT-autocorr →
+// A/NLSF) → bit-exact LSF quantizer → perceptual model + multi-stage pitch estimator
+// + voicing classifier → CELP excitation encode → candidate selection (voiced LTP /
+// unvoiced nrgres / silent), committed to a shadow synth for warm history, advancing
+// the entropy predictor mirror. Validated end-to-end by the tone round-trip.
+
+const (
+ smplLpcHistLen = 144 // C lpc_buf_mem
+ smplLpcPre = 96
+ smplLsfSurv = 6 // lsf_surv at complexity 8
+ smplWinnextWbLen = 32
+ smplLsfRdwAdj float32 = 1.1952286
+
+ smplMainBitRate = 20000
+ smplComplexity = 8
+
+ smplCelpLowRate = false
+ smplCelpPercRespLen = 32
+ smplCelpFcbSubfrlen = 80
+ smplCelpSubfrPerPacket = 12
+ smplPercRLen = smplCelpPercRespLen + 1 // 33
+ smplFcbTotSurv20msMax = 100
+ smplEncHpFcornerHz float32 = 35.0
+
+ smplPercEmphPitch float32 = -0.82
+ smplPitchPercRespLen = 17
+ smplPitchLagMax = 320
+ smplPitchLookaheadLen = 7
+ smplVoicedNormGain float64 = 1.0
+)
+
+// SmplEncoderState is the cross-frame analysis history (only the LPC-analysis input
+// history + the persistent sub-models persist; the decoder rebuilds synth per frame).
+type SmplEncoderState struct {
+ hist []float64
+ hpMA, hpAR [3]float32
+ hpSet bool
+ hpState [4]float32
+ celp *CelpEncoder
+ perc *PercModelState
+ percPrev []float32
+ bitrate *BitrateController
+ lpcHist []float32
+ prevLsfq []float32
+ prevVoiced bool
+ vad *SmplVadState
+ vuv VuvMode
+ hpPitchHist []float32
+ ltpBuf []float32
+ pitchEst PitchEstState
+}
+
+func unvoicedPitch() SmplPitchSynth { return SmplPitchSynth{} }
+
+type candidate struct {
+ ip SmplInternalParams
+ stage1 int32
+ grid int32
+ qsym [16]int32
+ pulseVec []int32
+ gainQ [4]int32
+ pitch SmplPitchSynth
+ silent bool
+}
+
+// celpFrameCtx is the borrowed CELP/perceptual state for one internal frame.
+type celpFrameCtx struct {
+ celp *CelpEncoder
+ perc *PercModelState
+ percPrev *[]float32
+ bitrate *BitrateController
+ hpN []float32
+ intf int
+ spActProb float32
+ codedAsActiveVoice bool
+ f2 [SmplFLen]float32
+ voicingStrength float32
+ vuv *VuvMode
+ hpPitchHist []float32
+ ltpBuf *[]float32
+ pitchEst *PitchEstState
+ percCorrs [][]float32
+ blockLags [SmplSubfrCount][2]float32
+}
+
+type frontEndLsf struct {
+ a [SmplLPCOrder + 1]float32
+ nlsf [SmplLPCOrder]float32
+ prevLsfq []float32
+ prevVoiced bool
+ intf int
+}
+
+// smplAnalyzeFrameSt turns one 60 ms PCM frame (960 f32 @16 kHz, ~[-1,1]) into params.
+func smplAnalyzeFrameSt(es *SmplEncoderState, pcm []float32) SmplFrameParams {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L857-L1046
+ need := SmplIntfLen * 3
+ if len(pcm) < need {
+ o := make([]float32, need)
+ copy(o, pcm)
+ pcm = o
+ }
+ synthT := LoadSmplSynthTables()
+
+ pcmI16 := make([]int16, need)
+ for i := 0; i < need; i++ {
+ v := math.Round(float64(pcm[i] * 32768.0))
+ if v > 32767 {
+ v = 32767
+ }
+ if v < -32768 {
+ v = -32768
+ }
+ pcmI16[i] = int16(v)
+ }
+ if es.vad == nil {
+ es.vad = NewSmplVadState()
+ }
+ vad := es.vad.ProcessPacket(pcmI16, SmplIntfLen)
+ spActProb := vad.VadResults
+ codedAsActiveVoice := vad.CodedAsActiveVoice
+
+ if !es.hpSet {
+ es.hpMA, es.hpAR = SmplGetHpCoefs(smplEncHpFcornerHz)
+ es.hpSet = true
+ }
+ pcmIn := append([]float32(nil), pcm[:need]...)
+ hp := make([]float32, need)
+ SmplFiltArma2(pcmIn, need, es.hpMA, es.hpAR, &es.hpState, hp)
+
+ x := make([]float64, SmplOrder+need)
+ if len(es.hist) >= SmplOrder {
+ copy(x[:SmplOrder], es.hist[len(es.hist)-SmplOrder:])
+ }
+ for i := 0; i < need; i++ {
+ x[SmplOrder+i] = float64(hp[i]) * 32768.0
+ }
+
+ shadow := NewSmplFrameSynth()
+ var prevNlsf []float32
+ var lstate SmplLsfState
+
+ if es.celp == nil {
+ es.celp = NewCelpEncoder(smplCelpLowRate, smplCelpPercRespLen, smplCelpFcbSubfrlen, smplCelpSubfrPerPacket)
+ }
+ if es.perc == nil {
+ es.perc = NewPercModelState()
+ }
+ if es.bitrate == nil {
+ es.bitrate = NewBitrateController()
+ }
+ if len(es.percPrev) != smplPercRLen {
+ es.percPrev = make([]float32, smplPercRLen)
+ }
+
+ resLead := SmplOrder + smplWinnextWbLen
+ xn := make([]float32, resLead+need)
+ if len(es.hist) >= resLead {
+ for i := 0; i < resLead; i++ {
+ xn[i] = float32(es.hist[len(es.hist)-resLead+i] / 32768.0)
+ }
+ }
+ copy(xn[resLead:resLead+need], hp[:need])
+
+ hpFull := make([]float32, smplLpcHistLen+need+smplWinnextWbLen)
+ if len(es.lpcHist) == smplLpcHistLen {
+ copy(hpFull[:smplLpcHistLen], es.lpcHist)
+ }
+ copy(hpFull[smplLpcHistLen:smplLpcHistLen+need], hp[:need])
+
+ hpPitchHist := make([]float32, smplPitchLagMax)
+ if len(es.hpPitchHist) == smplPitchLagMax {
+ copy(hpPitchHist, es.hpPitchHist)
+ }
+ es.hpPitchHist = append([]float32(nil), hp[need-smplPitchLagMax:need]...)
+
+ if len(es.ltpBuf) != MaxLTPBufLen {
+ es.ltpBuf = make([]float32, MaxLTPBufLen)
+ }
+
+ prevLsfq := append([]float32(nil), es.prevLsfq...)
+ prevVoiced := es.prevVoiced
+
+ var internal [3]SmplInternalParams
+ for f := 0; f < 3; f++ {
+ base := SmplOrder + f*SmplIntfLen
+ win := x[base-SmplOrder : base+SmplIntfLen]
+ nbase := resLead + f*SmplIntfLen
+ winN := xn[nbase-resLead : nbase+SmplIntfLen]
+
+ lpcStart := smplLpcHistLen - smplLpcPre + f*SmplIntfLen
+ var lpcbuf [SmplLPCBufLen]float32
+ copy(lpcbuf[:], hpFull[lpcStart:lpcStart+SmplLPCBufLen])
+ windowed := smplWindowLPC20(&lpcbuf, f < 2)
+ a, f2 := smplLPCAnalyzeWithF2(&windowed)
+ nlsf := smplA2NLSF16(a[:])
+
+ cs := celpFrameCtx{
+ celp: es.celp,
+ perc: es.perc,
+ percPrev: &es.percPrev,
+ bitrate: es.bitrate,
+ hpN: hp,
+ intf: f,
+ spActProb: spActProb[f],
+ codedAsActiveVoice: codedAsActiveVoice,
+ f2: f2,
+ vuv: &es.vuv,
+ hpPitchHist: hpPitchHist,
+ ltpBuf: &es.ltpBuf,
+ pitchEst: &es.pitchEst,
+ }
+ var feA [SmplLPCOrder + 1]float32
+ copy(feA[:], a[:])
+ var feNlsf [SmplLPCOrder]float32
+ copy(feNlsf[:], nlsf[:])
+ fe := frontEndLsf{a: feA, nlsf: feNlsf, prevLsfq: prevLsfq, prevVoiced: prevVoiced, intf: f}
+
+ ip, nlsfOut, voicedOut := smplAnalyzeInternal(synthT, shadow, &lstate, f, win, winN, prevNlsf, &fe, &cs)
+ prevNlsf = nlsfOut
+ prevLsfq = nlsfOut
+ prevVoiced = voicedOut
+ internal[f] = ip
+ if f == 2 {
+ es.pitchEst.ResetCond()
+ }
+ }
+
+ es.hist = append([]float64(nil), x[len(x)-(SmplOrder+smplWinnextWbLen):]...)
+ es.lpcHist = append([]float32(nil), hp[need-smplLpcHistLen:need]...)
+ es.prevLsfq = prevLsfq
+ es.prevVoiced = prevVoiced
+ return SmplFrameParams{TOC: 0x50, Config: 0, Internal: internal}
+}
+
+// quantize runs the bit-exact LSF quantizer + the C cond-coding condition.
+func (fe *frontEndLsf) quantize(synthT *SmplSynthTables, voiced int, prevNlsf []float32) (int32, [16]int32, []float32, [17]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1065-L1105
+ cond := (fe.prevVoiced == (voiced != 0)) && fe.intf > 0
+ var res LsfQuantResult
+ if cond && len(fe.prevLsfq) == SmplLPCOrder {
+ res = LsfQuantCond(fe.a[:], fe.nlsf[:], fe.prevLsfq, voiced, 0, smplLsfRdwAdj, smplLsfSurv)
+ } else {
+ res = LsfQuant(fe.a[:], fe.nlsf[:], voiced, 0, smplLsfRdwAdj, smplLsfSurv)
+ }
+ grid := res.Qi[0]
+ var stage2 [16]int32
+ copy(stage2[:], res.Qi[1:1+SmplLPCOrder])
+ committed := SmplReconstructNLSF(synthT, voiced, 0, int(grid), &stage2, prevNlsf)
+ aVq := SmplNLSF2A(committed)
+ var predcoef [17]float32
+ for i := 0; i < 17 && i < len(aVq); i++ {
+ predcoef[i] = aVq[i]
+ }
+ predcoef[0] = 1.0
+ return grid, stage2, committed, predcoef
+}
+
+func commitCandidate(synthT *SmplSynthTables, st *SmplFrameSynth, cand *candidate, prevNlsf []float32) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1108-L1151
+ if cand.silent {
+ nlsf := SmplReconstructNLSF(synthT, 0, 0, int(cand.ip.Lsf.Grid), &cand.ip.Lsf.Stage2, prevNlsf)
+ pulseVec := make([]int32, SmplIntfLen)
+ var st2 [16]int32 = cand.ip.Lsf.Stage2
+ SynthInternalFrame(synthT, st, 0, 0, int(cand.ip.Lsf.Grid), &st2, prevNlsf, pulseVec, &cand.gainQ, &cand.pitch)
+ return nlsf
+ }
+ var qsym [16]int32 = cand.qsym
+ _, nlsf := SynthInternalFrame(synthT, st, int(cand.stage1), 0, int(cand.grid), &qsym, prevNlsf, cand.pulseVec, &cand.gainQ, &cand.pitch)
+ return nlsf
+}
+
+func smplUnvoicedCandidate(synthT *SmplSynthTables, _ *SmplFrameSynth, win []float64, winN []float32, prevNlsf []float32, fe *frontEndLsf, cs *celpFrameCtx) candidate {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1153-L1273
+ frame := win[SmplOrder:]
+ r0 := smplAutocorr(frame, 0)[0]
+ if r0 <= 0.0 {
+ var flat [SmplSubfrCount][17]float32
+ for sf := range flat {
+ flat[sf][0] = 1.0
+ }
+ percCorrs := cs.percCorrs
+ runCelpSubframes(cs, &flat, make([]float32, SmplIntfLen), &[SmplSubfrCount][2]float32{}, percCorrs, SmplPercEmphUV, 0)
+ return smplSilentInternal(synthT)
+ }
+
+ bgrid, bsym, brec, _ := fe.quantize(synthT, 0, prevNlsf)
+ predcoefs, resLpc, interpolIdx := smplLsfInterpolSearch(brec, fe.prevLsfq, winN)
+
+ percCorrs := cs.percCorrs
+ celpOut := runCelpSubframes(cs, &predcoefs, resLpc, &[SmplSubfrCount][2]float32{}, percCorrs, SmplPercEmphUV, 0)
+
+ pulseVec := make([]int32, SmplIntfLen)
+ var fcbgIdx [4]int32
+ const main = 1
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ out := &celpOut[sf]
+ for _, v := range out.Pulses[main] {
+ sign := int32(1) + 2*(int32(v)>>15)
+ pos := int32(v)*sign - 1
+ if pos >= 0 && pos < int32(SmplSubfrLen) {
+ pulseVec[sf*SmplSubfrLen+int(pos)] += sign
+ }
+ }
+ fcbgIdx[sf] = int32(out.GainIdx[main])
+ }
+
+ var nrgres [4]float32
+ for sf := 0; sf < 4; sf++ {
+ res := resLpc[sf*SmplSubfrLen : (sf+1)*SmplSubfrLen]
+ var e float32
+ for _, v := range res {
+ e += v * v
+ }
+ nrgres[sf] = e / float32(SmplSubfrLen)
+ }
+ nq := QuantNrgRes4(&nrgres)
+ gm := nq.FrameQi
+ gd := nq.ShapeQi
+ gainQ := nq.DbqQ14
+
+ pp := smplBuildPulseParams(pulseVec)
+ gains := SmplGainParams{GainMain: gm, GainDelta: gd, NrgRes: [4]int32{-1, -1, -1, -1}}
+ for sf := 0; sf < 4; sf++ {
+ if pp.Subfr[sf] > 0 {
+ gains.NrgRes[sf] = fcbgIdx[sf]
+ } else {
+ gains.NrgRes[sf] = -1
+ }
+ }
+
+ return candidate{
+ ip: SmplInternalParams{
+ Lsf: SmplLsfParams{Stage1: 0, Grid: bgrid, Stage2: bsym, Extra: interpolIdx},
+ Pulses: pp,
+ Gains: gains,
+ },
+ stage1: 0,
+ grid: bgrid,
+ qsym: bsym,
+ pulseVec: pulseVec,
+ gainQ: gainQ,
+ pitch: unvoicedPitch(),
+ }
+}
+
+func runCelpSubframes(cs *celpFrameCtx, predcoefs *[SmplSubfrCount][17]float32, resLpc []float32, blockLags *[SmplSubfrCount][2]float32, percCorrs [][]float32, emph [2]float32, voiced int32) []CelpSubframeOut {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1279-L1359
+ percWght := percCorrsToWght(percCorrs, emph, smplCelpPercRespLen)
+ outs := make([]CelpSubframeOut, 0, SmplSubfrCount)
+
+ wnrgs := make([]float32, SmplSubfrCount)
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ res := resLpc[sf*SmplSubfrLen : (sf+1)*SmplSubfrLen]
+ const scale = 32768.0
+ var s float32
+ for _, v := range res {
+ s += (v * scale) * (v * scale)
+ }
+ wnrgs[sf] = s
+ }
+
+ enc := BitrateControllerInputs{
+ InternalSampleRate: 16000, PayloadSizeMs: 60, FecBitRate: 0, MainBitRate: smplMainBitRate,
+ Complexity: smplComplexity, UseFecRateCompensation: 0, UseDtx: 0, SubFrameImportanceFactor: 1.0,
+ }
+
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ wnrg := wnrgs[sf]
+ wnrgNext := wnrgs[sf]
+ if sf+1 < SmplSubfrCount {
+ wnrgNext = wnrgs[sf+1]
+ }
+ var nonflatness float32 = 2.0
+ if voiced != 0 {
+ nonflatness = 0.0
+ }
+ maxPulses, importance := cs.bitrate.control(&enc, 0, boolToInt(cs.codedAsActiveVoice), cs.spActProb, nonflatness, cs.voicingStrength, voiced, wnrg, wnrgNext, 0, 320, 80)
+ numsurv := make([]int16, smplMaxPulsesPerSf)
+ for i := range numsurv {
+ numsurv[i] = 1
+ }
+ totSurv := int32(1000 * (smplFcbTotSurv20msMax * smplCelpFcbSubfrlen) / (20 * 16000))
+ smplDistributeFcbSurv(numsurv, int32(maxPulses[1]), totSurv)
+
+ lags := []float32{blockLags[sf][0], blockLags[sf][1], blockLags[sf][1]}
+ res := resLpc[sf*SmplSubfrLen : (sf+1)*SmplSubfrLen]
+ pc := predcoefs[sf]
+ out := cs.celp.EncodeSubframe(res, &pc, percWght[sf], lags, importance, maxPulses, numsurv)
+ outs = append(outs, out)
+ }
+ return outs
+}
+
+// computePercCorrs computes the per-subframe perceptual autocorrelation (advances
+// perc state EXACTLY ONCE per internal frame).
+func computePercCorrs(cs *celpFrameCtx) [SmplSubfrCount][]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1367-L1397
+ const frameMs = 20
+ const shorter = 32
+ var corrs [SmplSubfrCount][]float32
+ for sf := 1; sf < SmplSubfrCount; sf += 2 {
+ start := cs.intf*SmplIntfLen + (sf-1)*SmplSubfrLen
+ xlen := 2*SmplSubfrLen + shorter
+ xsubfr := make([]float32, xlen)
+ for i := 0; i < xlen; i++ {
+ idx := start + i
+ if idx < len(cs.hpN) {
+ xsubfr[i] = cs.hpN[idx]
+ }
+ }
+ isLast := int32(0)
+ if cs.intf == 2 && sf == SmplSubfrCount-1 {
+ isLast = 1
+ }
+ r := SmplPercModel(cs.perc, xsubfr, xlen, frameMs, isLast, smplPercRLen)
+ even := make([]float32, smplPercRLen)
+ for i := 0; i < smplPercRLen; i++ {
+ var prev float32
+ if i < len(*cs.percPrev) {
+ prev = (*cs.percPrev)[i]
+ }
+ even[i] = 0.5 * (r[i] + prev)
+ }
+ corrs[sf-1] = even
+ *cs.percPrev = append([]float32(nil), r...)
+ corrs[sf] = r
+ }
+ return corrs
+}
+
+func percCorrsToWght(corrs [][]float32, emph [2]float32, respLen int) [][]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1401-L1414
+ idx := 0
+ if smplCelpLowRate {
+ idx = 1
+ }
+ out := make([][]float32, len(corrs))
+ for i, c := range corrs {
+ out[i] = SmplPercAc2a(c, smplPercRLen, emph[idx], respLen, SmplPercReg)
+ }
+ return out
+}
+
+func smplLsfInterpolSearch(brec, prevLsfq []float32, winN []float32) ([SmplSubfrCount][17]float32, []float32, int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1420-L1447
+ residualFor := func(idx int) ([SmplSubfrCount][17]float32, []float32, float32) {
+ pc4, _ := smplLPCInterpolIdx(brec, prevLsfq, idx, SmplNLSF2A)
+ var predcoefs [SmplSubfrCount][17]float32
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ predcoefs[sf] = pc4[sf]
+ }
+ res := make([]float32, SmplIntfLen)
+ var sumRms float32
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ r := smplAnalysisResidualSubfr(&predcoefs[sf], winN, sf)
+ var nrg float32
+ for _, v := range r {
+ nrg += v * v
+ }
+ sumRms += float32(math.Sqrt(float64(nrg + 1e-30)))
+ copy(res[sf*SmplSubfrLen:(sf+1)*SmplSubfrLen], r[:])
+ }
+ return predcoefs, res, sumRms
+ }
+ pc0, res0, rms0 := residualFor(0)
+ pc1, res1, rms1 := residualFor(1)
+ if rms1 < rms0*0.998 {
+ return pc1, res1, 1
+ }
+ return pc0, res0, 0
+}
+
+func smplAnalysisResidualSubfr(aSyn *[17]float32, winN []float32, sf int) [SmplSubfrLen]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1451-L1466
+ var res [SmplSubfrLen]float32
+ for n := 0; n < SmplSubfrLen; n++ {
+ idx := SmplOrder + sf*SmplSubfrLen + n
+ acc := winN[idx]
+ for j := 1; j <= SmplOrder; j++ {
+ acc += aSyn[j] * winN[idx-j]
+ }
+ res[n] = acc
+ }
+ return res
+}
+
+func smplSilentInternal(synthT *SmplSynthTables) candidate {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1468-L1500
+ var sym [16]int32
+ for k := 0; k < 16; k++ {
+ sym[k] = int32(len(synthT.Valtables[0][0][0][k]) / 2)
+ }
+ gm, gd, _ := smplRateControlGains(0.0)
+ return candidate{
+ ip: SmplInternalParams{
+ Lsf: SmplLsfParams{Stage1: 0, Grid: 0, Stage2: sym, Extra: 0},
+ Gains: SmplGainParams{GainMain: gm, GainDelta: gd, NrgRes: [4]int32{-1, -1, -1, -1}},
+ },
+ stage1: 0,
+ grid: 0,
+ qsym: sym,
+ pulseVec: make([]int32, SmplIntfLen),
+ pitch: unvoicedPitch(),
+ silent: true,
+ }
+}
+
+func smplAutocorr(x []float64, order int) []float64 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1502-L1513
+ n := len(x)
+ r := make([]float64, order+1)
+ for lag := 0; lag <= order; lag++ {
+ var s float64
+ for i := lag; i < n; i++ {
+ s += x[i] * x[i-lag]
+ }
+ r[lag] = s
+ }
+ return r
+}
+
+func smplBuildPulseParams(pulse []int32) SmplPulseParams {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1515-L1580
+ const p3 = 4
+ posPer := SmplIntfLen / p3
+ var pp SmplPulseParams
+ for sf := 0; sf < p3; sf++ {
+ var s int32
+ for n := sf * posPer; n < (sf+1)*posPer; n++ {
+ a := pulse[n]
+ if a < 0 {
+ a = -a
+ }
+ s += a
+ }
+ pp.Subfr[sf] = s
+ }
+ pp.Total = pp.Subfr[0] + pp.Subfr[1] + pp.Subfr[2] + pp.Subfr[3]
+
+ var magRuns []int32
+ var signs []int32
+ for sf := 0; sf < p3; sf++ {
+ if pp.Subfr[sf] <= 0 {
+ continue
+ }
+ basePos := posPer * sf
+ runPos := int32(basePos)
+ first := true
+ for n := basePos; n < basePos+posPer; n++ {
+ if pulse[n] == 0 {
+ continue
+ }
+ magv := pulse[n]
+ mag := magv
+ if mag < 0 {
+ mag = -mag
+ }
+ var m int32
+ if first {
+ m = int32(n) - int32(basePos)
+ } else {
+ m = int32(n) - runPos
+ }
+ magRuns = append(magRuns, m)
+ runPos = int32(n)
+ if mag > 1 {
+ for k := int32(0); k < mag-1; k++ {
+ magRuns = append(magRuns, 0)
+ }
+ }
+ if magv < 0 {
+ signs = append(signs, -1)
+ } else {
+ signs = append(signs, 1)
+ }
+ first = false
+ }
+ }
+ pp.MagRuns = magRuns
+
+ numPos := len(signs)
+ var signSyms []SmplRawSym
+ p := 0
+ for p < numPos {
+ nbits := numPos - p
+ if nbits > 15 {
+ nbits = 15
+ }
+ var sym uint32
+ for q := 0; q < nbits; q++ {
+ var bit uint32
+ if signs[p+q] > 0 {
+ bit = 1
+ }
+ sym |= bit << uint(nbits-1-q)
+ }
+ signSyms = append(signSyms, SmplRawSym{Sym: sym, Nbits: uint32(nbits)})
+ p += nbits
+ }
+ pp.SignSyms = signSyms
+ return pp
+}
+
+func smplRateControlGains(targetLinear float64) (int32, int32, int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1583-L1605
+ mem := LoadSmplMem()
+ cfgSel := uint32(2)
+ cb1 := int32(mem.I16(0xf35e0 + cfgSel*2))
+ gainTabAddr := uint32(0xf35f0)
+ bestD := math.Inf(1)
+ var bgm, bgd, bgq int32
+ for gm := int32(0); gm < 84; gm++ {
+ base7 := gm*cb1 - 0x154000
+ for gd := int32(0); gd < 98; gd++ {
+ cbv := int32(mem.I16(gainTabAddr + uint32(4*gd)*2))
+ gq := base7 + (cbv << 4)
+ d := math.Abs(SmplGainLin(gq) - targetLinear)
+ if d < bestD {
+ bestD = d
+ bgm, bgd, bgq = gm, gd, gq
+ }
+ }
+ }
+ return bgm, bgd, bgq
+}
+
+// buildLtpBuf rolls the persistent perceptually-weighted speech buffer and writes
+// this internal frame's weighted speech + lookahead into its tail.
+func buildLtpBuf(cs *celpFrameCtx, percCorrs [][]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1625-L1690
+ respPitch := percCorrsToWght(percCorrs, [2]float32{smplPercEmphPitch, smplPercEmphPitch}, smplPitchPercRespLen)
+ maxLen := MaxLTPBufLen
+ look := smplPitchLookaheadLen
+ framelen := SmplIntfLen
+ ltp := *cs.ltpBuf
+ keep := maxLen - framelen - look
+ copy(ltp[0:keep], ltp[framelen:framelen+keep])
+
+ frameStart := cs.intf*SmplIntfLen - smplWinnextWbLen
+ hist := smplPitchLagMax
+ sample := func(rel int) float32 {
+ idx := frameStart + rel
+ if idx >= 0 {
+ if idx < len(cs.hpN) {
+ return cs.hpN[idx]
+ }
+ return 0.0
+ }
+ if len(cs.hpPitchHist) == hist {
+ k := idx + hist
+ if k >= 0 {
+ return cs.hpPitchHist[k]
+ }
+ }
+ return 0.0
+ }
+ wOrigin := maxLen - SmplSubfrCount*SmplSubfrLen - look
+ for i := 0; i < SmplSubfrCount; i++ {
+ coef := respPitch[i]
+ for n := 0; n < SmplSubfrLen; n++ {
+ pos := i*SmplSubfrLen + n
+ res := sample(pos)
+ for j := 1; j < smplPitchPercRespLen; j++ {
+ res += coef[j] * sample(pos-j)
+ }
+ ltp[wOrigin+i*SmplSubfrLen+n] = res
+ }
+ }
+ coef := respPitch[SmplSubfrCount-1]
+ for n := 0; n < look; n++ {
+ pos := framelen + n
+ res := sample(pos)
+ for j := 1; j < smplPitchPercRespLen; j++ {
+ res += coef[j] * sample(pos-j)
+ }
+ ltp[maxLen-look+n] = res
+ }
+}
+
+func smplAnalyzeInternal(synthT *SmplSynthTables, st *SmplFrameSynth, lstate *SmplLsfState, intf int, win []float64, winN []float32, prevNlsf []float32, fe *frontEndLsf, cs *celpFrameCtx) (SmplInternalParams, []float32, bool) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1697-L1771
+ corrs := computePercCorrs(cs)
+ cs.percCorrs = corrs[:]
+ buildLtpBuf(cs, append([][]float32(nil), cs.percCorrs...))
+ f2 := cs.f2
+ ltpBuf := append([]float32(nil), (*cs.ltpBuf)...)
+ pr := SmplPitch(cs.pitchEst, ltpBuf, &f2, cs.codedAsActiveVoice)
+ lags8 := pr.Lags
+ lagSamples := pr.Lags[0]
+ vstr := SmplGetSignalMode(pr.Pitchcorr, lags8[:], pr.AvgLag, pr.HarmStrength, &f2, cs.spActProb, cs.vuv)
+ cs.voicingStrength = vstr
+ isVoicedDecision := vstr > 0.0 && cs.codedAsActiveVoice
+ if isVoicedDecision {
+ lstate.PrevLagSamples = lagSamples
+ } else {
+ lstate.PrevLagSamples = 0.0
+ }
+ if !isVoicedDecision {
+ cs.pitchEst.ResetCond()
+ lags8 = [8]float32{}
+ }
+
+ voicedLstate := *lstate
+ SmplAdvanceLsfState(&voicedLstate, intf, 1)
+ var vd *voicedDecision
+ if isVoicedDecision {
+ vd = smplVoicedDecisionForLag(pr.BlocksegIdx, &pr.Laginds, cs, &lags8)
+ }
+
+ var chosen candidate
+ var chosenLstate *SmplLsfState
+ var isVoiced bool
+ if vd != nil {
+ chosen = smplVoicedCandidate(synthT, win, prevNlsf, fe, cs, vd)
+ chosenLstate = &voicedLstate
+ isVoiced = true
+ } else {
+ chosen = smplUnvoicedCandidate(synthT, st, win, winN, prevNlsf, fe, cs)
+ isVoiced = false
+ }
+ committedNlsf := commitCandidate(synthT, st, &chosen, prevNlsf)
+ if chosen.stage1 == 1 {
+ *lstate = *chosenLstate
+ smplReplayPitchState(lstate, 4, chosen.ip.Pulses.Subfr, &chosen.ip.Pitch)
+ } else {
+ SmplAdvanceLsfState(lstate, intf, chosen.stage1)
+ }
+ return chosen.ip, committedNlsf, isVoiced
+}
+
+func smplReplayPitchState(st *SmplLsfState, p3 int32, subfrCounts [4]int32, pp *SmplPitchParams) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1776-L1794
+ take := int(p3)
+ if take > 4 {
+ take = 4
+ }
+ for sf := 0; sf < take; sf++ {
+ st.PrevGainIdx = pp.GainIdx[sf]
+ if subfrCounts[sf] > 0 {
+ st.PrevFiltIdx = pp.FiltIdx[sf]
+ }
+ }
+ tab := LoadPitchTables()
+ nblk, nidx := smplLagsPredictorAfter(tab, pp.BlocksegIdx, &pp.Laginds)
+ st.PrevLagblk = nblk
+ st.PrevLagidx = nidx
+}
+
+type voicedDecision struct {
+ pp SmplPitchParams
+ pitch SmplPitchSynth
+}
+
+func smplVoicedDecisionForLag(blocksegIdx int, laginds *[8]int32, cs *celpFrameCtx, lags8 *[8]float32) *voicedDecision {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1808-L1838
+ var blockLags8 [8]float32
+ for b := 0; b < 8; b++ {
+ v := float32(laginds[b])*0.5 + 32.0
+ if v > 320.0 {
+ v = 320.0
+ }
+ blockLags8[b] = v
+ }
+ *lags8 = blockLags8
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ cs.blockLags[sf] = [2]float32{blockLags8[2*sf], blockLags8[2*sf+1]}
+ }
+ var meanLag float32
+ for _, v := range blockLags8 {
+ meanLag += v
+ }
+ meanLag /= 8.0
+
+ pp := SmplPitchParams{GainIdx: [4]int32{5, 5, 5, 5}, BlocksegIdx: blocksegIdx, Laginds: *laginds}
+ pitch := SmplPitchSynth{Voiced: true, LagSubfr: [4]float64{float64(meanLag), float64(meanLag), float64(meanLag), float64(meanLag)}, NormGain: smplVoicedNormGain}
+ return &voicedDecision{pp: pp, pitch: pitch}
+}
+
+func smplVoicedCandidate(synthT *SmplSynthTables, win []float64, prevNlsf []float32, fe *frontEndLsf, cs *celpFrameCtx, vd *voicedDecision) candidate {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/analysis.rs#L1846-L1930
+ winN := make([]float32, len(win))
+ for i, v := range win {
+ winN[i] = float32(v / 32768.0)
+ }
+ gainQ := [4]int32{}
+
+ bgrid, bsym, brec, _ := fe.quantize(synthT, 1, prevNlsf)
+ pc4, _ := smplLPCInterpol(brec, fe.prevLsfq, SmplNLSF2A)
+ var predcoefs [SmplSubfrCount][17]float32
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ predcoefs[sf] = pc4[sf]
+ }
+ resLpc := make([]float32, SmplIntfLen)
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ r := smplAnalysisResidualSubfr(&predcoefs[sf], winN, sf)
+ copy(resLpc[sf*SmplSubfrLen:(sf+1)*SmplSubfrLen], r[:])
+ }
+
+ blockLags := cs.blockLags
+ percCorrs := cs.percCorrs
+ celpOut := runCelpSubframes(cs, &predcoefs, resLpc, &blockLags, percCorrs, SmplPercEmphV, 1)
+
+ const main = 1
+ pulseVec := make([]int32, SmplIntfLen)
+ var acbg, fcbg [4]int32
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ out := &celpOut[sf]
+ for _, v := range out.Pulses[main] {
+ sign := int32(1) + 2*(int32(v)>>15)
+ pos := int32(v)*sign - 1
+ if pos >= 0 && pos < int32(SmplSubfrLen) {
+ pulseVec[sf*SmplSubfrLen+int(pos)] += sign
+ }
+ }
+ ai := int32(out.AcbIdx[main])
+ if ai < 0 {
+ ai = 0
+ }
+ if ai > 15 {
+ ai = 15
+ }
+ acbg[sf] = ai
+ fi := int32(out.GainIdx[main])
+ if fi < 0 {
+ fi = 0
+ }
+ fcbg[sf] = fi
+ }
+ ppPulses := smplBuildPulseParams(pulseVec)
+ subfr := ppPulses.Subfr
+ pp := vd.pp
+ pp.GainIdx = acbg
+ for sf := 0; sf < 4; sf++ {
+ if subfr[sf] > 0 {
+ pp.FiltIdx[sf] = fcbg[sf]
+ } else {
+ pp.FiltIdx[sf] = -1
+ }
+ }
+
+ return candidate{
+ ip: SmplInternalParams{
+ Lsf: SmplLsfParams{Stage1: 1, Grid: bgrid, Stage2: bsym, Extra: 0},
+ Pulses: ppPulses,
+ HasPitch: true,
+ Pitch: pp,
+ },
+ stage1: 1,
+ grid: bgrid,
+ qsym: bsym,
+ pulseVec: pulseVec,
+ gainQ: gainQ,
+ pitch: vd.pitch,
+ }
+}
diff --git a/pkg/call/voip/media/mlow/cc_seed.bin b/pkg/call/voip/media/mlow/cc_seed.bin
new file mode 100644
index 00000000..004e32a0
Binary files /dev/null and b/pkg/call/voip/media/mlow/cc_seed.bin differ
diff --git a/pkg/call/voip/media/mlow/cc_tables.go b/pkg/call/voip/media/mlow/cc_tables.go
new file mode 100644
index 00000000..3e5319c0
--- /dev/null
+++ b/pkg/call/voip/media/mlow/cc_tables.go
@@ -0,0 +1,488 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "bytes"
+ "compress/zlib"
+ _ "embed"
+ "io"
+ "math/bits"
+)
+
+// Logical seed-built tables for the nrgres/gains (Group A/E), LTP gain (Group C),
+// and pulse (Group B) decode — built from a small DCMF seed (cc_seed.bin) instead
+// of read by absolute pointer off the old cc_blob heap window. Port of
+// smpl_cc_tables.rs. CDFs are the integer dcmf_to_cmf expansion; the split/runlen
+// pulse CDFs are computed from the SILK fixed-point model; the gain-reconstruction
+// rodata is carried verbatim. (Group D pitch lag/contour still uses SmplMem.)
+
+//go:embed cc_seed.bin
+var ccSeedBlob []byte
+
+const (
+ ccMaxPulsesPerSf = 40
+ ccRunlengthStep = 8
+ ccNumRunlenCmfs = 20 // SMPL_MAX_SF_LEN(160)/RUNLENGTH_STEP
+ ccSplitNumTables = ccMaxPulsesPerSf*4 - 1
+ ccFcbgOffsetSteps = 176
+ ccFcbgOffsetBuckets = 4
+ ccAcbgN = 16
+ ccAcbgRows = ccAcbgN + 1
+ ccFcbgVN = 34
+ ccFcbgVDeltaN = 67
+)
+
+// --- SILK fixed-point primitives (cc-prefixed to avoid the vad.go set) ---
+
+func ccSmulbb(a, b int32) int32 { return int32(int16(a)) * int32(int16(b)) }
+
+func ccSmlawb(a, b, c int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L18-L21
+ return int32(int64(a) + ((int64(b) * int64(int16(c))) >> 16))
+}
+
+func ccClzFrac(in int32) (int32, int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L24-L30
+ u := uint32(in)
+ lz := int32(bits.LeadingZeros32(u))
+ fracQ7 := int32(bits.RotateLeft32(u, -int((24-lz)&31))) & 0x7f
+ return lz, fracQ7
+}
+
+func ccLin2log(inLin int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L33-L37
+ lz, fracQ7 := ccClzFrac(inLin)
+ return ccSmlawb(fracQ7, fracQ7*(128-fracQ7), 179) + ((31 - lz) << 7)
+}
+
+func ccLog2lin(inLogQ7 int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L39-L57
+ if inLogQ7 < 0 {
+ return 0
+ }
+ if inLogQ7 >= 3967 {
+ return 0x7fffffff
+ }
+ out := int32(1) << uint(inLogQ7>>7)
+ fracQ7 := inLogQ7 & 0x7f
+ inner := ccSmlawb(fracQ7, ccSmulbb(fracQ7, 128-fracQ7), -174)
+ if inLogQ7 < 2048 {
+ out += (out * inner) >> 7
+ } else {
+ out += (out >> 7) * inner
+ }
+ return out
+}
+
+func ccSigmQ15(inQ5 int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L59-L79
+ slope := [6]int32{237, 153, 73, 30, 12, 7}
+ pos := [6]int32{16384, 23955, 28861, 31213, 32178, 32548}
+ neg := [6]int32{16384, 8812, 3906, 1554, 589, 219}
+ if inQ5 < 0 {
+ v := -inQ5
+ if v >= 6*32 {
+ return 0
+ }
+ ind := v >> 5
+ return neg[ind] - ccSmulbb(slope[ind], v&0x1f)
+ }
+ if inQ5 >= 6*32 {
+ return 32767
+ }
+ ind := inQ5 >> 5
+ return pos[ind] + ccSmulbb(slope[ind], inQ5&0x1f)
+}
+
+// --- pulse-coding table builders (all integer/deterministic) ---
+
+// pdfToCmf is smpl_pdf_to_CMF (maxval==-1 path): truncating-int normalize into a u16 CDF.
+func pdfToCmf(pdf []int32) []uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L83-L96
+ n := int64(len(pdf))
+ const maxval int64 = 32767
+ var sump int64
+ for _, x := range pdf {
+ sump += int64(x)
+ }
+ cmf := make([]uint16, len(pdf)+1)
+ for i := 0; i < len(pdf); i++ {
+ p := (int64(pdf[i])*(maxval-n))/sump + 1
+ cmf[i+1] = uint16(int32(cmf[i]) + int32(p))
+ }
+ return cmf
+}
+
+const (
+ ccLog2Exp1Q15 = 47274
+ ccLog22piQ14 = 43442
+ ccOneQ31 = int64(1) << 31
+)
+
+func ccStirling(n int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L101-L110
+ if n == 0 {
+ return 0
+ }
+ ret := ((n << 1) + 1) * (ccLin2log(n) << 7)
+ ret -= int32(ccLog2Exp1Q15) * n
+ ret += ccLog22piQ14
+ return ret + ccLog2Exp1Q15/(12*n)
+}
+
+func ccProbSplitFast(k, n int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L112-L123
+ tmp := ccStirling(n) - ccStirling(k) - ccStirling(n-k) - n*(1<<15)
+ if tmp == 0 {
+ return 1 << 30
+ }
+ ret := ccLog2lin((-tmp) >> 8)
+ return (1 << 30) / ret
+}
+
+func ccCreateSplitCmfs() [][]uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L125-L137
+ out := make([][]uint16, 0, ccSplitNumTables)
+ for numPulses := int32(1); numPulses <= ccSplitNumTables; numPulses++ {
+ minSplit := numPulses - ccMaxPulsesPerSf*2
+ if minSplit < 0 {
+ minSplit = 0
+ }
+ maxSplit := numPulses - minSplit
+ p := make([]int32, 0, maxSplit-minSplit+1)
+ for k := minSplit; k <= maxSplit; k++ {
+ p = append(p, ccProbSplitFast(k, numPulses))
+ }
+ out = append(out, pdfToCmf(p))
+ }
+ return out
+}
+
+type runlenCmfs struct {
+ maxSamples int32
+ cmfs [][]uint16
+}
+
+func ccCreateRunlenTable(maxSamples int32) runlenCmfs {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L141-L185
+ ms := maxSamples
+ cmfs := make([][]uint16, 0, ccMaxPulsesPerSf)
+ for nump := int32(1); nump <= ccMaxPulsesPerSf; nump++ {
+ plongerQ31 := ccOneQ31
+ p := make([]int32, ms)
+ for nums := int32(1); nums <= ms; nums++ {
+ tmp := ccOneQ31 - (ccOneQ31 / int64(ms-nums+1))
+ p1Q31 := tmp
+ for r := int32(0); r < nump-1; r++ {
+ p1Q31 = (p1Q31 * tmp) >> 31
+ }
+ p1Q31 = ccOneQ31 - p1Q31
+ if p1Q31 > 2147376274 {
+ p1Q31 = 2147376274
+ }
+ var logOutQ7 int32
+ if nump > ms {
+ logOutQ7 = ccLin2log((nump<<10)/ms) - 10*128
+ } else {
+ logOutQ7 = -(ccLin2log((ms<<10)/nump) - 10*128)
+ }
+ const sigmBiasQ5 = 146
+ const scaleMaxQ15 = 36000
+ const scaleMinQ15 = 26000
+ scaleFacQ15 := int32(scaleMaxQ15) - (((scaleMaxQ15 - scaleMinQ15) * ccSigmQ15((logOutQ7>>2)+sigmBiasQ5)) >> 15)
+ p1Q31 = ccOneQ31 - int64(ccLog2lin(((scaleFacQ15*(ccLin2log(int32(ccOneQ31-p1Q31))-31*128))>>15)+31*128))
+ if p1Q31 > 2147376274 {
+ p1Q31 = 2147376274
+ }
+ p[nums-1] = int32((plongerQ31 * p1Q31) >> 31)
+ plongerQ31 = (plongerQ31 * (ccOneQ31 - p1Q31)) >> 31
+ }
+ cmfs = append(cmfs, pdfToCmf(p))
+ }
+ return runlenCmfs{maxSamples: ms, cmfs: cmfs}
+}
+
+func (r *runlenCmfs) MaxSamples() int32 { return r.maxSamples }
+func (r *runlenCmfs) Cmf(c int32) []uint16 { return r.cmfs[c-1] }
+
+// --- seed parse + table build ---
+
+type ccSeed struct {
+ nrgresGain4Dcmf []byte
+ nrgresShape4Dcmf []byte
+ fcbgOffsetDcmf []byte
+ acbgainsHrDcmf []byte
+ fcbgainsVDcmf []byte
+ fcbgainsVDeltaDcmf []byte
+ acbgainsCbHrQ14 []int32
+ gainReconBase uint32
+ gainRecon []byte
+ nPulsesDcmfBgn []byte
+ nPulsesDcmfUv []byte
+ nPulsesDcmfV []byte
+}
+
+// protoField holds one decoded protobuf field (wiretype 0 varint or 2 bytes).
+type protoField struct {
+ wire int
+ varint uint64
+ bytes []byte
+}
+
+func parseProto(b []byte) map[int]protoField {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_tables_blob.rs#L26-L29
+ out := make(map[int]protoField)
+ i := 0
+ readVarint := func() (uint64, bool) {
+ var v uint64
+ var shift uint
+ for i < len(b) {
+ c := b[i]
+ i++
+ v |= uint64(c&0x7f) << shift
+ if c&0x80 == 0 {
+ return v, true
+ }
+ shift += 7
+ }
+ return 0, false
+ }
+ for i < len(b) {
+ key, ok := readVarint()
+ if !ok {
+ break
+ }
+ field := int(key >> 3)
+ wire := int(key & 7)
+ switch wire {
+ case 0:
+ v, ok := readVarint()
+ if !ok {
+ return out
+ }
+ out[field] = protoField{wire: 0, varint: v}
+ case 2:
+ ln, ok := readVarint()
+ if !ok || i+int(ln) > len(b) {
+ return out
+ }
+ out[field] = protoField{wire: 2, bytes: b[i : i+int(ln)]}
+ i += int(ln)
+ default:
+ return out
+ }
+ }
+ return out
+}
+
+// decodeZigzagVarints decodes a packed repeated sint32 field.
+func decodeZigzagVarints(b []byte) []int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L217-L218
+ var out []int32
+ i := 0
+ for i < len(b) {
+ var v uint64
+ var shift uint
+ for i < len(b) {
+ c := b[i]
+ i++
+ v |= uint64(c&0x7f) << shift
+ if c&0x80 == 0 {
+ break
+ }
+ shift += 7
+ }
+ out = append(out, int32(int64(v>>1)^-int64(v&1)))
+ }
+ return out
+}
+
+func loadCcSeed() *ccSeed {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L331-L337
+ zr, err := zlib.NewReader(bytes.NewReader(ccSeedBlob))
+ if err != nil {
+ panic("mlow: inflate cc seed: " + err.Error())
+ }
+ raw, err := io.ReadAll(zr)
+ zr.Close()
+ if err != nil {
+ panic("mlow: read cc seed: " + err.Error())
+ }
+ f := parseProto(raw)
+ return &ccSeed{
+ nrgresGain4Dcmf: f[1].bytes,
+ nrgresShape4Dcmf: f[2].bytes,
+ fcbgOffsetDcmf: f[3].bytes,
+ acbgainsHrDcmf: f[4].bytes,
+ fcbgainsVDcmf: f[5].bytes,
+ fcbgainsVDeltaDcmf: f[6].bytes,
+ acbgainsCbHrQ14: decodeZigzagVarints(f[7].bytes),
+ gainReconBase: uint32(f[8].varint),
+ gainRecon: f[9].bytes,
+ nPulsesDcmfBgn: f[10].bytes,
+ nPulsesDcmfUv: f[11].bytes,
+ nPulsesDcmfV: f[12].bytes,
+ }
+}
+
+// ccDcmf is the integer dcmf→cmf (reusing the CELP port), returning a u16 CDF.
+func ccDcmf(dcmf []byte) []uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L83-L96
+ c := make([]uint16, len(dcmf)+1)
+ celpDcmfToCmf(dcmf, len(dcmf), c)
+ return c
+}
+
+func ccDcmfChunks(b []byte, step int) [][]uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L262-L266
+ out := make([][]uint16, 0, len(b)/step)
+ for i := 0; i+step <= len(b); i += step {
+ out = append(out, ccDcmf(b[i:i+step]))
+ }
+ return out
+}
+
+// CcTables is the runtime nrgres/gains/LTP/pulse table set.
+type CcTables struct {
+ nrgresGain4 []uint16
+ nrgresShape4 []uint16
+ fcbgOffset [][]uint16
+ acbgainsHr []uint16
+ acbgainsLr []uint16
+ fcbgainsV []uint16
+ fcbgainsVDelta []uint16
+ acbgainsCbHrQ14 []int16
+ acbgainsCbLrQ14 []int16
+ gainRecon []int16
+ gainReconBase uint32
+ nPulseCmfs [3][]uint16
+ splitCmfs [][]uint16
+ runlen []runlenCmfs
+}
+
+func (s *ccSeed) build() *CcTables {
+ t := &CcTables{
+ nrgresGain4: ccDcmf(s.nrgresGain4Dcmf),
+ nrgresShape4: ccDcmf(s.nrgresShape4Dcmf),
+ fcbgOffset: ccDcmfChunks(s.fcbgOffsetDcmf, ccFcbgOffsetSteps),
+ fcbgainsV: ccDcmf(s.fcbgainsVDcmf),
+ fcbgainsVDelta: ccDcmf(s.fcbgainsVDeltaDcmf),
+ gainReconBase: s.gainReconBase,
+ }
+ // acbgains HR rows (17×17, flattened), then the LR variant from the const DCMF.
+ for i := 0; i+ccAcbgN <= len(s.acbgainsHrDcmf); i += ccAcbgN {
+ t.acbgainsHr = append(t.acbgainsHr, ccDcmf(s.acbgainsHrDcmf[i:i+ccAcbgN])...)
+ }
+ for i := 0; i+ccAcbgN <= len(celpAcbgainsDcmfLR); i += ccAcbgN {
+ t.acbgainsLr = append(t.acbgainsLr, ccDcmf(celpAcbgainsDcmfLR[i:i+ccAcbgN])...)
+ }
+ for _, x := range s.acbgainsCbHrQ14 {
+ t.acbgainsCbHrQ14 = append(t.acbgainsCbHrQ14, int16(x))
+ }
+ t.acbgainsCbLrQ14 = cbAcbgainsLRQ14[:]
+ for i := 0; i+1 < len(s.gainRecon); i += 2 {
+ t.gainRecon = append(t.gainRecon, int16(uint16(s.gainRecon[i])|uint16(s.gainRecon[i+1])<<8))
+ }
+ t.nPulseCmfs = [3][]uint16{ccDcmf(s.nPulsesDcmfBgn), ccDcmf(s.nPulsesDcmfUv), ccDcmf(s.nPulsesDcmfV)}
+ t.splitCmfs = ccCreateSplitCmfs()
+ for oct := int32(1); oct <= ccNumRunlenCmfs; oct++ {
+ t.runlen = append(t.runlen, ccCreateRunlenTable(oct*ccRunlengthStep))
+ }
+ return t
+}
+
+var ccTablesInst *CcTables
+
+// LoadCcTables expands the embedded cc seed ROM into the nrgres/gains/LTP/pulse tables once.
+func LoadCcTables() *CcTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L331-L337
+ if ccTablesInst == nil {
+ ccTablesInst = loadCcSeed().build()
+ }
+ return ccTablesInst
+}
+
+// --- accessors (logical-index API matching smpl_cc_tables.rs) ---
+
+func (t *CcTables) NrgresGain4() []uint16 { return t.nrgresGain4 }
+func (t *CcTables) NrgresShape4() []uint16 { return t.nrgresShape4 }
+
+func (t *CcTables) FcbgOffset(tableIx, bucket, minOffset int) []uint16 {
+ row := t.fcbgOffset[tableIx*ccFcbgOffsetBuckets+bucket]
+ return row[minOffset : minOffset+92]
+}
+
+func (t *CcTables) AcbgainRow(prev int32) []uint16 {
+ base := int(prev+1) * (ccAcbgN + 1)
+ return t.acbgainsHr[base : base+ccAcbgN+1]
+}
+
+func (t *CcTables) AcbgainRowLr(prev int32) []uint16 {
+ base := int(prev+1) * (ccAcbgN + 1)
+ return t.acbgainsLr[base : base+ccAcbgN+1]
+}
+
+func (t *CcTables) AcbgainWeights(gi int32) (int32, int32) {
+ i := int(gi) * 2
+ return int32(t.acbgainsCbHrQ14[i]), int32(t.acbgainsCbHrQ14[i+1])
+}
+
+func (t *CcTables) AcbgainWeightsLr(gi int32) (int32, int32) {
+ i := int(gi) * 2
+ return int32(t.acbgainsCbLrQ14[i]), int32(t.acbgainsCbLrQ14[i+1])
+}
+
+func (t *CcTables) FcbgainV() []uint16 { return t.fcbgainsV }
+
+func (t *CcTables) FcbgainVDelta(prevFilt int32) []uint16 {
+ start := int(ccFcbgVN) - 1 - int(prevFilt)
+ return t.fcbgainsVDelta[start : start+35]
+}
+
+func (t *CcTables) gainReconAt(addr uint32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_cc_tables.rs#L412-L421
+ off := int(addr - t.gainReconBase)
+ if off >= 0 && off%2 == 0 && off/2 < len(t.gainRecon) {
+ return int32(t.gainRecon[off/2])
+ }
+ return 0
+}
+
+func (t *CcTables) NrgStep(cfg int32) int32 {
+ return t.gainReconAt(t.gainReconBase + uint32(cfg)*2)
+}
+
+func (t *CcTables) GainRecon(p4 bool, idx int32) int32 {
+ base := uint32(0xf3970)
+ if p4 {
+ base = 0xf35f0
+ }
+ return t.gainReconAt(base + uint32(idx)*2)
+}
+
+func (t *CcTables) NPulseCount(idx int32) []uint16 { return t.nPulseCmfs[idx] }
+
+func (t *CcTables) SplitCmf(total int32) []uint16 {
+ i := int(total - 1)
+ if i < 0 || i >= len(t.splitCmfs) {
+ return nil
+ }
+ return t.splitCmfs[i]
+}
+
+func (t *CcTables) Runlen(oct int32) *runlenCmfs { return &t.runlen[oct-1] }
+
+// cdfWindow returns the n-entry CDF window base[start:start+n], zero-filling any
+// out-of-range entries — the seed-table equivalent of the old mem.CDFAt zero-fill
+// (RangeDecoder.decode_cdf_window in the reference).
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/rangecoder.rs#L228-L245
+func cdfWindow(base []uint16, start, n int) []uint16 {
+ w := make([]uint16, n)
+ for i := 0; i < n; i++ {
+ if j := start + i; j >= 0 && j < len(base) {
+ w[i] = base[j]
+ }
+ }
+ return w
+}
diff --git a/pkg/call/voip/media/mlow/celp_enc.go b/pkg/call/voip/media/mlow/celp_enc.go
new file mode 100644
index 00000000..f380eed3
--- /dev/null
+++ b/pkg/call/voip/media/mlow/celp_enc.go
@@ -0,0 +1,1536 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "math"
+ "sync"
+)
+
+// MLow encoder-side CELP excitation — faithful port of smpl_celp.rs (Meta's
+// smpl_celp_enc.c). Per subframe: build the perceptually-weighted impulse response,
+// run the ACB/LTP search (voiced) and the FCB pulse search (greedy or delayed-
+// decision beam), quantize the gains, and return the chosen pulses + indices + the
+// reconstructed LPC excitation. The encode-side counterpart of mlow/synth's CELP.
+//
+// Datasheet: datasheets/mlow-celp.md. Reuses cbAcbgains{HR,LR}Q14, acbgN/acbgM,
+// SmplLPCOrder, and the perc constants. No isolated byte-exact vector; validated by
+// the encode_*_runs smoke tests and the end-to-end encoder tone round-trip.
+
+// celpMaxPitchLag, fcbgVN, uvGainIdxLen, acbgN/acbgM, celpInterpolKernel,
+// cbAcbgains{HR,LR}Q14 are defined in celpdec.go and reused here.
+const (
+ celpLtpInterpolDelay = 8
+ celpLagSubfrlen = 40
+ celpMaxpitchLen = 320
+
+ celpGAcbRdMu float32 = 0.014999999664723873
+ fcbgVDeltaN = 67
+
+ vGainMinDb float32 = -100.0
+ vGainMaxDb float32 = 0.0
+ vGainStepDb float32 = 3.0
+ uvGainMinDb float32 = -90.0
+ uvGainMaxDb float32 = 0.0
+ uvGainStepDb float32 = 1.0
+
+ rateAcbScale float32 = 0.9
+ pitchSharpeningCoef float32 = 0.9881
+ fcbSrvMax = 4
+ celpMaxNumsurv = 8
+ nGainSteps = 2
+)
+
+var celpAcbgainsDcmfLR = [(acbgN + 1) * acbgN]uint8{
+ 103, 70, 48, 3, 122, 135, 47, 192, 2, 255, 99, 96, 186, 194, 4, 28,
+ 161, 90, 76, 3, 181, 60, 37, 219, 2, 132, 81, 146, 255, 43, 3, 36,
+ 114, 222, 55, 6, 203, 34, 42, 154, 6, 255, 33, 209, 225, 78, 6, 45,
+ 198, 161, 110, 8, 239, 26, 35, 162, 4, 117, 42, 214, 255, 33, 6, 72,
+ 55, 255, 124, 55, 124, 55, 55, 55, 55, 78, 55, 215, 111, 55, 55, 167,
+ 154, 136, 77, 4, 220, 33, 38, 166, 2, 144, 50, 196, 255, 43, 4, 41,
+ 56, 21, 19, 3, 48, 255, 38, 220, 2, 225, 107, 31, 122, 227, 2, 11,
+ 63, 38, 23, 4, 77, 85, 58, 190, 4, 255, 53, 53, 145, 138, 4, 14,
+ 95, 47, 33, 2, 110, 146, 53, 255, 2, 219, 79, 73, 198, 122, 2, 15,
+ 84, 255, 84, 84, 147, 84, 84, 84, 84, 120, 84, 120, 84, 84, 84, 84,
+ 73, 58, 25, 1, 95, 99, 52, 175, 1, 255, 48, 69, 151, 184, 1, 15,
+ 105, 32, 43, 2, 84, 225, 34, 255, 2, 156, 129, 49, 189, 124, 3, 19,
+ 152, 230, 89, 6, 253, 28, 40, 153, 2, 195, 31, 255, 249, 58, 5, 61,
+ 138, 84, 54, 3, 173, 96, 45, 247, 2, 176, 83, 128, 255, 69, 2, 26,
+ 22, 17, 8, 1, 23, 106, 26, 88, 1, 182, 37, 18, 50, 255, 1, 6,
+ 218, 174, 228, 65, 186, 65, 65, 92, 65, 65, 65, 255, 174, 65, 65, 174,
+ 117, 255, 101, 16, 180, 20, 33, 94, 10, 131, 20, 222, 143, 38, 15, 105,
+}
+
+var celpAcbgainsDcmfHR = [(acbgN + 1) * acbgN]uint8{
+ 254, 105, 212, 26, 110, 255, 202, 93, 152, 121, 110, 43, 150, 20, 81, 176,
+ 255, 28, 100, 5, 26, 184, 61, 29, 36, 26, 28, 9, 61, 4, 27, 116,
+ 121, 255, 161, 39, 195, 215, 191, 75, 186, 178, 119, 82, 68, 41, 43, 56,
+ 188, 65, 243, 15, 74, 255, 205, 79, 123, 84, 95, 26, 139, 13, 67, 154,
+ 81, 219, 173, 70, 219, 165, 234, 102, 231, 255, 191, 119, 87, 60, 62, 59,
+ 106, 255, 182, 49, 242, 196, 233, 95, 247, 228, 152, 96, 81, 45, 54, 61,
+ 236, 55, 178, 10, 56, 255, 131, 54, 85, 58, 59, 18, 93, 9, 43, 133,
+ 123, 95, 224, 24, 113, 202, 255, 105, 186, 134, 135, 38, 141, 18, 82, 111,
+ 126, 97, 204, 34, 126, 186, 255, 141, 210, 147, 149, 46, 165, 22, 113, 122,
+ 96, 156, 185, 42, 188, 178, 255, 116, 248, 199, 157, 66, 109, 29, 69, 75,
+ 102, 207, 194, 57, 224, 193, 255, 107, 253, 242, 180, 95, 97, 44, 60, 64,
+ 105, 119, 202, 39, 140, 189, 255, 110, 207, 173, 165, 54, 119, 24, 75, 85,
+ 74, 255, 142, 59, 214, 150, 182, 76, 194, 215, 138, 122, 61, 56, 41, 45,
+ 200, 53, 255, 17, 66, 238, 222, 109, 129, 78, 101, 21, 227, 11, 110, 243,
+ 74, 255, 128, 50, 187, 149, 154, 63, 165, 184, 115, 101, 52, 47, 37, 34,
+ 159, 66, 232, 26, 86, 196, 255, 146, 171, 113, 134, 31, 245, 16, 145, 190,
+ 255, 29, 182, 7, 33, 235, 115, 55, 59, 37, 47, 11, 139, 6, 60, 234,
+}
+
+var celpFcbgVDcmf = [fcbgVN]uint8{
+ 107, 12, 17, 25, 31, 41, 52, 65, 83, 103, 122, 146, 169, 191, 210, 227,
+ 240, 249, 255, 253, 246, 229, 200, 161, 120, 82, 51, 29, 14, 6, 2, 2,
+ 2, 2,
+}
+
+var celpFcbgVDeltaDcmf = [fcbgVDeltaN]uint8{
+ 1, 1, 1, 1, 1, 1, 1, 1, 2, 3, 4, 6, 8, 10, 12, 12,
+ 12, 13, 14, 14, 14, 13, 12, 11, 10, 9, 8, 9, 15, 33, 65, 119,
+ 196, 255, 220, 144, 90, 57, 36, 23, 17, 14, 12, 12, 12, 13, 12, 12,
+ 12, 12, 12, 11, 11, 10, 9, 7, 6, 4, 3, 2, 1, 1, 1, 1,
+ 1, 1, 1,
+}
+
+// --- leaf math helpers ------------------------------------------------------
+
+func celpDotProd(a, b []float32, l int) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L202-L208
+ var r float32
+ for i := 0; i < l; i++ {
+ r += a[i] * b[i]
+ }
+ return r
+}
+
+func celpNrg(x []float32, n int) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L211-L217
+ var s float32
+ for k := 0; k < n; k++ {
+ s += x[k] * x[k]
+ }
+ return s
+}
+
+func celpReverse(x []float32, l int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L220-L224
+ for i := 0; i < l/2; i++ {
+ x[i], x[l-i-1] = x[l-i-1], x[i]
+ }
+}
+
+func celpSubVec(y, z, x []float32, l int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L243-L247
+ for i := 0; i < l; i++ {
+ x[i] = y[i] - z[i]
+ }
+}
+
+func celpAddVecInplace(y, x []float32, l int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L251-L255
+ for i := 0; i < l; i++ {
+ x[i] += y[i]
+ }
+}
+
+func celpScaleVecInplace(x []float32, l int, g float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L266-L270
+ for i := 0; i < l; i++ {
+ x[i] *= g
+ }
+}
+
+func celpScaleVec(x, y []float32, l int, g float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L273-L277
+ for i := 0; i < l; i++ {
+ y[i] = x[i] * g
+ }
+}
+
+func celpAddScaleVecInplace(x, y []float32, l int, g float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L281-L285
+ for i := 0; i < l; i++ {
+ y[i] += g * x[i]
+ }
+}
+
+func celpAddScaleVec(x0, x1, y []float32, l int, g float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L289-L293
+ for i := 0; i < l; i++ {
+ y[i] = x0[i] + g*x1[i]
+ }
+}
+
+func celpMulVecInplace(x, y []float32, l int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L296-L300
+ for i := 0; i < l; i++ {
+ y[i] *= x[i]
+ }
+}
+
+func celpQ(num, den []float32, l int, q []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L303-L307
+ for i := 0; i < l; i++ {
+ q[i] = (num[i] * num[i]) / den[i]
+ }
+}
+
+// celpMultSymtoepl2: symmetric Toeplitz multiply. c carries the trailing zero at
+// 2*lResp-1; x must be readable up to n+lResp (zero padded).
+func celpMultSymtoepl2(c []float32, lResp int, x, y []float32, n int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L312-L334
+ length := lResp
+ nn := 0
+ for nn < lResp-1 {
+ y[nn] = celpDotProd(c[lResp-1-nn:], x[0:], length)
+ length++
+ nn++
+ }
+ length = 2 * lResp
+ for nn < n-lResp {
+ y[nn] = celpDotProd(c[0:], x[nn+1-lResp:], length)
+ nn++
+ }
+ for nn < n {
+ length--
+ y[nn] = celpDotProd(c[0:], x[nn+1-lResp:], length)
+ nn++
+ }
+}
+
+// celpFiltAr16: 16th-order AR filter; the 16-sample state sits in y[yBase-16 .. yBase].
+func celpFiltAr16(x []float32, n int, coef []float32, yBase int, y []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L338-L347
+ for nn := 0; nn < n; nn++ {
+ res := x[nn]
+ for i := 0; i < 16; i++ {
+ res -= coef[16-i] * y[yBase+nn-16+i]
+ }
+ y[yBase+nn] = res
+ }
+}
+
+// celpFiltMa: MA filter; (coefLen-1) history samples sit before x[xBase]. x != y.
+func celpFiltMa(x []float32, xBase, n int, coef []float32, coefLen int, y []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L350-L370
+ var i int
+ if coef[0] == 1.0 {
+ for k := 0; k < n; k++ {
+ y[k] = x[xBase+k] + coef[1]*x[xBase+k-1]
+ }
+ i = 2
+ } else {
+ for k := 0; k < n; k++ {
+ y[k] = coef[0] * x[xBase+k]
+ }
+ i = 1
+ }
+ for i < coefLen {
+ for k := 0; k < n; k++ {
+ y[k] += coef[i] * x[xBase+k-i]
+ }
+ i++
+ }
+}
+
+// celpFiltMa9: 9th-order MA; the 9-sample history sits before x[xBase].
+func celpFiltMa9(x []float32, xBase, n int, coef []float32, _ int, y []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L373-L388
+ for nn := 0; nn < n; nn++ {
+ var res float32
+ for i := 0; i < 10; i++ {
+ res += coef[i] * x[xBase+nn-i]
+ }
+ y[nn] = res
+ }
+}
+
+// celpDcmfToCmf: INTEGER, bit-exact dcmf→cmf (truncating-int normalize).
+func celpDcmfToCmf(dcmf []uint8, dcmfLen int, cmf []uint16) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L403-L419
+ var sum int32
+ for n := 0; n < dcmfLen; n++ {
+ tmp := int32(dcmf[n]) + 1
+ tmp *= tmp
+ if tmp > 65535 {
+ tmp = 65535
+ }
+ cmf[n+1] = uint16(tmp)
+ sum += tmp
+ }
+ cmf[0] = 0
+ for n := 1; n < dcmfLen+1; n++ {
+ cmf[n] = cmf[n-1] + uint16((int32(cmf[n])*(32767-int32(dcmfLen)))/sum) + 1
+ }
+}
+
+func celpCmfToBits(cmf []uint16, cmfLen int, bits []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L421-L425
+ for i := 0; i < cmfLen-1; i++ {
+ bits[i] = -float32(math.Log2(float64(float32(cmf[i+1]-cmf[i]) / float32(cmf[cmfLen-1]))))
+ }
+}
+
+func celpGetMaxi(x []float32, xLen int) int {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L430-L440
+ i := 0
+ mx := x[0]
+ for n := 1; n < xLen; n++ {
+ if x[n] > mx {
+ mx = x[n]
+ i = n
+ }
+ }
+ return i
+}
+
+func celpGetMaxiK(x []float32, idx []int32, xLen, k int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L445-L462
+ taken := make([]bool, xLen)
+ for kk := 0; kk < k; kk++ {
+ var best float32 = -math.MaxFloat32
+ bi := 0
+ found := false
+ for n := 0; n < xLen; n++ {
+ if !taken[n] && (!found || x[n] > best) {
+ best = x[n]
+ bi = n
+ found = true
+ }
+ }
+ taken[bi] = true
+ idx[kk] = int32(bi)
+ }
+}
+
+// --- CELP tables (smpl_create_celp_tables) ---------------------------------
+
+type celpTables struct {
+ acbgInvProbLR [(acbgN + 1) * acbgN]float32
+ acbgInvProbHR [(acbgN + 1) * acbgN]float32
+ fcbgainsV [fcbgVN]float32
+ fcbgainsUV [uvGainIdxLen + 1]float32
+ fcbgVInvProb [fcbgVN]float32
+ fcbgVDeltaInvProb [fcbgVDeltaN]float32
+}
+
+var (
+ celpTablesOnce sync.Once
+ celpTablesInst *celpTables
+)
+
+func getCelpTables() *celpTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L483-L485
+ celpTablesOnce.Do(func() { celpTablesInst = buildCelpTables() })
+ return celpTablesInst
+}
+
+func buildCelpTables() *celpTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L487-L566
+ t := &celpTables{}
+ acbCmfLR := make([]uint16, (acbgN+1)*(acbgN+1))
+ acbCmfHR := make([]uint16, (acbgN+1)*(acbgN+1))
+ for i := 0; i < acbgN+1; i++ {
+ celpDcmfToCmf(celpAcbgainsDcmfLR[i*acbgN:], acbgN, acbCmfLR[i*(acbgN+1):])
+ celpDcmfToCmf(celpAcbgainsDcmfHR[i*acbgN:], acbgN, acbCmfHR[i*(acbgN+1):])
+ }
+ for i := 0; i < acbgN+1; i++ {
+ celpCmfToBits(acbCmfLR[i*(acbgN+1):], acbgN+1, t.acbgInvProbLR[i*acbgN:])
+ celpCmfToBits(acbCmfHR[i*(acbgN+1):], acbgN+1, t.acbgInvProbHR[i*acbgN:])
+ for j := 0; j < acbgN; j++ {
+ t.acbgInvProbLR[i*acbgN+j] = float32(math.Pow(2.0, float64(t.acbgInvProbLR[i*acbgN+j]*celpGAcbRdMu)))
+ t.acbgInvProbHR[i*acbgN+j] = float32(math.Pow(2.0, float64(t.acbgInvProbHR[i*acbgN+j]*celpGAcbRdMu)))
+ }
+ }
+ fcbgVCmf := make([]uint16, fcbgVN+1)
+ fcbgVDeltaCmf := make([]uint16, fcbgVDeltaN+1)
+ celpDcmfToCmf(celpFcbgVDcmf[:], fcbgVN, fcbgVCmf)
+ celpDcmfToCmf(celpFcbgVDeltaDcmf[:], fcbgVDeltaN, fcbgVDeltaCmf)
+ celpCmfToBits(fcbgVCmf, fcbgVN+1, t.fcbgVInvProb[:])
+ for i := 0; i < fcbgVN; i++ {
+ t.fcbgVInvProb[i] = float32(math.Pow(2.0, float64(t.fcbgVInvProb[i]*celpGAcbRdMu)))
+ }
+ celpCmfToBits(fcbgVDeltaCmf, fcbgVDeltaN+1, t.fcbgVDeltaInvProb[:])
+ for i := 0; i < fcbgVDeltaN; i++ {
+ t.fcbgVDeltaInvProb[i] = float32(math.Pow(2.0, float64(t.fcbgVDeltaInvProb[i]*celpGAcbRdMu)))
+ }
+ for ix := 0; ix < fcbgVN; ix++ {
+ db := float32(ix)*vGainStepDb + vGainMinDb
+ t.fcbgainsV[ix] = float32(math.Pow(10.0, float64(0.05*db)))
+ }
+ for ix := 0; ix <= uvGainIdxLen; ix++ {
+ db := float32(ix)*uvGainStepDb + uvGainMinDb
+ t.fcbgainsUV[ix] = float32(math.Pow(10.0, float64(0.05*db)))
+ }
+ return t
+}
+
+// --- LTP / ACB synthesis ----------------------------------------------------
+
+func celpAcbDequant(lowRate bool, acbIdx int32, acbG *[acbgM]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L573-L583
+ cb := &cbAcbgainsHRQ14
+ if lowRate {
+ cb = &cbAcbgainsLRQ14
+ }
+ scQ14 := 1.0 / float32(int32(1)<<14)
+ for m := 0; m < acbgM; m++ {
+ acbG[m] = float32(cb[int(acbIdx)*acbgM+m]) * scQ14
+ }
+}
+
+func celpAcbSynthesize(fcbSubfrlen int, acbBasis []float32, acbG *[acbgM]float32, acb []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L587-L595
+ celpScaleVec(acbBasis, acb, fcbSubfrlen, acbG[0])
+ celpAddScaleVecInplace(acbBasis[fcbSubfrlen:], acb, fcbSubfrlen, acbG[1])
+}
+
+func celpPitchSharp(x []float32, lag, l int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L598-L602
+ for i := lag; i < l; i++ {
+ x[i] += x[i-lag] * pitchSharpeningCoef
+ }
+}
+
+// celpSynLtpBasis builds the LTP basis per 40-sample sub-block and extends state in place.
+func celpSynLtpBasis(lags []float32, nLags int, state []float32, stateLen int, acbBasis []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L607-L678
+ p := stateLen - nLags*celpLagSubfrlen
+ for subfr := 0; subfr < nLags; subfr++ {
+ iLag := int32(math.Floor(float64(lags[subfr])))
+ if float32(iLag) == lags[subfr] {
+ il := int(iLag)
+ for i := 0; i < celpLagSubfrlen; i++ {
+ state[p+i] = state[(p+i)-il]
+ }
+ for i := 0; i < celpLagSubfrlen; i++ {
+ acbBasis[subfr*celpLagSubfrlen+i] = state[p+i]
+ }
+ for i := 0; i < celpLagSubfrlen; i++ {
+ a := state[(p+i)-il-1]
+ b := state[(p+i)-il+1]
+ acbBasis[(nLags+subfr)*celpLagSubfrlen+i] = a + b
+ }
+ } else {
+ il := int(iLag)
+ baseFirst := p + (-1 - il - celpLtpInterpolDelay)
+ first := celpDotProd(state[baseFirst:], celpInterpolKernel[:], 2*celpLtpInterpolDelay)
+ srcBase := p + (-il - celpLtpInterpolDelay)
+ for nn := 0; nn < celpLagSubfrlen; nn++ {
+ var ret float32
+ for i := 0; i < 8; i++ {
+ s0 := state[srcBase+nn+i]
+ s1 := state[srcBase+nn+15-i]
+ ret += (s0 + s1) * celpInterpolKernel[i]
+ }
+ state[p+nn] = ret
+ }
+ baseLast := p + (celpLagSubfrlen - 1 - il - celpLtpInterpolDelay)
+ last := celpDotProd(state[baseLast:], celpInterpolKernel[:], 2*celpLtpInterpolDelay)
+ for i := 0; i < celpLagSubfrlen; i++ {
+ acbBasis[subfr*celpLagSubfrlen+i] = state[p+i]
+ }
+ b1 := (nLags + subfr) * celpLagSubfrlen
+ acbBasis[b1] = first + state[p+1]
+ for i := 0; i < celpLagSubfrlen-2; i++ {
+ acbBasis[b1+1+i] = state[p+i] + state[p+i+2]
+ }
+ iLast := celpLagSubfrlen - 1
+ acbBasis[b1+iLast] = state[p+iLast-1] + last
+ }
+ p += celpLagSubfrlen
+ }
+}
+
+// --- FCB search helpers -----------------------------------------------------
+
+func celpCalcDAbsAndSign(d []float32, l int, dAbs, dSign []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L726-L736
+ for i := 0; i < l; i++ {
+ if d[i] > 0.0 {
+ dAbs[i] = d[i]
+ dSign[i] = 1.0
+ } else {
+ dAbs[i] = -d[i]
+ dSign[i] = -1.0
+ }
+ }
+}
+
+func celpCheckIfBetter(wnrg float32, nrgThr *float32, wnrgPerPulse float32) bool {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L738-L746
+ *nrgThr += wnrgPerPulse
+ if wnrg > *nrgThr {
+ *nrgThr = wnrg
+ return true
+ }
+ return false
+}
+
+func celpPhiColOffset(col int32) int32 { return int32(smplMaxSfLen) - col }
+
+func celpNonZeroRange(col int32, percRespLen, fcbSubfrlen int) (int, int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L756-L760
+ lo := col - int32(percRespLen) + 1
+ if lo < 0 {
+ lo = 0
+ }
+ hi := col + int32(percRespLen)
+ if hi > int32(fcbSubfrlen) {
+ hi = int32(fcbSubfrlen)
+ }
+ return int(lo), int(hi)
+}
+
+// CelpSubframeOut is the per-subframe encode result.
+type CelpSubframeOut struct {
+ Pulses [smplCelpMaxRates][]int16
+ NPulses [smplCelpMaxRates]int16
+ AcbIdx [smplCelpMaxRates]int16
+ GainIdx [smplCelpMaxRates]int16
+ ExcLpc []float32
+}
+
+type acbgParams struct {
+ werrIn float32
+ phiAcb [acbgM * acbgM]float32
+ dAcbLpc [acbgM]float32
+ acbBasisPhi []float32
+}
+
+type fcb struct {
+ wnrg float32
+ nPulses int32
+ posNew int32
+ signNew float32
+ sgntr uint64
+ fcbStateIdx int
+}
+
+type fcbState struct {
+ pulsePositions []int32
+ pulseSigns []float32
+ num []float32
+ den []float32
+}
+
+func newFcbState() fcbState {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L715-L724
+ return fcbState{
+ pulsePositions: make([]int32, smplMaxSfLen),
+ pulseSigns: make([]float32, smplMaxSfLen),
+ num: make([]float32, smplMaxSfLen),
+ den: make([]float32, smplMaxSfLen),
+ }
+}
+
+func (s *fcbState) cloneFrom(o *fcbState) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L707-L724
+ copy(s.pulsePositions, o.pulsePositions)
+ copy(s.pulseSigns, o.pulseSigns)
+ copy(s.num, o.num)
+ copy(s.den, o.den)
+}
+
+// CelpEncoder is the persistent encode-side CELP state.
+type CelpEncoder struct {
+ stateWghtBuf []float32
+ stateErrLpcSyn [SmplLPCOrder]float32
+ hanningWin []float32
+ sgntrs []uint64
+ acbState []float32
+ acbStateLen int
+ prevAcbIdx [smplCelpMaxRates]int32
+ prevFcbIdx [smplCelpMaxRates]int32
+ subfrCnt int32
+ subfrPerPacket int32
+ fcbSubfrlen int
+ percRespLen int
+ lowRate bool
+ ignoreZir bool
+ fcbgain float32
+ useMa9 bool
+
+ impLpcBuf []float32
+ phi []float32
+ phiFlip []float32
+}
+
+// NewCelpEncoder builds the encoder (mirrors CelpEncoder::new).
+func NewCelpEncoder(lowRate bool, percRespLen, fcbSubfrlen, subfrPerPacket int) *CelpEncoder {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L816-L871
+ _ = getCelpTables()
+ acbStateLen := fcbSubfrlen + celpMaxpitchLen + celpLtpInterpolDelay
+
+ sgntrs := make([]uint64, smplMaxSfLen)
+ var s uint64 = 0x9E3779B97F4A7C15
+ for i := range sgntrs {
+ s = s*6364136223846793005 + 1442695040888963407
+ sgntrs[i] = s
+ }
+
+ hanningWin := make([]float32, percRespLen)
+ scale := 1.0 / float32(2*smplPercRespLen+1)
+ for i := 0; i < percRespLen; i++ {
+ hanningWin[i] = float32(math.Sin(float64(smplPI * float32(percRespLen+i+1) * scale)))
+ }
+
+ e := &CelpEncoder{
+ stateWghtBuf: make([]float32, smplMaxSfLen+SmplLPCOrder),
+ hanningWin: hanningWin,
+ sgntrs: sgntrs,
+ acbState: make([]float32, celpMaxPitchLag+smplMaxSfLen+celpLtpInterpolDelay),
+ acbStateLen: acbStateLen,
+ prevAcbIdx: [smplCelpMaxRates]int32{-1, -1},
+ prevFcbIdx: [smplCelpMaxRates]int32{-1, -1},
+ subfrPerPacket: int32(subfrPerPacket),
+ fcbSubfrlen: fcbSubfrlen,
+ percRespLen: percRespLen,
+ lowRate: lowRate,
+ useMa9: percRespLen == 10,
+ impLpcBuf: make([]float32, smplMaxSfLen+SmplLPCOrder),
+ phi: make([]float32, smplMaxSfLen),
+ phiFlip: make([]float32, 2*smplMaxSfLen),
+ }
+ return e
+}
+
+func (e *CelpEncoder) percFiltMa(x []float32, xBase, n int, coef []float32, coefLen int, y []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L873-L888
+ if e.useMa9 {
+ celpFiltMa9(x, xBase, n, coef, coefLen, y)
+ } else {
+ celpFiltMa(x, xBase, n, coef, coefLen, y)
+ }
+}
+
+// --- greedy FCB search (smpl_fcb_search) ------------------------------------
+
+func (e *CelpEncoder) smplFcbSearch(d []float32, wnrgPerPulse *[smplCelpMaxRates]float32, fcbPulsesMax *[smplCelpMaxRates]int16,
+ pulses *[smplCelpMaxRates][smplMaxPulsesPerSf]int16, nPulses *[smplCelpMaxRates]int16, wnrg, gainFromSearch, fcbWnrg *[smplCelpMaxRates]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L946-L1064
+ fcbSubfrlen := e.fcbSubfrlen
+ percRespLen := e.percRespLen
+ *nPulses = [smplCelpMaxRates]int16{}
+
+ var positions [smplMaxPulsesPerSf]int32
+ dAbs := make([]float32, smplMaxSfLen)
+ dSign := make([]float32, smplMaxSfLen)
+ num := make([]float32, smplMaxSfLen)
+ den := make([]float32, smplMaxSfLen)
+ phi0 := e.phi[0]
+ celpCalcDAbsAndSign(d, fcbSubfrlen, dAbs, dSign)
+
+ for i := 0; i < fcbSubfrlen; i++ {
+ den[i] = phi0 + 1e-16
+ }
+ copy(num[:fcbSubfrlen], dAbs[:fcbSubfrlen])
+ positions[0] = int32(celpGetMaxi(num, fcbSubfrlen))
+ var nrgThr [smplCelpMaxRates]float32
+ p0 := int(positions[0])
+ ratio := num[p0] / den[p0]
+ wnrg0 := num[p0] * ratio
+ if celpCheckIfBetter(wnrg0, &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) {
+ nPulses[smplCelpIdxMain] = 1
+ wnrg[smplCelpIdxMain] = wnrg0
+ wnrg[smplCelpIdxFec] = wnrg0
+ gainFromSearch[smplCelpIdxMain] = ratio
+ gainFromSearch[smplCelpIdxFec] = ratio
+ fcbWnrg[smplCelpIdxMain] = den[p0]
+ fcbWnrg[smplCelpIdxFec] = den[p0]
+ if fcbPulsesMax[smplCelpIdxFec] > 0 {
+ nPulses[smplCelpIdxFec] = nPulses[smplCelpIdxMain]
+ wnrg[smplCelpIdxFec] = wnrg[smplCelpIdxMain]
+ gainFromSearch[smplCelpIdxFec] = gainFromSearch[smplCelpIdxMain]
+ fcbWnrg[smplCelpIdxFec] = fcbWnrg[smplCelpIdxMain]
+ }
+ }
+
+ for pulseNr := 1; pulseNr < int(fcbPulsesMax[smplCelpIdxMain]); pulseNr++ {
+ position := positions[pulseNr-1]
+ sgn := dSign[position]
+ for i := 0; i < fcbSubfrlen; i++ {
+ num[i] += dAbs[position]
+ }
+ nz0, nz1 := celpNonZeroRange(position, percRespLen, fcbSubfrlen)
+ colOff := celpPhiColOffset(position)
+ var dDen float32
+ for i := 0; i < pulseNr-1; i++ {
+ pi := int(positions[i])
+ dDen += e.phiFlip[int(colOff)+pi] * dSign[pi]
+ }
+ dDen *= 2.0 * sgn
+ dDen += e.phiFlip[int(colOff+position)]
+ for i := 0; i < fcbSubfrlen; i++ {
+ den[i] += dDen
+ }
+ for i := nz0; i < nz1; i++ {
+ den[i] += 2.0 * sgn * dSign[i] * e.phiFlip[int(colOff)+i]
+ }
+ q := make([]float32, smplMaxSfLen)
+ celpQ(num, den, fcbSubfrlen, q)
+ positions[pulseNr] = int32(celpGetMaxi(q, fcbSubfrlen))
+ pp := int(positions[pulseNr])
+ if celpCheckIfBetter(q[pp], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain]) {
+ nPulses[smplCelpIdxMain] = int16(pulseNr + 1)
+ wnrg[smplCelpIdxMain] = q[pp]
+ gainFromSearch[smplCelpIdxMain] = num[pp] / den[pp]
+ fcbWnrg[smplCelpIdxMain] = den[pp]
+ }
+ if int(fcbPulsesMax[smplCelpIdxFec]) >= pulseNr &&
+ celpCheckIfBetter(q[pp], &nrgThr[smplCelpIdxFec], wnrgPerPulse[smplCelpIdxFec]) {
+ nPulses[smplCelpIdxFec] = int16(pulseNr + 1)
+ wnrg[smplCelpIdxFec] = q[pp]
+ gainFromSearch[smplCelpIdxFec] = num[pp] / den[pp]
+ fcbWnrg[smplCelpIdxFec] = den[pp]
+ }
+ }
+
+ for r := smplCelpIdxFec; r <= smplCelpIdxMain; r++ {
+ if nrgThr[r] > 0.0 {
+ for i := 0; i < int(nPulses[r]); i++ {
+ position := positions[i]
+ if dSign[position] > 0.0 {
+ pulses[r][i] = 1 + int16(position)
+ } else {
+ pulses[r][i] = -(1 + int16(position))
+ }
+ }
+ } else {
+ wnrg[r] = 0.0
+ gainFromSearch[r] = 0.0
+ fcbWnrg[r] = 0.0
+ nPulses[r] = 0
+ }
+ }
+}
+
+// --- delayed-decision beam FCB search ---------------------------------------
+
+type fcbSearchScratch struct {
+ fcbStates [2][]fcbState
+ readIdx int
+ writeIdx int
+ fcbs []fcb
+ fcbsSize int
+ fcbCandidates []fcb
+ fcbCandidatesSize int
+ uniqueSgntr []uint64
+ uniqueSgntrSize int
+}
+
+func newFcbSearchScratch() *fcbSearchScratch {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L908-L926
+ mk := func() []fcbState {
+ v := make([]fcbState, celpMaxNumsurv)
+ for i := range v {
+ v[i] = newFcbState()
+ }
+ return v
+ }
+ return &fcbSearchScratch{
+ fcbStates: [2][]fcbState{mk(), mk()},
+ readIdx: 0,
+ writeIdx: 1,
+ fcbs: make([]fcb, celpMaxNumsurv),
+ fcbCandidates: make([]fcb, celpMaxNumsurv*celpMaxNumsurv),
+ uniqueSgntr: make([]uint64, celpMaxNumsurv*celpMaxNumsurv),
+ }
+}
+
+func (sc *fcbSearchScratch) swapRw() { sc.readIdx, sc.writeIdx = sc.writeIdx, sc.readIdx }
+
+func (sc *fcbSearchScratch) isUnique(sgntr uint64) bool {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L928-L930
+ for i := 0; i < sc.uniqueSgntrSize; i++ {
+ if sc.uniqueSgntr[i] == sgntr {
+ return false
+ }
+ }
+ return true
+}
+
+func (e *CelpEncoder) addPulse(sc *fcbSearchScratch, fcbIdxIn int, dAbs, dSign []float32, numsurv, idx int, lag int32, pitchSharp float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1071-L1242
+ fcbSubfrlen := e.fcbSubfrlen
+ percRespLen := e.percRespLen
+
+ fcbPosNew := sc.fcbs[fcbIdxIn].posNew
+ fcbSignNew := sc.fcbs[fcbIdxIn].signNew
+ fcbNPulses := sc.fcbs[fcbIdxIn].nPulses
+ fcbStateIdx := sc.fcbs[fcbIdxIn].fcbStateIdx
+ fcbSgntrBase := sc.fcbs[fcbIdxIn].sgntr
+
+ ri := sc.readIdx
+ wi := sc.writeIdx
+
+ add := dAbs[fcbPosNew]
+ for i := 0; i < fcbSubfrlen; i++ {
+ sc.fcbStates[wi][idx].num[i] = sc.fcbStates[ri][fcbStateIdx].num[i] + add
+ }
+ for i := 0; i < fcbSubfrlen; i++ {
+ sc.fcbStates[wi][idx].den[i] = sc.fcbStates[ri][fcbStateIdx].den[i]
+ }
+
+ if pitchSharp == 0.0 {
+ nz0, nz1 := celpNonZeroRange(fcbPosNew, percRespLen, fcbSubfrlen)
+ colOff := celpPhiColOffset(fcbPosNew)
+ var dDen float32
+ for i := 0; i < int(fcbNPulses); i++ {
+ pos := sc.fcbStates[ri][fcbStateIdx].pulsePositions[i]
+ sgn := sc.fcbStates[ri][fcbStateIdx].pulseSigns[i]
+ dDen += e.phiFlip[int(colOff+pos)] * sgn
+ }
+ dDen *= 2.0 * fcbSignNew
+ dDen += e.phiFlip[int(colOff+fcbPosNew)]
+ for i := 0; i < fcbSubfrlen; i++ {
+ sc.fcbStates[wi][idx].den[i] += dDen
+ }
+ for i := nz0; i < nz1; i++ {
+ sc.fcbStates[wi][idx].den[i] += 2.0 * fcbSignNew * dSign[i] * e.phiFlip[int(colOff)+i]
+ }
+ } else {
+ var g1 float32
+ var dDen float32
+ g1 = 1.0
+ for pos := fcbPosNew; pos < int32(fcbSubfrlen); pos += lag {
+ colOff := celpPhiColOffset(pos)
+ for i := 0; i < int(fcbNPulses); i++ {
+ g2 := g1
+ pulsePos := sc.fcbStates[ri][fcbStateIdx].pulsePositions[i]
+ pulseSgn := sc.fcbStates[ri][fcbStateIdx].pulseSigns[i]
+ for posq := pulsePos; posq < int32(fcbSubfrlen); posq += lag {
+ dDen += g2 * e.phiFlip[int(colOff+posq)] * pulseSgn
+ g2 *= pitchSharp
+ }
+ }
+ g1 *= pitchSharp
+ }
+ dDen *= 2.0 * fcbSignNew
+ g1 = 1.0
+ for pos1 := fcbPosNew; pos1 < int32(fcbSubfrlen); pos1 += lag {
+ colOff := celpPhiColOffset(pos1)
+ g2 := g1
+ for pos2 := fcbPosNew; pos2 < int32(fcbSubfrlen); pos2 += lag {
+ dDen += g2 * e.phiFlip[int(colOff+pos2)]
+ g2 *= pitchSharp
+ }
+ g1 *= pitchSharp
+ }
+ for i := 0; i < fcbSubfrlen; i++ {
+ sc.fcbStates[wi][idx].den[i] += dDen
+ }
+ ddDen := make([]float32, smplMaxSfLen)
+ g1 = 1.0
+ for pos := fcbPosNew; pos < int32(fcbSubfrlen); pos += lag {
+ nz0, nz1 := celpNonZeroRange(pos, percRespLen, fcbSubfrlen)
+ colOff := celpPhiColOffset(pos)
+ g2 := g1
+ for k := int32(0); k < int32(fcbSubfrlen); k += lag {
+ startI := int32(nz0) - k
+ if startI < 0 {
+ startI = 0
+ }
+ endI := int32(fcbSubfrlen) - k
+ if int32(nz1)-k < endI {
+ endI = int32(nz1) - k
+ }
+ for i := startI; i < endI; i++ {
+ ddDen[i] += g2 * e.phiFlip[int(colOff+i+k)]
+ }
+ g2 *= pitchSharp
+ }
+ g1 *= pitchSharp
+ }
+ for i := 0; i < fcbSubfrlen; i++ {
+ sc.fcbStates[wi][idx].den[i] += 2.0 * fcbSignNew * dSign[i] * ddDen[i]
+ }
+ }
+
+ for i := 0; i < int(fcbNPulses); i++ {
+ sc.fcbStates[wi][idx].pulsePositions[i] = sc.fcbStates[ri][fcbStateIdx].pulsePositions[i]
+ sc.fcbStates[wi][idx].pulseSigns[i] = sc.fcbStates[ri][fcbStateIdx].pulseSigns[i]
+ }
+ sc.fcbStates[wi][idx].pulsePositions[fcbNPulses] = fcbPosNew
+ sc.fcbStates[wi][idx].pulseSigns[fcbNPulses] = fcbSignNew
+
+ newNPulses := fcbNPulses + 1
+ q := make([]float32, smplMaxSfLen)
+ celpQ(sc.fcbStates[wi][idx].num, sc.fcbStates[wi][idx].den, fcbSubfrlen, q)
+ var sortIx [celpMaxNumsurv]int32
+ celpGetMaxiK(q, sortIx[:], fcbSubfrlen, numsurv)
+ for i := 0; i < numsurv; i++ {
+ pos := int(sortIx[i])
+ sgntr := fcbSgntrBase + e.sgntrs[pos]
+ if sc.isUnique(sgntr) {
+ sc.fcbCandidates[sc.fcbCandidatesSize] = fcb{wnrg: q[pos], nPulses: newNPulses, posNew: int32(pos), signNew: dSign[pos], sgntr: sgntr, fcbStateIdx: idx}
+ sc.fcbCandidatesSize++
+ sc.uniqueSgntr[sc.uniqueSgntrSize] = sgntr
+ sc.uniqueSgntrSize++
+ }
+ }
+}
+
+func (e *CelpEncoder) smplFcbSearchDeldec(d []float32, pitchSharp float32, lag int32, wnrgPerPulse *[smplCelpMaxRates]float32, fcbPulsesMax *[smplCelpMaxRates]int16, surv []int16,
+ pulses *[smplCelpMaxRates][smplMaxPulsesPerSf]int16, nPulses *[smplCelpMaxRates]int16, wnrg, gainFromSearch, fcbWnrg *[smplCelpMaxRates]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1247-L1494
+ fcbSubfrlen := e.fcbSubfrlen
+ sc := newFcbSearchScratch()
+
+ dNew := make([]float32, smplMaxSfLen)
+ dAbs := make([]float32, smplMaxSfLen)
+ dSign := make([]float32, smplMaxSfLen)
+ phi0 := e.phi[0]
+
+ if pitchSharp != 0.0 && lag > 0 && lag < int32(fcbSubfrlen) {
+ copy(dNew[:fcbSubfrlen], d[:fcbSubfrlen])
+ for j := 0; j < fcbSubfrlen; j++ {
+ g := pitchSharp
+ for i := lag + int32(j); i < int32(fcbSubfrlen); i += lag {
+ dNew[j] += g * d[i]
+ g *= pitchSharp
+ }
+ }
+ celpCalcDAbsAndSign(dNew, fcbSubfrlen, dAbs, dSign)
+ } else {
+ celpCalcDAbsAndSign(d, fcbSubfrlen, dAbs, dSign)
+ pitchSharp = 0.0
+ }
+
+ sc.readIdx = 0
+ sc.writeIdx = 1
+ var bestFcb [smplCelpMaxRates]fcb
+ bestFcbState := [smplCelpMaxRates]fcbState{newFcbState(), newFcbState()}
+ var nrgThr [smplCelpMaxRates]float32
+
+ {
+ wi := sc.writeIdx
+ copy(sc.fcbStates[wi][0].num[:fcbSubfrlen], dAbs[:fcbSubfrlen])
+ if pitchSharp == 0.0 {
+ for i := 0; i < fcbSubfrlen; i++ {
+ sc.fcbStates[wi][0].den[i] = phi0 + 1e-16
+ }
+ } else {
+ offset := int32(fcbSubfrlen) - 1
+ for i := int32(fcbSubfrlen) - 1; i >= 0; i -= lag {
+ res := float32(1e-16)
+ g1 := float32(1.0)
+ for j := i; j < int32(fcbSubfrlen); j += lag {
+ colOff := celpPhiColOffset(j)
+ g2 := float32(1.0)
+ for k := i; k < int32(fcbSubfrlen); k += lag {
+ res += g1 * g2 * e.phiFlip[int(colOff+k)]
+ g2 *= pitchSharp
+ }
+ g1 *= pitchSharp
+ }
+ length := lag
+ if offset+1 < length {
+ length = offset + 1
+ }
+ for jj := int32(0); jj < length; jj++ {
+ sc.fcbStates[wi][0].den[offset-jj] = res
+ }
+ offset -= length
+ }
+ }
+ }
+
+ sc.swapRw()
+ q := make([]float32, smplMaxSfLen)
+ {
+ ri := sc.readIdx
+ if pitchSharp == 0.0 {
+ copy(q[:fcbSubfrlen], sc.fcbStates[ri][0].num[:fcbSubfrlen])
+ } else {
+ celpQ(sc.fcbStates[ri][0].num, sc.fcbStates[ri][0].den, fcbSubfrlen, q)
+ }
+ }
+
+ var sortIx [celpMaxNumsurv]int32
+ celpGetMaxiK(q, sortIx[:], fcbSubfrlen, int(surv[0]))
+ sc.fcbsSize = 0
+ {
+ ri := sc.readIdx
+ for i := 0; i < int(surv[0]); i++ {
+ pos := int(sortIx[i])
+ sc.fcbs[sc.fcbsSize] = fcb{
+ sgntr: e.sgntrs[pos],
+ posNew: int32(pos),
+ signNew: dSign[pos],
+ wnrg: (sc.fcbStates[ri][0].num[pos] * sc.fcbStates[ri][0].num[pos]) / sc.fcbStates[ri][0].den[pos],
+ }
+ sc.fcbsSize++
+ }
+ }
+
+ e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxMain], &bestFcbState[smplCelpIdxMain], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain])
+ if fcbPulsesMax[smplCelpIdxFec] > 0 {
+ e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxFec], &bestFcbState[smplCelpIdxFec], &nrgThr[smplCelpIdxFec], wnrgPerPulse[smplCelpIdxFec])
+ }
+
+ if fcbPulsesMax[smplCelpIdxMain] > 1 {
+ for pulseNr := 2; pulseNr < int(fcbPulsesMax[smplCelpIdxMain]); pulseNr++ {
+ sc.fcbCandidatesSize = 0
+ sc.uniqueSgntrSize = 0
+ fcbsSize := sc.fcbsSize
+ for i := 0; i < fcbsSize; i++ {
+ e.addPulse(sc, i, dAbs, dSign, int(surv[pulseNr-1]), i, lag, pitchSharp)
+ }
+ sc.swapRw()
+ candSize := sc.fcbCandidatesSize
+ for i := 0; i < candSize; i++ {
+ q[i] = sc.fcbCandidates[i].wnrg
+ }
+ celpGetMaxiK(q, sortIx[:], candSize, int(surv[pulseNr-1]))
+ sc.fcbsSize = 0
+ for i := 0; i < int(surv[pulseNr-1]); i++ {
+ sc.fcbs[sc.fcbsSize] = sc.fcbCandidates[sortIx[i]]
+ sc.fcbsSize++
+ }
+ e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxMain], &bestFcbState[smplCelpIdxMain], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain])
+ if int(fcbPulsesMax[smplCelpIdxFec]) >= pulseNr {
+ e.checkIfBetterDeldec(sc, false, 0, &bestFcb[smplCelpIdxFec], &bestFcbState[smplCelpIdxFec], &nrgThr[smplCelpIdxFec], wnrgPerPulse[smplCelpIdxFec])
+ }
+ }
+ sc.fcbCandidatesSize = 0
+ sc.uniqueSgntrSize = 0
+ fcbsSize := sc.fcbsSize
+ for i := 0; i < fcbsSize; i++ {
+ e.addPulse(sc, i, dAbs, dSign, 1, i, lag, pitchSharp)
+ }
+ sc.swapRw()
+ bestIdx := 0
+ maxWnrg := sc.fcbCandidates[0].wnrg
+ for i := 1; i < sc.fcbCandidatesSize; i++ {
+ if sc.fcbCandidates[i].wnrg > maxWnrg {
+ maxWnrg = sc.fcbCandidates[i].wnrg
+ bestIdx = i
+ }
+ }
+ e.checkIfBetterDeldec(sc, true, bestIdx, &bestFcb[smplCelpIdxMain], &bestFcbState[smplCelpIdxMain], &nrgThr[smplCelpIdxMain], wnrgPerPulse[smplCelpIdxMain])
+ }
+
+ for r := smplCelpIdxFec; r <= smplCelpIdxMain; r++ {
+ for i := 0; i < int(bestFcb[r].nPulses); i++ {
+ if bestFcbState[r].pulseSigns[i] > 0.0 {
+ pulses[r][i] = 1 + int16(bestFcbState[r].pulsePositions[i])
+ } else {
+ pulses[r][i] = -(1 + int16(bestFcbState[r].pulsePositions[i]))
+ }
+ }
+ if bestFcb[r].signNew > 0.0 {
+ pulses[r][bestFcb[r].nPulses] = 1 + int16(bestFcb[r].posNew)
+ } else {
+ pulses[r][bestFcb[r].nPulses] = -(1 + int16(bestFcb[r].posNew))
+ }
+ if bestFcb[r].wnrg > 0.0 {
+ wnrg[r] = bestFcb[r].wnrg
+ pn := int(bestFcb[r].posNew)
+ gainFromSearch[r] = bestFcbState[r].num[pn] / bestFcbState[r].den[pn]
+ fcbWnrg[r] = bestFcbState[r].den[pn]
+ nPulses[r] = int16(bestFcb[r].nPulses) + 1
+ } else {
+ wnrg[r] = 0.0
+ gainFromSearch[r] = 0.0
+ fcbWnrg[r] = 0.0
+ nPulses[r] = 0
+ }
+ }
+}
+
+// checkIfBetterDeldec: fromCand selects sc.fcbCandidates[idx] vs sc.fcbs[idx].
+func (e *CelpEncoder) checkIfBetterDeldec(sc *fcbSearchScratch, fromCand bool, idx int, bestFcb *fcb, bestFcbState *fcbState, nrgThr *float32, wnrgPerPulse float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1497-L1532
+ *nrgThr += wnrgPerPulse
+ var f *fcb
+ if fromCand {
+ f = &sc.fcbCandidates[idx]
+ } else {
+ f = &sc.fcbs[idx]
+ }
+ if f.wnrg > *nrgThr {
+ *nrgThr = f.wnrg
+ *bestFcb = *f
+ bestFcbState.cloneFrom(&sc.fcbStates[sc.readIdx][f.fcbStateIdx])
+ }
+}
+
+// --- gain quant -------------------------------------------------------------
+
+func celpWnrg2(c, x []float32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1540-L1542
+ return x[0]*(c[0]*x[0]+c[1]*x[1]) + x[1]*(c[2]*x[0]+c[3]*x[1])
+}
+
+func celpWnrg3(c, x []float32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1545-L1549
+ return x[0]*(c[0]*x[0]+c[1]*x[1]+c[2]*x[2]) +
+ x[1]*(c[3]*x[0]+c[4]*x[1]+c[5]*x[2]) +
+ x[2]*(c[6]*x[0]+c[7]*x[1]+c[8]*x[2])
+}
+
+func celpQuantGainUv(gainFromSearch float32) int16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1552-L1556
+ gainDb := 20.0 * float32(math.Log10(float64(gainFromSearch+1.0e-16)))
+ if gainDb < uvGainMinDb {
+ gainDb = uvGainMinDb
+ }
+ if gainDb > uvGainMaxDb {
+ gainDb = uvGainMaxDb
+ }
+ return int16(math.Round(float64((gainDb - uvGainMinDb) / uvGainStepDb)))
+}
+
+func celpFcbSynthesize(fcbSubfrlen int, pulses []int16, nPulses int, fcb []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1558-L1568
+ for i := 0; i < fcbSubfrlen; i++ {
+ fcb[i] = 0.0
+ }
+ for n := 0; n < nPulses; n++ {
+ sign := int32(1) + 2*(int32(pulses[n])>>15)
+ pos := int32(pulses[n])*sign - 1
+ fcb[pos] += float32(sign)
+ }
+}
+
+func (e *CelpEncoder) calcAcbGain(lResp int, acbBasis, dLpc []float32, acbg *acbgParams, dLtp []float32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1572-L1646
+ fcbSubfrlen := e.fcbSubfrlen
+ for m := 0; m < acbgM; m++ {
+ cOff := smplMaxSfLen - lResp + 1
+ tmp := make([]float32, fcbSubfrlen)
+ celpMultSymtoepl2(e.phiFlip[cOff:], lResp, acbBasis[m*fcbSubfrlen:], tmp, fcbSubfrlen)
+ copy(acbg.acbBasisPhi[m*fcbSubfrlen:m*fcbSubfrlen+fcbSubfrlen], tmp)
+ for i := 0; i < acbgM; i++ {
+ acbg.phiAcb[m*acbgM+i] = celpDotProd(acbBasis[i*fcbSubfrlen:], acbg.acbBasisPhi[m*fcbSubfrlen:], fcbSubfrlen)
+ }
+ acbg.dAcbLpc[m] = celpDotProd(acbBasis[m*fcbSubfrlen:], dLpc, fcbSubfrlen)
+ }
+
+ bestRd := float32(1e30)
+ bestAcbgIdx := int32(0)
+ transitionIdx := int32(0)
+ if e.prevAcbIdx[smplCelpIdxMain] != -1 {
+ transitionIdx = e.prevAcbIdx[smplCelpIdxMain] + 1
+ }
+ invProbFull := e.acbgInvProb()
+ invProb := invProbFull[int(transitionIdx)*acbgN:]
+ cb := &cbAcbgainsHRQ14
+ if e.lowRate {
+ cb = &cbAcbgainsLRQ14
+ }
+ scQ14 := 1.0 / float32(int32(1)<<14)
+ var acbGains [acbgM]float32
+ for n := 0; n < acbgN; n++ {
+ for m := 0; m < acbgM; m++ {
+ acbGains[m] = float32(cb[n*acbgM+m]) * scQ14
+ }
+ werrOut := acbg.werrIn + celpWnrg2(acbg.phiAcb[:], acbGains[:]) -
+ 2.0*(acbg.dAcbLpc[0]*acbGains[0]+acbg.dAcbLpc[1]*acbGains[1])
+ rd := werrOut * invProb[n]
+ if rd < bestRd {
+ bestRd = rd
+ bestAcbgIdx = int32(n)
+ }
+ }
+
+ g0 := -float32(cb[int(bestAcbgIdx)*acbgM]) * scQ14
+ celpAddScaleVec(dLpc, acbg.acbBasisPhi, dLtp, fcbSubfrlen, g0)
+ g1 := -float32(cb[int(bestAcbgIdx)*acbgM+1]) * scQ14
+ celpAddScaleVecInplace(acbg.acbBasisPhi[fcbSubfrlen:], dLtp, fcbSubfrlen, g1)
+ return bestAcbgIdx
+}
+
+func (e *CelpEncoder) acbgInvProb() []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1613-L1618
+ if e.lowRate {
+ return getCelpTables().acbgInvProbLR[:]
+ }
+ return getCelpTables().acbgInvProbHR[:]
+}
+
+func (e *CelpEncoder) calcGainsV(fcbWnrg, gainFromSearch float32, excFcb, dLpc []float32, acbg *acbgParams, rateIdx int, acbIdx, fcbIdx *[smplCelpMaxRates]int16) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1649-L1761
+ tbl := getCelpTables()
+ fcbSubfrlen := e.fcbSubfrlen
+
+ fcbgain := gainFromSearch
+ if fcbgain < 0.0 {
+ fcbgain = 0.0
+ }
+ gainDb := 20.0 * float32(math.Log10(float64(fcbgain+1.0e-16)))
+ if gainDb < vGainMinDb {
+ gainDb = vGainMinDb
+ }
+ if gainDb > vGainMaxDb {
+ gainDb = vGainMaxDb
+ }
+ maxGainIdx := int32(math.Round(float64((vGainMaxDb - vGainMinDb) / vGainStepDb)))
+
+ bestAcbgIdx := int32(0)
+ bestFcbgIdx := int32(0)
+
+ var acbFcb [acbgM]float32
+ for i := 0; i < acbgM; i++ {
+ acbFcb[i] = celpDotProd(acbg.acbBasisPhi[i*fcbSubfrlen:], excFcb, fcbSubfrlen)
+ }
+ var phiAll [(acbgM + 1) * (acbgM + 1)]float32
+ stride := acbgM + 1
+ for i := 0; i < acbgM; i++ {
+ for j := 0; j < acbgM; j++ {
+ phiAll[i*stride+j] = acbg.phiAcb[i*acbgM+j]
+ }
+ }
+ for i := 0; i < acbgM; i++ {
+ phiAll[i*stride+acbgM] = acbFcb[i]
+ phiAll[acbgM*stride+i] = acbFcb[i]
+ }
+ phiAll[acbgM*stride+acbgM] = fcbWnrg
+
+ var dall [acbgM + 1]float32
+ copy(dall[:acbgM], acbg.dAcbLpc[:])
+ dall[acbgM] = celpDotProd(dLpc, excFcb, fcbSubfrlen)
+
+ var gainIdxs [nGainSteps]int32
+ var fcbgains [nGainSteps]float32
+ var fcbgInvProb [nGainSteps]float32
+ firstGainIdx := int32(math.Floor(float64((gainDb-vGainMinDb)/vGainStepDb))) - (nGainSteps-1)/2
+ if firstGainIdx < 0 {
+ firstGainIdx = 0
+ }
+ if firstGainIdx > maxGainIdx-1 {
+ firstGainIdx = maxGainIdx - 1
+ }
+ offset := int32(math.Floor(float64((vGainMinDb - vGainMaxDb) / vGainStepDb)))
+ for i := 0; i < nGainSteps; i++ {
+ gainIdxs[i] = firstGainIdx + int32(i)
+ fcbgains[i] = tbl.fcbgainsV[gainIdxs[i]]
+ if e.prevFcbIdx[rateIdx] == -1 {
+ fcbgInvProb[i] = tbl.fcbgVInvProb[gainIdxs[i]]
+ } else {
+ delta := e.prevFcbIdx[rateIdx] - gainIdxs[i]
+ cmfIdx := delta - offset
+ fcbgInvProb[i] = tbl.fcbgVDeltaInvProb[cmfIdx]
+ }
+ }
+
+ bestRd := float32(1e30)
+ transitionIdx := int32(0)
+ if e.prevAcbIdx[rateIdx] != -1 {
+ transitionIdx = e.prevAcbIdx[rateIdx] + 1
+ }
+ cb := &cbAcbgainsHRQ14
+ if e.lowRate {
+ cb = &cbAcbgainsLRQ14
+ }
+ invProb := e.acbgInvProb()[int(transitionIdx)*acbgN:]
+ scQ14 := 1.0 / float32(int32(1)<<14)
+ for n := 0; n < acbgN; n++ {
+ var gains [acbgM + 1]float32
+ for m := 0; m < acbgM; m++ {
+ gains[m] = float32(cb[n*acbgM+m]) * scQ14
+ }
+ for i := 0; i < nGainSteps; i++ {
+ gains[acbgM] = fcbgains[i]
+ werrOut := acbg.werrIn + celpWnrg3(phiAll[:], gains[:]) -
+ 2.0*(dall[0]*gains[0]+dall[1]*gains[1]+dall[2]*gains[2])
+ rd := werrOut * fcbgInvProb[i] * invProb[n]
+ if rd < bestRd {
+ bestRd = rd
+ bestAcbgIdx = int32(n)
+ bestFcbgIdx = gainIdxs[i]
+ }
+ }
+ }
+ acbIdx[rateIdx] = int16(bestAcbgIdx)
+ fcbIdx[rateIdx] = int16(bestFcbgIdx)
+ if fcbIdx[rateIdx] < 0 {
+ fcbIdx[rateIdx] = 0
+ }
+ if fcbIdx[rateIdx] > int16(maxGainIdx) {
+ fcbIdx[rateIdx] = int16(maxGainIdx)
+ }
+ return tbl.fcbgainsV[fcbIdx[rateIdx]]
+}
+
+// EncodeSubframe is the main per-subframe CELP encoder (smpl_celp_encoder).
+func (e *CelpEncoder) EncodeSubframe(resLpc []float32, predcoef *[17]float32, percWghtResp, lags []float32, subfrImportance [smplCelpMaxRates]float32, fcbPulsesMax [smplCelpMaxRates]int16, surv []int16) CelpSubframeOut {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L1769-L2148
+ lResp := e.percRespLen
+ fcbSubfrlen := e.fcbSubfrlen
+ voiced := lags[1] > 0.0
+
+ celpFiltAr16(percWghtResp, lResp, predcoef[:], SmplLPCOrder, e.impLpcBuf)
+ celpMulVecInplace(e.hanningWin, e.impLpcBuf[SmplLPCOrder:], lResp)
+
+ impLpcRev := make([]float32, 2*smplMaxLResp-1)
+ revBase := smplMaxLResp - 1
+ {
+ imp := e.impLpcBuf[SmplLPCOrder:]
+ for i := 0; i < lResp; i++ {
+ impLpcRev[revBase+i] = imp[lResp-i-1]
+ }
+ }
+ {
+ imp := append([]float32(nil), e.impLpcBuf[SmplLPCOrder:SmplLPCOrder+lResp]...)
+ phi := make([]float32, smplMaxSfLen)
+ e.percFiltMa(impLpcRev, revBase, lResp, imp, lResp, phi)
+ celpReverse(phi, lResp)
+ for i := lResp; i < fcbSubfrlen; i++ {
+ phi[i] = 0.0
+ }
+ copy(e.phi, phi)
+ }
+ for i := range e.phiFlip {
+ e.phiFlip[i] = 0.0
+ }
+ e.phiFlip[smplMaxSfLen] = e.phi[0]
+ for i := 0; i < lResp+1; i++ {
+ e.phiFlip[smplMaxSfLen-i] = e.phi[i]
+ e.phiFlip[smplMaxSfLen+i] = e.phi[i]
+ }
+
+ resLpcPad := make([]float32, fcbSubfrlen+lResp+1)
+ copy(resLpcPad[:fcbSubfrlen], resLpc[:fcbSubfrlen])
+ dLpc := make([]float32, smplMaxSfLen)
+ {
+ cOff := smplMaxSfLen - lResp + 1
+ celpMultSymtoepl2(e.phiFlip[cOff:], lResp, resLpcPad, dLpc, fcbSubfrlen)
+ }
+
+ acbg := acbgParams{acbBasisPhi: make([]float32, acbgM*fcbSubfrlen)}
+ zirLpc := make([]float32, smplMaxSfLen)
+
+ if !e.ignoreZir {
+ zirTmp := make([]float32, smplMaxSfLen+smplMaxLResp-1)
+ zt := smplMaxLResp - 1
+ htZir := make([]float32, 2*smplMaxLResp-1)
+ ht := smplMaxLResp - 1
+
+ stateLen := SmplLPCOrder
+ if lResp-1 > stateLen {
+ stateLen = lResp - 1
+ }
+ for i := 0; i < stateLen; i++ {
+ zirTmp[zt-stateLen+i] = e.stateWghtBuf[SmplLPCOrder+(fcbSubfrlen-stateLen)+i]
+ }
+ for nn := 0; nn < lResp; nn++ {
+ res := zirTmp[zt+nn]
+ for i := 0; i < 16; i++ {
+ res -= predcoef[16-i] * zirTmp[zt+nn-16+i]
+ }
+ zirTmp[zt+nn] = res
+ }
+ e.percFiltMa(zirTmp, zt, lResp, percWghtResp, lResp, zirLpc)
+ for i := 0; i < lResp; i++ {
+ zirTmp[zt+i] = zirLpc[lResp-i-1]
+ }
+ for i := 0; i < lResp-1; i++ {
+ zirTmp[zt-(lResp-1)+i] = 0.0
+ }
+ {
+ imp := append([]float32(nil), e.impLpcBuf[SmplLPCOrder:SmplLPCOrder+lResp]...)
+ e.percFiltMa(zirTmp, zt, lResp, imp, lResp, htZir[ht:])
+ }
+ celpReverse(htZir[ht:], lResp)
+
+ if voiced {
+ acbg.werrIn = celpDotProd(dLpc, resLpc, fcbSubfrlen) +
+ 2.0*celpDotProd(htZir[ht:], resLpc, lResp) + celpNrg(zirLpc, lResp)
+ }
+ for i := 0; i < lResp; i++ {
+ dLpc[i] += htZir[ht+i]
+ }
+ } else {
+ for i := 0; i < lResp; i++ {
+ zirLpc[i] = 0.0
+ }
+ if voiced {
+ acbg.werrIn = celpDotProd(dLpc, resLpc, fcbSubfrlen)
+ }
+ }
+
+ acbBasis := make([]float32, smplMaxSfLen*acbgM)
+ acb := make([]float32, smplMaxSfLen)
+ dLtp := make([]float32, smplMaxSfLen)
+ acbIdx := [smplCelpMaxRates]int16{-1, -1}
+
+ if voiced {
+ celpSynLtpBasis(lags, fcbSubfrlen/celpLagSubfrlen, e.acbState, e.acbStateLen, acbBasis)
+ idx := e.calcAcbGain(lResp, acbBasis, dLpc, &acbg, dLtp)
+ acbIdx[smplCelpIdxMain] = int16(idx)
+ var acbGain [acbgM]float32
+ celpAcbDequant(e.lowRate, int32(acbIdx[smplCelpIdxMain]), &acbGain)
+ celpAcbSynthesize(fcbSubfrlen, acbBasis, &acbGain, acb)
+ acbIdx[smplCelpIdxFec] = acbIdx[smplCelpIdxMain]
+ }
+
+ wtgtTmp := make([]float32, smplMaxSfLen+2*smplMaxLResp-1)
+ wt := smplMaxLResp - 1
+ wtgt := make([]float32, smplMaxSfLen+smplMaxLResp)
+ copy(wtgtTmp[wt:wt+fcbSubfrlen], resLpc[:fcbSubfrlen])
+ if voiced {
+ for i := 0; i < fcbSubfrlen; i++ {
+ wtgtTmp[wt+i] += -rateAcbScale * acb[i]
+ }
+ }
+ {
+ imp := append([]float32(nil), e.impLpcBuf[SmplLPCOrder:SmplLPCOrder+lResp]...)
+ e.percFiltMa(wtgtTmp, wt, fcbSubfrlen+lResp, imp, lResp, wtgt)
+ }
+ for i := 0; i < lResp; i++ {
+ wtgt[i] += zirLpc[i]
+ }
+ nrgWtgt := celpNrg(wtgt, fcbSubfrlen+lResp)
+ var wnrgPerPulse [smplCelpMaxRates]float32
+ for r := 0; r < smplCelpMaxRates; r++ {
+ wnrgPerPulse[r] = nrgWtgt / (subfrImportance[r] + 1.0e-3)
+ }
+ iLag := int32(lags[(fcbSubfrlen/celpLagSubfrlen)-1])
+
+ var nPulses [smplCelpMaxRates]int16
+ var gainFromSearch [smplCelpMaxRates]float32
+ var fcbWnrg [smplCelpMaxRates]float32
+ var wnrg [smplCelpMaxRates]float32
+ var pulses [smplCelpMaxRates][smplMaxPulsesPerSf]int16
+
+ if fcbPulsesMax[smplCelpIdxMain] > 0 {
+ target := dLpc
+ if voiced {
+ target = dLtp
+ }
+ useGreedy := fcbPulsesMax[smplCelpIdxMain]-1 > 0 &&
+ surv[fcbPulsesMax[smplCelpIdxMain]-2] == 1 && !e.lowRate
+ if useGreedy {
+ e.smplFcbSearch(target, &wnrgPerPulse, &fcbPulsesMax, &pulses, &nPulses, &wnrg, &gainFromSearch, &fcbWnrg)
+ } else {
+ ps := float32(0.0)
+ if e.lowRate {
+ ps = pitchSharpeningCoef
+ }
+ e.smplFcbSearchDeldec(target, ps, iLag, &wnrgPerPulse, &fcbPulsesMax, surv, &pulses, &nPulses, &wnrg, &gainFromSearch, &fcbWnrg)
+ }
+ }
+
+ gainIdx := [smplCelpMaxRates]int16{-1, -1}
+ var fcbgain float32
+ excFcb := make([]float32, smplMaxSfLen)
+ tbl := getCelpTables()
+ for r := 0; r < smplCelpMaxRates; r++ {
+ excFcbRaw := make([]float32, smplMaxSfLen)
+ celpFcbSynthesize(fcbSubfrlen, pulses[r][:], int(nPulses[r]), excFcbRaw)
+ copy(excFcb[:fcbSubfrlen], excFcbRaw[:fcbSubfrlen])
+ if nPulses[r] > 0 {
+ if voiced {
+ if e.lowRate {
+ celpPitchSharp(excFcb, int(iLag), fcbSubfrlen)
+ }
+ fcbgain = e.calcGainsV(fcbWnrg[r], gainFromSearch[r], excFcb, dLpc, &acbg, r, &acbIdx, &gainIdx)
+ } else {
+ gainIdx[r] = celpQuantGainUv(gainFromSearch[r])
+ fcbgain = tbl.fcbgainsUV[gainIdx[r]]
+ }
+ celpScaleVecInplace(excFcb, fcbSubfrlen, fcbgain)
+ }
+ }
+
+ excLpc := make([]float32, fcbSubfrlen)
+ copy(excLpc, excFcb[:fcbSubfrlen])
+ if voiced {
+ var acbGain [acbgM]float32
+ celpAcbDequant(e.lowRate, int32(acbIdx[smplCelpIdxMain]), &acbGain)
+ celpAcbSynthesize(fcbSubfrlen, acbBasis, &acbGain, acb)
+ celpAddVecInplace(acb, excLpc, fcbSubfrlen)
+ }
+
+ copy(e.acbState[0:e.acbStateLen-fcbSubfrlen], e.acbState[fcbSubfrlen:e.acbStateLen])
+ writeOff := e.acbStateLen - 2*fcbSubfrlen
+ copy(e.acbState[writeOff:writeOff+fcbSubfrlen], excLpc[:fcbSubfrlen])
+
+ if !e.ignoreZir {
+ lpcResErr := make([]float32, smplMaxSfLen)
+ celpSubVec(resLpc, excLpc, lpcResErr, fcbSubfrlen)
+ for i := 0; i < SmplLPCOrder; i++ {
+ e.stateWghtBuf[i] = e.stateErrLpcSyn[i]
+ }
+ celpFiltAr16(lpcResErr, fcbSubfrlen, predcoef[:], SmplLPCOrder, e.stateWghtBuf)
+ for i := 0; i < SmplLPCOrder; i++ {
+ e.stateErrLpcSyn[i] = e.stateWghtBuf[SmplLPCOrder+(fcbSubfrlen-SmplLPCOrder)+i]
+ }
+ }
+
+ e.subfrCnt++
+ if e.subfrCnt == e.subfrPerPacket {
+ for r := 0; r < smplCelpMaxRates; r++ {
+ e.prevAcbIdx[r] = -1
+ e.prevFcbIdx[r] = -1
+ }
+ e.subfrCnt = 0
+ } else {
+ for r := 0; r < smplCelpMaxRates; r++ {
+ if voiced {
+ e.prevAcbIdx[r] = int32(acbIdx[r])
+ e.prevFcbIdx[r] = int32(gainIdx[r])
+ } else {
+ e.prevAcbIdx[r] = -1
+ e.prevFcbIdx[r] = -1
+ }
+ }
+ }
+ e.fcbgain = fcbgain
+
+ nFec := int(nPulses[smplCelpIdxFec])
+ if nFec < 0 {
+ nFec = 0
+ }
+ nMain := int(nPulses[smplCelpIdxMain])
+ if nMain < 0 {
+ nMain = 0
+ }
+ pulsesFec := append([]int16(nil), pulses[smplCelpIdxFec][:nFec]...)
+ pulsesMain := append([]int16(nil), pulses[smplCelpIdxMain][:nMain]...)
+
+ return CelpSubframeOut{
+ Pulses: [smplCelpMaxRates][]int16{pulsesFec, pulsesMain},
+ NPulses: nPulses,
+ AcbIdx: acbIdx,
+ GainIdx: gainIdx,
+ ExcLpc: excLpc,
+ }
+}
+
+// smplDistributeFcbSurv splits tot_surv survivors across pulse counts.
+func smplDistributeFcbSurv(numsurv []int16, maxPulses, totSurv int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celp.rs#L2155-L2182
+ if maxPulses <= 1 {
+ numsurv[0] = 1
+ return
+ }
+ for i := 0; i < int(maxPulses); i++ {
+ numsurv[i] = 1
+ }
+ sumSurv := maxPulses
+ extraSurv := totSurv - maxPulses
+ extra := extraSurv / (maxPulses - 1)
+ if extra > fcbSrvMax-1 {
+ extra = fcbSrvMax - 1
+ }
+ for i := 0; i < int(maxPulses-1); i++ {
+ numsurv[i] += int16(extra)
+ }
+ sumSurv += extra * (maxPulses - 1)
+ ix := maxPulses - 2
+ for sumSurv < totSurv {
+ if int32(numsurv[ix]) < fcbSrvMax {
+ numsurv[ix]++
+ sumSurv++
+ }
+ ix--
+ if ix < 0 {
+ break
+ }
+ }
+}
diff --git a/pkg/call/voip/media/mlow/celpdec.go b/pkg/call/voip/media/mlow/celpdec.go
new file mode 100644
index 00000000..61b07183
--- /dev/null
+++ b/pkg/call/voip/media/mlow/celpdec.go
@@ -0,0 +1,352 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "math"
+ "sync"
+)
+
+// Decoder-side CELP synthesis in the codec's native float domain — a faithful port
+// of the per-subframe loop in smpl_core_decoder.c (excitation → CELP/ACB decode →
+// gen_noise → LPC synthesis) plus LSF interpolation and the FCB gain tables. Output
+// is float in [-1, 1]. Reuses SmplNLSF2A (synth), the noise generator (noise.go),
+// and the HP postfilter (postfilter.go).
+
+const (
+ celpLagSubfrLen = 40
+ celpLTPInterpolDelay = 8
+ celpMaxPitchLag = 320
+ acbgM = 2
+ acbgN = 16
+ pitchSharpCoef = float32(0.9881)
+ fcbgVN = 34
+ uvGainIdxLen = 90
+ vGainMinDB = float32(-100.0)
+ vGainStepDB = float32(3.0)
+ uvGainMinDB = float32(-90.0)
+ uvGainStepDB = float32(1.0)
+)
+
+// decAcbHighBoost: ACB high-boost endpoints (smpl_dec_acb_high_boost).
+var decAcbHighBoost = [2]float32{0.35, 0.18}
+
+// lsfInterpol4: LSF→LPC interpolation factors per subframe, [lsf_interpol_idx][sf].
+var lsfInterpol4 = [2][4]float32{{0.55, 0.88, 1.0, 1.0}, {0.3, 0.65, 0.95, 1.0}}
+
+// celpInterpolKernel: 16-tap symmetric LTP interpolation kernel.
+var celpInterpolKernel = [2 * celpLTPInterpolDelay]float32{
+ -6.3925986e-6, 0.00011064114, -0.0009153038, 0.00484772, -0.018698348, 0.05759091, -0.15997477, 0.6170455,
+ 0.61704546, -0.15997475, 0.057590906, -0.018698348, 0.00484772, -0.0009153038, 0.000110641144, -6.392598e-6,
+}
+
+// Per-subframe ACB-gain codebook (Q14), [acbgN*acbgM]. Mirrors smpl_celp's
+// cb_acbgains_{hr,lr}_q14 (only these two small tables are needed on the decode path).
+var cbAcbgainsHRQ14 = [acbgN * acbgM]int16{
+ 16039, 91, 0, 0, 4310, 4930, -1431, 2862, 2893, 0, 8009, 4075, 2754, 4223, 8367, 354,
+ 4640, 1254, -176, 2734, -1222, 5017, -476, 1506, 11351, 567, 1243, 0, 10601, 22, 14088, 108,
+}
+var cbAcbgainsLRQ14 = [acbgN * acbgM]int16{
+ 2812, 2484, 0, 0, -362, 2465, -337, 703, 3033, 1474, 13536, 220, -2630, 9226, 6032, 3499,
+ -220, 441, 7661, 4243, 11521, 0, 1430, 779, 4495, 2724, 15535, 343, -779, 1559, 480, 481,
+}
+
+type fcbGainsT struct {
+ uv [uvGainIdxLen + 1]float32
+ v [fcbgVN]float32
+}
+
+var (
+ fcbGainsOnce sync.Once
+ fcbGainsV fcbGainsT
+)
+
+func fcbGains() *fcbGainsT {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L64-L77
+ fcbGainsOnce.Do(func() {
+ for ix := 0; ix <= uvGainIdxLen; ix++ {
+ fcbGainsV.uv[ix] = float32(math.Pow(10, float64(0.05*(float32(ix)*uvGainStepDB+uvGainMinDB))))
+ }
+ for ix := 0; ix < fcbgVN; ix++ {
+ fcbGainsV.v[ix] = float32(math.Pow(10, float64(0.05*(float32(ix)*vGainStepDB+vGainMinDB))))
+ }
+ })
+ return &fcbGainsV
+}
+
+func celpDot(a, b []float32, l int) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L79-L86
+ var r float32
+ for i := 0; i < l; i++ {
+ r += a[i] * b[i]
+ }
+ return r
+}
+
+// lpcInterpol: per-subframe interpolation of the LSF between prevLsf and lsf, then
+// NLSF→A. Mutates prevLsf to the last interpolated LSF (carried across frames).
+func lpcInterpol(lsf []float32, prevLsf *[SmplOrder]float32, interpol [4]float32, aOut *[SmplSubfrCount][SmplOrder + 1]float32, lsfsOut *[SmplSubfrCount][SmplOrder]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L126-L155
+ if prevLsf[SmplOrder-1] == 0.0 {
+ copy(prevLsf[:], lsf[:SmplOrder])
+ }
+ var ilsf [SmplOrder]float32
+ prevFactor := float32(-1.0)
+ for j := 0; j < SmplSubfrCount; j++ {
+ if interpol[j] == prevFactor {
+ aOut[j] = aOut[j-1]
+ } else {
+ if interpol[j] == 1.0 {
+ copy(ilsf[:], lsf[:SmplOrder])
+ } else {
+ for k := 0; k < SmplOrder; k++ {
+ ilsf[k] = prevLsf[k]*(1.0-interpol[j]) + lsf[k]*interpol[j]
+ }
+ }
+ copy(aOut[j][:], SmplNLSF2A(ilsf[:]))
+ }
+ prevFactor = interpol[j]
+ lsfsOut[j] = ilsf
+ }
+ copy(prevLsf[:], ilsf[:])
+}
+
+func acbDequant(lowRate bool, acbIdx int32, acbG *[acbgM]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L157-L168
+ cb := &cbAcbgainsHRQ14
+ if lowRate {
+ cb = &cbAcbgainsLRQ14
+ }
+ const sc = 1.0 / float32(int32(1)<<14)
+ for m := 0; m < acbgM; m++ {
+ acbG[m] = float32(cb[int(acbIdx)*acbgM+m]) * sc
+ }
+}
+
+// acbSynthesize: adjust_acbgains (high-boost) then 3-tap symmetric ACB synthesis.
+func acbSynthesize(fcbSubfrlen int, acbBasis []float32, acbGIn *[acbgM]float32, highBoost float32, acb []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L170-L193
+ acbG := *acbGIn
+ if highBoost != 0.0 {
+ f0 := acbG[0] + 2.0*acbG[1]
+ f1 := acbG[0] - acbG[1]
+ absF2new := minF32(absF32(f1)+highBoost, absF32(f0))
+ f1 = f1 * (absF2new / (absF32(f1) + 1e-12))
+ acbG[0] = (f0 + 2.0*f1) / 3.0
+ acbG[1] = (f0 - f1) / 3.0
+ }
+ for i := 0; i < fcbSubfrlen; i++ {
+ acb[i] = acbG[0] * acbBasis[i]
+ }
+ for i := 0; i < fcbSubfrlen; i++ {
+ acb[i] += acbG[1] * acbBasis[fcbSubfrlen+i]
+ }
+}
+
+func pitchSharp(x []float32, lag, l int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L195-L200
+ for i := lag; i < l; i++ {
+ x[i] += x[i-lag] * pitchSharpCoef
+ }
+}
+
+// synLTPBasis: build the ACB basis from the excitation history; mutates state forward.
+func synLTPBasis(lags []float32, nLags int, state []float32, stateLen int, acbBasis []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L202-L269
+ p := stateLen - nLags*celpLagSubfrLen
+ for subfr := 0; subfr < nLags; subfr++ {
+ iLag := int(math.Floor(float64(lags[subfr])))
+ if float32(iLag) == lags[subfr] {
+ il := iLag
+ for i := 0; i < celpLagSubfrLen; i++ {
+ state[p+i] = state[p+i-il]
+ }
+ for i := 0; i < celpLagSubfrLen; i++ {
+ acbBasis[subfr*celpLagSubfrLen+i] = state[p+i]
+ }
+ for i := 0; i < celpLagSubfrLen; i++ {
+ acbBasis[(nLags+subfr)*celpLagSubfrLen+i] = state[p+i-il-1] + state[p+i-il+1]
+ }
+ } else {
+ il := iLag
+ baseFirst := p + (-1 - il - celpLTPInterpolDelay)
+ first := celpDot(state[baseFirst:], celpInterpolKernel[:], 2*celpLTPInterpolDelay)
+ srcBase := p + (-il - celpLTPInterpolDelay)
+ for nn := 0; nn < celpLagSubfrLen; nn++ {
+ var ret float32
+ for i := 0; i < 8; i++ {
+ s0 := state[srcBase+nn+i]
+ s1 := state[srcBase+nn+15-i]
+ ret += (s0 + s1) * celpInterpolKernel[i]
+ }
+ state[p+nn] = ret
+ }
+ baseLast := p + (celpLagSubfrLen - il - celpLTPInterpolDelay)
+ last := celpDot(state[baseLast:], celpInterpolKernel[:], 2*celpLTPInterpolDelay)
+ for i := 0; i < celpLagSubfrLen; i++ {
+ acbBasis[subfr*celpLagSubfrLen+i] = state[p+i]
+ }
+ b1 := (nLags + subfr) * celpLagSubfrLen
+ acbBasis[b1] = first + state[p+1]
+ for i := 0; i < celpLagSubfrLen-2; i++ {
+ acbBasis[b1+1+i] = state[p+i] + state[p+i+2]
+ }
+ iLast := celpLagSubfrLen - 1
+ acbBasis[b1+iLast] = state[p+iLast-1] + last
+ }
+ p += celpLagSubfrLen
+ }
+}
+
+// celpDecode: add the ACB (LTP) contribution into lpcRes (voiced), then push the
+// subframe into the ACB state.
+func celpDecode(acbState []float32, acbStateLen int, voiced bool, acbGainIdx int32, lags []float32, numLags, subfrlen int, lowRate bool, normalizedBitrate float32, lpcRes []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L271-L306
+ if voiced {
+ highBoost := decAcbHighBoost[0] + (decAcbHighBoost[1]-decAcbHighBoost[0])*normalizedBitrate
+ iLag := int(lags[numLags-1])
+ if lowRate {
+ pitchSharp(lpcRes, iLag, subfrlen)
+ }
+ acbBasis := make([]float32, subfrlen*acbgM)
+ acb := make([]float32, subfrlen)
+ synLTPBasis(lags, numLags, acbState, acbStateLen, acbBasis)
+ var acbGain [acbgM]float32
+ acbDequant(lowRate, acbGainIdx, &acbGain)
+ acbSynthesize(subfrlen, acbBasis, &acbGain, highBoost, acb)
+ for i := 0; i < subfrlen; i++ {
+ lpcRes[i] += acb[i]
+ }
+ }
+ // Update ACB state: shift left by subfrlen, append this subframe's excitation.
+ copy(acbState[0:], acbState[subfrlen:acbStateLen-subfrlen])
+ copy(acbState[acbStateLen-2*subfrlen:acbStateLen-subfrlen], lpcRes[:subfrlen])
+}
+
+// filtAR16: y[n] = x[n] - sum_i a[16-i]*y[n-16+i]; ybuf holds a 16-sample history
+// prefix at ybuf[base-16..base].
+func filtAR16(x []float32, a *[SmplOrder + 1]float32, ybuf []float32, base, n int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L308-L319
+ for nn := 0; nn < n; nn++ {
+ res := x[nn]
+ for i := 0; i < SmplOrder; i++ {
+ res -= a[SmplOrder-i] * ybuf[base+nn-SmplOrder+i]
+ }
+ ybuf[base+nn] = res
+ }
+}
+
+// CelpDecParams holds the per-subframe decoded params the synthesis consumes.
+type CelpDecParams struct {
+ Voiced bool
+ SfPulses [SmplSubfrCount]int32
+ FcbgIdx [SmplSubfrCount]int32
+ NrgresDbqQ14 [SmplSubfrCount]int32
+ AcbgIdx [SmplSubfrCount]int32
+ BlockLags [2 * SmplSubfrCount]float32 // per-40-block pitch lag (codec units), 0 for unvoiced
+ TotalPulses int32
+}
+
+// CelpDecState is the persistent decoder synthesis state (C float domain).
+type CelpDecState struct {
+ noise NoiseGenerator
+ acbState []float32
+ acbStateLen int
+ lpcSynthMem [SmplOrder]float32
+ lsfPrev [SmplOrder]float32
+ prevNrgres float32
+ hp HpPostfilterState
+ // traceExcPre captures the per-subframe pre-noise excitation into ExcPre (KAT only).
+ traceExcPre bool
+ ExcPre []float32
+}
+
+// NewCelpDecState allocates a fresh CELP decoder state.
+func NewCelpDecState() *CelpDecState {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L351-L366
+ acbStateLen := SmplSubfrLen + 2*celpMaxPitchLag + celpLTPInterpolDelay
+ return &CelpDecState{
+ acbState: make([]float32, acbStateLen),
+ acbStateLen: acbStateLen,
+ hp: *NewHpPostfilterState(),
+ }
+}
+
+// SynthFrame synthesizes one 20 ms internal frame (4 subframes) into 320 float
+// samples in [-1, 1]. nlsf is the reconstructed order-16 NLSF; pulses are the signed
+// FCB pulse magnitudes (320 positions); lowRate is the TOC bit.
+func (s *CelpDecState) SynthFrame(nlsf []float32, lsfInterpolIdx int, pulses []int32, params *CelpDecParams, lowRate bool, frameLength16 int32, out []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_celpdec.rs#L372-L488
+ // Validation: the deterministic pre-noise excitation (FCB×gain + voiced ACB/LTP) is
+ // covered bit-tight by TestExcPre (exc_pre_lags.json); the noise and HP-postfilter
+ // stages it composes are each KAT-verified in their own modules. The full combined
+ // PCM output is validated end-to-end by the decoder module (e2e_vectors.json).
+ gains := fcbGains()
+ var a [SmplSubfrCount][SmplOrder + 1]float32
+ var lsfs [SmplSubfrCount][SmplOrder]float32
+ idx := lsfInterpolIdx
+ if idx > 1 {
+ idx = 1
+ }
+ lpcInterpol(nlsf, &s.lsfPrev, lsfInterpol4[idx], &a, &lsfs)
+
+ normBr := SmplGetNormalizedBitrate(params.TotalPulses, frameLength16)
+
+ var lpcRes [SmplIntfLen]float32
+ gainTab := gains.uv[:]
+ if params.Voiced {
+ gainTab = gains.v[:]
+ }
+ for pos := 0; pos < SmplIntfLen; pos++ {
+ if pulses[pos] != 0 {
+ sf := pos / SmplSubfrLen
+ lpcRes[pos] = float32(pulses[pos]) * gainTab[params.FcbgIdx[sf]]
+ }
+ }
+
+ const lagsPerSubfr = 2
+ var ybuf [SmplOrder + SmplIntfLen]float32
+ copy(ybuf[:SmplOrder], s.lpcSynthMem[:])
+ if s.traceExcPre {
+ s.ExcPre = s.ExcPre[:0]
+ }
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ base := sf * SmplSubfrLen
+ sfLags := []float32{params.BlockLags[2*sf], params.BlockLags[2*sf+1]}
+ celpDecode(s.acbState, s.acbStateLen, params.Voiced, params.AcbgIdx[sf], sfLags, lagsPerSubfr, SmplSubfrLen, lowRate, normBr, lpcRes[base:base+SmplSubfrLen])
+
+ if s.traceExcPre {
+ s.ExcPre = append(s.ExcPre, lpcRes[base:base+SmplSubfrLen]...)
+ }
+
+ nrgres := SmplDecodeResnrg(params.NrgresDbqQ14[sf], int32(SmplSubfrLen))
+ if !params.Voiced {
+ s.prevNrgres = nrgres
+ }
+ var noise [160]float32
+ SmplCelpGenNoise(&s.noise, lpcRes[base:base+SmplSubfrLen], SmplSubfrLen, params.Voiced, params.SfPulses[sf], nrgres, params.FcbgIdx[sf], lsfs[sf][:], normBr, gains.uv[:], noise[:])
+ for i := 0; i < SmplSubfrLen; i++ {
+ lpcRes[base+i] += noise[i]
+ }
+
+ filtAR16(lpcRes[base:base+SmplSubfrLen], &a[sf], ybuf[:], SmplOrder+base, SmplSubfrLen)
+ }
+ copy(out[:SmplIntfLen], ybuf[SmplOrder:])
+ copy(s.lpcSynthMem[:], ybuf[SmplOrder+SmplIntfLen-SmplOrder:])
+
+ // Post-LPC HP (pitch-harmonic) postfilter. The comb lag is the energy-weighted
+ // mean of the 8 per-40-block lags (0 → default fixed-corner curve, unvoiced).
+ var lag float32
+ if params.Voiced {
+ var sl, sll float32
+ for _, l := range params.BlockLags {
+ sl += l
+ sll += l * l
+ }
+ if sl > 0.0 {
+ lag = sll / sl
+ }
+ }
+ var hpOut [SmplIntfLen]float32
+ SmplHpPostfilter(&s.hp, out[:SmplIntfLen], SmplIntfLen, lag, hpOut[:])
+ copy(out[:SmplIntfLen], hpOut[:])
+}
diff --git a/pkg/call/voip/media/mlow/decoder.go b/pkg/call/voip/media/mlow/decoder.go
new file mode 100644
index 00000000..9d4b3a6b
--- /dev/null
+++ b/pkg/call/voip/media/mlow/decoder.go
@@ -0,0 +1,188 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "github.com/rs/zerolog"
+
+// MLow top-level decoder: RED strip → TOC routing → active-frame decode (3 chained
+// 20 ms internal frames: LSF → pulses → pitch/gains → reconstruct → CELP synthesis)
+// → per-packet harmonic postfilter → 60 ms PCM. Cross-frame predictor and synthesis
+// history persist across calls (the stream is continuous).
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L1-L218
+
+const opusFrameSamps = 960 // 60 ms @ 16 kHz
+
+// SmplDecoderState is the cross-frame decoder state: LSF predictor, previous NLSF,
+// the CELP synthesis state, and the harmonic-postfilter state.
+type SmplDecoderState struct {
+ Lstate SmplLsfState
+ PrevNLSF []float32
+ Celp *CelpDecState
+ Harm *HarmPostfilterState
+}
+
+func newSmplDecoderState() *SmplDecoderState {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L641-L672
+ return &SmplDecoderState{Celp: NewCelpDecState(), Harm: NewHarmPostfilterState()}
+}
+
+// MlowDecoder is a stateful pure-Go MLow decoder.
+type MlowDecoder struct {
+ state *SmplDecoderState
+ redundancy int32
+ log zerolog.Logger
+}
+
+// NewMlowDecoder allocates a fresh decoder.
+func NewMlowDecoder(opts ...Option) *MlowDecoder {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L36-L41
+ return &MlowDecoder{state: newSmplDecoderState(), log: resolveConfig(opts).log}
+}
+
+// SetRedundancy sets the negotiated RED redundancy level (0 = bare frames).
+func (d *MlowDecoder) SetRedundancy(n int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L44-L46
+ d.redundancy = int32(n)
+}
+
+// Reset clears the cross-frame state (call at a stream discontinuity).
+func (d *MlowDecoder) Reset() {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L49-L51
+ d.state = newSmplDecoderState()
+}
+
+// Decode decodes one RTP MLow payload into a 60 ms (960-sample) PCM frame, float in [-1, 1].
+func (d *MlowDecoder) Decode(payload []byte) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L54-L72
+ if len(payload) == 0 {
+ d.log.Trace().Msg("decode: empty payload, emitting silence")
+ return make([]float32, opusFrameSamps)
+ }
+ d.log.Trace().Int("payload_bytes", len(payload)).Int32("redundancy", d.redundancy).Msg("decode packet")
+ if d.redundancy > 0 {
+ frames, err := DepackSplitRed(payload, d.log)
+ if err != nil {
+ d.log.Debug().Err(err).Int("payload_bytes", len(payload)).Msg("decode: RED depack failed, emitting silence")
+ return make([]float32, opusFrameSamps)
+ }
+ var main []byte
+ if len(frames) > 0 {
+ main = frames[len(frames)-1].Data // the main (current) frame is last
+ }
+ d.log.Trace().Int("red_frames", len(frames)).Int("main_bytes", len(main)).Msg("decode: RED depacked")
+ return d.decodeFrame(main)
+ }
+ return d.decodeFrame(payload)
+}
+
+func (d *MlowDecoder) decodeFrame(frame []byte) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L74-L99
+ if len(frame) == 0 {
+ d.log.Trace().Msg("decode frame: empty, emitting silence")
+ return make([]float32, opusFrameSamps)
+ }
+ toc := ParseSmplTOC(frame[0], d.log)
+ var outLen int
+ if toc.StdOpus {
+ outLen = 16000 / 1000 * toc.FrameMs
+ } else {
+ outLen = toc.SampleRate / 1000 * toc.FrameMs
+ }
+ d.log.Trace().Int("frame_bytes", len(frame)).Uint8("toc_byte", frame[0]).
+ Bool("std_opus", toc.StdOpus).Bool("sid", toc.SID).Bool("active", toc.Active).
+ Bool("voiced", toc.Voiced).Int("frame_ms", toc.FrameMs).Int("sample_rate", toc.SampleRate).
+ Int("out_len", outLen).Msg("decode frame")
+ if toc.StdOpus {
+ d.log.Debug().Msg("decode frame: standard-Opus packet, not handled, emitting silence")
+ return make([]float32, outLen)
+ }
+ if toc.SID || !toc.Active {
+ d.log.Trace().Bool("sid", toc.SID).Bool("active", toc.Active).Msg("decode frame: inactive/SID, emitting silence")
+ return make([]float32, outLen)
+ }
+ return d.decodeActiveFrame(frame, outLen)
+}
+
+func (d *MlowDecoder) decodeActiveFrame(frame []byte, outLen int) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/decoder.rs#L101-L217
+ config := int(frame[0]>>2) & 1
+ tbl := LoadSmplTables()
+ synthT := LoadSmplSynthTables()
+ mem := LoadSmplMem()
+ dec := NewRangeDecoder(frame[1:])
+ lowRate := (frame[0]>>2)&1 != 0
+
+ d.log.Trace().Int("config", config).Bool("low_rate", lowRate).Int("body_bytes", len(frame)-1).Int("internal_frames", 3).Msg("decode active frame")
+
+ out := make([]float32, 0, 3*SmplIntfLen)
+ packetLags := make([]float32, 0, 3*8)
+ var avgNormBr float32
+ for f := 0; f < 3; f++ {
+ lsf := DecodeSmplLsf(dec, tbl, &d.state.Lstate, config, f)
+ pulses := DecodeSmplPulses(dec, mem, SmplIntfLen, 4, 1, int32(config), lsf.Stage1)
+ voiced := lsf.Stage1 == 1
+ var total int32
+ for _, c := range pulses.Subfr {
+ total += c
+ }
+ params := CelpDecParams{Voiced: voiced, SfPulses: pulses.Subfr, TotalPulses: total}
+ if voiced {
+ pr := DecodeSmplPitch(dec, mem, &d.state.Lstate, SmplIntfLen, 4, int32(config), pulses.Subfr)
+ for b := 0; b < 8; b++ {
+ v := float64(pr.BlockLags[b])*0.5 + 32.0
+ if v > 320.0 {
+ v = 320.0
+ }
+ params.BlockLags[b] = float32(v)
+ }
+ for sf := 0; sf < 4; sf++ {
+ params.AcbgIdx[sf] = pr.GainIdx[sf]
+ if pr.FiltIdx[sf] > 0 {
+ params.FcbgIdx[sf] = pr.FiltIdx[sf]
+ }
+ }
+ } else {
+ g := DecodeSmplGains(dec, mem, 4, pulses.Subfr)
+ params.NrgresDbqQ14 = g.GainQ
+ params.FcbgIdx = g.NrgRes
+ }
+ packetLags = append(packetLags, params.BlockLags[:]...)
+ avgNormBr += SmplGetNormalizedBitrate(params.TotalPulses, SmplIntfLen)
+
+ d.log.Trace().Int("intf", f).Bool("voiced", voiced).Int32("stage1", lsf.Stage1).
+ Int32("grid", lsf.Grid).Int32("total_pulses", total).Int("nlsf_len", len(d.state.PrevNLSF)).
+ Msg("decode internal frame params")
+
+ nlsf := SmplReconstructNLSF(synthT, int(lsf.Stage1), config, int(lsf.Grid), &lsf.Stage2, d.state.PrevNLSF)
+ var sig [SmplIntfLen]float32
+ d.state.Celp.SynthFrame(nlsf, int(lsf.Extra), pulses.Pulses, ¶ms, lowRate, SmplIntfLen, sig[:])
+ d.state.PrevNLSF = nlsf
+ out = append(out, sig[:]...)
+ }
+
+ // Per-packet harmonic postfilter (final pitch comb + 48-sample group delay) over the whole packet.
+ plen := len(out)
+ d.log.Trace().Int("samples", plen).Int("packet_lags", len(packetLags)).Msg("decode active frame: applying harmonic postfilter")
+ SmplHarmPostfilter(d.state.Harm, out, plen, packetLags, len(packetLags), avgNormBr/3.0)
+
+ pcm := make([]float32, len(out))
+ for i, v := range out {
+ switch {
+ case v > 1.0:
+ v = 1.0
+ case v < -1.0:
+ v = -1.0
+ }
+ pcm[i] = v
+ }
+ if outLen > 0 && outLen != len(pcm) {
+ if outLen <= len(pcm) {
+ pcm = pcm[:outLen]
+ } else {
+ np := make([]float32, outLen)
+ copy(np, pcm)
+ pcm = np
+ }
+ }
+ return pcm
+}
diff --git a/pkg/call/voip/media/mlow/encoder.go b/pkg/call/voip/media/mlow/encoder.go
new file mode 100644
index 00000000..a81e34f9
--- /dev/null
+++ b/pkg/call/voip/media/mlow/encoder.go
@@ -0,0 +1,647 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "errors"
+ "math"
+
+ "github.com/rs/zerolog"
+)
+
+// MLow ENCODER (module #16, outbound counterpart of mlow/decoder).
+//
+// This file holds the voiced/unvoiced classifier (smpl_signal_mode.rs) and the
+// entropy coder (EncodeSmplFrame — the exact inverse of the byte-exact decoder).
+// The classifier folds five voicing strengths (pitch correlation, VAD, spectral
+// tilt, harmonicity, short lag) plus a per-stream hysteresis into a single
+// voicing_strength; the encoder codes a frame voiced when that is positive and the
+// packet is coded-as-active. The full PCM→wire path (MlowEncoder.Encode) drives the
+// analysis front-end (analysis.go: LPC, perc, pitch, CELP, bitrate) → EncodeSmplFrame
+// and round-trips a tone through the decoder (TestEncodeRoundTripsATone, corr 0.89).
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L1-L222
+
+// SmplEncodeBufBytes is the range-encoder body capacity (mirrors SMPL_ENCODE_BUF_BYTES).
+const SmplEncodeBufBytes = 512
+
+// smpl_vuv_weights (smpl_tables.c): weights on corrs, vad, tilt, harmonicity,
+// short lags. The C declares 6 but sums only the first 5.
+var smplVuvWeights = [5]float32{1.0, 0.5, 0.5, 0.7, 0.3}
+
+const (
+ smplVuvBias float32 = -0.1038
+ smplVuvHyst float32 = 0.05
+ transitionIx = SmplFLen / 3 // low/high spectral-tilt band split
+ harmonicityUndef float32 = -10000.0
+ numHarms = 4
+)
+
+// smplInvSigmoid is the C smpl_inv_sigmoid: -ln(1/x - 1).
+func smplInvSigmoid(x float32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L30-L33
+ return -float32(math.Log(float64(1.0/x - 1.0)))
+}
+
+// vuvDot is smpl_dot_prod over the first l elements (float32 accumulation, to
+// match the reference's f32 rounding).
+func vuvDot(a, b []float32, l int) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L35-L42
+ var s float32
+ for i := 0; i < l; i++ {
+ s += a[i] * b[i]
+ }
+ return s
+}
+
+// vuvSum is smpl_sum_vec over the first l elements.
+func vuvSum(x []float32, l int) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L44-L51
+ var s float32
+ for i := 0; i < l && i < len(x); i++ {
+ s += x[i]
+ }
+ return s
+}
+
+// VuvMode is the per-stream voicing hysteresis + spectral-tilt background tracker
+// (VUV_Mode in the C). The encoder threads one instance across the whole stream;
+// the zero value matches the C calloc init.
+type VuvMode struct {
+ nrgLoBgn float32
+ nrgHiBgn float32
+ voicingPrev float32
+ lastLagPrev float32
+}
+
+// spectralHarmonicity (smpl_pitch_util.c): harmonic peak/valley energy ratio at
+// low frequencies, from the per-bin weighted power spectrum f2w. cache is the C's
+// per-call harmonicity memo keyed by harmonic bin; reset clears it.
+func spectralHarmonicity(avgLag float32, f2w []float32, cache []float32, reset bool) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L78-L99
+ if reset {
+ for i := range cache {
+ cache[i] = harmonicityUndef
+ }
+ }
+ invF2StepHz := 2.0 * float32(SmplFLen-1) / 16000.0
+ harmHz := 16000.0 / avgLag
+ harmIx := int32(math.Round(float64(harmHz * 2.0 * invF2StepHz)))
+ cacheLen := int32(len(cache))
+ if harmIx >= cacheLen {
+ // The C asserts this never happens; guard defensively and recompute.
+ return recomputeHarmonicity(harmHz, invF2StepHz, f2w)
+ }
+ if cache[harmIx] > harmonicityUndef {
+ return cache[harmIx]
+ }
+ hs := recomputeHarmonicity(harmHz, invF2StepHz, f2w)
+ cache[harmIx] = hs
+ return hs
+}
+
+func recomputeHarmonicity(harmHz, invF2StepHz float32, f2w []float32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L103-L147
+ harmWidth := harmHz * invF2StepHz
+ harmStrength := float32(0.1)
+ if harmWidth > 1.97 {
+ var peakValleyMags [2*numHarms + 1]float32
+ invHarmWidth := 1.0 / harmWidth
+ for numHarm := 0; numHarm < len(peakValleyMags); numHarm++ {
+ ixStart := 0.5 * float32(numHarm) * harmWidth
+ ixEnd := ixStart + harmWidth
+ idxStart := int32(math.Ceil(float64(ixStart)))
+ idxEnd := int32(math.Floor(float64(ixEnd)))
+ weightsLen := int(idxEnd - idxStart + 1)
+ if weightsLen < 0 {
+ weightsLen = 0
+ }
+ var weights [20]float32
+ for i := 0; i < weightsLen && i < len(weights); i++ {
+ tmp := (float32(idxStart) - ixStart + float32(i)) * invHarmWidth
+ tmp -= tmp * tmp
+ weights[i] = tmp * tmp
+ }
+ base := int(idxStart)
+ if base < 0 {
+ base = 0
+ }
+ if base > len(f2w) {
+ base = len(f2w)
+ }
+ avail := len(f2w) - base
+ if avail > weightsLen {
+ avail = weightsLen
+ }
+ peakValleyNrg := vuvDot(f2w[base:], weights[:], avail) / vuvSum(weights[:], weightsLen)
+ peakValleyMags[numHarm] = float32(math.Sqrt(float64(peakValleyNrg + 1e-30)))
+ }
+ var magRatiosLog [numHarms]float32
+ var magWeights [numHarms]float32
+ magPeakW := [3]float32{1.0, 10.0, 1.0}
+ magValleyW := [3]float32{5.0, 2.0, 5.0}
+ for numHarm := 0; numHarm < numHarms; numHarm++ {
+ magPeak := magPeakW[0]*peakValleyMags[2*numHarm] +
+ magPeakW[1]*peakValleyMags[2*numHarm+1] +
+ magPeakW[2]*peakValleyMags[2*numHarm+2]
+ magValley := magValleyW[0]*peakValleyMags[2*numHarm] +
+ magValleyW[1]*peakValleyMags[2*numHarm+1] +
+ magValleyW[2]*peakValleyMags[2*numHarm+2]
+ magRatiosLog[numHarm] = float32(math.Log(float64(magPeak / magValley)))
+ magWeights[numHarm] = float32(math.Sqrt(float64(magPeak + magValley + 1e-30)))
+ }
+ harmStrength = vuvDot(magWeights[:], magRatiosLog[:], numHarms) / vuvSum(magWeights[:], numHarms)
+ }
+ return harmStrength
+}
+
+// BuildF2w builds the C F2w (F2[i] * (i+3), with F2w[0]=F2w[1]=0).
+func BuildF2w(f2 *[SmplFLen]float32) [SmplFLen]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L150-L156
+ var f2w [SmplFLen]float32
+ for i := 2; i < SmplFLen; i++ {
+ f2w[i] = f2[i] * float32(i+3)
+ }
+ return f2w
+}
+
+// HarmStrengthAt is the harmonicity at avgLag with a fresh cache (the C call
+// right after the pitch search). Reused by the pitch estimator so its
+// harm_strength matches the value fed to SmplGetSignalMode.
+func HarmStrengthAt(avgLag float32, f2w *[SmplFLen]float32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L160-L163
+ var cache [50]float32
+ return spectralHarmonicity(avgLag, f2w[:], cache[:], true)
+}
+
+// SmplGetSignalMode combines the five voicing strengths + hysteresis into the
+// voicing strength; it mutates vuv. lags is the per-lag-subframe pitch lag in
+// samples; f2 is the power spectrum F2[0..256].
+func SmplGetSignalMode(
+ pitchcorr float32,
+ lags []float32,
+ avgLag float32,
+ harmStrength float32,
+ f2 *[SmplFLen]float32,
+ spActProb float32,
+ vuv *VuvMode,
+) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_signal_mode.rs#L168-L222
+ pc := pitchcorr
+ if pc < 0.0 {
+ pc = 0.0
+ } else if pc > 1.0 {
+ pc = 1.0
+ }
+ corrStrength := smplInvSigmoid(0.1 + 0.75*pc) // -1.4 .. 1.4
+ vadStrength := 0.04 * (1.0 - 1.04/(spActProb+0.04)) // -1 .. 0
+
+ // spectral tilt
+ var nrgLo float32
+ for i := 2; i < transitionIx; i++ {
+ tmp := f2[i] * float32(i+3)
+ nrgLo += tmp * float32(transitionIx-i)
+ }
+ var nrgHi float32
+ for i := transitionIx; i < SmplFLen; i++ {
+ tmp := f2[i] * float32(i+3)
+ nrgHi += tmp * float32(i-transitionIx)
+ }
+ if vadStrength < -0.1 {
+ smthCoef := -0.5 * vadStrength
+ vuv.nrgLoBgn += smthCoef * (nrgLo - vuv.nrgLoBgn)
+ vuv.nrgHiBgn += smthCoef * (nrgHi - vuv.nrgHiBgn)
+ }
+ loDiff := nrgLo - vuv.nrgLoBgn
+ if loDiff < 0.0 {
+ loDiff = 0.0
+ }
+ hiDiff := nrgHi - vuv.nrgHiBgn
+ if hiDiff < 0.0 {
+ hiDiff = 0.0
+ }
+ tiltLin := (loDiff - hiDiff) / (nrgLo + nrgHi + 1e-9)
+ tiltStrength := tiltLin * tiltLin * tiltLin // make less binary
+ lagStrength := -smplSigmoid(0.25 * (38.0 - avgLag))
+
+ voicingStrength := (smplVuvWeights[0]*corrStrength+
+ smplVuvWeights[1]*vadStrength+
+ smplVuvWeights[2]*tiltStrength+
+ smplVuvWeights[3]*harmStrength+
+ smplVuvWeights[4]*lagStrength)/
+ vuvSum(smplVuvWeights[:], 5) + smplVuvBias
+
+ // hysteresis
+ if vuv.lastLagPrev > 0.0 {
+ tmp := float32(math.Log2(float64(lags[0] / vuv.lastLagPrev)))
+ if tmp > 0.0 {
+ tmp *= 0.5
+ }
+ vuv.voicingPrev /= 0.4 + tmp*tmp
+ }
+ voicingStrength += vuv.voicingPrev * smplVuvHyst
+ vuv.voicingPrev = float32(math.Tanh(float64(3.0 * voicingStrength)))
+ vuv.lastLagPrev = lags[len(lags)-1]
+
+ return voicingStrength
+}
+
+// --- entropy encoder (the exact inverse of the byte-exact decoder) ----------
+
+// ErrEncodeUnimplemented marks the parts of the encode path that are not yet built.
+var ErrEncodeUnimplemented = errors.New("mlow encode: analysis front-end (pcm→params) not yet implemented")
+
+// SmplRawSym is one uniform raw-symbol write (encode(sym, sym+1, 1<> 1
+ return (a - b) & 0xffff
+ }
+ ft := triT(l)
+ if ft == 0 {
+ ft = 1
+ }
+ var fl uint32
+ if total > 0 {
+ fl = triT(uint32(total - 1))
+ }
+ fh := triT(uint32(total))
+ enc.Encode(fl, fh, ft)
+ if total == 0 {
+ return
+ }
+
+ // --- recursive binary SPLIT ---
+ finalSum := pp.Subfr[0] + pp.Subfr[1]
+ initSum := total - subfrLen16*2
+ if initSum < 0 {
+ initSum = 0
+ }
+ lo := total - 80
+ if lo < 0 {
+ lo = 0
+ }
+ if initSum < lo {
+ return
+ }
+ hiBound := total - lo
+ if initSum < hiBound {
+ cdf := cdfWindow(cc.SplitCmf(total), int(initSum-lo), int((hiBound-initSum)+2))
+ enc.EncodeCDF(finalSum-initSum, cdf)
+ }
+ if finalSum > 0 {
+ encodeSplit3537(enc, cc, finalSum, subfrLen16, pp.Subfr[0])
+ }
+ if finalSum < total {
+ encodeSplit3537(enc, cc, total-finalSum, subfrLen16, pp.Subfr[2])
+ }
+
+ // --- MAGNITUDE block: replay recorded run-length symbols through the same loop ---
+ posPer := p2 / p3
+ magIdx := 0
+ for subfr := int32(0); subfr < p3; subfr++ {
+ cnt := pp.Subfr[subfr]
+ if cnt <= 0 {
+ continue
+ }
+ pos := posPer
+ c := cnt
+ k := int32(0)
+ for k < cnt {
+ oct := (pos + 7) / 8
+ bucket := cc.Runlen(oct)
+ start := int(bucket.MaxSamples() - pos)
+ m := pp.MagRuns[magIdx]
+ magIdx++
+ enc.EncodeCDF(m, cdfWindow(bucket.Cmf(c), start, int(pos+1)))
+ if m > 0 || k == 0 {
+ pos -= m
+ }
+ c--
+ k++
+ }
+ }
+
+ // --- SIGN block: replay recorded raw sign symbols ---
+ for _, rs := range pp.SignSyms {
+ enc.EncodeRawSymbol(rs.Sym, rs.Nbits)
+ }
+}
+
+// encodeSplit3537 is the inverse of smplSplit3537: encode the first-half count s0.
+func encodeSplit3537(enc *RangeEncoder, cc *CcTables, count, granularity int32, s0 int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L273-L292
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/encode.rs#L273-L292 (seed cc-table rewire: SplitCmf)
+ lo := count
+ if granularity < lo {
+ lo = granularity
+ }
+ minSplit := count - granularity
+ if minSplit < 0 {
+ minSplit = 0
+ }
+ if lo < minSplit || minSplit == lo {
+ return
+ }
+ cdf := cdfWindow(cc.SplitCmf(count), int(minSplit), int((lo-minSplit)+2))
+ enc.EncodeCDF(s0-minSplit, cdf)
+}
+
+// encodeSmplGains is the inverse of DecodeSmplGains: encode main/delta gain, then
+// per-subframe nrgres with the same gain-derived address shift.
+func encodeSmplGains(enc *RangeEncoder, _ *SmplMem, p3 int32, subfrCounts [4]int32, gp *SmplGainParams) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L294-L335
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/encode.rs#L294-L335 (seed cc-table rewire: Group A/E from CcTables)
+ cc := LoadCcTables()
+ enc.EncodeCDF(gp.GainMain, cc.NrgresGain4())
+ enc.EncodeCDF(gp.GainDelta, cc.NrgresShape4())
+ cfgSel := int32(2)
+
+ off6 := p3 * gp.GainDelta
+ base7 := gp.GainMain*cc.NrgStep(cfgSel) - 0x154000
+ var gainQ [4]int32
+ take := int(p3)
+ if take > 4 {
+ take = 4
+ }
+ for sf := 0; sf < take; sf++ {
+ cbv := cc.GainRecon(p3 == 4, int32(sf)+off6)
+ gainQ[sf] = base7 + (cbv << 4)
+ }
+
+ for sf := 0; sf < take; sf++ {
+ cnt := subfrCounts[sf]
+ if cnt <= 0 {
+ continue
+ }
+ var bucket int32
+ if cnt >= 30 {
+ bucket = 3
+ } else {
+ bucket = (cnt & 0xffff) / 10
+ }
+ g := (gainQ[sf] + 8192) >> 14
+ if g < -85 {
+ g = -85
+ }
+ negPart := (g >> 31) & g
+ minOffset := int(-negPart)
+ enc.EncodeCDF(gp.NrgRes[sf], cc.FcbgOffset(int(cfgSel), int(bucket), minOffset))
+ }
+}
+
+// encodeSmplPitch is the inverse of DecodeSmplPitch: encode the LTP gains/filters,
+// then the lag contour (blockseg selector + per-block lag indices) via the pitch
+// tables, mutating the predictor state identically.
+func encodeSmplPitch(enc *RangeEncoder, _ *SmplMem, st *SmplLsfState, p2, p3, p6 int32, subfrCounts [4]int32, pp *SmplPitchParams) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L337-L405
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/encode.rs#L337-L405 (seed cc-table rewire: Group C LTP gains from CcTables)
+ cc := LoadCcTables()
+
+ var gainAccum int32
+ take := int(p3)
+ if take > 4 {
+ take = 4
+ }
+ for sf := 0; sf < take; sf++ {
+ cnt := subfrCounts[sf]
+ gi := pp.GainIdx[sf]
+ if p6 != 0 {
+ enc.EncodeCDF(gi, cc.AcbgainRowLr(st.PrevGainIdx))
+ } else {
+ enc.EncodeCDF(gi, cc.AcbgainRow(st.PrevGainIdx))
+ }
+ st.PrevGainIdx = gi
+ var w0, w2 int32
+ if p6 != 0 {
+ w0, w2 = cc.AcbgainWeightsLr(gi)
+ } else {
+ w0, w2 = cc.AcbgainWeights(gi)
+ }
+ gainAccum += w0 + 2*w2
+ if cnt > 0 {
+ fi := pp.FiltIdx[sf]
+ if st.PrevFiltIdx == -1 {
+ enc.EncodeCDF(fi, cc.FcbgainV())
+ } else {
+ enc.EncodeCDF(fi, cc.FcbgainVDelta(st.PrevFiltIdx))
+ }
+ st.PrevFiltIdx = fi
+ }
+ }
+ avgGain := gainAccum / p3
+
+ mode := 0
+ if avgGain >= 10007 {
+ if avgGain < 14085 {
+ mode = 1
+ } else {
+ mode = 2
+ }
+ }
+ tab := LoadPitchTables()
+ encodeLagsWire(tab, enc, pp.BlocksegIdx, &pp.Laginds, st.PrevLagblk, st.PrevLagidx, mode)
+ nblk, nidx := smplLagsPredictorAfter(tab, pp.BlocksegIdx, &pp.Laginds)
+ st.PrevLagblk = nblk
+ st.PrevLagidx = nidx
+}
+
+// EncodeSmplFrame builds [TOC || range-coded body] from analyzed frame parameters
+// (the exact inverse of the decoder's active-frame body decode).
+func EncodeSmplFrame(fp *SmplFrameParams, log ...zerolog.Logger) ([]byte, error) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L61-L102
+ lg := pickLog(log)
+ const p2, p3, p4 = int32(320), int32(4), int32(1)
+ p6 := int32(fp.Config)
+ lg.Trace().Uint8("toc_byte", fp.TOC).Int("config", fp.Config).Int("internal_frames", 3).Msg("encode frame")
+ tbl := LoadSmplTables()
+ mem := LoadSmplMem()
+ enc := NewRangeEncoder(1 + SmplEncodeBufBytes)
+ var st SmplLsfState
+ for f := 0; f < 3; f++ {
+ ip := &fp.Internal[f]
+ lg.Trace().Int("intf", f).Bool("voiced", ip.Lsf.Stage1 == 1).Int32("stage1", ip.Lsf.Stage1).
+ Int32("total_pulses", ip.Pulses.Total).Bool("has_pitch", ip.HasPitch).Msg("encode internal frame params")
+ encodeSmplLsf(enc, tbl, &st, fp.Config, f, &ip.Lsf)
+ encodeSmplPulses(enc, mem, p2, p3, p4, p6, ip.Lsf.Stage1, &ip.Pulses)
+ if ip.Lsf.Stage1 == 1 {
+ encodeSmplPitch(enc, mem, &st, p2, p3, p6, ip.Pulses.Subfr, &ip.Pitch)
+ } else {
+ encodeSmplGains(enc, mem, p3, ip.Pulses.Subfr, &ip.Gains)
+ }
+ }
+ enc.Done()
+ if enc.Err() != 0 {
+ lg.Debug().Int32("err", enc.Err()).Msg("encode frame: range-encoder buffer overflow")
+ return nil, errors.New("mlow encode: range-encoder buffer overflow")
+ }
+ n := enc.ConsumedLen()
+ body := enc.Bytes()
+ out := make([]byte, 0, 1+n)
+ out = append(out, fp.TOC)
+ out = append(out, body[:n]...)
+ lg.Trace().Int("frame_bytes", len(out)).Int("body_bytes", n).Msg("encode frame: done")
+ return out, nil
+}
+
+// MlowEncoder is the stateful top-level MLow encoder. The cross-frame analysis
+// history (SmplEncoderState, in analysis.go) persists across Encode calls.
+type MlowEncoder struct {
+ state SmplEncoderState
+ log zerolog.Logger
+}
+
+// NewMlowEncoder allocates a fresh encoder.
+func NewMlowEncoder(opts ...Option) *MlowEncoder {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L33-L37
+ return &MlowEncoder{log: resolveConfig(opts).log}
+}
+
+// Reset clears the cross-frame analysis history (call at a stream discontinuity).
+func (e *MlowEncoder) Reset() {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L40-L43
+ e.state = SmplEncoderState{}
+}
+
+// Encode turns one 60 ms frame (exactly 960 samples) into a wire MLow frame:
+// sanitize (NaN→0, clamp [-1,1]) → analysis (PCM → SmplFrameParams) → entropy code.
+func (e *MlowEncoder) Encode(pcm []float32) ([]byte, error) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/encode.rs#L46-L57
+ if len(pcm) != opusFrameSamps {
+ e.log.Debug().Int("samples", len(pcm)).Int("want", opusFrameSamps).Msg("encode: wrong frame size")
+ return nil, errors.New("mlow encode: expected 960 samples (60 ms @16 kHz)")
+ }
+ e.log.Trace().Int("samples", len(pcm)).Msg("encode frame: sanitizing and analyzing")
+ clean := make([]float32, len(pcm))
+ for i, s := range pcm {
+ switch {
+ case math.IsNaN(float64(s)):
+ s = 0.0
+ case s < -1.0:
+ s = -1.0
+ case s > 1.0:
+ s = 1.0
+ }
+ clean[i] = s
+ }
+ fp := smplAnalyzeFrameSt(&e.state, clean)
+ return EncodeSmplFrame(&fp, e.log)
+}
diff --git a/pkg/call/voip/media/mlow/fft.go b/pkg/call/voip/media/mlow/fft.go
new file mode 100644
index 00000000..83e82e15
--- /dev/null
+++ b/pkg/call/voip/media/mlow/fft.go
@@ -0,0 +1,104 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math"
+
+// cpx is a single-precision complex value.
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L318-L343
+type cpx struct {
+ re, im float32
+}
+
+func (a cpx) add(b cpx) cpx {
+ return cpx{re: a.re + b.re, im: a.im + b.im}
+}
+
+func (a cpx) mul(b cpx) cpx {
+ return cpx{
+ re: a.re*b.re - a.im*b.im,
+ im: a.re*b.im + a.im*b.re,
+ }
+}
+
+// smallestFactor returns the smallest prime factor of n (>= 2).
+func smallestFactor(n int) int {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L346-L358
+ if n%2 == 0 {
+ return 2
+ }
+ p := 3
+ for p*p <= n {
+ if n%p == 0 {
+ return p
+ }
+ p += 2
+ }
+ return n
+}
+
+// fftRec is the recursive mixed-radix Cooley-Tukey DFT. sign is -1 forward, +1
+// inverse (unnormalized). x holds n inputs at the given stride; out is contiguous.
+func fftRec(x []cpx, stride, n int, sign float32, out []cpx) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L362-L405
+ if n == 1 {
+ out[0] = x[0]
+ return
+ }
+ p := smallestFactor(n)
+ if p == n {
+ for k := 0; k < n; k++ {
+ var acc cpx
+ angK := sign * 2.0 * smplPI * float32(k) / float32(n)
+ for j := 0; j < n; j++ {
+ ang := angK * float32(j)
+ w := cpx{re: float32(math.Cos(float64(ang))), im: float32(math.Sin(float64(ang)))}
+ acc = acc.add(x[j*stride].mul(w))
+ }
+ out[k] = acc
+ }
+ return
+ }
+ m := n / p
+ sub := make([]cpx, n)
+ for q := 0; q < p; q++ {
+ fftRec(x[q*stride:], stride*p, m, sign, sub[q*m:(q+1)*m])
+ }
+ for k := 0; k < n; k++ {
+ kmod := k % m
+ var acc cpx
+ for q := 0; q < p; q++ {
+ ang := sign * 2.0 * smplPI * float32(k) * float32(q) / float32(n)
+ tw := cpx{re: float32(math.Cos(float64(ang))), im: float32(math.Sin(float64(ang)))}
+ acc = acc.add(sub[q*m+kmod].mul(tw))
+ }
+ out[k] = acc
+ }
+}
+
+// cfft computes the complex FFT of a mixed-radix length into out. sign=-1 forward,
+// +1 inverse.
+func cfft(input, out []cpx, sign float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L408-L412
+ fftRec(input, 1, len(input), sign, out)
+}
+
+// rfftForwardOrdered is the forward real FFT of n real samples, re-packed into the
+// ordered REAL layout: f[0]=DC.re, f[1]=Nyquist.re, then [re,im] pairs for bins
+// 1..n/2-1. Output length is n.
+func rfftForwardOrdered(time, f []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_perc.rs#L416-L432
+ n := len(time)
+ cin := make([]cpx, n)
+ for i := 0; i < n; i++ {
+ cin[i].re = time[i]
+ }
+ spec := make([]cpx, n)
+ cfft(cin, spec, -1.0)
+ f[0] = spec[0].re
+ f[1] = spec[n/2].re
+ for i := 1; i < n/2; i++ {
+ f[2*i] = spec[i].re
+ f[2*i+1] = spec[i].im
+ }
+}
diff --git a/pkg/call/voip/media/mlow/gains.go b/pkg/call/voip/media/mlow/gains.go
new file mode 100644
index 00000000..09fb81f7
--- /dev/null
+++ b/pkg/call/voip/media/mlow/gains.go
@@ -0,0 +1,66 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+// Per-subframe gains + energy-residual decode (func 3545 GAINS block), for UNVOICED
+// internal frames (LSF stage-1 selector 0) — mutually exclusive with the pitch block.
+
+// SmplGainResult holds the decoded per-subframe gains and energy-residual symbols.
+type SmplGainResult struct {
+ GainQ [4]int32 // per-subframe quantized log-gain (Q-domain)
+ NrgRes [4]int32 // per-subframe energy-residual symbol (only subframes with pulses are read)
+ // Raw entropy symbols (for the encoder to replay): the main + delta gain symbols.
+ GainMain int32
+ GainDelta int32
+}
+
+// DecodeSmplGains decodes the gains+nrgres reads (the p3==4 path). subfrCounts are
+// the per-subframe pulse counts. Group A/E tables come from the seed-built CcTables
+// (the mem param is retained for call-site stability; only pitch lag reads use it).
+func DecodeSmplGains(dec *RangeDecoder, _ *SmplMem, p3 int32, subfrCounts [4]int32) SmplGainResult {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gains.rs#L18-L69
+ var res SmplGainResult
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_gains.rs#L29-L67 (seed cc-table rewire: Group A/E from CcTables)
+ cc := LoadCcTables()
+
+ // main gain (n=85) + delta gain (n=99)
+ gainMain := dec.DecodeCDF(cc.NrgresGain4())
+ gainDelta := dec.DecodeCDF(cc.NrgresShape4())
+ res.GainMain = gainMain
+ res.GainDelta = gainDelta
+ cfgSel := int32(2)
+
+ // gain reconstruction: base7 = gain_main*nrg_step - 0x154000; cbv = gain_recon[sf + p3*delta].
+ off6 := p3 * gainDelta
+ base7 := gainMain*cc.NrgStep(cfgSel) - 0x154000
+ take := int(p3)
+ if take > 4 {
+ take = 4
+ }
+ for sf := 0; sf < take; sf++ {
+ cbv := cc.GainRecon(p3 == 4, int32(sf)+off6)
+ res.GainQ[sf] = base7 + (cbv << 4)
+ }
+
+ // nrgres: per-subframe bucketed CDF (n=92) sliced by the gain-derived offset.
+ for sf := 0; sf < take; sf++ {
+ cnt := subfrCounts[sf]
+ if cnt <= 0 {
+ continue
+ }
+ var bucket int32
+ if cnt >= 30 {
+ bucket = 3
+ } else {
+ bucket = (cnt & 0xffff) / 10
+ }
+ // g = clamp((gainQ[sf]+8192)>>14, floor -85); min_offset = -neg_part (forward entry shift).
+ g := (res.GainQ[sf] + 8192) >> 14
+ if g < -85 {
+ g = -85
+ }
+ negPart := (g >> 31) & g
+ minOffset := int(-negPart)
+ res.NrgRes[sf] = dec.DecodeCDF(cc.FcbgOffset(int(cfgSel), int(bucket), minOffset))
+ }
+ return res
+}
diff --git a/pkg/call/voip/media/mlow/logging.go b/pkg/call/voip/media/mlow/logging.go
new file mode 100644
index 00000000..f15a53da
--- /dev/null
+++ b/pkg/call/voip/media/mlow/logging.go
@@ -0,0 +1,36 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "github.com/rs/zerolog"
+
+// Option configures optional, non-behavioral aspects of the codec — currently the
+// diagnostic logger. The zero configuration logs nothing.
+type Option func(*config)
+
+type config struct {
+ log zerolog.Logger
+}
+
+func resolveConfig(opts []Option) config {
+ c := config{log: zerolog.Nop()}
+ for _, opt := range opts {
+ opt(&c)
+ }
+ return c
+}
+
+// WithLogger sets the zerolog logger for debug/trace diagnostics. The library never
+// configures logging itself; without this option the codec is silent at zero cost.
+// Pass the logger from a context, e.g. WithLogger(*zerolog.Ctx(ctx)).
+func WithLogger(l zerolog.Logger) Option {
+ return func(c *config) { c.log = l }
+}
+
+// pickLog resolves the optional trailing logger of a stateless codec function: the
+// first supplied logger, or a silent Nop logger when none was passed.
+func pickLog(log []zerolog.Logger) zerolog.Logger {
+ if len(log) > 0 {
+ return log[0]
+ }
+ return zerolog.Nop()
+}
diff --git a/pkg/call/voip/media/mlow/lpc.go b/pkg/call/voip/media/mlow/lpc.go
new file mode 100644
index 00000000..66f74046
--- /dev/null
+++ b/pkg/call/voip/media/mlow/lpc.go
@@ -0,0 +1,585 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math"
+
+const (
+ SmplLPCOrder = 16
+ SmplLPCBufLen = 448
+ SmplLPCNFFT = 512
+ SmplFLen = SmplLPCNFFT/2 + 1
+)
+
+// smplPI is the truncated literal the reference uses (not math.Pi) — load-bearing
+// for bit-faithful window/NLSF math.
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L25
+const smplPI = 3.1415926535897
+
+const (
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L411-L414
+ lsfCosTabSzFix = 128
+ binDivStepsA2NLSFFix = 3
+ maxIterationsA2NLSFFix = 16
+ silkInt16Max = 32767
+)
+
+const (
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L26-L32
+ //
+ // smplPIF64 mirrors the reference's `SMPL_PI as f64`: the f32 literal widened,
+ // not full-precision pi.
+ smplPIF64 = float64(float32(3.1415926535897))
+ smplLPCReg = 5e-7
+ smplLPCBwe = 0.9999
+ smplLPCWin120msLen = 264
+ smplWin3LongLen = 64
+ smplWin3ShortLen = 32
+
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L94
+ nfft4 = SmplLPCNFFT / 4 // 128
+)
+
+const (
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L291-L292
+ smplSubfrs = 4
+ maxRCStable float32 = 0.9995
+)
+
+// smplLSFInterpol4Tbl holds the per-subframe interpolation weight rows (idx 0 and
+// the alternative idx 1).
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L289-L290
+var smplLSFInterpol4Tbl = [2][smplSubfrs]float32{
+ {0.55, 0.88, 1.0, 1.0},
+ {0.3, 0.65, 0.95, 1.0},
+}
+
+func genSinWin(n int) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L39-L43
+ w := make([]float32, n)
+ for i := 0; i < n; i++ {
+ t := (float32(i) + 1.0) / (float32(n) + 1.0) * smplPI / 2.0
+ w[i] = float32(math.Sin(float64(t)))
+ }
+ return w
+}
+
+func genCosWin(n int) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L46-L50
+ w := make([]float32, n)
+ for i := 0; i < n; i++ {
+ t := (float32(i) + 1.0) / (float32(n) + 1.0) * smplPI / 2.0
+ w[i] = float32(math.Cos(float64(t)))
+ }
+ return w
+}
+
+// smplWindowLPC20 applies the 20 ms LPC analysis window to a raw analysis buffer,
+// producing the windowed buffer the autocorrelation FFT consumes. useLongWin
+// selects the 64-tap vs 32-tap trailing cosine taper.
+func smplWindowLPC20(input *[SmplLPCBufLen]float32, useLongWin bool) [SmplLPCBufLen]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L55-L90
+ win1 := genSinWin(smplLPCWin120msLen)
+ var win3 []float32
+ var win3len int
+ if useLongWin {
+ win3, win3len = genCosWin(smplWin3LongLen), smplWin3LongLen
+ } else {
+ win3, win3len = genCosWin(smplWin3ShortLen), smplWin3ShortLen
+ }
+ var out [SmplLPCBufLen]float32
+ for i := 0; i < smplLPCWin120msLen; i++ {
+ out[i] = input[i] * win1[i]
+ }
+ mid := SmplLPCBufLen - smplLPCWin120msLen - smplWin3LongLen
+ copy(out[smplLPCWin120msLen:smplLPCWin120msLen+mid], input[smplLPCWin120msLen:smplLPCWin120msLen+mid])
+ base := SmplLPCBufLen - smplWin3LongLen
+ for i := 0; i < win3len; i++ {
+ out[base+i] = input[base+i] * win3[i]
+ }
+ if !useLongWin {
+ for s := base + smplWin3ShortLen; s < base+smplWin3LongLen; s++ {
+ out[s] = 0.0
+ }
+ }
+ return out
+}
+
+// genCosRow accumulates row[k] = cos(omega)*scale, advancing omega by a running
+// fmod in f64 (matching the reference, not cos(k*domega)).
+func genCosRow(domega, scale float64) [nfft4]float64 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L98-L107
+ var row [nfft4]float64
+ omega := 0.0
+ twoPi := 2.0 * smplPIF64
+ for k := 0; k < nfft4; k++ {
+ row[k] = math.Cos(omega) * scale
+ omega = math.Mod(omega+domega, twoPi)
+ if omega < 0 {
+ omega += twoPi
+ }
+ }
+ return row
+}
+
+type dctTables struct {
+ cdif [SmplLPCOrder / 2][nfft4]float64
+ csumdiff [SmplLPCOrder / 4][nfft4]float64
+ csumsum [SmplLPCOrder / 4][nfft4]float64
+}
+
+func buildDctTables() dctTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L115-L143
+ twoPi := 2.0 * smplPIF64
+ nfft := float64(SmplLPCNFFT)
+ var t dctTables
+ for j := 0; j < SmplLPCOrder/2; j++ {
+ t.cdif[j] = genCosRow(float64(1+j*2)*twoPi/nfft, 2.0/nfft)
+ }
+ for j := 0; j < SmplLPCOrder/4; j++ {
+ t.csumdiff[j] = genCosRow(float64(2+j*4)*twoPi/nfft, 1.0/nfft)
+ }
+ for j := 0; j < SmplLPCOrder/4; j++ {
+ t.csumsum[j] = genCosRow(float64(4+j*4)*twoPi/nfft, 1.0/nfft)
+ }
+ return t
+}
+
+// bruteDct derives the autocorrelation R[0..order] from the power spectrum via the
+// precomputed cosine sums. All accumulation in f64.
+func bruteDct(t *dctTables, f2 []float64, order int, r []float64) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L147-L186
+ half := SmplLPCNFFT / 2
+ f2sum := 0.0
+ var f2dif, f2sumsum, f2sumdif [nfft4]float64
+ for n := 0; n < nfft4; n++ {
+ f2sum += f2[n] + f2[nfft4+n]
+ f2dif[n] = f2[n] - f2[half-n]
+ f2sumsum[n] = f2[n] + f2[half-n] + f2[nfft4+n] + f2[nfft4-n]
+ f2sumdif[n] = f2[n] + f2[half-n] - f2[nfft4+n] - f2[nfft4-n]
+ }
+ f2dif[0] *= 0.5
+ r[0] = (2.0*f2sum - f2[0] + f2[half]) / float64(SmplLPCNFFT)
+ for j := 0; j < order/2; j++ {
+ rtmp := 0.0
+ row := &t.cdif[j]
+ for k := 0; k < nfft4; k++ {
+ rtmp += row[k] * f2dif[k]
+ }
+ r[1+j*2] = rtmp
+ }
+ for j := 0; j < order/4; j++ {
+ rtmp := 0.0
+ row := &t.csumdiff[j]
+ for k := 0; k < nfft4; k++ {
+ rtmp += row[k] * f2sumdif[k]
+ }
+ r[2+j*4] = rtmp
+ }
+ for j := 0; j < order/4; j++ {
+ rtmp := 0.0
+ row := &t.csumsum[j]
+ for k := 0; k < nfft4; k++ {
+ rtmp += row[k] * f2sumsum[k]
+ }
+ r[4+j*4] = rtmp
+ }
+}
+
+// ac2rcDbl converts autocorrelation R[0..order] to reflection coefficients (Schur),
+// with C0[0] *= (1+reg). Each rc[k] is truncated to f32, matching the reference.
+func ac2rcDbl(corr []float64, order int, reg float32, rc []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L190-L220
+ c0 := make([]float64, order+1)
+ c1 := make([]float64, order+1)
+ copy(c0, corr[:order+1])
+ c0[0] *= float64(1.0 + reg)
+ copy(c1, c0[:order+1])
+ for i := 0; i < order; i++ {
+ rc[i] = 0
+ }
+ for k := 0; k < order; k++ {
+ if c0[k+1] > c1[0] {
+ rc[k] = -1.0
+ break
+ }
+ if c0[k+1] < -c1[0] {
+ rc[k] = 1.0
+ break
+ }
+ if c1[0] == 0.0 {
+ break
+ }
+ rcTmp := -c0[k+1] / c1[0]
+ rc[k] = float32(rcTmp)
+ for n := 0; n < order-k; n++ {
+ ctmp1 := c0[n+k+1]
+ ctmp2 := c1[n]
+ c0[n+k+1] = ctmp1 + ctmp2*rcTmp
+ c1[n] = ctmp2 + ctmp1*rcTmp
+ }
+ }
+}
+
+// rc2a converts reflection coefficients to monic LPC A[0..order] (A[0]=1).
+func rc2a(rc []float32, order int, a []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L223-L238
+ for i := 1; i < order+1; i++ {
+ a[i] = 0
+ }
+ a[0] = 1.0
+ for k := 0; k < order; k++ {
+ rcTmp := rc[k]
+ for n := 0; n < (k+1)/2; n++ {
+ tmp1 := a[n+1]
+ tmp2 := a[k-n]
+ a[n+1] = tmp1 + tmp2*rcTmp
+ a[k-n] = tmp2 + tmp1*rcTmp
+ }
+ a[k+1] = rcTmp
+ }
+}
+
+// bweExpand bandwidth-expands the monic LPC coefficients: A[i] *= bwe^i.
+func bweExpand(a []float32, order int, bwe float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L241-L247
+ c := bwe
+ for i := 1; i < order+1; i++ {
+ a[i] *= c
+ c *= bwe
+ }
+}
+
+// smplLPCAnalyzeWithF2 runs the full LPC analysis over a windowed buffer: returns
+// the post-bandwidth-expansion monic LPC A[0..16] (A[0]=1) and the power spectrum
+// F2[0..256] that the pitch and signal-mode paths consume.
+func smplLPCAnalyzeWithF2(windowed *[SmplLPCBufLen]float32) ([SmplLPCOrder + 1]float32, [SmplFLen]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L255-L283
+ var xbuf [SmplLPCNFFT]float32
+ copy(xbuf[:SmplLPCBufLen], windowed[:])
+ var f [SmplLPCNFFT]float32
+ rfftForwardOrdered(xbuf[:], f[:])
+
+ var f2 [SmplFLen]float32
+ f2[0] = f[0] * f[0]
+ f2[SmplLPCNFFT/2] = f[1] * f[1]
+ for i := 1; i < SmplLPCNFFT/2; i++ {
+ f2[i] = f[2*i]*f[2*i] + f[2*i+1]*f[2*i+1]
+ }
+ f2d := make([]float64, SmplFLen)
+ for i := 0; i < SmplFLen; i++ {
+ f2d[i] = float64(f2[i])
+ }
+
+ tables := buildDctTables()
+ var r [SmplLPCOrder + 1]float64
+ bruteDct(&tables, f2d, SmplLPCOrder, r[:])
+
+ var rc [SmplLPCOrder]float32
+ ac2rcDbl(r[:], SmplLPCOrder, smplLPCReg, rc[:])
+ var a [SmplLPCOrder + 1]float32
+ rc2a(rc[:], SmplLPCOrder, a[:])
+ bweExpand(a[:], SmplLPCOrder, smplLPCBwe)
+ return a, f2
+}
+
+// lpcIsStable reports whether the monic LPC A[0..16] (A[0]=1) is a stable
+// all-pole filter.
+func lpcIsStable(a []float32) bool {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L295-L338
+ order := SmplLPCOrder
+ if a[order]*a[order] > maxRCStable {
+ return false
+ }
+ var a0, a1 [SmplLPCOrder]float64
+ for i := 0; i < order; i++ {
+ a0[i] = float64(a[i+1])
+ }
+ m := order - 1
+ for {
+ den := 1.0 - a0[m]*a0[m]
+ if den == 0.0 {
+ return false
+ }
+ inv := 1.0 / den
+ for k := 0; k < m; k++ {
+ a1[k] = (a0[k] - a0[m]*a0[m-k-1]) * inv
+ }
+ if a1[m-1]*a1[m-1] > float64(maxRCStable) {
+ return false
+ }
+ if m == 1 {
+ return true
+ }
+ m--
+ den = 1.0 - a1[m]*a1[m]
+ if den == 0.0 {
+ return false
+ }
+ inv = 1.0 / den
+ for k := 0; k < m; k++ {
+ a0[k] = (a1[k] - a1[m]*a1[m-k-1]) * inv
+ }
+ if a0[m-1]*a0[m-1] > float64(maxRCStable) {
+ return false
+ }
+ if m == 1 {
+ return true
+ }
+ m--
+ }
+}
+
+// lpcStabilize bandwidth-expands the coefficients until the filter is stable.
+func lpcStabilize(a []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L341-L353
+ if lpcIsStable(a) {
+ return
+ }
+ iter := 0
+ for {
+ iter++
+ bweExpand(a, SmplLPCOrder, 1.0-float32(iter)*0.001)
+ if lpcIsStable(a) {
+ return
+ }
+ }
+}
+
+// smplLPCInterpol returns the per-subframe interpolated LPC predictor coefficients
+// (interpolation index 0) and the carried last-subframe NLSF. nlsf2a is the
+// decoder's NLSF→A conversion, supplied by the caller.
+func smplLPCInterpol(
+ lsf, prevLSF []float32,
+ nlsf2a func(nlsf []float32) []float32,
+) (predcoefs [4][SmplLPCOrder + 1]float32, ilsf [SmplLPCOrder]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L358-L367
+ return smplLPCInterpolIdx(lsf, prevLSF, 0, nlsf2a)
+}
+
+// smplLPCInterpolIdx is smplLPCInterpol for an explicit interpolation-weight row.
+func smplLPCInterpolIdx(
+ lsf, prevLSF []float32,
+ interpolIdx int,
+ nlsf2a func(nlsf []float32) []float32,
+) (predcoefs [4][SmplLPCOrder + 1]float32, ilsf [SmplLPCOrder]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L370-L407
+ interp := &smplLSFInterpol4Tbl[min(interpolIdx, 1)]
+ var prev [SmplLPCOrder]float32
+ if len(prevLSF) == SmplLPCOrder && prevLSF[SmplLPCOrder-1] != 0.0 {
+ copy(prev[:], prevLSF)
+ } else {
+ copy(prev[:], lsf[:SmplLPCOrder])
+ }
+ for j := 0; j < smplSubfrs; j++ {
+ w := interp[j]
+ if w == 1.0 {
+ copy(ilsf[:], lsf[:SmplLPCOrder])
+ } else {
+ for k := 0; k < SmplLPCOrder; k++ {
+ ilsf[k] = (1.0-w)*prev[k] + w*lsf[k]
+ }
+ }
+ a := nlsf2a(ilsf[:])
+ var pc [SmplLPCOrder + 1]float32
+ for i := 0; i < SmplLPCOrder+1 && i < len(a); i++ {
+ pc[i] = a[i]
+ }
+ pc[0] = 1.0
+ lpcStabilize(pc[:])
+ predcoefs[j] = pc
+ }
+ return predcoefs, ilsf
+}
+
+func silkRshiftRound(a, shift int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L419-L425
+ if shift == 1 {
+ return (a >> 1) + (a & 1)
+ }
+ return ((a >> (shift - 1)) + 1) >> 1
+}
+
+func silkSmlaww(a32, b32, c32 int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L428-L434
+ return int32(int64(a32) + ((int64(b32) * int64(c32)) >> 16))
+}
+
+func silkDiv32(a, b int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L437-L439
+ return a / b
+}
+
+// silkBwexpander32 chirp-expands the Q16 LPC coefficients in place.
+func silkBwexpander32(ar []int32, d int, chirpQ16 int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L448-L457
+ chirp := chirpQ16
+ chirpMinusOne := chirpQ16 - 65536
+ for i := 0; i < d-1; i++ {
+ ar[i] = int32((int64(chirp) * int64(ar[i])) >> 16)
+ mul := chirp * chirpMinusOne
+ chirp += silkRshiftRound(mul, 16)
+ }
+ ar[d-1] = int32((int64(chirp) * int64(ar[d-1])) >> 16)
+}
+
+func silkA2NLSFTransPoly(p []int32, dd int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L459-L466
+ for k := 2; k <= dd; k++ {
+ for n := dd; n >= k+1; n-- {
+ p[n-2] -= p[n]
+ }
+ p[k-2] -= p[k] << 1
+ }
+}
+
+func silkA2NLSFEvalPoly(p []int32, x int32, dd int) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L468-L475
+ xQ16 := x << 4
+ y32 := p[dd]
+ for n := dd - 1; n >= 0; n-- {
+ y32 = silkSmlaww(p[n], y32, xQ16)
+ }
+ return y32
+}
+
+func silkA2NLSFInit(aQ16, p, q []int32, dd int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L477-L490
+ p[dd] = 1 << 16
+ q[dd] = 1 << 16
+ for k := 0; k < dd; k++ {
+ p[k] = -aQ16[dd-k-1] - aQ16[dd+k]
+ q[k] = -aQ16[dd-k-1] + aQ16[dd+k]
+ }
+ for k := dd; k >= 1; k-- {
+ p[k-1] -= p[k]
+ q[k-1] += q[k]
+ }
+ silkA2NLSFTransPoly(p, dd)
+ silkA2NLSFTransPoly(q, dd)
+}
+
+// silkA2NLSF converts monic whitening coefficients (Q16) to NLSF (Q15). It mutates
+// aQ16 (bandwidth expansion on non-convergence). d is the even filter order.
+func silkA2NLSF(nlsf, aQ16 []int32, d int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/smpl_lpc.rs#L494-L589
+ dd := d >> 1
+ p := make([]int32, dd+1)
+ q := make([]int32, dd+1)
+ silkA2NLSFInit(aQ16, p, q, dd)
+
+ useQ := false
+ poly := func() []int32 {
+ if useQ {
+ return q
+ }
+ return p
+ }
+ xlo := silkLSFCosTabFIXQ12[0]
+ ylo := silkA2NLSFEvalPoly(poly(), xlo, dd)
+
+ var rootIx int
+ if ylo < 0 {
+ nlsf[0] = 0
+ useQ = true
+ ylo = silkA2NLSFEvalPoly(q, xlo, dd)
+ rootIx = 1
+ }
+ k := 1
+ var iter, thr int32
+ for {
+ xhi := silkLSFCosTabFIXQ12[k]
+ yhi := silkA2NLSFEvalPoly(poly(), xhi, dd)
+
+ if (ylo <= 0 && yhi >= thr) || (ylo >= 0 && yhi <= -thr) {
+ if yhi == 0 {
+ thr = 1
+ } else {
+ thr = 0
+ }
+ xloL, yloL, xhiL := xlo, ylo, xhi
+ ffrac := int32(-256)
+ for m := int32(0); m < binDivStepsA2NLSFFix; m++ {
+ xmid := silkRshiftRound(xloL+xhiL, 1)
+ ymid := silkA2NLSFEvalPoly(poly(), xmid, dd)
+ if (yloL <= 0 && ymid >= 0) || (yloL >= 0 && ymid <= 0) {
+ xhiL = xmid
+ yhi = ymid
+ } else {
+ xloL = xmid
+ yloL = ymid
+ ffrac += 128 >> m
+ }
+ }
+ absYloL := yloL
+ if absYloL < 0 {
+ absYloL = -absYloL
+ }
+ if absYloL < 65536 {
+ den := yloL - yhi
+ nom := (yloL << (8 - binDivStepsA2NLSFFix)) + (den >> 1)
+ if den != 0 {
+ ffrac += silkDiv32(nom, den)
+ }
+ } else {
+ ffrac += silkDiv32(yloL, (yloL-yhi)>>(8-binDivStepsA2NLSFFix))
+ }
+ nlsf[rootIx] = min((int32(k)<<8)+ffrac, silkInt16Max)
+
+ rootIx++
+ if rootIx >= d {
+ break
+ }
+ useQ = rootIx&1 != 0
+ xlo = silkLSFCosTabFIXQ12[k-1]
+ ylo = (1 - (int32(rootIx) & 2)) << 12
+ } else {
+ k++
+ xlo = xhi
+ ylo = yhi
+ thr = 0
+ if k > lsfCosTabSzFix {
+ iter++
+ if iter > maxIterationsA2NLSFFix {
+ nlsf[0] = silkDiv32(1<<15, int32(d)+1)
+ for kk := 1; kk < d; kk++ {
+ nlsf[kk] = nlsf[kk-1] + nlsf[0]
+ }
+ return
+ }
+ silkBwexpander32(aQ16, d, int32(65536-(1< cumulative CDF
+ LsfExtra []uint16 `json:"lsf_extra"`
+}
+
+// LoadSmplTables returns the runtime LSF CDF table set, built from the embedded
+// seed ROM (lsf_seed.bin) and shared read-only.
+func LoadSmplTables() *SmplTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L23-L43
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_decode.rs#L29-L31 (seed rewire: build from lsf_seed.bin)
+ return loadLsfBuilt().tables
+}
+
+// SmplLsfState is the cross-internal-frame decoder state. The LSF block resets the
+// pitch/LTP predictor fields to -1 whenever the stage-1 selector does not match the
+// previous internal frame. PrevLagSamples, PrevLagblk and PrevLagidx are encoder-only
+// (pitch-search/lag-predictor continuity) and unused by the decoder.
+type SmplLsfState struct {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L169-L186
+ PrevStage1 int32
+ PrevMatch bool
+ HavePrev bool
+ PrevGainIdx int32
+ PrevFiltIdx int32
+ PrevLag int32
+ PrevFracLag int32
+ PrevLagSamples float32
+ PrevLagblk int32
+ PrevLagidx int32
+}
+
+// SmplAdvanceLsfState advances the LSF predictor mirror exactly as the
+// encode/decode path does for an internal frame with the given stage-1 selector:
+// on a no-match (intf 0, or stage1 differs from the previous frame) it resets the
+// four pitch/LTP predictor fields to -1, then records PrevStage1/PrevMatch. The
+// encoder analysis runs this so its PrevLag tracks what the entropy encoder will
+// compute (driving the abs-vs-delta lag pick).
+func SmplAdvanceLsfState(st *SmplLsfState, intf int, stage1 int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L192-L205
+ m := intf != 0 && stage1 == st.PrevStage1
+ if !m {
+ st.PrevGainIdx = -1
+ st.PrevFiltIdx = -1
+ st.PrevLag = -1
+ st.PrevFracLag = -1
+ st.PrevLagblk = -1
+ st.PrevLagidx = -1
+ }
+ st.PrevStage1 = stage1
+ st.PrevMatch = m
+ st.HavePrev = true
+}
+
+// SmplLsfIndices is the decoded per-internal-frame LSF index set. StageNraw[k] is
+// the raw symbol count for coefficient k (len(cdf)-2), carried for the dequantizer.
+type SmplLsfIndices struct {
+ Stage1 int32
+ Grid int32
+ Stage2 [16]int32
+ StageNraw [16]int32
+ Extra int32
+}
+
+// DecodeSmplLsf decodes the LSF block of one internal frame (the first block of the
+// frame body). config is the smpl config (0/1); intf is the internal-frame index
+// (0,1,2) within the 60 ms packet. It mutates st, applying the no-match predictor
+// reset in place exactly where the reference does.
+//
+// The four reads, in order: (1) the stage-1 selector — intf 0 uses dedicated row 0,
+// later frames pick row 1/2 by the previous frame's stage-1; (2) the stage-1 grid,
+// whose CDF is selected by (match, current stage1!=0); (3) 16 stage-2 residuals,
+// each coeff k from its own CDF LsfStage2[stage1][config][grid][k]; (4) the 3-symbol
+// "extra" LSF CDF, which always fires for our 1:1 path.
+func DecodeSmplLsf(
+ dec *RangeDecoder,
+ t *SmplTables,
+ st *SmplLsfState,
+ config int,
+ intf int,
+) SmplLsfIndices {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/c697c36ffa7875c304ceea9154be30b66cada914/wacore/src/voip/mlow/smpl_decode.rs#L218-L291
+ var idx SmplLsfIndices
+
+ // Read 1 — stage-1 selector. Frame 0 uses dedicated row 0; later frames pick
+ // row 2 if the previous stage-1 was nonzero, else row 1.
+ sel := 0
+ if intf != 0 {
+ if st.PrevStage1 != 0 {
+ sel = 2
+ } else {
+ sel = 1
+ }
+ }
+ stage1 := dec.DecodeCDF(t.LsfSel[sel])
+ idx.Stage1 = stage1
+
+ // match := (not the first frame) && stage1 == prev. On a no-match the four
+ // pitch/LTP predictor fields reset to -1, recorded BEFORE PrevStage1 is updated.
+ m := intf != 0 && stage1 == st.PrevStage1
+ if !m {
+ st.PrevGainIdx = -1
+ st.PrevFiltIdx = -1
+ st.PrevLag = -1
+ st.PrevFracLag = -1
+ }
+ st.PrevStage1 = stage1
+
+ // Read 2 — stage-1 grid. Outer select on match, inner on the current stage1.
+ var gridCDF []uint16
+ switch {
+ case m && stage1 != 0:
+ gridCDF = t.LsfGrid.Match1
+ case m:
+ gridCDF = t.LsfGrid.Match1Alt
+ case stage1 != 0:
+ gridCDF = t.LsfGrid.Match0Alt
+ default:
+ gridCDF = t.LsfGrid.Match0
+ }
+ grid := dec.DecodeCDF(gridCDF)
+ idx.Grid = grid
+ st.PrevMatch = m
+ st.HavePrev = true
+
+ // Read 3 — 16 stage-2 residuals, each coeff k from LsfStage2[stage1][config][grid][k].
+ st2 := t.LsfStage2[int(stage1)][config][int(grid)]
+ for k := 0; k < 16; k++ {
+ c := st2[k]
+ idx.Stage2[k] = dec.DecodeCDF(c)
+ idx.StageNraw[k] = int32(len(c)) - 2
+ }
+
+ // Read 4 — the 3-symbol "extra" LSF CDF, which always fires for the 1:1 path.
+ idx.Extra = dec.DecodeCDF(t.LsfExtra)
+ return idx
+}
diff --git a/pkg/call/voip/media/mlow/lsf_quant.go b/pkg/call/voip/media/mlow/lsf_quant.go
new file mode 100644
index 00000000..d32d7747
--- /dev/null
+++ b/pkg/call/voip/media/mlow/lsf_quant.go
@@ -0,0 +1,502 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "math"
+)
+
+// LSFCBCentroids is the number of stage-1 LSF codebook centroids. SmplLPCOrder
+// (the 16-tap LPC order) is shared with lpc.go.
+const LSFCBCentroids = 16
+
+const lsfQstepCondMult = 0.9
+const smplPi = float32(math.Pi)
+
+// LsfQuantResult is one LSF quantization: Qi[0] (=grid), Qi[1..16] (=stage2), and
+// the reconstructed quantized NLSF — the same envelope the decoder rebuilds.
+type LsfQuantResult struct {
+ Qi [SmplLPCOrder + 1]int32
+ QLsf [SmplLPCOrder]float32
+}
+
+// st1Tables is the per-codebook (voiced/unvoiced) stage-1 table set, mirroring the
+// reference St1Json layout dumped from the C smpl_get_lsf_CBks().
+type st1Tables struct {
+ Cbhalf [][]float32 // [16][16]
+ CInv [][]float32 // [16][16]
+ BitsCond []float32 // [17]
+ Rotcond [][][]float32 // [2][16][16]
+ CbCinv [][]float32 // [16][16]
+ We [][][]float32 // [16][16][16]
+ Bits []float32 // [16]
+ Wie [][][]float32 // [16][16][16]
+}
+
+// st2Tables is one stage-2 table set (per voiced/lowRate/qi1); the per-coeff Qlvls
+// and NumBits rows are ragged, so they stay slices.
+type st2Tables struct {
+ NumQlvls []int32
+ Qlvls [][]float32 // [16][numQlvls[i]]
+ NumBits [][]float32 // [16][numQlvls[i]]
+}
+
+// LsfCb holds the loaded LSF codebook tables (the C smpl_get_lsf_CBks() output plus
+// the static smpl_lsf_tables.c constants).
+type LsfCb struct {
+ St1 []st1Tables // [2]
+ St2 [][][]st2Tables // [2][2][17]
+ MinQi [][][][]int32 // [2][2][17][16]
+ MaxQi [][][][]int32 // [2][2][17][16]
+ Qstep [][]float32 // [2][2]
+ MeanV []float32 // [16]
+ MeanUV []float32 // [16]
+ RegCond []float32 // [2]
+ MinDistV []float32 // [17]
+ MinDistUV []float32 // [17]
+}
+
+// LoadLsfCb returns the LSF quantizer codebook, built from the embedded seed ROM
+// (lsf_seed.bin) and shared read-only.
+func LoadLsfCb() *LsfCb {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_lsf_quant.rs#L79-L85
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_quant.rs#L117-L119 (seed rewire: build from lsf_seed.bin)
+ return loadLsfBuilt().cb
+}
+
+// ----- f32 scalar helpers (single-precision throughout: qi[] is decided by f32 comparisons) -----
+
+func cosF32(x float32) float32 { return float32(math.Cos(float64(x))) }
+func sinF32(x float32) float32 { return float32(math.Sin(float64(x))) }
+func sqrtF32(x float32) float32 { return float32(math.Sqrt(float64(x))) }
+func log2F32(x float32) float32 { return float32(math.Log2(float64(x))) }
+func roundF32(x float32) float32 { return float32(math.Round(float64(x))) }
+
+func absF32(x float32) float32 {
+ if x < 0 {
+ return -x
+ }
+ return x
+}
+
+func maxF32(a, b float32) float32 {
+ if a > b {
+ return a
+ }
+ return b
+}
+
+func smplSign(a float32) int32 {
+ if a > 0 {
+ return 1
+ }
+ if a == 0 {
+ return 0
+ }
+ return -1
+}
+
+// ----- vector helpers (faithful ports) -----
+
+func subVec(y, z, out []float32) {
+ for i := 0; i < SmplLPCOrder; i++ {
+ out[i] = y[i] - z[i]
+ }
+}
+
+func dotProd(a, b []float32) float32 {
+ var s float32
+ for i := 0; i < SmplLPCOrder; i++ {
+ s += a[i] * b[i]
+ }
+ return s
+}
+
+func werr(x, y, w []float32) float32 {
+ var s float32
+ for k := 0; k < SmplLPCOrder; k++ {
+ e := x[k] - y[k]
+ s += w[k] * e * e
+ }
+ return s
+}
+
+// matrixMultTransp16: y[i] = sum_j c[j][i]*x[j].
+func matrixMultTransp16(c [][]float32, x, y []float32, lenX int) {
+ var yt [SmplLPCOrder]float32
+ xtmp := x[0]
+ for i := 0; i < SmplLPCOrder; i++ {
+ yt[i] = c[0][i] * xtmp
+ }
+ for j := 1; j < lenX; j++ {
+ xtmp := x[j]
+ for i := 0; i < SmplLPCOrder; i++ {
+ yt[i] += c[j][i] * xtmp
+ }
+ }
+ copy(y[:SmplLPCOrder], yt[:])
+}
+
+// getMaxiK: top-K indices of the K largest values in x (descending), ties toward the lower index.
+func getMaxiK(x []float32, idx []int32, k int) {
+ n := len(x)
+ used := make([]bool, n)
+ for slot := 0; slot < k; slot++ {
+ bestI := int32(-1)
+ bestV := float32(math.Inf(-1))
+ for i := 0; i < n; i++ {
+ if used[i] {
+ continue
+ }
+ if x[i] > bestV {
+ bestV = x[i]
+ bestI = int32(i)
+ }
+ }
+ if bestI < 0 {
+ idx[slot] = 0
+ } else {
+ used[bestI] = true
+ idx[slot] = bestI
+ }
+ }
+}
+
+// lsfWeightsSpectral: RD weight = inverse spectral envelope magnitude 1/sqrt(|A(e^jw)|^2 * scale),
+// scale = 1/min. a is the monic LPC A[0..16] (A[0]=1).
+func lsfWeightsSpectral(a, lsf []float32) [SmplLPCOrder]float32 {
+ var lsfw [SmplLPCOrder]float32
+ for i := 0; i < SmplLPCOrder; i++ {
+ eRe := cosF32(lsf[i])
+ eIm := sinF32(lsf[i])
+ accRe := float32(1.0)
+ accIm := float32(0.0)
+ epRe := eRe
+ epIm := eIm
+ for j := 1; j < SmplLPCOrder; j++ {
+ accRe += epRe * a[j]
+ accIm -= epIm * a[j]
+ nr := epRe*eRe - epIm*eIm
+ ni := epRe*eIm + epIm*eRe
+ epRe = nr
+ epIm = ni
+ }
+ accRe += epRe * a[SmplLPCOrder]
+ accIm -= epIm * a[SmplLPCOrder]
+ lsfw[i] = accRe*accRe + accIm*accIm
+ }
+ minLsfw := lsfw[0]
+ for _, v := range lsfw[1:] {
+ if v < minLsfw {
+ minLsfw = v
+ }
+ }
+ scale := 1.0 / minLsfw
+ for i := range lsfw {
+ lsfw[i] = 1.0 / sqrtF32(lsfw[i]*scale)
+ }
+ return lsfw
+}
+
+// LsfWeightsLaroia is the Laroia LSF weighting (inverse adjacent-spacing sum), used by the
+// conditional path's rotation weighting.
+func LsfWeightsLaroia(lsf []float32) [SmplLPCOrder]float32 {
+ minDist := float32(1e-3)
+ var invDelta [SmplLPCOrder + 1]float32
+ invDelta[0] = 1.0 / maxF32(lsf[0], minDist)
+ for i := 1; i < SmplLPCOrder; i++ {
+ invDelta[i] = 1.0 / maxF32(lsf[i]-lsf[i-1], minDist)
+ }
+ invDelta[SmplLPCOrder] = 1.0 / maxF32(smplPi-lsf[SmplLPCOrder-1], minDist)
+ var lsfw [SmplLPCOrder]float32
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfw[i] = invDelta[i] + invDelta[i+1]
+ }
+ return lsfw
+}
+
+// lsfMinDist pushes LSFs apart so consecutive spacings exceed min_dist (SMPL_lsf_min_dist).
+func lsfMinDist(lsfs, minDist []float32) {
+ n := SmplLPCOrder
+ var dlsfs [SmplLPCOrder + 1]float32
+ dlsfs[0] = lsfs[0] - minDist[0]
+ for i := 1; i < n; i++ {
+ dlsfs[i] = (lsfs[i] - lsfs[i-1]) - minDist[i]
+ }
+ dlsfs[n] = (smplPi - lsfs[n-1]) - minDist[n]
+ findMin := func(d []float32) (float32, int) {
+ m := d[0]
+ mi := 0
+ for i := 1; i < n+1; i++ {
+ if d[i] < m {
+ m = d[i]
+ mi = i
+ }
+ }
+ return m, mi
+ }
+ dm, minIx := findMin(dlsfs[:])
+ if dm > 0.0 {
+ return
+ }
+ for k := 0; k < 1000; k++ {
+ delta := float32(k)*1.0e-6 - dm
+ dlsfs[minIx] += delta
+ if minIx == 0 {
+ dlsfs[1] -= delta
+ } else if minIx == n {
+ dlsfs[n-1] -= delta
+ } else {
+ delta *= 0.5
+ dlsfs[minIx-1] -= delta
+ dlsfs[minIx+1] -= delta
+ }
+ ndm, nmi := findMin(dlsfs[:])
+ dm = ndm
+ minIx = nmi
+ if dm >= 0.0 {
+ lsfs[0] = dlsfs[0] + minDist[0]
+ for i := 1; i < n; i++ {
+ lsfs[i] = lsfs[i-1] + (dlsfs[i] + minDist[i])
+ }
+ return
+ }
+ }
+ // C asserts here; we fall through with the best-effort spacing (do not panic).
+}
+
+// condParams is the VQ_temp cond centroid (built from the previous frame's quantized NLSF).
+type condParams struct {
+ st1Cbhalf [SmplLPCOrder]float32
+ st1CbCinv [SmplLPCOrder]float32
+ st1We [][]float32 // [16][16]
+ st1Wie [][]float32 // [16][16]
+}
+
+// vqTemp: Mahalanobis shortlist of `surv` stage-1 centroids (plus the cond centroid when present).
+func vqTemp(lsf []float32, cbhalf, cbCinv [][]float32, cond *condParams, surv int, idxs []int32) {
+ var err [LSFCBCentroids + 1]float32
+ var tmp [SmplLPCOrder]float32
+ for s := 0; s < LSFCBCentroids; s++ {
+ subVec(cbhalf[s], lsf, tmp[:])
+ err[s] = -dotProd(tmp[:], cbCinv[s])
+ }
+ cbCentroids := LSFCBCentroids
+ if cond != nil {
+ subVec(cond.st1Cbhalf[:], lsf, tmp[:])
+ err[LSFCBCentroids] = -dotProd(tmp[:], cond.st1CbCinv[:])
+ cbCentroids++
+ }
+ getMaxiK(err[:cbCentroids], idxs, surv)
+}
+
+// lsfQuantCore is the faithful port of smpl_lsf_quant_core.
+func lsfQuantCore(cb *LsfCb, a, nlsf []float32, voiced, lowRate int, cond *condParams, rdWAdj float32, surv int) LsfQuantResult {
+ st1 := &cb.St1[voiced]
+ st2v := cb.St2[voiced][lowRate]
+ minQi := cb.MinQi[voiced][lowRate]
+ maxQi := cb.MaxQi[voiced][lowRate]
+ minDist := cb.MinDistUV
+ if voiced == 1 {
+ minDist = cb.MinDistV
+ }
+
+ var lsf [SmplLPCOrder]float32
+ copy(lsf[:], nlsf[:SmplLPCOrder])
+ wlsf := lsfWeightsSpectral(a, lsf[:])
+
+ qstep := cb.Qstep[voiced][lowRate]
+ qstepCond := qstep * lsfQstepCondMult
+
+ var qim1 [LSFCBCentroids + 1]int32
+ vqTemp(lsf[:], st1.Cbhalf, st1.CbCinv, cond, surv, qim1[:])
+
+ rdBest := float32(math.MaxFloat32)
+ var outQi [SmplLPCOrder + 1]int32
+ var outQlsf [SmplLPCOrder]float32
+
+ for s1 := 0; s1 < surv; s1++ {
+ qi1 := int(qim1[s1])
+ isCond := qi1 == LSFCBCentroids
+
+ // lsfq1 = 2 * cbhalf[qi1] (or cond centroid).
+ var lsfq1 [SmplLPCOrder]float32
+ if isCond {
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfq1[i] = cond.st1Cbhalf[i] * 2.0
+ }
+ } else {
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfq1[i] = st1.Cbhalf[qi1][i] * 2.0
+ }
+ }
+
+ // qerr = wie^T * (lsf - lsfq1).
+ var qerrIn [SmplLPCOrder]float32
+ subVec(lsf[:], lsfq1[:], qerrIn[:])
+ var wiePtr [][]float32
+ if isCond {
+ wiePtr = cond.st1Wie
+ } else {
+ wiePtr = st1.Wie[qi1]
+ }
+ var qerr [SmplLPCOrder]float32
+ matrixMultTransp16(wiePtr, qerrIn[:], qerr[:], SmplLPCOrder)
+
+ invQstep := 1.0 / qstep
+ if isCond {
+ invQstep = 1.0 / qstepCond
+ }
+ for i := range qerr {
+ qerr[i] *= invQstep
+ }
+
+ var bits float32
+ if cond == nil {
+ bits = st1.Bits[qi1]
+ } else {
+ bits = st1.BitsCond[qi1]
+ }
+
+ var alt [SmplLPCOrder]int32
+ var absQerr [SmplLPCOrder]float32
+ var qres [SmplLPCOrder]float32
+ var qi2 [SmplLPCOrder]int32
+ st2 := &st2v[qi1]
+ for i := 0; i < SmplLPCOrder; i++ {
+ qi2i := int32(roundF32(qerr[i]))
+ mn := minQi[qi1][i]
+ mx := maxQi[qi1][i]
+ if qi2i > mx {
+ qi2i = mx
+ }
+ if qi2i < mn {
+ qi2i = mn
+ }
+ qerr[i] -= float32(qi2i)
+ alt[i] = smplSign(qerr[i])
+ if (qi2i == mx && alt[i] > 0) || (qi2i == mn && alt[i] < 0) {
+ absQerr[i] = -1.0
+ } else {
+ absQerr[i] = absF32(qerr[i])
+ }
+ qi2i -= mn
+ qi2u := int(qi2i)
+ bits += st2.NumBits[i][qi2u]
+ qres[i] = st2.Qlvls[i][qi2u]
+ qi2[i] = qi2i
+ }
+
+ var iAlt [SmplLPCOrder]int32
+ getMaxiK(absQerr[:], iAlt[:], surv)
+
+ var wePtr [][]float32
+ if isCond {
+ wePtr = cond.st1We
+ } else {
+ wePtr = st1.We[qi1]
+ }
+ var lsfq [SmplLPCOrder]float32
+ matrixMultTransp16(wePtr, qres[:], lsfq[:], SmplLPCOrder)
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfq[i] += lsfq1[i]
+ }
+
+ surv2 := surv - s1
+ indChgd := int32(-1)
+ bitsOrig := bits
+ // Beam base is FIXED to the initial lsfq (C memcpy BEFORE the loop); each refinement flips
+ // ONE coeff relative to this base, undoing the previous flip.
+ lsfqBase := lsfq
+ curBits := bits
+ for s2 := 0; s2 < surv2; s2++ {
+ lsfMinDist(lsfq[:], minDist)
+ w := werr(lsf[:], lsfq[:], wlsf[:])
+ rd := 0.5*float32(SmplLPCOrder)*log2F32(w)*rdWAdj + curBits
+ if rd < rdBest {
+ rdBest = rd
+ outQi[0] = int32(qi1)
+ copy(outQi[1:SmplLPCOrder+1], qi2[:])
+ copy(outQlsf[:], lsfq[:])
+ }
+ if s2 == surv2-1 || absQerr[iAlt[s2]] < 0.25 {
+ break
+ }
+ if s2 > 0 {
+ ic := int(indChgd)
+ qi2[ic] -= alt[ic]
+ }
+ indChgd = iAlt[s2]
+ ic := int(indChgd)
+ qi2Old := qi2[ic]
+ qi2[ic] += alt[ic]
+ qi2New := qi2[ic]
+ qlvlsDiff := st2.Qlvls[ic][qi2New] - st2.Qlvls[ic][qi2Old]
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfq[i] = lsfqBase[i] + qlvlsDiff*wePtr[ic][i]
+ }
+ curBits = bitsOrig + st2.NumBits[ic][qi2New] - st2.NumBits[ic][qi2Old]
+ }
+ }
+
+ return LsfQuantResult{Qi: outQi, QLsf: outQlsf}
+}
+
+// LsfQuant is the non-conditional LSF quantization (smpl_lsf_quant). a is the monic LPC A[0..16].
+func LsfQuant(a, nlsf []float32, voiced, lowRate int, rdWAdj float32, surv int) LsfQuantResult {
+ cb := LoadLsfCb()
+ return lsfQuantCore(cb, a, nlsf, voiced, lowRate, nil, rdWAdj, surv)
+}
+
+// LsfQuantCond is the conditional LSF quantization given the previous frame's quantized NLSF
+// (smpl_lsf_quant_cond). a is the monic LPC A[0..16].
+func LsfQuantCond(a, nlsf, lsfqPrev []float32, voiced, lowRate int, rdWAdj float32, surv int) LsfQuantResult {
+ cb := LoadLsfCb()
+ st1 := &cb.St1[voiced]
+ cbMean := cb.MeanUV
+ if voiced == 1 {
+ cbMean = cb.MeanV
+ }
+ reg := cb.RegCond[voiced]
+ var lsfqPrevReg [SmplLPCOrder]float32
+ var st1Cbhalf [SmplLPCOrder]float32
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfqPrevReg[i] = lsfqPrev[i] + reg*(cbMean[i]-lsfqPrev[i])
+ st1Cbhalf[i] = 0.5 * lsfqPrevReg[i]
+ }
+ var st1CbCinv [SmplLPCOrder]float32
+ matrixMultTransp16(st1.CInv, lsfqPrevReg[:], st1CbCinv[:], SmplLPCOrder)
+ we, wie := rotApplyWght(st1.Rotcond[lowRate], lsfqPrevReg[:])
+ cond := &condParams{
+ st1Cbhalf: st1Cbhalf,
+ st1CbCinv: st1CbCinv,
+ st1We: we,
+ st1Wie: wie,
+ }
+ return lsfQuantCore(cb, a, nlsf, voiced, lowRate, cond, rdWAdj, surv)
+}
+
+// rotApplyWght builds wrot1 (=we) and wrot2 (=wie) for the cond centroid from the rotation matrix
+// and the Laroia-weighted previous LSF (smpl_rot_apply_wght).
+func rotApplyWght(rot [][]float32, lsf []float32) (we, wie [][]float32) {
+ lsfw := LsfWeightsLaroia(lsf)
+ for i := range lsfw {
+ lsfw[i] = sqrtF32(lsfw[i])
+ }
+ var lsfwInv [SmplLPCOrder]float32
+ for i := 0; i < SmplLPCOrder; i++ {
+ lsfwInv[i] = 1.0 / lsfw[i]
+ }
+ wrot1 := make([][]float32, SmplLPCOrder)
+ wrot2 := make([][]float32, SmplLPCOrder)
+ for i := 0; i < SmplLPCOrder; i++ {
+ wrot1[i] = make([]float32, SmplLPCOrder)
+ wrot2[i] = make([]float32, SmplLPCOrder)
+ }
+ for i := 0; i < SmplLPCOrder; i++ {
+ for j := 0; j < SmplLPCOrder; j++ {
+ wrot1[i][j] = rot[i][j] * lsfwInv[j]
+ wrot2[j][i] = rot[i][j] * lsfw[j]
+ }
+ }
+ return wrot1, wrot2
+}
diff --git a/pkg/call/voip/media/mlow/lsf_seed.bin b/pkg/call/voip/media/mlow/lsf_seed.bin
new file mode 100644
index 00000000..a9b8004c
Binary files /dev/null and b/pkg/call/voip/media/mlow/lsf_seed.bin differ
diff --git a/pkg/call/voip/media/mlow/lsf_seed.go b/pkg/call/voip/media/mlow/lsf_seed.go
new file mode 100644
index 00000000..278ad029
--- /dev/null
+++ b/pkg/call/voip/media/mlow/lsf_seed.go
@@ -0,0 +1,643 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "bytes"
+ "compress/zlib"
+ _ "embed"
+ "encoding/binary"
+ "io"
+ "math"
+ "sync"
+)
+
+// Build-from-seed for the MLow LSF runtime tables. The expanded LSF tables
+// (SmplSynthTables, SmplTables, LsfCb) are the expansion of one small packed ROM
+// (lsf_seed.bin), so we store the ROM and rerun the init at load instead of
+// committing the pre-expanded f32. The float op order here is load-bearing
+// (matmul accumulation, sqrt-then-reciprocal in rotApplyWght, integer truncation
+// in lsfDcmfToCmf, scalar unpack8) so the rebuilt tables are bit-faithful.
+//
+// min_spacing and lsf_extra are NOT separate ROM: min_spacing[v] is min_dist[1-v],
+// and lsf_extra is the extra-symbol selector CDF carried in the seed.
+
+// lsfSeedBlob is the packed LSF ROM (zlib-compressed tables.proto LsfSeed),
+// expanded at load — mirrors pitch_seed.bin / cc_seed.bin.
+//
+//go:embed lsf_seed.bin
+var lsfSeedBlob []byte
+
+const (
+ lsfOrder = SmplLPCOrder // 16
+ lsfCentroids = LSFCBCentroids
+ lsfCinvLen = lsfOrder * (lsfOrder + 1) / 2 // 136
+ lsfST2Len = 9593 // LSF_ST2_ALL_QLVLS_LEN
+)
+
+// Per-voiced (index 0 = unvoiced, 1 = voiced) scale/min constants.
+var (
+ lsfCBMin = [2]float32{-0.5873778, -0.24721986}
+ lsfCBScale = [2]float32{1.3145164e-5, 7.226229e-6}
+ lsfCinvMin = [2]float32{-3.5960955e-5, -2.778548e-5}
+ lsfCinvScale = [2]float32{1.8589316e-9, 1.2180106e-9}
+ lsfRotMin = [2]float32{-0.9124832, -0.8455929}
+ lsfRotScale = [2]float32{0.006554049, 0.0069253775}
+ lsfRotCondMin = [2]float32{-0.67291605, -0.8248211}
+ lsfRotCondScale = [2]float32{0.0052386564, 0.0064186584}
+)
+
+const (
+ lsfST2QlvlsMin = float32(-0.45)
+ lsfST2QlvlsScale = float32(0.0034478905)
+)
+
+// lsfSeed is the packed ROM reshaped into the nested arrays the expansion indexes.
+// Outer index [voiced] (0 = unvoiced, 1 = voiced).
+type lsfSeed struct {
+ cb16 [2][lsfCentroids][lsfOrder]uint16
+ cinv16 [2][lsfCinvLen]uint16
+ rot8 [2][lsfCentroids][lsfOrder][lsfOrder]byte
+ rotCond8 [2][2][lsfOrder][lsfOrder]byte
+ mean [2][lsfOrder]float32
+ cmf [2][17]uint16
+ cmfCond [2][18]uint16
+ minDist [2][17]float32
+ regCond [2]float32
+ minQi [2][2][17][lsfOrder]int8
+ maxQi [2][2][17][lsfOrder]int8
+ qstep [2][2]float32
+ st2Qlvls8 []byte // [9593]
+ st2Dcmfs []byte // [9593]
+ lsfSel [3][3]uint16
+ lsfExtra [3]uint16
+}
+
+// decodeVarintsU32 decodes a packed repeated uint32 protobuf field (plain varints).
+func decodeVarintsU32(b []byte) []uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L53-L64
+ var out []uint32
+ i := 0
+ for i < len(b) {
+ var v uint64
+ var shift uint
+ for i < len(b) {
+ c := b[i]
+ i++
+ v |= uint64(c&0x7f) << shift
+ if c&0x80 == 0 {
+ break
+ }
+ shift += 7
+ }
+ out = append(out, uint32(v))
+ }
+ return out
+}
+
+// decodeFloats decodes a packed repeated float protobuf field (fixed32 little-endian).
+func decodeFloats(b []byte) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L65-L72
+ out := make([]float32, len(b)/4)
+ for i := range out {
+ out[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[i*4:]))
+ }
+ return out
+}
+
+// loadLsfSeed inflates and parses the packed ROM into the nested seed arrays
+// (the reference's LsfSeed::reshape, expressed as fixed-shape fills).
+func loadLsfSeed() *lsfSeed {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L138-L208
+ zr, err := zlib.NewReader(bytes.NewReader(lsfSeedBlob))
+ if err != nil {
+ panic("mlow: inflate lsf seed: " + err.Error())
+ }
+ raw, err := io.ReadAll(zr)
+ zr.Close()
+ if err != nil {
+ panic("mlow: read lsf seed: " + err.Error())
+ }
+ f := parseProto(raw)
+ s := &lsfSeed{}
+
+ // rot_8 [2][16][16][16] u8 (flat row-major).
+ p := 0
+ for v := 0; v < 2; v++ {
+ for c := 0; c < lsfCentroids; c++ {
+ for i := 0; i < lsfOrder; i++ {
+ for j := 0; j < lsfOrder; j++ {
+ s.rot8[v][c][i][j] = f[1].bytes[p]
+ p++
+ }
+ }
+ }
+ }
+ // rot_cond_8 [2][2][16][16] u8.
+ p = 0
+ for v := 0; v < 2; v++ {
+ for lr := 0; lr < 2; lr++ {
+ for i := 0; i < lsfOrder; i++ {
+ for j := 0; j < lsfOrder; j++ {
+ s.rotCond8[v][lr][i][j] = f[2].bytes[p]
+ p++
+ }
+ }
+ }
+ }
+ s.st2Qlvls8 = append([]byte(nil), f[3].bytes...)
+ s.st2Dcmfs = append([]byte(nil), f[4].bytes...)
+ // st2_min_qi / st2_max_qi [2][2][17][16] i8.
+ for idx, src := range [][]byte{f[5].bytes, f[6].bytes} {
+ p = 0
+ for v := 0; v < 2; v++ {
+ for lr := 0; lr < 2; lr++ {
+ for c := 0; c < 17; c++ {
+ for i := 0; i < lsfOrder; i++ {
+ q := int8(src[p])
+ if idx == 0 {
+ s.minQi[v][lr][c][i] = q
+ } else {
+ s.maxQi[v][lr][c][i] = q
+ }
+ p++
+ }
+ }
+ }
+ }
+ }
+ // cb_16 [2][16][16] (u32 -> u16).
+ cb := decodeVarintsU32(f[7].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for c := 0; c < lsfCentroids; c++ {
+ for i := 0; i < lsfOrder; i++ {
+ s.cb16[v][c][i] = uint16(cb[p])
+ p++
+ }
+ }
+ }
+ // cinv_16 [2][136].
+ cinv := decodeVarintsU32(f[8].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for i := 0; i < lsfCinvLen; i++ {
+ s.cinv16[v][i] = uint16(cinv[p])
+ p++
+ }
+ }
+ // cmf [2][17].
+ cmf := decodeVarintsU32(f[9].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for i := 0; i < 17; i++ {
+ s.cmf[v][i] = uint16(cmf[p])
+ p++
+ }
+ }
+ // cmf_cond [2][18].
+ cmfc := decodeVarintsU32(f[10].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for i := 0; i < 18; i++ {
+ s.cmfCond[v][i] = uint16(cmfc[p])
+ p++
+ }
+ }
+ // lsf_sel [3][3].
+ sel := decodeVarintsU32(f[11].bytes)
+ p = 0
+ for a := 0; a < 3; a++ {
+ for b := 0; b < 3; b++ {
+ s.lsfSel[a][b] = uint16(sel[p])
+ p++
+ }
+ }
+ // lsf_extra [3].
+ ex := decodeVarintsU32(f[12].bytes)
+ for i := 0; i < 3; i++ {
+ s.lsfExtra[i] = uint16(ex[i])
+ }
+ // mean [2][16].
+ mean := decodeFloats(f[13].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for i := 0; i < lsfOrder; i++ {
+ s.mean[v][i] = mean[p]
+ p++
+ }
+ }
+ // min_dist [2][17].
+ md := decodeFloats(f[14].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for i := 0; i < 17; i++ {
+ s.minDist[v][i] = md[p]
+ p++
+ }
+ }
+ // reg_cond [2].
+ rc := decodeFloats(f[15].bytes)
+ s.regCond[0], s.regCond[1] = rc[0], rc[1]
+ // qstep [2][2].
+ qs := decodeFloats(f[16].bytes)
+ p = 0
+ for v := 0; v < 2; v++ {
+ for i := 0; i < 2; i++ {
+ s.qstep[v][i] = qs[p]
+ p++
+ }
+ }
+ return s
+}
+
+// ---- float expansion primitives (op order is load-bearing) ----
+
+// lsfMatMultTransp16: transposed 16x16 matrix-vector multiply,
+// y[i] = sum_j C[j][i] * x[j] (accumulate seeded at j=0, then += for j>0).
+func lsfMatMultTransp16(c *[lsfOrder][lsfOrder]float32, x *[lsfOrder]float32) [lsfOrder]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L210-L225
+ var y [lsfOrder]float32
+ x0 := x[0]
+ for i := 0; i < lsfOrder; i++ {
+ y[i] = c[0][i] * x0
+ }
+ for j := 1; j < lsfOrder; j++ {
+ xj := x[j]
+ for i := 0; i < lsfOrder; i++ {
+ // Round the product before accumulating: Go would otherwise fuse
+ // `y[i] + c*xj` into an FMA (one rounding), but the reference rounds
+ // the multiply and the add separately.
+ prod := float32(c[j][i] * xj)
+ y[i] += prod
+ }
+ }
+ return y
+}
+
+// lsfSeedLaroia: Laroia inverse-gap LSF weights, with the gap floored at 1e-3.
+func lsfSeedLaroia(lsf *[lsfOrder]float32) [lsfOrder]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L227-L242
+ const minDist = float32(1e-3)
+ var inv [lsfOrder + 1]float32
+ inv[0] = 1.0 / maxF32(lsf[0], minDist)
+ for i := 1; i < lsfOrder; i++ {
+ inv[i] = 1.0 / maxF32(lsf[i]-lsf[i-1], minDist)
+ }
+ inv[lsfOrder] = 1.0 / maxF32(smplPi-lsf[lsfOrder-1], minDist)
+ var w [lsfOrder]float32
+ for i := 0; i < lsfOrder; i++ {
+ w[i] = inv[i] + inv[i+1]
+ }
+ return w
+}
+
+// lsfRotApplyWght: apply the Laroia weights to the rotation. lsfw = sqrt(laroia(lsf)),
+// we[i][j] = rot[i][j]/lsfw[j], wie[j][i] = rot[i][j]*lsfw[j].
+func lsfRotApplyWght(rot *[lsfOrder][lsfOrder]float32, lsf *[lsfOrder]float32) (we, wie [lsfOrder][lsfOrder]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L244-L267
+ lsfw := lsfSeedLaroia(lsf)
+ for i := range lsfw {
+ lsfw[i] = sqrtF32(lsfw[i])
+ }
+ var lsfwInv [lsfOrder]float32
+ for i := 0; i < lsfOrder; i++ {
+ lsfwInv[i] = 1.0 / lsfw[i]
+ }
+ for i := 0; i < lsfOrder; i++ {
+ for j := 0; j < lsfOrder; j++ {
+ we[i][j] = rot[i][j] * lsfwInv[j]
+ wie[j][i] = rot[i][j] * lsfw[j]
+ }
+ }
+ return
+}
+
+// lsfCmfToBits: per-symbol bit cost, bits[i] = -log2f((cmf[i+1]-cmf[i]) / cmf[len-1]).
+func lsfCmfToBits(cmf []uint16) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L269-L279
+ n := len(cmf)
+ den := float32(cmf[n-1])
+ bits := make([]float32, n-1)
+ for i := 0; i < n-1; i++ {
+ num := float32(int32(cmf[i+1]) - int32(cmf[i]))
+ bits[i] = -log2F32(num / den)
+ }
+ return bits
+}
+
+// lsfDcmfToCmf: integer expansion of a delta-CMF to a cumulative u16 CDF of length len+1.
+func lsfDcmfToCmf(dcmf []byte) []uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L281-L302
+ n := len(dcmf)
+ cmf := make([]uint16, n+1)
+ var sum int64
+ for i := 0; i < n; i++ {
+ tmp := int32(dcmf[i]) + 1
+ tmp *= tmp
+ if tmp > 65535 {
+ tmp = 65535
+ }
+ cmf[i+1] = uint16(tmp)
+ sum += int64(tmp)
+ }
+ cmf[0] = 0
+ for i := 1; i < n+1; i++ {
+ prev := int64(cmf[i-1])
+ add := int64(cmf[i])*int64(32767-n)/sum + 1
+ cmf[i] = uint16(prev + add)
+ }
+ return cmf
+}
+
+// lsfUnpack8: out[i][j] = min + packed[i][j]*scale, scalar.
+func lsfUnpack8(packed *[lsfOrder][lsfOrder]byte, scale, min float32) [lsfOrder][lsfOrder]float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L304-L312
+ var out [lsfOrder][lsfOrder]float32
+ for i := 0; i < lsfOrder; i++ {
+ for j := 0; j < lsfOrder; j++ {
+ // Round packed*scale before adding min (defeat FMA fusion; reference rounds separately).
+ prod := float32(float32(packed[i][j]) * scale)
+ out[i][j] = min + prod
+ }
+ }
+ return out
+}
+
+// ---- small slice converters ----
+
+func arr16ToSlice(a *[lsfOrder]float32) []float32 {
+ out := make([]float32, lsfOrder)
+ copy(out, a[:])
+ return out
+}
+
+func mat16ToSlice(m *[lsfOrder][lsfOrder]float32) [][]float32 {
+ out := make([][]float32, lsfOrder)
+ for i := range out {
+ out[i] = arr16ToSlice(&m[i])
+ }
+ return out
+}
+
+// lsfBuilt holds the three LSF runtime structs rebuilt from one seed.
+type lsfBuilt struct {
+ synth *SmplSynthTables
+ tables *SmplTables
+ cb *LsfCb
+}
+
+// buildLsfFromSeed runs the LSF codebook expansion to produce all three runtime structs.
+func buildLsfFromSeed(s *lsfSeed) *lsfBuilt {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L314-L401
+ st1 := make([]st1Tables, 0, 2)
+ // Decoder-side accumulators for SmplSynthTables (centroids/matrices = cbhalf/we).
+ synthCentroids := make([][][]float32, 2)
+ synthMatrices := make([][][][]float32, 2)
+ // grid==16 decorr matrices: the same Rotcond unpack8(rot_cond_8), flattened [lr][256].
+ synthGrid16Matrices := make([][][]float32, 2)
+
+ for voiced := 0; voiced < 2; voiced++ {
+ // cInv (symmetric lower-triangular fill).
+ var cInv [lsfOrder][lsfOrder]float32
+ p := 0
+ for i := 0; i < lsfOrder; i++ {
+ for j := 0; j <= i; j++ {
+ // Round scale*cinv before adding min (defeat FMA fusion).
+ prod := float32(lsfCinvScale[voiced] * float32(s.cinv16[voiced][p]))
+ v := lsfCinvMin[voiced] + prod
+ cInv[i][j] = v
+ cInv[j][i] = v
+ p++
+ }
+ }
+
+ var cbhalf [lsfCentroids][lsfOrder]float32
+ var cbCinv [lsfCentroids][lsfOrder]float32
+ var we [lsfCentroids][lsfOrder][lsfOrder]float32
+ var wie [lsfCentroids][lsfOrder][lsfOrder]float32
+ for c := 0; c < lsfCentroids; c++ {
+ var lsfCB [lsfOrder]float32
+ for i := 0; i < lsfOrder; i++ {
+ // Round cb16*scale before the additions (defeat FMA fusion of min + cb16*scale).
+ prod := float32(float32(s.cb16[voiced][c][i]) * lsfCBScale[voiced])
+ lsfCB[i] = lsfCBMin[voiced] + prod + s.mean[voiced][i]
+ cbhalf[c][i] = lsfCB[i] * 0.5
+ }
+ cbCinv[c] = lsfMatMultTransp16(&cInv, &lsfCB)
+ rot := lsfUnpack8(&s.rot8[voiced][c], lsfRotScale[voiced], lsfRotMin[voiced])
+ weC, wieC := lsfRotApplyWght(&rot, &lsfCB)
+ we[c] = weC
+ wie[c] = wieC
+ }
+
+ // Rotcond[lowRate] = unpack8(rot_cond_8[lowRate]).
+ var rotcond [2][lsfOrder][lsfOrder]float32
+ for lr := 0; lr < 2; lr++ {
+ rotcond[lr] = lsfUnpack8(&s.rotCond8[voiced][lr], lsfRotCondScale[voiced], lsfRotCondMin[voiced])
+ }
+
+ bits := lsfCmfToBits(s.cmf[voiced][:]) // 16
+ bitsCond := lsfCmfToBits(s.cmfCond[voiced][:]) // 17
+
+ t := st1Tables{
+ Cbhalf: make([][]float32, lsfCentroids),
+ CInv: mat16ToSlice(&cInv),
+ BitsCond: bitsCond,
+ Rotcond: [][][]float32{mat16ToSlice(&rotcond[0]), mat16ToSlice(&rotcond[1])},
+ CbCinv: make([][]float32, lsfCentroids),
+ We: make([][][]float32, lsfCentroids),
+ Bits: bits,
+ Wie: make([][][]float32, lsfCentroids),
+ }
+ for c := 0; c < lsfCentroids; c++ {
+ t.Cbhalf[c] = arr16ToSlice(&cbhalf[c])
+ t.CbCinv[c] = arr16ToSlice(&cbCinv[c])
+ t.We[c] = mat16ToSlice(&we[c])
+ t.Wie[c] = mat16ToSlice(&wie[c])
+ }
+ st1 = append(st1, t)
+
+ // SmplSynthTables decoder centroids/matrices: grid g<16 == cbhalf[g]/we[g]. The
+ // grid==16 row is never read (grid==16 returns before indexing it), so not appended.
+ sc := make([][]float32, lsfCentroids)
+ sm := make([][][]float32, lsfCentroids)
+ for g := 0; g < lsfCentroids; g++ {
+ sc[g] = arr16ToSlice(&cbhalf[g])
+ sm[g] = mat16ToSlice(&we[g])
+ }
+ synthCentroids[voiced] = sc
+ synthMatrices[voiced] = sm
+ // grid16_matrices[voiced][lr] = the Rotcond computed above, flattened row-major to 256.
+ g16 := make([][]float32, 2)
+ for lr := 0; lr < 2; lr++ {
+ flat := make([]float32, 0, lsfOrder*lsfOrder)
+ for i := 0; i < lsfOrder; i++ {
+ flat = append(flat, rotcond[lr][i][:]...)
+ }
+ g16[lr] = flat
+ }
+ synthGrid16Matrices[voiced] = g16
+ }
+
+ // Stage 2: the flat QlvlsTable / cmfTable / numBitsTable walks.
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L403-L480
+ qlvlsFlat := make([]float32, lsfST2Len)
+ var numqlvlsFlat [2][2][17][lsfOrder]int32
+ var qoffFlat [2][2][17][lsfOrder]int
+ var cmfSlices [2][2][17][lsfOrder][]uint16
+ var numbitsSlices [2][2][17][lsfOrder][]float32
+
+ qPtr, q8Ptr, dcmfPtr := 0, 0, 0
+ for voiced := 0; voiced < 2; voiced++ {
+ for lr := 0; lr < 2; lr++ {
+ for c := 0; c < lsfCentroids+1; c++ {
+ qstep := s.qstep[voiced][lr]
+ if c == lsfCentroids {
+ qstep *= lsfQstepCondMult
+ }
+ for i := 0; i < lsfOrder; i++ {
+ minQi := int32(s.minQi[voiced][lr][c][i])
+ maxQi := int32(s.maxQi[voiced][lr][c][i])
+ numQlvls := int(maxQi - minQi + 1)
+ numqlvlsFlat[voiced][lr][c][i] = int32(numQlvls)
+ qoffFlat[voiced][lr][c][i] = qPtr
+ for lvl := 0; lvl < numQlvls; lvl++ {
+ q8 := float32(s.st2Qlvls8[q8Ptr])
+ // Round scale*q8 before adding min (defeat FMA fusion).
+ prod := float32(lsfST2QlvlsScale * q8)
+ qlvlsFlat[qPtr] = (lsfST2QlvlsMin + prod +
+ float32(lvl) + float32(minQi)) * qstep
+ qPtr++
+ q8Ptr++
+ }
+ dcmf := s.st2Dcmfs[dcmfPtr : dcmfPtr+numQlvls]
+ cmf := lsfDcmfToCmf(dcmf) // numQlvls+1
+ nb := lsfCmfToBits(cmf) // numQlvls
+ dcmfPtr += numQlvls
+ cmfSlices[voiced][lr][c][i] = cmf
+ numbitsSlices[voiced][lr][c][i] = nb
+ }
+ }
+ }
+ }
+ if qPtr != lsfST2Len || q8Ptr != lsfST2Len || dcmfPtr != lsfST2Len {
+ panic("mlow: lsf seed stage-2 pointer miscount (corrupt seed)")
+ }
+
+ // Assemble st2 (LsfCb) and valtables / lsf_stage2 (sliced from the flat tables).
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L482-L529
+ st2 := make([][][]st2Tables, 2)
+ valtables := make([][][][][]float32, 2)
+ lsfStage2 := make([][][][][]uint16, 2)
+ for voiced := 0; voiced < 2; voiced++ {
+ st2[voiced] = make([][]st2Tables, 2)
+ valtables[voiced] = make([][][][]float32, 2)
+ lsfStage2[voiced] = make([][][][]uint16, 2)
+ for lr := 0; lr < 2; lr++ {
+ st2[voiced][lr] = make([]st2Tables, lsfCentroids+1)
+ valtables[voiced][lr] = make([][][]float32, lsfCentroids+1)
+ lsfStage2[voiced][lr] = make([][][]uint16, lsfCentroids+1)
+ for c := 0; c < lsfCentroids+1; c++ {
+ nq := make([]int32, lsfOrder)
+ qlvls := make([][]float32, lsfOrder)
+ vt := make([][]float32, lsfOrder)
+ nb := make([][]float32, lsfOrder)
+ cmfRows := make([][]uint16, lsfOrder)
+ for i := 0; i < lsfOrder; i++ {
+ n := int(numqlvlsFlat[voiced][lr][c][i])
+ off := qoffFlat[voiced][lr][c][i]
+ slice := append([]float32(nil), qlvlsFlat[off:off+n]...)
+ nq[i] = int32(n)
+ qlvls[i] = slice
+ vt[i] = append([]float32(nil), qlvlsFlat[off:off+n]...)
+ nb[i] = numbitsSlices[voiced][lr][c][i]
+ cmfRows[i] = cmfSlices[voiced][lr][c][i]
+ }
+ st2[voiced][lr][c] = st2Tables{NumQlvls: nq, Qlvls: qlvls, NumBits: nb}
+ valtables[voiced][lr][c] = vt
+ lsfStage2[voiced][lr][c] = cmfRows
+ }
+ }
+ }
+
+ // Assemble the runtime structs.
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L531-L578
+ cb := &LsfCb{
+ St1: st1,
+ St2: st2,
+ MinQi: lsfCloneQi(&s.minQi),
+ MaxQi: lsfCloneQi(&s.maxQi),
+ Qstep: [][]float32{{s.qstep[0][0], s.qstep[0][1]}, {s.qstep[1][0], s.qstep[1][1]}},
+ MeanV: arr16ToSlice(&s.mean[1]),
+ MeanUV: arr16ToSlice(&s.mean[0]),
+ RegCond: []float32{s.regCond[0], s.regCond[1]},
+ MinDistV: append([]float32(nil), s.minDist[1][:]...),
+ MinDistUV: append([]float32(nil), s.minDist[0][:]...),
+ }
+
+ tables := &SmplTables{
+ LsfSel: [][]uint16{
+ {s.lsfSel[0][0], s.lsfSel[0][1], s.lsfSel[0][2]},
+ {s.lsfSel[1][0], s.lsfSel[1][1], s.lsfSel[1][2]},
+ {s.lsfSel[2][0], s.lsfSel[2][1], s.lsfSel[2][2]},
+ },
+ LsfGrid: LsfGrid{
+ // match1 = CMF_cond_v, match1_alt = CMF_cond_uv, match0 = CMF_uv, match0_alt = CMF_v.
+ Match1: append([]uint16(nil), s.cmfCond[1][:]...),
+ Match1Alt: append([]uint16(nil), s.cmfCond[0][:]...),
+ Match0: append([]uint16(nil), s.cmf[0][:]...),
+ Match0Alt: append([]uint16(nil), s.cmf[1][:]...),
+ },
+ LsfStage2: lsfStage2,
+ LsfExtra: []uint16{s.lsfExtra[0], s.lsfExtra[1], s.lsfExtra[2]},
+ }
+
+ synth := &SmplSynthTables{
+ Valtables: valtables,
+ Centroids: synthCentroids,
+ Matrices: synthMatrices,
+ // min_spacing[v] = min_dist[1-v] (the index swap), not separate ROM.
+ MinSpacing: [][]float32{append([]float32(nil), s.minDist[1][:]...), append([]float32(nil), s.minDist[0][:]...)},
+ // grid16_w[v] = mean[1-v] (the 1-v swap bakes in the synth's INVERTED selection);
+ // grid16_alpha = reg_cond; grid16_matrices = unpack8(rot_cond_8) computed above.
+ Grid16W: [][]float32{arr16ToSlice(&s.mean[1]), arr16ToSlice(&s.mean[0])},
+ Grid16Alpha: []float32{s.regCond[0], s.regCond[1]},
+ Grid16Matrices: synthGrid16Matrices,
+ }
+
+ return &lsfBuilt{synth: synth, tables: tables, cb: cb}
+}
+
+// lsfCloneQi widens the i8 stage-2 qi bounds to the [2][2][17][16]int32 runtime shape.
+func lsfCloneQi(qi *[2][2][17][lsfOrder]int8) [][][][]int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L581-L593
+ out := make([][][][]int32, 2)
+ for v := 0; v < 2; v++ {
+ out[v] = make([][][]int32, 2)
+ for lr := 0; lr < 2; lr++ {
+ out[v][lr] = make([][]int32, 17)
+ for c := 0; c < 17; c++ {
+ row := make([]int32, lsfOrder)
+ for i := 0; i < lsfOrder; i++ {
+ row[i] = int32(qi[v][lr][c][i])
+ }
+ out[v][lr][c] = row
+ }
+ }
+ }
+ return out
+}
+
+var (
+ lsfBuiltOnce sync.Once
+ lsfBuiltVal *lsfBuilt
+)
+
+// loadLsfBuilt loads the LSF seed ROM and builds all three runtime structs once.
+func loadLsfBuilt() *lsfBuilt {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_lsf_seed.rs#L595-L604
+ lsfBuiltOnce.Do(func() {
+ lsfBuiltVal = buildLsfFromSeed(loadLsfSeed())
+ })
+ return lsfBuiltVal
+}
diff --git a/pkg/call/voip/media/mlow/mem.go b/pkg/call/voip/media/mlow/mem.go
new file mode 100644
index 00000000..bfcde129
--- /dev/null
+++ b/pkg/call/voip/media/mlow/mem.go
@@ -0,0 +1,214 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "encoding/binary"
+ "sync"
+)
+
+type smplMemRegion struct {
+ base uint32
+ data []byte
+}
+
+// SmplMem is an embedded window of the codec's heap holding the runtime-built CDF
+// tables, plus the table-base pointers, so the decode paths can replicate the
+// original pointer arithmetic exactly.
+type SmplMem struct {
+ regions []smplMemRegion
+ GCC uint32
+ GNrg uint32
+ GPitch uint32
+ GClk uint32
+}
+
+// Fixed WASM-build globals for the Group-D heap layout (smpl_mem.rs). The window is
+// built at these absolute addresses so the pitch lag/contour pointer-chase lands
+// unchanged.
+const (
+ memGClk = 0xb9f9a8
+ memGPitch = 0xb9d378
+ memPcfg = memGClk + 0x5704
+ memHdrContourMap = 0xe7c10
+ memHdrLagCdf = 0xbaa7b0
+ memHdrFracBase = 0xbaa9be
+ memHdrDeltaCdf = 0xbab13e
+ memDeltaBounds = 0xe7ef0
+ memNumContours = 217
+)
+
+var memHdrUnused = [3]uint32{0xe7d20, 0xe7ef0, 0xe8096}
+
+func u16Bytes(v []uint32) []byte {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_mem.rs#L129-L130
+ b := make([]byte, len(v)*2)
+ for i, x := range v {
+ binary.LittleEndian.PutUint16(b[2*i:], uint16(x))
+ }
+ return b
+}
+
+// buildSmplMemFromSeed builds the pitch lag/contour (Group D) heap window from the
+// pitch seed (port of smpl_mem.rs build_smpl_mem), reproducing the carved window
+// byte-for-byte at every address the consumer reads. Groups A/B/C/E moved to the
+// logical CcTables, so GCC/GNrg are 0 here.
+func buildSmplMemFromSeed() *SmplMem {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_mem.rs#L90-L156
+ w := buildContourWindow()
+ var regions []smplMemRegion
+ push := func(base uint32, data []byte) { regions = append(regions, smplMemRegion{base: base, data: data}) }
+
+ var r0 []byte
+ put32 := func(x int32) {
+ var b [4]byte
+ binary.LittleEndian.PutUint32(b[:], uint32(x))
+ r0 = append(r0, b[:]...)
+ }
+ for _, rec := range w.records {
+ blocks, seglens := rec[0], rec[1]
+ for i := 0; i < 8; i++ {
+ v := 0
+ if i < len(blocks) {
+ v = blocks[i]
+ }
+ put32(int32(v))
+ }
+ for i := 0; i < 8; i++ {
+ v := 0
+ if i < len(seglens) {
+ v = seglens[i]
+ }
+ put32(int32(v))
+ }
+ put32(int32(len(blocks)))
+ }
+ put32(187) // NUM_BLOCKTRACKS gap
+ for _, h := range []uint32{memNumContours, memHdrContourMap, memHdrLagCdf, memHdrFracBase, memHdrUnused[0], memHdrUnused[1], memHdrUnused[2], memHdrDeltaCdf} {
+ var b [4]byte
+ binary.LittleEndian.PutUint32(b[:], h)
+ r0 = append(r0, b[:]...)
+ }
+ push(memPcfg+0x1d38, r0)
+
+ push(memHdrLagCdf, u16Bytes(w.lagCdf))
+ var frac []uint32
+ for _, c := range w.fracCmfs {
+ frac = append(frac, c...)
+ }
+ push(memHdrFracBase, u16Bytes(frac))
+ var delta []uint32
+ for _, c := range w.deltaCmfs {
+ delta = append(delta, c...)
+ }
+ push(memHdrDeltaCdf, u16Bytes(delta))
+ push(memHdrContourMap, append([]byte(nil), w.contourMap...))
+
+ bounds := make([]byte, 0, len(w.firstblockRange)*2+2)
+ for _, p := range w.firstblockRange {
+ bounds = append(bounds, byte(p[0]), byte(p[1]))
+ }
+ bounds = append(bounds, 0, 0)
+ push(memDeltaBounds, bounds)
+
+ return &SmplMem{regions: regions, GCC: 0, GNrg: 0, GPitch: memGPitch, GClk: memGClk}
+}
+
+var (
+ smplMemOnce sync.Once
+ smplMem *SmplMem
+)
+
+// LoadSmplMem builds the pitch lag/contour (Group D) heap window from the pitch seed
+// once and returns the shared, read-only window. Groups A/B/C/E moved to the logical
+// CcTables (cc_tables.go), so this no longer reads a cc_blob snapshot.
+func LoadSmplMem() *SmplMem {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_mem.rs#L158-L160
+ smplMemOnce.Do(func() { smplMem = buildSmplMemFromSeed() })
+ return smplMem
+}
+
+// regionFor returns the region data containing [addr, addr+n) and the byte offset
+// of addr within it. ok is false when no region covers the range.
+func (m *SmplMem) regionFor(addr uint32, n int) (data []byte, off int, ok bool) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L79-L86
+ for _, r := range m.regions {
+ if addr >= r.base && int(addr-r.base)+n <= len(r.data) {
+ return r.data, int(addr - r.base), true
+ }
+ }
+ return nil, 0, false
+}
+
+// U8 reads one byte at addr, or 0 if addr is outside every region.
+func (m *SmplMem) U8(addr uint32) uint8 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L88-L90
+ if data, off, ok := m.regionFor(addr, 1); ok {
+ return data[off]
+ }
+ return 0
+}
+
+// U16 reads a little-endian uint16 at addr, or 0 if out of region.
+func (m *SmplMem) U16(addr uint32) uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L92-L95
+ if data, off, ok := m.regionFor(addr, 2); ok {
+ return binary.LittleEndian.Uint16(data[off:])
+ }
+ return 0
+}
+
+// I16 is the signed reinterpretation of U16.
+func (m *SmplMem) I16(addr uint32) int16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L97-L99
+ return int16(m.U16(addr))
+}
+
+// U32 reads a little-endian uint32 at addr, or 0 if out of region.
+func (m *SmplMem) U32(addr uint32) uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L101-L105
+ if data, off, ok := m.regionFor(addr, 4); ok {
+ return binary.LittleEndian.Uint32(data[off:])
+ }
+ return 0
+}
+
+// I32 is the signed reinterpretation of U32.
+func (m *SmplMem) I32(addr uint32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L107-L109
+ return int32(m.U32(addr))
+}
+
+// CDFAt materializes the n-entry cumulative uint16 CDF at addr; entries outside
+// the window read as 0.
+func (m *SmplMem) CDFAt(addr uint32, n int) []uint16 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/smpl_mem.rs#L113-L117
+ out := make([]uint16, n)
+ for i := range n {
+ out[i] = m.U16(addr + uint32(i)*2)
+ }
+ return out
+}
+
+// silkLSFCosTabFIXQ12 is the Q12 cosine approximation table (129 entries,
+// symmetric around index 64) for the LSF root search.
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/b90291b1ae979d504adf71d9555b3daf5c7325b1/wacore/src/voip/mlow/silk_lsf_cos_tab.rs#L4-L22
+var silkLSFCosTabFIXQ12 = [129]int32{
+ 8192, 8190, 8182, 8170, 8152, 8130, 8104, 8072,
+ 8034, 7994, 7946, 7896, 7840, 7778, 7714, 7644,
+ 7568, 7490, 7406, 7318, 7226, 7128, 7026, 6922,
+ 6812, 6698, 6580, 6458, 6332, 6204, 6070, 5934,
+ 5792, 5648, 5502, 5352, 5198, 5040, 4880, 4718,
+ 4552, 4382, 4212, 4038, 3862, 3684, 3502, 3320,
+ 3136, 2948, 2760, 2570, 2378, 2186, 1990, 1794,
+ 1598, 1400, 1202, 1002, 802, 602, 402, 202,
+ 0, -202, -402, -602, -802, -1002, -1202, -1400,
+ -1598, -1794, -1990, -2186, -2378, -2570, -2760, -2948,
+ -3136, -3320, -3502, -3684, -3862, -4038, -4212, -4382,
+ -4552, -4718, -4880, -5040, -5198, -5352, -5502, -5648,
+ -5792, -5934, -6070, -6204, -6332, -6458, -6580, -6698,
+ -6812, -6922, -7026, -7128, -7226, -7318, -7406, -7490,
+ -7568, -7644, -7714, -7778, -7840, -7896, -7946, -7994,
+ -8034, -8072, -8104, -8130, -8152, -8170, -8182, -8190,
+ -8192,
+}
diff --git a/pkg/call/voip/media/mlow/noise.go b/pkg/call/voip/media/mlow/noise.go
new file mode 100644
index 00000000..36c6fffc
--- /dev/null
+++ b/pkg/call/voip/media/mlow/noise.go
@@ -0,0 +1,528 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "math"
+ "sync"
+)
+
+// CELP decoder-side noise generator: builds the shaped residual noise the CELP
+// synthesis mixes into the excitation (smpl_gennoise.rs). The perceptual-weighting
+// front-end and bitrate controller the datasheet also bundles are encoder/analysis
+// concerns and are scaffolded with the encoder module, not here.
+
+const (
+ smplMaxSFLen = 160
+ smplNoiseCorrOrder = 2
+ smplNoiseDCTOrder = 16
+ smplCelpFsKHz = 16
+ smplPiNoise = float32(3.1415926535897)
+
+ decNoiseVNoiseGain = float32(0.35)
+ decNoiseUVNoiseGain = float32(0.8)
+ decNoiseUVFcornerHz = float32(800.0)
+ envSmthCoefV = float32(0.95)
+ envSmthCoefUV = float32(0.995)
+ envSmthCoefUVV = float32(0.99)
+)
+
+var coefMAV = [3]float32{0.25, -0.496, 0.25}
+
+// NoiseGenerator is the persistent decoder-side noise generator state.
+type NoiseGenerator struct {
+ EnvSmth float32
+ EnvLast float32
+ OutStateUV [2]float32
+ OutStateV [2]float32
+ CorrSmth [smplNoiseCorrOrder + 1]float32
+ ShapeState [smplNoiseCorrOrder]float32
+ PrevVoiced bool
+ SinceUnvoiced int32
+ RandSeed int32
+}
+
+// NewNoiseGenerator allocates a zeroed noise generator.
+func NewNoiseGenerator() *NoiseGenerator {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L359-L374
+ return &NoiseGenerator{}
+}
+
+// smpl_RAND: LCG, wrapping i32 arithmetic (907633515 + (u32)seed*196314165).
+func smplRand(seed int32) int32 {
+ return int32(907633515) + int32(uint32(seed)*196314165)
+}
+
+// smpl_sigmoid with the same +/-80 clamp as C.
+func smplSigmoid(x float32) float32 {
+ if x > 80.0 {
+ return 1.0
+ }
+ if x < -80.0 {
+ return 0.0
+ }
+ return 1.0 / (1.0 + float32(math.Exp(float64(-x))))
+}
+
+func smplNrg(x []float32) float32 {
+ var nrg float32
+ for _, v := range x {
+ nrg += v * v
+ }
+ return nrg
+}
+
+func smplSum(x []float32) float32 {
+ var s float32
+ for _, v := range x {
+ s += v
+ }
+ return s
+}
+
+func smplMaximum(x []float32) float32 {
+ m := x[0]
+ for _, v := range x[1:] {
+ if v > m {
+ m = v
+ }
+ }
+ return m
+}
+
+// smpl_gen_rand_pulses: 4-at-a-time bit-rotated white pulses scaled by 8.1e-10.
+func smplGenRandPulses(noise []float32, l int, seed *int32) {
+ const sc = float32(8.1e-10)
+ i := 0
+ for i+3 < l {
+ *seed = smplRand(*seed)
+ s := uint32(*seed)
+ noise[i] = sc * float32(*seed)
+ noise[i+1] = sc * float32(int32(s<<8))
+ noise[i+2] = sc * float32(int32(s<<16))
+ noise[i+3] = sc * float32(int32(s<<24))
+ i += 4
+ }
+ for i < l {
+ *seed = smplRand(*seed)
+ noise[i] = sc * float32(*seed)
+ i++
+ }
+}
+
+// smpl_get_env: squared-signal smoothing envelope (4-wide, mirrors the C order).
+func smplGetEnv(exc []float32, length int, smthCoef float32, smthState *float32, env []float32) {
+ smthCoef *= smthCoef // operate on squared signal
+ state := *smthState + 1e-8
+ state *= state
+ gainCoef := 1.0 - smthCoef
+ smthCoef2 := smthCoef * smthCoef
+ gainSmthCoef := gainCoef * smthCoef
+ i := 0
+ for i+3 < length {
+ tmp0 := float32(exc[i]*exc[i]) + float32(exc[i+1]*exc[i+1])
+ tmp1 := float32(exc[i+2]*exc[i+2]) + float32(exc[i+3]*exc[i+3])
+ y1 := float32(gainCoef*tmp1) + float32(gainSmthCoef*tmp0) + float32(smthCoef2*state)
+ y0 := float32(gainCoef*tmp0) + float32(smthCoef*state)
+ env[i] = float32(math.Sqrt(float64(y0)))
+ env[i+1] = env[i]
+ env[i+2] = float32(math.Sqrt(float64(y1)))
+ env[i+3] = env[i+2]
+ state = y1
+ i += 4
+ }
+ *smthState = env[length-1]
+}
+
+// smpl_get_env0: decaying envelope when there is no excitation to seed from.
+func smplGetEnv0(length int, smthCoef float32, smthState *float32, env []float32) {
+ smthCoef2 := smthCoef * smthCoef
+ env[0] = (*smthState + 1e-8) * smthCoef
+ env[1] = env[0]
+ i := 2
+ for i+2 < length {
+ env[i+2] = env[i-1] * smthCoef2
+ env[i+3] = env[i+2]
+ env[i] = env[i-1] * smthCoef
+ env[i+1] = env[i]
+ i += 4
+ }
+ env[length-2] = env[length-3] * smthCoef
+ env[length-1] = env[length-2]
+ *smthState = env[length-1]
+}
+
+// smpl_filt_ma1 (coef_len=2, state_len=1). x != y.
+func smplFiltMA1(x []float32, n int, coef [2]float32, state *float32, y []float32) {
+ if coef[0] == 1.0 {
+ for k := 1; k < n; k++ {
+ y[k] = x[k] + coef[1]*x[k-1]
+ }
+ } else {
+ for k := 0; k < n; k++ {
+ y[k] = coef[0] * x[k]
+ }
+ for k := 1; k < n; k++ {
+ y[k] += coef[1] * x[k-1]
+ }
+ }
+ y[0] = coef[0]*x[0] + coef[1]*(*state)
+ *state = x[n-1]
+}
+
+// smpl_filt_ar1 (coef_len=2, state_len=1, coef[0]==1).
+func smplFiltAR1(x []float32, n int, coef [2]float32, state *float32, y []float32) {
+ ar1 := -coef[1]
+ ytmp := *state
+ for nn := 0; nn < n; nn++ {
+ ytmp = x[nn] + ytmp*ar1
+ y[nn] = ytmp
+ }
+ *state = ytmp
+}
+
+// smpl_filt_arma1: MA1 then AR1, state {ma, ar}.
+func smplFiltARMA1(x []float32, n int, coefMA, coefAR [2]float32, state *[2]float32, y []float32) {
+ var tmp [smplMaxSFLen]float32
+ maState := state[0]
+ smplFiltMA1(x, n, coefMA, &maState, tmp[:])
+ state[0] = maState
+ arState := state[1]
+ smplFiltAR1(tmp[:], n, coefAR, &arState, y)
+ state[1] = arState
+}
+
+// smpl_filt_ma2 (coef_len=3, state_len=2). x != y.
+func smplFiltMA2(x []float32, n int, coef [3]float32, state *[2]float32, y []float32) {
+ if coef[0] == 1.0 {
+ for i := 1; i < n; i++ {
+ y[i] = x[i] + coef[1]*x[i-1]
+ }
+ } else {
+ for i := 0; i < n; i++ {
+ y[i] = coef[0] * x[i]
+ }
+ for i := 1; i < n; i++ {
+ y[i] += coef[1] * x[i-1]
+ }
+ }
+ for i := 2; i < n; i++ {
+ y[i] += coef[2] * x[i-2]
+ }
+ y[0] = coef[0]*x[0] + coef[1]*state[0] + coef[2]*state[1]
+ y[1] += coef[2] * state[0]
+ state[0] = x[n-1]
+ state[1] = x[n-2]
+}
+
+// smpl_spec_fact2: spectral factorization of a 3-tap autocorrelation into a 3-tap MA.
+func smplSpecFact2(cIn [3]float32, a *[3]float32) {
+ c := cIn
+ c[0] += 1e-30
+ invC0 := 1.0 / c[0]
+ r2 := c[2] * invC0
+ r1 := c[1] / (c[0] * (1.0 + r2))
+ for iter := 0; iter < 2; iter++ {
+ v0 := 1.0 + r1*r1 + r2*r2
+ v1 := r1 + r1*r2
+ s := -2.0 / v0
+ da0 := s * r1
+ da1 := s * r2
+ s = v0 * invC0
+ e1 := s*c[1] - v1
+ e2 := s*c[2] - r2
+ r0 := 2.0*r1 + v0*da0
+ r3 := 2.0*r2 + v0*da1
+ rr00 := r0 * r0
+ rr01 := r0 * r3
+ rr11 := r3 * r3
+ rcap1 := 1.0 + r2 + v1*da0
+ r4 := r1 + v1*da1
+ rr00 += rcap1 * rcap1
+ rr01 += rcap1 * r4
+ rr11 += r4 * r4
+ re0 := rcap1 * e1
+ re1 := r4 * e1
+ r2c := r2 * da0
+ r5 := 1.0 + r2*da1
+ rr00 += r2c * r2c
+ rr01 += r2c * r5
+ rr11 += r5 * r5
+ re0 += r2c * e2
+ re1 += r5 * e2
+ s = rr00*rr11 - rr01*rr01
+ if s < 1e-4 {
+ break
+ }
+ s = 1.0 / s
+ r1 += (rr11*re0 - rr01*re1) * s
+ r2 += (-rr01*re0 + rr00*re1) * s
+ }
+ sc := float32(math.Sqrt(float64(c[0] / (1.0 + r1*r1 + r2*r2))))
+ a[0] = sc
+ a[1] = sc * r1
+ a[2] = sc * r2
+}
+
+// noiseDCT builds the noise DCT matrix (dct_mat_t[CORR+1][DCT_ORDER]), once.
+func noiseDCT() *[smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32 {
+ noiseDCTOnce.Do(func() {
+ sc := 1.0 / float32(math.Sqrt(float64(smplNoiseDCTOrder)))
+ for i := 0; i < smplNoiseDCTOrder; i++ {
+ dOmega := ((0.5 + float32(i)) * smplPiNoise) / float32(smplNoiseDCTOrder)
+ var omega float32
+ for j := 0; j < smplNoiseCorrOrder+1; j++ {
+ noiseDCTMat[j][i] = float32(math.Cos(float64(omega))) * sc
+ omega += dOmega
+ }
+ }
+ })
+ return &noiseDCTMat
+}
+
+var (
+ noiseDCTOnce sync.Once
+ noiseDCTMat [smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32
+)
+
+// noiseMatMultTransp16: y[0..16] = sum_j C[j][i]*x[j].
+func noiseMatMultTransp16(c *[smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32, x, y []float32, lenX int) {
+ var yt [smplNoiseDCTOrder]float32
+ xtmp := x[0]
+ for i := 0; i < smplNoiseDCTOrder; i++ {
+ yt[i] = c[0][i] * xtmp
+ }
+ for j := 1; j < lenX; j++ {
+ xt := x[j]
+ for i := 0; i < smplNoiseDCTOrder; i++ {
+ yt[i] += c[j][i] * xt
+ }
+ }
+ copy(y[:smplNoiseDCTOrder], yt[:])
+}
+
+// noiseMatMult: y[i] = dot(C[i], x) over DCT_ORDER, for i in 0..CORR+1.
+func noiseMatMult(c *[smplNoiseCorrOrder + 1][smplNoiseDCTOrder]float32, x, y []float32) {
+ for i := 0; i < smplNoiseCorrOrder+1; i++ {
+ var acc float32
+ for k := 0; k < smplNoiseDCTOrder; k++ {
+ acc += c[i][k] * x[k]
+ }
+ y[i] = acc
+ }
+}
+
+// SmplGetNormalizedBitrate maps the per-frame pulse count to the normalized bitrate.
+func SmplGetNormalizedBitrate(numPulses, frameLength16 int32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L329-L332
+ pulsesPer20ms := float32(numPulses*frameLength16) / (20.0 * 16.0)
+ return smplSigmoid(1.4*float32(math.Log2(float64(pulsesPer20ms+1.0))) - 6.5)
+}
+
+// SmplDecodeResnrg maps the quantized residual-energy floor to a linear residual energy.
+func SmplDecodeResnrg(nrgresFrameDbqQ14, fcbSubfrlen int32) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L336-L343
+ exp := 0.1 * (float32(nrgresFrameDbqQ14) / float32(int32(1)<<14))
+ resnrg := float32(math.Pow(10, float64(exp))) - smplResNrgBias
+ if resnrg < 0.0 {
+ resnrg = 0.0
+ }
+ return resnrg * float32(fcbSubfrlen)
+}
+
+// add_noise_uv: HP-shape the unvoiced noise and add it into noise.
+func addNoiseUV(ng *NoiseGenerator, excNoiseUV []float32, l int, lsf []float32, nrgRatio float32, noise []float32) {
+ lsfHz := 16000.0 * (lsf[0] + lsf[1]) / (4.0 * smplPiNoise)
+ minUVFcornerHz := lsfHz * 3.0 * smplSigmoid(0.2/(lsf[1]-lsf[0]+1e-30)-3.0)
+ uvFcornerHz := decNoiseUVFcornerHz * minF32(0.6+0.4*nrgRatio, 1.0)
+ uvFcornerHz = maxF32(uvFcornerHz, minUVFcornerHz)
+ uvFcornerHz = minF32(uvFcornerHz, 1500.0)
+ coefTmp := 6.0 * uvFcornerHz / 16000.0
+ g := (1.0 - 0.5*coefTmp) * decNoiseUVNoiseGain
+ coefMAUV := [2]float32{g, -g}
+ coefARUV := [2]float32{1.0, -1.0 + coefTmp}
+ var filtered [smplMaxSFLen]float32
+ smplFiltARMA1(excNoiseUV, l, coefMAUV, coefARUV, &ng.OutStateUV, filtered[:])
+ copy(excNoiseUV[:l], filtered[:l])
+ for i := 0; i < l; i++ {
+ noise[i] += excNoiseUV[i]
+ }
+}
+
+func minF32(a, b float32) float32 {
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// SmplCelpGenNoise builds the shaped residual noise for one subframe (writes l
+// samples into noise).
+func SmplCelpGenNoise(ng *NoiseGenerator, excLpc []float32, l int, voiced bool, numPulses int32, nrgres float32, fcbgIdx int32, lsf []float32, normalizedBitrate float32, fcbgainsUV []float32, noise []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_gennoise.rs#L416-L611
+ nrgRatio := float32(1.0)
+ var noiseUV, noiseV, noiseV2, env [smplMaxSFLen]float32
+
+ if voiced {
+ var corrs, c, ctgt [smplNoiseCorrOrder + 1]float32
+ for i := 0; i < smplNoiseCorrOrder+1; i++ {
+ var acc float32
+ for k := 0; k < l-i; k++ {
+ acc += excLpc[k] * excLpc[k+i]
+ }
+ corrs[i] = acc
+ }
+ corrs[0] += 1e-12
+ corrSmthCoef := float32(0.16)
+ if l == smplCelpFsKHz*10 {
+ corrSmthCoef = 0.4
+ }
+ for i := 0; i < smplNoiseCorrOrder+1; i++ {
+ ng.CorrSmth[i] += corrSmthCoef * (corrs[i] - ng.CorrSmth[i])
+ }
+ scale := decNoiseVNoiseGain * decNoiseVNoiseGain * corrs[0] / ng.CorrSmth[0]
+ for i := 0; i < smplNoiseCorrOrder+1; i++ {
+ c[i] = ng.CorrSmth[i] * scale
+ }
+ c[1] *= 2.0
+ c[2] *= 2.0
+
+ dct := noiseDCT()
+ var f2, f2Tgt [smplNoiseDCTOrder]float32
+ noiseMatMultTransp16(dct, c[:], f2[:], smplNoiseCorrOrder+1)
+ m := smplMaximum(f2[:smplNoiseDCTOrder]) * 1.5
+ for i := 0; i < smplNoiseDCTOrder; i++ {
+ f2Tgt[i] = m - f2[i]
+ }
+ noiseMatMult(dct, f2Tgt[:], ctgt[:])
+ smplGenRandPulses(noiseV[:], l, &ng.RandSeed)
+ if !ng.PrevVoiced {
+ ng.EnvSmth = ng.EnvLast
+ }
+ smplGetEnv(excLpc, l, envSmthCoefV, &ng.EnvSmth, env[:])
+ for i := 0; i < l; i++ {
+ noiseV[i] *= env[i]
+ }
+ nrgNoise := smplNrg(noiseV[:l])
+ inv := 1.0 / (nrgNoise + 1e-12)
+ for i := 0; i < smplNoiseCorrOrder+1; i++ {
+ ctgt[i] *= inv
+ }
+ var coefMA [smplNoiseCorrOrder + 1]float32
+ smplSpecFact2(ctgt, &coefMA)
+ smplFiltMA2(noiseV[:], l, coefMA, &ng.ShapeState, noiseV2[:])
+
+ if !ng.PrevVoiced {
+ smplGenRandPulses(noiseUV[:], l, &ng.RandSeed)
+ envVal := ng.EnvLast * envSmthCoefUVV
+ for i := 0; i < l; i += 2 {
+ noiseUV[i] *= envVal
+ noiseUV[i+1] *= envVal * envSmthCoefUVV
+ envVal *= envSmthCoefUVV * envSmthCoefUVV
+ }
+ } else if ng.SinceUnvoiced < 2 {
+ for i := 0; i < l; i++ {
+ noiseUV[i] = 0.0
+ }
+ }
+ ng.EnvLast = env[l-1]
+ } else {
+ for i := range ng.CorrSmth {
+ ng.CorrSmth[i] = 0.0
+ }
+ for i := range ng.ShapeState {
+ ng.ShapeState[i] = 0.0
+ }
+ for i := 0; i < l; i++ {
+ noiseV2[i] = 0.0
+ }
+
+ var nrgTgt float32
+ if numPulses > 0 {
+ nrgRatio = smplNrg(excLpc[:l]) / (nrgres + 1e-20)
+ hardness := 10.0 + 20.0*normalizedBitrate
+ nrgTgt = nrgres * float32(math.Log(float64(float32(math.Exp(float64(hardness*(1.0-nrgRatio))))+1.0))) / hardness
+ smplGetEnv(excLpc, l, envSmthCoefUV, &ng.EnvSmth, env[:])
+ } else {
+ nrgRatio = 0.0
+ nrgTgt = nrgres
+ smplGetEnv0(l, envSmthCoefUV, &ng.EnvSmth, env[:])
+ }
+
+ scale := 1.0 / float32(l)
+ nrgTgt = nrgTgt*scale + 1e-30
+ nrgEnv := smplNrg(env[:l]) * scale
+ f := float32(math.Sqrt(float64(nrgTgt)))
+ gg := float32(math.Sqrt(float64(nrgTgt / nrgEnv)))
+ ge := gg * env[0]
+ envLast := ng.EnvLast
+ if envLast < minF32(f, ge) {
+ if f < ge {
+ gg = 0.0
+ } else {
+ f = 0.0
+ }
+ } else if envLast > maxF32(f, ge) {
+ if f > ge {
+ gg = 0.0
+ } else {
+ f = 0.0
+ }
+ } else {
+ sumEnv := smplSum(env[:l]) * scale
+ a := nrgEnv + env[0]*env[0] - 2.0*sumEnv*env[0]
+ b := 2.0 * envLast * (sumEnv - env[0])
+ cc := envLast*envLast - nrgTgt
+ tmp := b*b - 4.0*a*cc
+ if tmp < 1e-35 || a < 1e-25 {
+ f = 0.0
+ gg = 0.0
+ } else {
+ tmp = float32(math.Sqrt(float64(tmp)))
+ scale = 0.5 / a
+ gg = (-b + tmp) * scale
+ f = envLast - env[0]*gg
+ if f < 0.0 {
+ gg = (-b - tmp) * scale
+ f = envLast - env[0]*gg
+ }
+ }
+ }
+
+ smplGenRandPulses(noiseUV[:], l, &ng.RandSeed)
+ if numPulses > 0 {
+ maxVal := fcbgainsUV[fcbgIdx] * 0.5
+ for i := 0; i < l; i++ {
+ if excLpc[i] == 0.0 {
+ noiseUV[i] *= minF32(f+gg*env[i], maxVal)
+ } else {
+ noiseUV[i] = 0.0
+ }
+ }
+ ng.EnvLast = minF32(f+gg*env[l-1], maxVal)
+ } else {
+ for i := 0; i < l; i++ {
+ noiseUV[i] *= f + gg*env[i]
+ }
+ ng.EnvLast = f + gg*env[l-1]
+ }
+ }
+
+ if ng.PrevVoiced || voiced {
+ smplFiltMA2(noiseV2[:], l, coefMAV, &ng.OutStateV, noise)
+ } else {
+ for i := 0; i < l; i++ {
+ noise[i] = 0.0
+ }
+ }
+ if ng.SinceUnvoiced < 2 || !voiced {
+ addNoiseUV(ng, noiseUV[:], l, lsf, nrgRatio, noise)
+ } else {
+ ng.OutStateUV = [2]float32{0.0, 0.0}
+ }
+ ng.PrevVoiced = voiced
+ if voiced {
+ ng.SinceUnvoiced++
+ } else {
+ ng.SinceUnvoiced = 0
+ }
+}
diff --git a/pkg/call/voip/media/mlow/perc.go b/pkg/call/voip/media/mlow/perc.go
new file mode 100644
index 00000000..e8f87b4a
--- /dev/null
+++ b/pkg/call/voip/media/mlow/perc.go
@@ -0,0 +1,528 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math"
+
+// MLow perceptual-weighting front-end — faithful port of smpl_perc.rs
+// (smpl_perc_wght.c FFT-based perceptual autocorrelation → perceptual LPC
+// response, and smpl_bitrate_controller.c per-subframe pulse budget + importance).
+// The C pffft (ordered real) is replaced by a self-contained mixed-radix complex
+// FFT re-packed into pffft's exact ordered layout, so smth_filt indexes identical
+// bins. PERCW_NFFT = 576 = 2^6 * 3^2 (not a power of two), hence mixed radix.
+//
+// Reuses smplPI (truncated literal), genSinWin/genCosWin, smplSigmoid from the
+// package. Validated by perc-model smoke + bitrate-controller KATs and ultimately
+// the encoder tone round-trip.
+
+const (
+ percwNfft = 512 + 64 // 576
+ percwFsKhz = 16.0
+ percMaskSmth = 0.1158
+ percMelFcHz = 320.0
+
+ winNextWbLen = 16 * 2 // 32
+ winNextWbLongLen = 16 * 4 // 64
+ win3ShortLen = winNextWbLen
+ win3LongLen = winNextWbLongLen
+ winPrevPercLen = 16 * 12 // 192
+ percWin110msLen = 192
+ percWin120msLen = 352
+
+ smplMaxLResp = 32 + 1 // 33
+ smplMaxSfLen = 16 * 10 // 160
+ smplPercRespLen = 16 * 2 // 32
+
+ // SmplPercReg is the perceptual-LPC autocorrelation regularization (smpl_perc_wght.h).
+ SmplPercReg float32 = 1e-3
+
+ smplE float32 = 2.7182818284590
+
+ // SmplFrameTypes
+ frameBackgroundNoise = 0
+ frameUnvoiced = 1
+ frameVoiced = 2
+
+ smplCelpIdxFec = 0
+ smplCelpIdxMain = 1
+ smplCelpMaxRates = smplCelpIdxMain + 1 // 2
+ smplMaxPulsesPerSf = 40
+ smplRateContScale = 26.0
+)
+
+// SmplPercEmphV / SmplPercEmphUV (smpl_tables.c): voiced / unvoiced pre-emphasis.
+var (
+ SmplPercEmphV = [2]float32{-0.72, -0.77}
+ SmplPercEmphUV = [2]float32{-0.55, -0.6}
+)
+
+// [lowRate][BACKGROUND_NOISE/UNVOICED/VOICED]
+var smplMaxPulsesPerFrame = [2][3]uint8{{80, 160, 160}, {16, 32, 32}}
+
+// [framelenidx][lowrate][8]
+var smplRateControlModelComp5 = [4][2][8]float32{
+ {
+ {5.166876656946171, -8.981699804753452, 0.07280811614105594, 0.1301196310618402, -0.01597680442864421, 1.7601470147884113, -3.8161195433141755, 0.3038629198331684},
+ {-71.71229978402292, 14.197572549553076, -0.9863630205846172, 0.032124893286072924, -0.0003538411576874928, 1.803705259861388e-11, 10.0, 1.2454667523627154},
+ },
+ {
+ {32.5371190670542, -41.270234279452104, 10.490270829170875, -1.102121269442237, 0.03848319274046071, 3.405326741403831, -5.102658181889428, 0.2141935195026695},
+ {-177.10486363500775, 43.952329593498376, -3.7049735533247454, 0.14239771116996938, -0.001919963993993193, 7.953695588409639e-6, 5.220317075476664, 0.6435364076926223},
+ },
+ {
+ {-79.2663194911617, 45.00981883522089, -10.063311543498518, 1.2311531056576501, -0.06023559069137118, 0.059204788212259364, 3.033961466462233, 1.0111383197827808},
+ {-122.04861900525415, 31.62096398905459, -2.613237037423586, 0.10050433143234094, -0.0013233009240188039, 2.14859438836692e-7, 1.9077791307787761, 0.7059420500333776},
+ },
+ {
+ {-182.64255084224325, 122.90780796179816, -31.308790671748525, 3.7850563849431462, -0.1750480676903051, 0.05399618467364628, 3.009451055091342, 1.1243365512229038},
+ {-132.4565456943888, 34.361297004632966, -2.7956546289118887, 0.10428149547078584, -0.001322667891395693, 2.678747426340249e-6, 6.9940208056381925, 0.7551244069345737},
+ },
+}
+
+// [framelenidx][lowrate]
+var smplRateControlThrsComp5 = [4][2]uint16{{7500, 10000}, {4500, 5750}, {4000, 5000}, {4000, 4750}}
+
+// --- leaf vector helpers (smpl_codec_util.c) -------------------------------
+
+func percMulVec(input, win, out []float32, l int) {
+ for i := 0; i < l; i++ {
+ out[i] = win[i] * input[i]
+ }
+}
+
+func percScaleVec(x, y []float32, l int, g float32) {
+ for i := 0; i < l; i++ {
+ y[i] = x[i] * g
+ }
+}
+
+func percAddScaleVec(x0, x1, y []float32, l int, g float32) {
+ for i := 0; i < l; i++ {
+ y[i] = x0[i] + g*x1[i]
+ }
+}
+
+func percAddScaleVecInplace(x, y []float32, l int, g float32) {
+ for i := 0; i < l; i++ {
+ y[i] += g * x[i]
+ }
+}
+
+// percFiltMa2 is smpl_filt_ma2: 2nd-order MA (may be non-monic). state is state[0..2].
+func percFiltMa2(x []float32, n int, coef []float32, state *[2]float32, y []float32) {
+ if coef[0] == 1.0 {
+ percAddScaleVec(x[1:], x, y[1:], n-1, coef[1])
+ } else {
+ percScaleVec(x, y, n, coef[0])
+ percAddScaleVecInplace(x, y[1:], n-1, coef[1])
+ }
+ percAddScaleVecInplace(x, y[2:], n-2, coef[2])
+ y[0] = coef[0]*x[0] + coef[1]*state[0] + coef[2]*state[1]
+ y[1] += coef[2] * state[0]
+}
+
+// percAc2rcDbl is smpl_ac2rc_dbl: autocorrelation → reflection coeffs (Levinson, f64).
+func percAc2rcDbl(corr []float64, order int, reg float64, rc []float32) {
+ c0 := make([]float64, order+1)
+ c1 := make([]float64, order+1)
+ copy(c0, corr[:order+1])
+ c0[0] *= 1.0 + reg
+ copy(c1, c0)
+ for i := 0; i < order; i++ {
+ rc[i] = 0.0
+ }
+ for k := 0; k < order; k++ {
+ if c0[k+1] > c1[0] {
+ rc[k] = -1.0
+ break
+ }
+ if c0[k+1] < -c1[0] {
+ rc[k] = 1.0
+ break
+ }
+ if c1[0] == 0.0 {
+ break
+ }
+ rcTmp := -c0[k+1] / c1[0]
+ rc[k] = float32(rcTmp)
+ for n := 0; n < order-k; n++ {
+ ctmp1 := c0[n+k+1]
+ ctmp2 := c1[n]
+ c0[n+k+1] = ctmp1 + ctmp2*rcTmp
+ c1[n] = ctmp2 + ctmp1*rcTmp
+ }
+ }
+}
+
+// percAc2rc is smpl_ac2rc: float wrapper promoting to double before Levinson.
+func percAc2rc(corr []float32, order int, reg float32, rc []float32) {
+ corrDbl := make([]float64, order+1)
+ for i := 0; i < order+1; i++ {
+ corrDbl[i] = float64(corr[i])
+ }
+ percAc2rcDbl(corrDbl, order, float64(reg), rc)
+}
+
+// percRc2a is smpl_rc2a: reflection coeffs → LPC polynomial A[0..order].
+func percRc2a(rc []float32, order int, a []float32) {
+ for v := 1; v <= order; v++ {
+ a[v] = 0.0
+ }
+ a[0] = 1.0
+ for k := 0; k < order; k++ {
+ rcTmp := rc[k]
+ for n := 0; n < (k+1)/2; n++ {
+ tmp1 := a[n+1]
+ tmp2 := a[k-n]
+ a[n+1] = tmp1 + tmp2*rcTmp
+ a[k-n] = tmp2 + tmp1*rcTmp
+ }
+ a[k+1] = rcTmp
+ }
+}
+
+// --- inverse real FFT (forward + cfft live in fft.go) ----------------------
+
+// rfftBackwardOrdered: inverse real FFT from the ordered REAL layout, unnormalized.
+func rfftBackwardOrdered(f []float32, time []float32) {
+ n := len(f)
+ spec := make([]cpx, n)
+ spec[0] = cpx{f[0], 0}
+ spec[n/2] = cpx{f[1], 0}
+ for i := 1; i < n/2; i++ {
+ re := f[2*i]
+ im := f[2*i+1]
+ spec[i] = cpx{re, im}
+ spec[n-i] = cpx{re, -im}
+ }
+ tout := make([]cpx, n)
+ cfft(spec, tout, 1.0)
+ for i := 0; i < n; i++ {
+ time[i] = tout[i].re
+ }
+}
+
+// --- perceptual model (smpl_perc_wght.c) -----------------------------------
+
+type percWindows struct {
+ percWin110ms []float32
+ percWin120ms []float32
+ win3Short []float32
+ win3Long []float32
+}
+
+func newPercWindows() percWindows {
+ return percWindows{
+ percWin110ms: genSinWin(percWin110msLen),
+ percWin120ms: genSinWin(percWin120msLen),
+ win3Short: genCosWin(win3ShortLen),
+ win3Long: genCosWin(win3LongLen),
+ }
+}
+
+// smplWindowPerc is smpl_window for the perc case (use_lpc_win == FALSE).
+func smplWindowPerc(win *percWindows, input, out []float32, length int, frameMs int32, useLongWin bool) {
+ win1len := percWin120msLen
+ win1 := win.percWin120ms
+ if frameMs == 10 {
+ win1len = percWin110msLen
+ win1 = win.percWin110ms
+ }
+ win3len := win3ShortLen
+ win3 := win.win3Short
+ if useLongWin {
+ win3len = win3LongLen
+ win3 = win.win3Long
+ }
+
+ percMulVec(input, win1, out, win1len)
+ mid := length - win1len - win3LongLen
+ copy(out[win1len:win1len+mid], input[win1len:win1len+mid])
+ percMulVec(input[length-win3LongLen:], win3, out[length-win3LongLen:], win3len)
+ if !useLongWin {
+ start := length - win3LongLen + win3ShortLen
+ for i := start; i < length; i++ {
+ out[i] = 0.0
+ }
+ }
+}
+
+// smthFilt is the bidirectional masking smooth across the power spectrum.
+func smthFilt(f []float32, smthcoef []float32) {
+ half := percwNfft / 2
+ f2smth := f[0]
+ for i := 1; i < half; i++ {
+ f2new := f[2*i]
+ f2smth = f2new + smthcoef[i]*(f2smth-f2new)
+ f[2*i] = f2smth
+ }
+ f[1] = f[1] + smthcoef[half]*(f2smth-f[1])
+ f2smth = f[1]
+ for i := half - 1; i > 0; i-- {
+ f2new := f[2*i]
+ f2smth = f2new + smthcoef[i]*(f2smth-f2new)
+ f[2*i] = f2smth
+ }
+ f[0] = f[0] + smthcoef[0]*(f2smth-f[0])
+}
+
+// PercModelState carries the buf history (PERCW_NFFT) across SmplPercModel calls.
+type PercModelState struct {
+ buf [percwNfft]float32
+ smthcoef []float32
+ windows percWindows
+}
+
+// NewPercModelState builds the per-bin mel-width smoothing coefficients (smpl_create_perc_model_tables).
+func NewPercModelState() *PercModelState {
+ fsStep := (percwFsKhz * 1000.0) / float32(percwNfft)
+ smthcoef := make([]float32, percwNfft/2+1)
+ for i := 0; i < percwNfft/2+1; i++ {
+ percWidthPerBin := percMaskSmth * (fsStep*float32(i) + percMelFcHz) / fsStep
+ smthcoef[i] = percWidthPerBin / (percWidthPerBin + 1.0)
+ }
+ return &PercModelState{smthcoef: smthcoef, windows: newPercWindows()}
+}
+
+// SmplPercModel: windowed power spectrum → bidirectional masking smooth → inverse →
+// 1/NFFT scale. Returns the first lenR autocorrelation lags. buf advances as the C.
+func SmplPercModel(state *PercModelState, xsubfr []float32, xsubfrLen int, frameMs int32, isLastSubfr int32, lenR int) []float32 {
+ srcOff := xsubfrLen - (winNextWbLongLen - winNextWbLen)
+ keep := percwNfft - xsubfrLen
+ copy(state.buf[0:keep], state.buf[srcOff:srcOff+keep])
+ copy(state.buf[keep:keep+xsubfrLen], xsubfr[:xsubfrLen])
+
+ winlen := winPrevPercLen + int(frameMs)*16 + win3LongLen
+ skipSamples := percwNfft - winlen
+
+ bufWin := make([]float32, percwNfft)
+ smplWindowPerc(&state.windows, state.buf[skipSamples:], bufWin[skipSamples:], winlen, frameMs, isLastSubfr == 0)
+
+ f := make([]float32, percwNfft)
+ rfftForwardOrdered(bufWin, f)
+ f[0] = f[0] * f[0]
+ f[1] = f[1] * f[1]
+ for i := 1; i < percwNfft/2; i++ {
+ f[2*i] = f[2*i]*f[2*i] + f[2*i+1]*f[2*i+1]
+ f[2*i+1] = 0.0
+ }
+ smthFilt(f, state.smthcoef)
+ rfftBackwardOrdered(f, bufWin)
+
+ r := make([]float32, lenR)
+ percScaleVec(bufWin, r, lenR, 1.0/float32(percwNfft))
+ return r
+}
+
+// SmplPercAc2a: ma2 (b={pe, 1+pe^2, pe}) on R[1..] then Levinson + rc2a → A[0..percRespLen].
+func SmplPercAc2a(r []float32, lenR int, percEmph float32, percRespLen int, reg float32) []float32 {
+ b := []float32{percEmph, 1.0 + percEmph*percEmph, percEmph}
+ state := [2]float32{r[0], r[1]}
+ rTmp := make([]float32, smplMaxLResp)
+ percFiltMa2(r[1:], percRespLen, b, &state, rTmp)
+
+ rc := make([]float32, smplMaxLResp)
+ percAc2rc(rTmp, percRespLen-1, reg, rc)
+
+ a := make([]float32, percRespLen)
+ percRc2a(rc, percRespLen-1, a)
+ return a
+}
+
+// --- bitrate controller (smpl_bitrate_controller.c) ------------------------
+
+func bitrate2pulses(rateKbps float32, coeff *[8]float32) float32 {
+ return coeff[0] +
+ coeff[1]*rateKbps +
+ coeff[2]*rateKbps*rateKbps +
+ coeff[3]*float32(math.Pow(float64(rateKbps), 3.0)) +
+ coeff[4]*float32(math.Pow(float64(rateKbps), 4.0)) +
+ coeff[5]*float32(math.Pow(float64(smplE), float64((rateKbps-coeff[6])*coeff[7])))
+}
+
+func bitrate2pulsesHrFec(rateKbps float32, coeff *[8]float32, onePulseRateBps float32) float32 {
+ const rateThresKbps float32 = 9.0
+ if rateKbps >= rateThresKbps {
+ return bitrate2pulses(rateKbps, coeff)
+ } else if onePulseRateBps >= rateThresKbps*1000.0 {
+ return 1.0
+ }
+ pulsesThres := bitrate2pulses(rateThresKbps, coeff)
+ sc := (rateThresKbps - rateKbps) / (rateThresKbps - onePulseRateBps/1000.0)
+ return pulsesThres - sc*(pulsesThres-1.0)
+}
+
+// BitrateControllerInputs are the smpl_EncControlStruct fields the controller reads.
+type BitrateControllerInputs struct {
+ InternalSampleRate int32
+ PayloadSizeMs int32
+ FecBitRate int32
+ MainBitRate int32
+ Complexity int32
+ UseFecRateCompensation int32
+ UseDtx int32
+ SubFrameImportanceFactor float32
+}
+
+// BitrateController state carried across frames.
+type BitrateController struct {
+ prevVoiced int32
+ rateContWnrgSmth float32
+ rateContBitrateScale [smplCelpMaxRates]float32
+ bitrateDeltaSmth [smplCelpMaxRates]float32
+ rateContBitrate [smplCelpMaxRates]float32
+ adjustmentFactor [smplCelpMaxRates]float32
+}
+
+// NewBitrateController is bitrate_controller_init + zeroed state.
+func NewBitrateController() *BitrateController {
+ bc := &BitrateController{}
+ for i := range bc.adjustmentFactor {
+ bc.adjustmentFactor[i] = 1.0
+ }
+ return bc
+}
+
+// control is bitrate_controller. Returns (max_pulses_per_subfr, subfr_importance).
+func (bc *BitrateController) control(
+ enc *BitrateControllerInputs,
+ dtxSidFrame, codedAsActiveVoice int32,
+ spActProb, nonflatness, voicingStrength float32,
+ voiced int32,
+ wnrg, wnrgNext float32,
+ lowRate, framelen, subfrlen int32,
+) ([smplCelpMaxRates]int16, [smplCelpMaxRates]float32) {
+ var bweBitrate int32
+ if enc.InternalSampleRate > 16000 {
+ if lowRate != 0 {
+ bweBitrate += 450
+ } else {
+ bweBitrate += 750
+ }
+ if enc.PayloadSizeMs == 10 {
+ bweBitrate += 450
+ }
+ }
+
+ bc.rateContWnrgSmth += 0.6 * (wnrg - bc.rateContWnrgSmth)
+
+ framelenIdx := 3
+ switch enc.PayloadSizeMs {
+ case 10:
+ framelenIdx = 0
+ case 20:
+ framelenIdx = 1
+ case 60:
+ framelenIdx = 2
+ }
+
+ var maxPulsesPerSubfr [smplCelpMaxRates]int16
+ var subfrImportance [smplCelpMaxRates]float32
+
+ startR := 0
+ if (smplCelpIdxFec+boolToInt(enc.FecBitRate == 0)) != 0 || enc.FecBitRate == enc.MainBitRate {
+ startR = 1
+ }
+
+ lrIdx := 1
+ if lowRate != 0 {
+ lrIdx = 0
+ }
+
+ for r := startR; r <= smplCelpIdxMain; r++ {
+ bitRate := float32(enc.MainBitRate)
+ if r == smplCelpIdxFec {
+ bitRate = float32(enc.FecBitRate)
+ }
+ if bitRate > 30000.0 {
+ bitRate = 30000.0
+ }
+ rateKbps := (bitRate - float32(bweBitrate)) / 1000.0
+ if lowRate == 0 {
+ switch enc.Complexity {
+ case 1, 2:
+ rateKbps *= 0.9900990
+ case 3, 4:
+ rateKbps *= 1.0101010
+ }
+ }
+
+ var pulsesPer20msTargetMax float32
+ rateControlThrs := float32(smplRateControlThrsComp5[framelenIdx][lrIdx])
+ if (bitRate - float32(bweBitrate)) < rateControlThrs {
+ pulsesPer20msTargetMax = 1.0
+ } else {
+ coeff := &smplRateControlModelComp5[framelenIdx][lrIdx]
+ if r == smplCelpIdxFec && lowRate == 0 && enc.UseFecRateCompensation != 0 {
+ pulsesPer20msTargetMax = maxF32(bitrate2pulsesHrFec(rateKbps, coeff, rateControlThrs), 1.0)
+ } else {
+ pulsesPer20msTargetMax = maxF32(bitrate2pulses(rateKbps, coeff), 1.0)
+ }
+ }
+
+ relPulserate := pulsesPer20msTargetMax / 16.0 * (320.0 / float32(framelen))
+ relPulserateLog := float32(math.Log(float64(relPulserate)))
+ if bc.rateContBitrate[r] != bitRate {
+ bitrateScale := float32(smplRateContScale) * relPulserate * (1.0 + 0.4*relPulserateLog*relPulserateLog)
+ bc.rateContBitrateScale[r] = bitrateScale
+ bc.rateContBitrate[r] = bitRate
+ }
+
+ numsubfrs := framelen / subfrlen
+ mpps := 1 + int32(math.Round(float64(pulsesPer20msTargetMax*(1.0+0.5)/float32(numsubfrs))))
+ if enc.UseDtx != 0 && dtxSidFrame != 0 {
+ mpps = 0
+ } else {
+ mpps = int32(math.Round(float64(float32(mpps) * (0.5 + 0.5*float32(math.Sqrt(float64(spActProb+1e-12)))))))
+ frameType := frameBackgroundNoise
+ if codedAsActiveVoice != 0 {
+ if voiced == 1 {
+ frameType = frameVoiced
+ } else {
+ frameType = frameUnvoiced
+ }
+ }
+ maxPulses := int32(smplMaxPulsesPerFrame[lowRate][frameType]) * framelen / 320
+ if m := maxPulses / numsubfrs; mpps > m {
+ mpps = m
+ }
+ }
+ maxPulsesPerSubfr[r] = int16(mpps)
+
+ imp := (wnrg + 0.01*wnrgNext) / (bc.rateContWnrgSmth + 0.02*wnrgNext + 1e-12)
+ if voiced != 0 {
+ if bitRate <= 9000.0 {
+ imp = float32(math.Sqrt(float64(imp + 1e-12)))
+ }
+ } else {
+ imp *= 0.9 + 0.3*smplSigmoid(nonflatness-2.0)
+ imp *= 0.8
+ }
+ if voiced != bc.prevVoiced {
+ imp *= 1.1
+ }
+ imp *= 0.9 + 0.3*1.0/(1.0+25.0*voicingStrength*voicingStrength)
+
+ impFactor := enc.SubFrameImportanceFactor
+ if impFactor <= 1.0 {
+ imp *= (1.0 - impFactor) + impFactor*float32(math.Sqrt(float64(spActProb+1e-12)))
+ } else if impFactor <= 2.0 {
+ impFactor -= 1.0
+ imp *= (1.0 - impFactor) + impFactor*spActProb
+ } else {
+ impFactor -= 2.0
+ imp *= (1.0 - impFactor) + impFactor*spActProb*spActProb
+ }
+ imp *= bc.adjustmentFactor[r] * bc.rateContBitrateScale[r]
+ subfrImportance[r] = imp
+ bc.prevVoiced = voiced
+ }
+
+ return maxPulsesPerSubfr, subfrImportance
+}
+
+func boolToInt(b bool) int32 {
+ if b {
+ return 1
+ }
+ return 0
+}
diff --git a/pkg/call/voip/media/mlow/pitch.go b/pkg/call/voip/media/mlow/pitch.go
new file mode 100644
index 00000000..64a34cd5
--- /dev/null
+++ b/pkg/call/voip/media/mlow/pitch.go
@@ -0,0 +1,345 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "sync"
+
+// Pitch / LTP parameters. The decode side (DecodeSmplPitch) reads the LTP gains and
+// pitch lags from the bitstream and is the KAT-verified path; the estimator side
+// (SmplPitch) is the encoder analysis and is a known soft-divergence (see datasheet).
+
+const (
+ // NumSubframes is the estimator's 8 pitch sub-blocks per 20 ms internal frame.
+ NumSubframes = 8
+ // MaxLTPBufLen is the perceptually-weighted speech buffer length the estimator reads.
+ MaxLTPBufLen = 659
+)
+
+// ---- Decode side ----
+
+// SmplPitchResult is the decoded LTP/pitch parameters for one internal frame.
+type SmplPitchResult struct {
+ GainIdx [4]int32
+ FiltIdx [4]int32
+ Lag int32
+ Contour int32
+ SampleLagQ6 [8]int32 // per-segment reconstructed pitch lag in Q6 (1/64-sample)
+ NumSeg int32
+ IntLagQ6 [4]int32 // per-subframe pitch lag in Q6
+ BlockLags [8]int32 // per-40-sample-block lags (8 per 20 ms frame)
+ NumSubfr int32
+}
+
+// DecodeSmplPitch decodes the LTP gains and pitch lags. p3 = num subframes,
+// p6 = config, subfrCounts = per-subframe pulse counts (from the pulse decode).
+func DecodeSmplPitch(dec *RangeDecoder, mem *SmplMem, st *SmplLsfState, p2, p3, p6 int32, subfrCounts [4]int32) SmplPitchResult {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch.rs#L32-L198
+ res := SmplPitchResult{FiltIdx: [4]int32{-1, -1, -1, -1}}
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_pitch.rs#L60-L88 (seed cc-table rewire: Group C LTP gains from CcTables; lag block below still mem)
+ cc := LoadCcTables()
+
+ // --- LTP gains loop (Group C, served from CcTables; the lag block below still
+ // reads the Group-D heap window via mem). Both selects key on p6 (active WB path
+ // is p6==0 HR; the p6!=0 LR variant); the filter CDFs are shared across p6.
+ var gainAccum int32
+ take := int(p3)
+ if take > 4 {
+ take = 4
+ }
+ for sf := 0; sf < take; sf++ {
+ cnt := subfrCounts[sf]
+ var gi int32
+ if p6 != 0 {
+ gi = dec.DecodeCDF(cc.AcbgainRowLr(st.PrevGainIdx))
+ } else {
+ gi = dec.DecodeCDF(cc.AcbgainRow(st.PrevGainIdx))
+ }
+ res.GainIdx[sf] = gi
+ st.PrevGainIdx = gi
+
+ var w0, w2 int32
+ if p6 != 0 {
+ w0, w2 = cc.AcbgainWeightsLr(gi)
+ } else {
+ w0, w2 = cc.AcbgainWeights(gi)
+ }
+ gainAccum += w0 + 2*w2
+
+ if cnt > 0 {
+ var fi int32
+ if st.PrevFiltIdx == -1 {
+ fi = dec.DecodeCDF(cc.FcbgainV())
+ } else {
+ fi = dec.DecodeCDF(cc.FcbgainVDelta(st.PrevFiltIdx))
+ }
+ res.FiltIdx[sf] = fi
+ st.PrevFiltIdx = fi
+ }
+ }
+ avgGain := gainAccum / p3 // drives the fractional-lag segment select
+
+ // --- Lag block ---
+ pcfg := mem.GClk + 0x5704
+ numContours := int32(mem.U32(pcfg + 22240))
+ lagCdf := mem.U32(pcfg + 22248)
+ contourMap := mem.U32(pcfg + 22244)
+ fracBase := mem.U32(pcfg + 22252)
+ deltaCdf := mem.U32(pcfg + 22268)
+
+ // primary lag:
+ var lag int32
+ if st.PrevLag < 0 {
+ cnt := numContours + 1
+ if cnt < 0 {
+ cnt = 0
+ }
+ lag = dec.DecodeCDF(mem.CDFAt(lagCdf, int(cnt)))
+ } else {
+ di := dec.DecodeCDF(mem.CDFAt(deltaCdf+uint32(st.PrevLag)*20, 10))
+ lo := int32(mem.U8(0xe7ef0 + uint32(di)*2))
+ hi := int32(mem.U8(0xe7ef0 + uint32(di)*2 + 1))
+ rN := (hi - lo) + 2
+ if rN < 2 {
+ res.Lag = -1
+ return res // malformed delta interval
+ }
+ sym := dec.DecodeCDF(mem.CDFAt(lagCdf+uint32(lo)*2, int(rN)))
+ lag = sym + lo
+ }
+
+ // contour-map search: find index where contour_map[i] == lag+1.
+ target := lag + 1
+ contour := int32(-1)
+ for i := int32(0); i < 217; i++ {
+ if int32(mem.U8(contourMap+uint32(i))) == target {
+ contour = i
+ break
+ }
+ }
+ res.Lag = lag
+ res.Contour = contour
+ if contour < 0 || contour >= numContours {
+ return res // out-of-range; stop consuming pitch bits
+ }
+
+ ctrBase := pcfg + uint32(contour)*0x44
+ baseLag := mem.I32(ctrBase + 0x1d38) // contour base lag
+
+ // (a) 64-symbol fine lag — read UNLESS prev_lag>=0 && -1 <= (base_lag-prev_lag) < 3.
+ curLag2 := baseLag
+ readFine := true
+ if st.PrevLag >= 0 {
+ delta := baseLag - st.PrevLag
+ if delta >= -1 && delta < 3 {
+ readFine = false
+ }
+ }
+ var subfrW int32
+ if readFine {
+ sym := dec.Decode64FineSym()
+ curLag2 = (baseLag << 6) + sym
+ st.PrevFracLag = curLag2
+ st.PrevLag = baseLag
+ segLen0 := mem.I32(ctrBase + 0x1d58)
+ for i := int32(0); i < segLen0; i++ {
+ if subfrW < 4 {
+ res.IntLagQ6[subfrW] = curLag2
+ }
+ if subfrW < 8 {
+ res.BlockLags[subfrW] = curLag2
+ }
+ subfrW++
+ }
+ if subfrW < 4 {
+ res.IntLagQ6[subfrW] = curLag2 // trailing write, subfr_w not incremented
+ }
+ if subfrW < 8 {
+ res.BlockLags[subfrW] = curLag2
+ }
+ }
+
+ // (b) fractional per-segment loop:
+ cnt2 := mem.I32(ctrBase + 0x1d78)
+ var segSel int32
+ if avgGain >= 10007 {
+ if avgGain < 14085 {
+ segSel = 1
+ } else {
+ segSel = 2
+ }
+ }
+ fracSegBase := fracBase + uint32(segSel)*0x280
+ l3 := st.PrevFracLag
+ l2 := curLag2
+ startSeg := int32(0)
+ if readFine {
+ startSeg = 1
+ }
+ res.NumSeg = cnt2
+ for seg := startSeg; seg < cnt2; seg++ {
+ segLag := mem.I32(ctrBase + 0x1d38 + uint32(seg)*4)
+ nl2 := ((l2 << 6) - l3) + ((segLag - l2) << 6)
+ off := fracSegBase + uint32(nl2*2) + 0xfe
+ sym := dec.DecodeCDF(mem.CDFAt(off, 65))
+ l3 = sym + st.PrevFracLag + nl2
+ if seg < 8 {
+ res.SampleLagQ6[seg] = l3
+ }
+ segLen := mem.I32(ctrBase + 0x1d58 + uint32(seg)*4)
+ for i := int32(0); i < segLen; i++ {
+ if subfrW < 4 {
+ res.IntLagQ6[subfrW] = l3
+ }
+ if subfrW < 8 {
+ res.BlockLags[subfrW] = l3
+ }
+ subfrW++
+ }
+ l2 = segLag
+ st.PrevFracLag = l3
+ st.PrevLag = segLag
+ }
+ res.NumSubfr = subfrW
+ return res
+}
+
+// ---- Estimator side ----
+
+// PitchEstState is the per-stream estimator state (cross-frame lag-block predictor).
+type PitchEstState struct {
+ PrevLag float32
+ PrevPitchCorr float32
+ PrevLagblk int32
+ PrevLagidx int32
+}
+
+// ResetCond clears the cross-frame lag-block predictor (smpl_pitch_reset_cond):
+// called after the last frame of a packet and after any unvoiced frame.
+func (s *PitchEstState) ResetCond() {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L337-L341
+ s.PrevLagblk = -1
+ s.PrevLagidx = -1
+}
+
+// PitchResult is the pitch estimator result for one internal frame.
+type PitchResult struct {
+ Pitchcorr float32
+ Lags [NumSubframes]float32
+ Laginds [NumSubframes]int32
+ AvgLag float32
+ HarmStrength float32
+ BlocksegIdx int
+}
+
+// pitchBlockSeg / pitchBlockTrack mirror the reference PitchTables sub-records.
+type pitchBlockSeg struct {
+ Nblocks int
+ Blocks []int
+ Seglens []int
+}
+
+type pitchBlockTrack struct {
+ Track [NumSubframes]int
+ Meanblock float32
+ Trackdeltas float32
+}
+
+// PitchTables holds the loaded constant tables (the smpl_pitch_tables dump).
+type PitchTables struct {
+ Blocksegs []pitchBlockSeg
+ Blocktracks []pitchBlockTrack
+ Blocksegs2idx []int
+ BlocksegIdxCmf []uint32
+ DeltaLagCmfs [][]uint32
+ BlocksegsIx [][2]int
+ FirstblockRange [][2]int
+ BlockTransitionCmf [][]uint32
+}
+
+var (
+ pitchTablesOnce sync.Once
+ pitchTables *PitchTables
+)
+
+// LoadPitchTables expands the embedded pitch seed ROM (pitch_seed.bin) into the full
+// pitch tables once and returns the shared set. The expansion (range-decode of the
+// blocksegs bitstream + integer DCMF→CDF) is in pitch_seed.go (buildPitchTablesFromSeed)
+// and is bit-identical to the old smpl_pitch_tables.json blob.
+func LoadPitchTables() *PitchTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L111-L158
+ pitchTablesOnce.Do(func() { pitchTables = buildPitchTablesFromSeed() })
+ return pitchTables
+}
+
+// pitch lag-contour wire constants (smpl_pitch_enc.rs).
+const (
+ pitchBlocksize = 64 // PITCHBLOCK_MS(2) * FS_KHZ(16) * 2
+ pitchNumBlocks = 9 // (MAXPITCH_MS - MINPITCH_MS)/PITCHBLOCK_MS
+ pitchNumSubframes = NumSubframes
+)
+
+// encodeLagsWire is the faithful port of C smpl_encode_lags (pEcCtx != NULL): write
+// the blockseg selector + the per-40-block lag indices (laginds) to the range stream.
+// This IS the voiced lag wire encode, the inverse of DecodeSmplPitch's contour
+// reconstruction. prevLagblk/prevLagidx are the lag predictor (-1 at packet start /
+// after a no-match); mode (0/1/2 by mean ACB gain) selects the delta-lag CMF.
+func encodeLagsWire(tab *PitchTables, enc *RangeEncoder, blocksegsIx int, laginds *[NumSubframes]int32, prevLagblk, prevLagidx int32, mode int) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L726-L799
+ ixJulia := int32(tab.Blocksegs2idx[blocksegsIx])
+ blocksize := int32(pitchBlocksize)
+ pblockseg := &tab.Blocksegs[blocksegsIx]
+
+ if prevLagblk < 0 {
+ cmf := tab.BlocksegIdxCmf
+ enc.Encode(cmf[ixJulia-1], cmf[ixJulia], cmf[len(tab.Blocksegs)])
+ } else {
+ cmf := tab.BlockTransitionCmf[prevLagblk]
+ b0 := pblockseg.Blocks[0]
+ enc.Encode(cmf[b0], cmf[b0+1], cmf[pitchNumBlocks])
+ startIx := int32(tab.FirstblockRange[b0][0])
+ cmfLen := int32(tab.FirstblockRange[b0][1] - tab.FirstblockRange[b0][0] + 1)
+ cmf2 := tab.BlocksegIdxCmf[startIx:]
+ lo := ixJulia - startIx - 1
+ hi := ixJulia - startIx
+ enc.Encode(cmf2[lo]-cmf2[0], cmf2[hi]-cmf2[0], cmf2[cmfLen]-cmf2[0])
+ }
+
+ blk := int32(pblockseg.Blocks[0])
+ deltaBlk := blk - prevLagblk
+ startSeg := 0
+ lagindsIx := 0
+ if !(prevLagblk > -1 && deltaBlk >= -1 && deltaBlk <= 2) {
+ idxMod := uint32(laginds[lagindsIx] - blk*blocksize)
+ enc.Encode(idxMod, idxMod+1, uint32(blocksize))
+ prevLagblk = blk
+ prevLagidx = laginds[lagindsIx]
+ lagindsIx += pblockseg.Seglens[0]
+ startSeg = 1
+ }
+ deltaLagCmf := tab.DeltaLagCmfs[mode]
+ for k := startSeg; k < pblockseg.Nblocks; k++ {
+ blk = int32(pblockseg.Blocks[k])
+ idx := laginds[lagindsIx]
+ lagindsIx += pblockseg.Seglens[k]
+ deltaBlk = blk - prevLagblk
+ deltaIdx := idx - prevLagidx
+ prevLagidxMod := prevLagidx - prevLagblk*blocksize
+ deltaRangeStart := -prevLagidxMod + deltaBlk*blocksize
+ cmfBase := int(deltaRangeStart + 2*blocksize - 1)
+ ix := int(deltaIdx - deltaRangeStart)
+ p0 := deltaLagCmf[cmfBase]
+ enc.Encode(deltaLagCmf[cmfBase+ix]-p0, deltaLagCmf[cmfBase+ix+1]-p0, deltaLagCmf[cmfBase+int(blocksize)]-p0)
+ prevLagblk = blk
+ prevLagidx = idx
+ }
+}
+
+// smplLagsPredictorAfter is the lag predictor after the voiced lag encode:
+// prevLagblk = blocks[nblocks-1], prevLagidx = laginds[NumSubframes-1].
+func smplLagsPredictorAfter(tab *PitchTables, blocksegsIx int, laginds *[NumSubframes]int32) (int32, int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L803-L811
+ pblockseg := &tab.Blocksegs[blocksegsIx]
+ lastBlk := int32(pblockseg.Blocks[pblockseg.Nblocks-1])
+ return lastBlk, laginds[NumSubframes-1]
+}
+
+// SmplPitch (the full multi-stage estimator) is implemented in pitch_enc.go.
diff --git a/pkg/call/voip/media/mlow/pitch_enc.go b/pkg/call/voip/media/mlow/pitch_enc.go
new file mode 100644
index 00000000..85f2c4ef
--- /dev/null
+++ b/pkg/call/voip/media/mlow/pitch_enc.go
@@ -0,0 +1,680 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math"
+
+// MLow pitch estimator — faithful port of smpl_pitch (smpl_pitch_enc.rs / the C
+// smpl_pitch_util.c). HP-filters + 2x-downsamples the perceptually-weighted
+// ltp_buf, runs an open-loop block-track survivor search at the coarse (16 kHz
+// upsampled from 8 kHz) resolution, refines per-block at full resolution around
+// the survivors, and folds in the rate / prev-lag / spectral-harmonicity biases.
+// Only the 20 ms / 8-subframe config (the active MLow 1:1 path) is supported.
+//
+// Validated by pitchio_ground_truth.json: exact laginds/blockseg_idx + pitchcorr/
+// avg_lag within 1e-3 + harm within the cache-aliasing tol.
+
+const (
+ peFsKhz = 16
+ peStage1FsKhz = 8
+ peCoarseFsKhz = 16
+ peTotInterpDelay = 6
+ peMinpitchMs = 2
+ peMaxpitchMs = 20
+ peMinpitchLen = peMinpitchMs * peFsKhz // 32
+ peMaxpitchLen = peMaxpitchMs * peFsKhz // 320
+ peMinpitchStage1 = peMinpitchMs*peStage1FsKhz - peTotInterpDelay // 10
+ peMaxpitchStage1 = peMaxpitchMs*peStage1FsKhz + peTotInterpDelay // 166
+
+ pePitchDeltawght float32 = 0.1439
+ pePitchShortwght1 float32 = 0.04
+ peSpecHarmBias float32 = 2.5
+ pePrevwght float32 = 0.7981
+ pePrevwghtSpan float32 = 0.15
+ peRatewghtHr float32 = 0.022
+
+ peLagSubfrlen = 40
+ peLagSubfrlenStage1 = peStage1FsKhz * peLagSubfrlen / peFsKhz // 20
+ pePitchblockMs = 2
+ pePitchLookaheadLen = 7
+
+ peDownsampDelay = 7
+ peInterpolDelayC = 4
+ pePitchblock = pePitchblockMs * peFsKhz // 32
+ peNumLagsStage1 = peMaxpitchStage1 - peMinpitchStage1 + 1 // 157
+ peNumlagsCoarse = peCoarseFsKhz * (peMaxpitchMs - peMinpitchMs) // 288
+ peNumlagsFs = peFsKhz * (peMaxpitchMs - peMinpitchMs) // 288
+ peNumstates1 = 24
+ peLowComplexity = false
+ peLowRate = false
+)
+
+// --- filters / DSP helpers --------------------------------------------------
+
+// pePitchHpFilter is smpl_filt_arma1 with pitch_hp_b={1,-1}, pitch_hp_a={1,-0.96},
+// zero state: MA1 then AR1 in the C's 5-sample unrolled form.
+func pePitchHpFilter(x []float32, out []float32) {
+ n := len(x)
+ var stateMa float32
+ for i := 0; i < n; i++ {
+ out[i] = x[i] - stateMa
+ stateMa = x[i]
+ }
+ const ar1 float32 = 0.96
+ ar12 := ar1 * ar1
+ ar13 := ar1 * ar12
+ ar14 := ar1 * ar13
+ ar15 := ar1 * ar14
+ var ytmp float32
+ idx := 0
+ for idx+4 < n {
+ x0, x1, x2, x3, x4 := out[idx], out[idx+1], out[idx+2], out[idx+3], out[idx+4]
+ out[idx+4] = x4 + ar1*x3 + ar12*x2 + ar13*x1 + ar14*x0 + ar15*ytmp
+ out[idx] = x0 + ar1*ytmp
+ out[idx+1] = x1 + ar1*x0 + ar12*ytmp
+ out[idx+2] = x2 + ar1*x1 + ar12*x0 + ar13*ytmp
+ out[idx+3] = x3 + ar1*x2 + ar12*x1 + ar13*x0 + ar14*ytmp
+ ytmp = out[idx+4]
+ idx += 5
+ }
+ for idx < n {
+ ytmp = out[idx] + ytmp*ar1
+ out[idx] = ytmp
+ idx++
+ }
+}
+
+var peDownsampFilt = [2*peDownsampDelay + 1]float32{
+ -0.045472838, 0.0, 0.06366198, 0.0, -0.10610329, 0.0, 0.31830987,
+ 0.5, 0.31830987, 0.0, -0.10610329, 0.0, 0.06366198, 0.0, -0.045472838,
+}
+
+// pePitchDownsample is smpl_pitch_downsample: 2x decimating FIR.
+func pePitchDownsample(ptrIn []float32, l int, ptrOut []float32) int {
+ d := peDownsampDelay
+ n := (l - 2*d) / 2
+ for j := 0; j < n; j++ {
+ tmp := ptrIn[2*j+d] * peDownsampFilt[d]
+ for i := 0; i < d; i += 2 {
+ tmp += (ptrIn[2*j+i] + ptrIn[2*j+2*d-i]) * peDownsampFilt[i]
+ }
+ ptrOut[j] = tmp
+ }
+ return n
+}
+
+var peInterpolFiltC = [2 * peInterpolDelayC]float32{
+ -0.0024414062, 0.023925781, -0.119628906, 0.59814453,
+ 0.59814453, -0.119628906, 0.023925783, -0.0024414062,
+}
+
+// peUpsampECore: writes 2*len samples backwards; even taps copy, odd taps average.
+func peUpsampECore(buf []float32, xEnd, yEnd, length int) {
+ xi := xEnd
+ yi := yEnd
+ for k := 0; k < length; k++ {
+ v := (buf[xi] + buf[xi+1]) * 0.5
+ buf[yi] = v
+ yi--
+ buf[yi] = buf[xi]
+ yi--
+ xi--
+ }
+}
+
+// peUpsampCCore: like upsamp_E but the interpolated sample uses the 8-tap filter.
+func peUpsampCCore(buf []float32, xEnd, yEnd, length int) {
+ xi := xEnd
+ yi := yEnd
+ for k := 0; k < length; k++ {
+ var tmp float32
+ for j := 0; j < peInterpolDelayC; j++ {
+ a := buf[xi+j-(peInterpolDelayC-1)]
+ b := buf[xi+peInterpolDelayC-j]
+ tmp += (a + b) * peInterpolFiltC[j]
+ }
+ buf[yi] = tmp
+ yi--
+ buf[yi] = buf[xi]
+ yi--
+ xi--
+ }
+}
+
+func peNrg(x []float32) float32 {
+ var s float32
+ for _, v := range x {
+ s += v * v
+ }
+ return s
+}
+
+func peMaximum(x []float32) float32 {
+ m := x[0]
+ for _, v := range x[1:] {
+ if v > m {
+ m = v
+ }
+ }
+ return m
+}
+
+// peGetMaxi is smpl_get_maxi: argmax, ties → first index (strict >).
+func peGetMaxi(x []float32) int {
+ bi := 0
+ best := x[0]
+ for n := 1; n < len(x); n++ {
+ if x[n] > best {
+ best = x[n]
+ bi = n
+ }
+ }
+ return bi
+}
+
+// peGetMaxiK is smpl_get_maxi_K: K highest indices in selection order (strict >, lowest-index-wins).
+func peGetMaxiK(x []float32, k int) []int {
+ taken := make([]bool, len(x))
+ out := make([]int, 0, k)
+ for c := 0; c < k; c++ {
+ bi := -1
+ var best float32
+ for n := 0; n < len(x); n++ {
+ if !taken[n] && (bi < 0 || x[n] > best) {
+ best = x[n]
+ bi = n
+ }
+ }
+ if bi < 0 {
+ break
+ }
+ taken[bi] = true
+ out = append(out, bi)
+ }
+ return out
+}
+
+func peDotProd(a, b []float32, n int) float32 {
+ var r float32
+ for i := 0; i < n; i++ {
+ r += a[i] * b[i]
+ }
+ return r
+}
+
+func peDotProd40(a, b []float32) float32 {
+ var r float32
+ for i := 0; i < 40; i++ {
+ r += a[i] * b[i]
+ }
+ return r
+}
+
+// peCalcE1Inner is smpl_calc_E1: running energy of lag_subfrlen-length windows.
+func peCalcE1Inner(e1, ltpbuf []float32, t int, minpitch, maxpitch, lagSubfrlen int) {
+ numlags := maxpitch - minpitch + 1
+ reg0 := t - minpitch
+ e1[0] = maxF32(peNrg(ltpbuf[reg0:reg0+lagSubfrlen]), 1e-9)
+ for i := 1; i < numlags; i++ {
+ rm := ltpbuf[reg0-i]
+ rs := ltpbuf[reg0+lagSubfrlen-i]
+ e1[i] = maxF32(e1[i-1]+rm*rm-rs*rs, 1e-9)
+ }
+}
+
+// peCalcE1 is smpl_pitch_calc_E1: per-subframe E1 via one extended E1_ then offsets.
+func peCalcE1(e1, ltpbuf []float32, ltpbufLen, numsubfrs, minpitch, maxpitch, lagSubfrlen int) {
+ numlags := maxpitch - minpitch + 1
+ maxpitch_ := maxpitch + (numsubfrs-1)*lagSubfrlen
+ numlags_ := maxpitch_ - minpitch + 1
+ t := ltpbufLen - lagSubfrlen
+ e1Ext := make([]float32, numlags_)
+ peCalcE1Inner(e1Ext, ltpbuf, t, minpitch, maxpitch_, lagSubfrlen)
+ offset := numlags_ - numlags
+ for sf := 0; sf < numsubfrs; sf++ {
+ for i := 0; i < numlags; i++ {
+ e1[sf*numlags+i] = e1Ext[offset+i]
+ }
+ offset -= lagSubfrlen
+ }
+}
+
+// peCalcCE2 is smpl_pitch_calc_C_E2: stage-1 cross-correlation C + target energy E2.
+func peCalcCE2(c, e2, ltpbuf []float32, ltpbufLen, numsubfrs int) {
+ t := ltpbufLen - peLagSubfrlenStage1*numsubfrs
+ for sf := 0; sf < numsubfrs; sf++ {
+ tgt := ltpbuf[t : t+20]
+ reg0 := t - peMinpitchStage1
+ for i := 0; i < peNumLagsStage1; i++ {
+ r := ltpbuf[reg0-i : reg0-i+20]
+ c[sf*peNumLagsStage1+i] = peDotProd(tgt, r, 20)
+ }
+ t += peLagSubfrlenStage1
+ e2[sf] = maxF32(peDotProd(tgt, tgt, 20), 1e-9)
+ }
+}
+
+// peUpsampEFast: in-place 2x upsample of a per-subframe E array, high subframe first.
+func peUpsampEFast(buf []float32, numsubfrs int, minpitch *int, numlags *int) {
+ nin := *numlags
+ nout := (nin - 1) * 2
+ for sf := numsubfrs - 1; sf >= 0; sf-- {
+ xEnd := sf*nin + nin - 2
+ yEnd := sf*nout + nout - 1
+ peUpsampECore(buf, xEnd, yEnd, nin-1)
+ }
+ *numlags = nout
+ *minpitch *= 2
+}
+
+// peUpsampCFast: in-place 2x upsample of a per-subframe C array via the interp filter.
+func peUpsampCFast(buf []float32, numsubfrs int, minpitch *int, numlags *int) {
+ nin := *numlags
+ nout := (nin - peInterpolDelayC) * 2
+ for sf := numsubfrs - 1; sf >= 0; sf-- {
+ xEnd := sf*nin + nin - 1 - peInterpolDelayC
+ yEnd := sf*nout + nout - 1
+ peUpsampCCore(buf, xEnd, yEnd, nin-(peInterpolDelayC*2-1))
+ }
+ *numlags = nout
+ *minpitch *= 2
+}
+
+func peSumdeltas(laginds []int32, numsubfrs int) int32 {
+ var ret int32
+ for i := 1; i < numsubfrs; i++ {
+ d := laginds[i] - laginds[i-1]
+ if d < 0 {
+ d = -d
+ }
+ ret += d
+ }
+ return ret
+}
+
+// peEcEncodeBits is ec_encode_wrap with pEcCtx==NULL: -log2((fh-fl)/ft).
+func peEcEncodeBits(fl, fh, ft uint32) float32 {
+ p := (float32(fh) - float32(fl)) / float32(ft)
+ if p <= 0.0 {
+ return 0.0
+ }
+ return -float32(math.Log2(float64(p)))
+}
+
+// peEncodeLagsBits is smpl_encode_lags(.., pEcCtx=NULL): the bit cost used as a survivor bias.
+func peEncodeLagsBits(tab *PitchTables, blocksegsIx int, laginds *[NumSubframes]int32, prevLagblk, prevLagidx int32, mode int) float32 {
+ var nBits float32
+ ixJulia := int32(tab.Blocksegs2idx[blocksegsIx])
+ blocksize := int32(pePitchblockMs * peFsKhz * 2) // 64
+ pblockseg := &tab.Blocksegs[blocksegsIx]
+
+ if prevLagblk < 0 {
+ cmf := tab.BlocksegIdxCmf
+ nBits += peEcEncodeBits(cmf[ixJulia-1], cmf[ixJulia], cmf[len(tab.Blocksegs)])
+ } else {
+ cmf := tab.BlockTransitionCmf[prevLagblk]
+ b0 := pblockseg.Blocks[0]
+ nBits += peEcEncodeBits(cmf[b0], cmf[b0+1], cmf[pitchNumBlocks])
+ startIx := int32(tab.FirstblockRange[b0][0])
+ cmfLen := int32(tab.FirstblockRange[b0][1] - tab.FirstblockRange[b0][0] + 1)
+ cmf2 := tab.BlocksegIdxCmf[startIx:]
+ lo := ixJulia - startIx - 1
+ hi := ixJulia - startIx
+ nBits += peEcEncodeBits(cmf2[lo]-cmf2[0], cmf2[hi]-cmf2[0], cmf2[cmfLen]-cmf2[0])
+ }
+
+ blk := int32(pblockseg.Blocks[0])
+ deltaBlk := blk - prevLagblk
+ startSeg := 0
+ lagindsIx := 0
+ if !(prevLagblk > -1 && deltaBlk >= -1 && deltaBlk <= 2) {
+ nBits += 6.0 // uniform first-lag cost
+ prevLagblk = blk
+ prevLagidx = laginds[lagindsIx]
+ lagindsIx += pblockseg.Seglens[0]
+ startSeg = 1
+ }
+ deltaLagCmf := tab.DeltaLagCmfs[mode]
+ for k := startSeg; k < pblockseg.Nblocks; k++ {
+ blk = int32(pblockseg.Blocks[k])
+ idx := laginds[lagindsIx]
+ lagindsIx += pblockseg.Seglens[k]
+ deltaBlk = blk - prevLagblk
+ deltaIdx := idx - prevLagidx
+ prevLagidxMod := prevLagidx - prevLagblk*blocksize
+ deltaRangeStart := -prevLagidxMod + deltaBlk*blocksize
+ cmfBase := int(deltaRangeStart + 2*blocksize - 1)
+ ix := int(deltaIdx - deltaRangeStart)
+ p0 := deltaLagCmf[cmfBase]
+ nBits += peEcEncodeBits(deltaLagCmf[cmfBase+ix]-p0, deltaLagCmf[cmfBase+ix+1]-p0, deltaLagCmf[cmfBase+int(blocksize)]-p0)
+ prevLagblk = blk
+ prevLagidx = idx
+ }
+ return nBits
+}
+
+// peSpectralHarmCached is spectral_harmonicity with a per-survivor cache keyed by harmonic bin.
+func peSpectralHarmCached(avgLag float32, f2w *[SmplFLen]float32, cache []float32, reset bool) float32 {
+ const harmUndef float32 = -10000.0
+ if reset {
+ for i := range cache {
+ cache[i] = harmUndef
+ }
+ }
+ invF2StepHz := 2.0 * float32(SmplFLen-1) / 16000.0
+ harmHz := 16000.0 / avgLag
+ harmIx := int(math.Round(float64(harmHz * 2.0 * invF2StepHz)))
+ if harmIx < 0 || harmIx >= len(cache) {
+ return HarmStrengthAt(avgLag, f2w)
+ }
+ if cache[harmIx] > harmUndef {
+ return cache[harmIx]
+ }
+ hs := HarmStrengthAt(avgLag, f2w)
+ cache[harmIx] = hs
+ return hs
+}
+
+func peGetPrevLagBias(st *PitchEstState, lag float32) float32 {
+ lagDiff := float32(math.Abs(float64(lag - st.PrevLag)))
+ diffThres := pePrevwghtSpan * st.PrevLag
+ if lagDiff < diffThres {
+ return st.PrevPitchCorr * (1.0 - lagDiff/diffThres) * pePrevwght
+ }
+ return 0.0
+}
+
+// SmplPitch is the full pitch estimator. ltpBuf is the perceptually-weighted speech
+// of length MaxLTPBufLen (last PITCH_LOOKAHEAD_LEN samples are lookahead); f2 is the
+// LPC power spectrum; codedAsActiveVoice gates the search. Mutates the predictor in st.
+func SmplPitch(st *PitchEstState, ltpBuf []float32, f2 *[SmplFLen]float32, codedAsActiveVoice bool) PitchResult {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pitch_enc.rs#L848-L1215
+ tab := LoadPitchTables()
+ numsubfrs := NumSubframes
+ l := MaxLTPBufLen
+ look := pePitchLookaheadLen
+
+ if !codedAsActiveVoice {
+ minLag := float32(peMinpitchMs * peFsKhz)
+ st.PrevLag = 0.0
+ st.PrevPitchCorr = 0.0
+ st.PrevLagblk = -1
+ st.PrevLagidx = -1
+ res := PitchResult{Pitchcorr: 0.0, AvgLag: minLag, HarmStrength: 0.0, BlocksegIdx: 0}
+ for i := 0; i < NumSubframes; i++ {
+ res.Lags[i] = minLag
+ }
+ return res
+ }
+
+ offset := peDownsampDelay
+ stage1 := make([]float32, l+offset+64)
+ pePitchHpFilter(ltpBuf, stage1[offset:offset+l])
+ hpLen := l - look
+ ltpBufHp := make([]float32, hpLen)
+ copy(ltpBufHp, stage1[offset:offset+hpLen])
+
+ stage1Ds := make([]float32, (l+offset)/2+8)
+ stage1Len := pePitchDownsample(stage1, l+offset, stage1Ds)
+
+ numlags0 := peNumLagsStage1
+ e1 := make([]float32, numlags0*numsubfrs+16)
+ peCalcE1(e1, stage1Ds, stage1Len, numsubfrs, peMinpitchStage1, peMaxpitchStage1, peLagSubfrlenStage1)
+ e2 := make([]float32, numsubfrs)
+ cap := (2*peFsKhz/peStage1FsKhz)*peNumLagsStage1*numsubfrs + 64
+ c := make([]float32, cap)
+ e := make([]float32, cap)
+ cStage1 := make([]float32, numlags0*numsubfrs)
+ peCalcCE2(cStage1, e2, stage1Ds, stage1Len, numsubfrs)
+ copy(c[:numlags0*numsubfrs], cStage1)
+
+ numlags := numlags0
+ for sf := 0; sf < numsubfrs; sf++ {
+ sqrtE1 := make([]float32, numlags)
+ for i := 0; i < numlags; i++ {
+ sqrtE1[i] = float32(math.Sqrt(float64(e1[sf*numlags+i] + 1e-30)))
+ }
+ sqrtE2 := float32(math.Sqrt(float64(e2[sf] + 1e-30)))
+ for i := 0; i < numlags; i++ {
+ tmp := 0.5 * (sqrtE1[i] + sqrtE2)
+ e[sf*numlags+i] = tmp * tmp
+ }
+ }
+
+ minpitchC := peMinpitchStage1
+ numlagsC := numlags
+ minpitchE := peMinpitchStage1
+ numlagsE := numlags
+ if peLowComplexity {
+ peUpsampEFast(c, numsubfrs, &minpitchC, &numlagsC)
+ } else {
+ peUpsampCFast(c, numsubfrs, &minpitchC, &numlagsC)
+ }
+ peUpsampEFast(e, numsubfrs, &minpitchE, &numlagsE)
+
+ minpitchCoarse := peCoarseFsKhz * peMinpitchMs
+ numlagsCoarse := peNumlagsCoarse
+ offsetC0 := minpitchCoarse - minpitchC
+ offsetE0 := minpitchCoarse - minpitchE
+
+ h := make([]float32, numlagsCoarse*numsubfrs*2+64)
+ for sf := 0; sf < numsubfrs; sf++ {
+ for i := 0; i < numlagsCoarse; i++ {
+ cv := c[sf*numlagsC+offsetC0+i]
+ ev := e[sf*numlagsE+offsetE0+i]
+ h[sf*numlagsCoarse+i] = cv / ev
+ }
+ }
+
+ pitchblockCoarse := pePitchblockMs * peCoarseFsKhz // 32
+ var hblk [NumSubframes][pitchNumBlocks]float32
+ for sf := 0; sf < numsubfrs; sf++ {
+ for block := 0; block < pitchNumBlocks; block++ {
+ base := sf*numlagsCoarse + block*pitchblockCoarse
+ hblk[sf][block] = peMaximum(h[base : base+pitchblockCoarse])
+ }
+ }
+
+ blocksizeFs := pePitchblock * 2 // 64
+ const reductionFactor float32 = 0.7
+ pitchDeltawght := pePitchDeltawght / float32(blocksizeFs)
+ var sfWght [NumSubframes]float32
+ {
+ var sumE2 float32
+ for sf := 0; sf < numsubfrs; sf++ {
+ sumE2 += e2[sf]
+ }
+ for sf := 0; sf < numsubfrs; sf++ {
+ sfWght[sf] = e2[sf] / sumE2
+ }
+ }
+ numBlocktracks := len(tab.Blocktracks)
+ utils := make([]float32, numBlocktracks)
+ for i := 0; i < numBlocktracks; i++ {
+ bt := &tab.Blocktracks[i]
+ var corr float32
+ for sf := 0; sf < numsubfrs; sf++ {
+ corr += hblk[sf][bt.Track[sf]] * sfWght[sf]
+ }
+ shortlagbias1 := (float32(peMaxpitchLen)/((bt.Meanblock+1.5)*float32(pePitchblock)) - 1.0) * pePitchShortwght1
+ utils[i] = 1.0/(1.1-corr) - reductionFactor*float32(pePitchblock)*pitchDeltawght*bt.Trackdeltas + shortlagbias1
+ }
+ trackIdx := peGetMaxiK(utils, peNumstates1)
+
+ e1Fs := make([]float32, numlagsE*numsubfrs+16)
+ peCalcE1(e1Fs, ltpBufHp, l-look, numsubfrs, minpitchE, minpitchE+numlagsE-1, peLagSubfrlen)
+
+ var uniqueblocks [NumSubframes]uint16
+ for _, ti := range trackIdx {
+ track := &tab.Blocktracks[ti].Track
+ for sf := 0; sf < numsubfrs; sf++ {
+ uniqueblocks[sf] |= 1 << uint(track[sf])
+ }
+ }
+
+ var hThres float32
+ if !peLowComplexity {
+ hThres = 0.25
+ }
+ offsetC := peMinpitchMs*peFsKhz - minpitchC
+ offsetE := peMinpitchMs*peFsKhz - minpitchE
+ for sf := 0; sf < numsubfrs; sf++ {
+ var mask uint16 = 1
+ cPtr := offsetC + sf*numlagsC
+ ePtr := offsetE + sf*numlagsE
+ e1Ptr := offsetE + sf*numlagsE
+ hPtr := sf * peNumlagsFs
+ ltpOff := (l - look) + (sf-numsubfrs)*peLagSubfrlen
+ e2sf := maxF32(peDotProd40(ltpBufHp[ltpOff:], ltpBufHp[ltpOff:]), 1e-9)
+ e2[sf] = e2sf
+ sqrtE2 := float32(math.Sqrt(float64(e2sf + 1e-30)))
+ for block := 0; block < pitchNumBlocks; block++ {
+ if uniqueblocks[sf]&mask != 0 {
+ var sqrtE1 [pePitchblock + 1]float32
+ for i := 0; i < pePitchblock+1; i++ {
+ sqrtE1[i] = float32(math.Sqrt(float64(e1Fs[e1Ptr+block*pePitchblock+i] + 1e-30)))
+ }
+ for i := 0; i < pePitchblock+1; i++ {
+ tmp := 0.5 * (sqrtE1[i] + sqrtE2)
+ e[ePtr+block*pePitchblock+i] = 0.5 * tmp * tmp
+ }
+ for i := 0; i < pePitchblock; i++ {
+ if h[hPtr+block*pePitchblock+i] > hThres {
+ lag := peMinpitchLen + block*pePitchblock + i
+ a := ltpBufHp[ltpOff:]
+ b := ltpBufHp[ltpOff-lag:]
+ c[cPtr+block*pePitchblock+i] = 0.5 * peDotProd40(a, b)
+ }
+ }
+ }
+ mask <<= 1
+ }
+ }
+
+ strideC := pitchNumBlocks*2*pePitchblock + offsetC
+ strideE := pitchNumBlocks*2*pePitchblock + offsetE
+ for sf := numsubfrs - 1; sf >= 0; sf-- {
+ cPtr := offsetC + sf*numlagsC
+ cPtrFrac := offsetC + sf*strideC
+ ePtr := offsetE + sf*numlagsE
+ ePtrFrac := offsetE + sf*strideE
+ hPtr := sf * 2 * pePitchblock * pitchNumBlocks
+ var mask uint16 = 1 << uint(pitchNumBlocks-1)
+ for block := pitchNumBlocks - 1; block >= 0; block-- {
+ if uniqueblocks[sf]&mask != 0 {
+ ein := ePtr + block*pePitchblock
+ eout := ePtrFrac + block*2*pePitchblock
+ peUpsampECore(e, ein+pePitchblock-1, eout+2*pePitchblock-1, pePitchblock)
+ cin := cPtr + block*pePitchblock
+ cout := cPtrFrac + block*2*pePitchblock
+ if peLowComplexity {
+ peUpsampECore(c, cin+pePitchblock-1, cout+2*pePitchblock-1, pePitchblock)
+ } else {
+ peUpsampCCore(c, cin+pePitchblock-1, cout+2*pePitchblock-1, pePitchblock)
+ }
+ for i := 0; i < 2*pePitchblock; i++ {
+ h[hPtr+block*2*pePitchblock+i] = c[cout+i] / e[eout+i]
+ }
+ }
+ mask >>= 1
+ }
+ }
+
+ // Fine search.
+ var lagindsSurv [][NumSubframes]int32
+ var blocksegsIxList []int
+ hComb := make([]float32, 2*pePitchblock)
+ lagindCache := make(map[int32]int32)
+ for _, idx := range trackIdx {
+ rng := tab.BlocksegsIx[idx]
+ for j := 0; j < rng[1]; j++ {
+ bsx := rng[0] + j
+ pblockseg := &tab.Blocksegs[bsx]
+ var lagindsRow [NumSubframes]int32
+ startSf := 0
+ for n := 0; n < pblockseg.Nblocks; n++ {
+ lookupKey := (((int32(startSf) << 3) + int32(pblockseg.Seglens[n])) << 4) | int32(pblockseg.Blocks[n])
+ bestI, ok := lagindCache[lookupKey]
+ if !ok {
+ for v := range hComb {
+ hComb[v] = 0.0
+ }
+ for sf := startSf; sf < startSf+pblockseg.Seglens[n]; sf++ {
+ hPtr := sf*2*pePitchblock*pitchNumBlocks + pblockseg.Blocks[n]*2*pePitchblock
+ for i := 0; i < 2*pePitchblock; i++ {
+ hComb[i] += h[hPtr+i] * e2[sf]
+ }
+ }
+ bestI = int32(peGetMaxi(hComb))
+ lagindCache[lookupKey] = bestI
+ }
+ for sf := startSf; sf < startSf+pblockseg.Seglens[n]; sf++ {
+ lagindsRow[sf] = bestI + int32(pblockseg.Blocks[n]*2*pePitchblock)
+ }
+ startSf += pblockseg.Seglens[n]
+ }
+ lagindsSurv = append(lagindsSurv, lagindsRow)
+ blocksegsIxList = append(blocksegsIxList, bsx)
+ }
+ }
+ nlaginds := len(lagindsSurv)
+
+ pitchRatewght := peRatewghtHr
+ if peLowRate {
+ pitchRatewght = 0.028
+ }
+ f2w := BuildF2w(f2)
+ maxIx := peGetMaxi(sfWght[:numsubfrs])
+ spectralHarmCache := make([]float32, 50)
+
+ var bestUtil, bestPitchcorr float32
+ bestSurv := 0
+ pitchDeltawghtFs := pePitchDeltawght / float32(blocksizeFs)
+
+ for surv := 0; surv < nlaginds; surv++ {
+ var sumC, sumE float32
+ for sf := 0; sf < numsubfrs; sf++ {
+ cBase := offsetC + sf*strideC
+ eBase := offsetE + sf*strideE
+ li := int(lagindsSurv[surv][sf])
+ sumC += c[cBase+li]
+ sumE += e[eBase+li]
+ }
+ rateBias := peEncodeLagsBits(tab, blocksegsIxList[surv], &lagindsSurv[surv], st.PrevLagblk, st.PrevLagidx, 1) * pitchRatewght
+ meanLag := float32(lagindsSurv[surv][maxIx])*0.5 + float32(peMinpitchLen)
+ pitchcorr := sumC / sumE
+ firstLag := 0.5*float32(lagindsSurv[surv][0]) + float32(peMinpitchLen)
+ prevLagBias := peGetPrevLagBias(st, firstLag)
+ spectralHarmBias := peSpecHarmBias * peSpectralHarmCached(meanLag, &f2w, spectralHarmCache, surv == 0)
+ util := 1.0/(1.1-pitchcorr) - pitchDeltawghtFs*float32(peSumdeltas(lagindsSurv[surv][:], numsubfrs)) + spectralHarmBias + prevLagBias - rateBias
+ if surv == 0 || util > bestUtil {
+ bestUtil = util
+ bestSurv = surv
+ }
+ if surv == 0 || pitchcorr > bestPitchcorr {
+ bestPitchcorr = pitchcorr
+ }
+ }
+
+ var lags [NumSubframes]float32
+ var lagindsOut [NumSubframes]int32
+ for sf := 0; sf < numsubfrs; sf++ {
+ lags[sf] = float32(lagindsSurv[bestSurv][sf])*0.5 + float32(peMinpitchLen)
+ lagindsOut[sf] = lagindsSurv[bestSurv][sf]
+ }
+ avgLag := float32(lagindsSurv[bestSurv][maxIx])*0.5 + float32(peMinpitchLen)
+ harmStrength := peSpectralHarmCached(avgLag, &f2w, spectralHarmCache, false)
+
+ st.PrevLag = lags[numsubfrs-1]
+ st.PrevPitchCorr = bestPitchcorr
+ st.PrevLagidx = lagindsSurv[bestSurv][numsubfrs-1]
+ st.PrevLagblk = st.PrevLagidx / int32(2*pePitchblock)
+
+ return PitchResult{
+ Pitchcorr: bestPitchcorr,
+ Lags: lags,
+ Laginds: lagindsOut,
+ AvgLag: avgLag,
+ HarmStrength: harmStrength,
+ BlocksegIdx: blocksegsIxList[bestSurv],
+ }
+}
diff --git a/pkg/call/voip/media/mlow/pitch_seed.bin b/pkg/call/voip/media/mlow/pitch_seed.bin
new file mode 100644
index 00000000..a70dfcdd
Binary files /dev/null and b/pkg/call/voip/media/mlow/pitch_seed.bin differ
diff --git a/pkg/call/voip/media/mlow/pitch_seed.go b/pkg/call/voip/media/mlow/pitch_seed.go
new file mode 100644
index 00000000..e4fa493e
--- /dev/null
+++ b/pkg/call/voip/media/mlow/pitch_seed.go
@@ -0,0 +1,256 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "bytes"
+ "compress/zlib"
+ _ "embed"
+ "io"
+)
+
+// Build-from-seed for the MLow pitch runtime tables (port of smpl_pitch_seed.rs).
+// The expanded PitchTables is the expansion of a small packed seed: the blocksegs
+// bitstream (range-decoded), the index maps, and the DCMF arrays (integer CDF
+// expansion). All integer — bit-exact with the reference. Replaces the larger
+// smpl_pitch_tables.json blob.
+
+//go:embed pitch_seed.bin
+var pitchSeedBlob []byte
+
+const (
+ pitchNumBlocksegs = 217
+ pitchNumBlocktracks = 187
+)
+
+// pitchSeed mirrors tables.proto PitchSeed (7 length-delimited byte fields).
+type pitchSeed struct {
+ blocksegsBitstream []byte // range-decoder source
+ blocksegs2idx []byte // [217]
+ blocksegsIx []byte // [187][2]
+ firstblockRange []byte // [9][2]
+ blocksegIdxDcmf []byte // [217]
+ deltaLagDcmfs []byte // [3][319]
+ blockTransitionDcmf []byte // [9][9]
+}
+
+// parseProtoBytes reads the length-delimited (wiretype 2) fields of a protobuf
+// message into field-number → bytes. The pitch/cc seeds are all byte fields.
+func parseProtoBytes(b []byte) map[int][]byte {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_tables_blob.rs#L26-L29
+ out := make(map[int][]byte)
+ i := 0
+ readVarint := func() (uint64, bool) {
+ var v uint64
+ var shift uint
+ for i < len(b) {
+ c := b[i]
+ i++
+ v |= uint64(c&0x7f) << shift
+ if c&0x80 == 0 {
+ return v, true
+ }
+ shift += 7
+ }
+ return 0, false
+ }
+ for i < len(b) {
+ key, ok := readVarint()
+ if !ok {
+ break
+ }
+ field := int(key >> 3)
+ wire := int(key & 7)
+ if wire != 2 {
+ break // seeds are all length-delimited
+ }
+ ln, ok := readVarint()
+ if !ok || i+int(ln) > len(b) {
+ break
+ }
+ out[field] = b[i : i+int(ln)]
+ i += int(ln)
+ }
+ return out
+}
+
+func loadPitchSeed() *pitchSeed {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L15-L31
+ zr, err := zlib.NewReader(bytes.NewReader(pitchSeedBlob))
+ if err != nil {
+ panic("mlow: inflate pitch seed: " + err.Error())
+ }
+ raw, err := io.ReadAll(zr)
+ zr.Close()
+ if err != nil {
+ panic("mlow: read pitch seed: " + err.Error())
+ }
+ f := parseProtoBytes(raw)
+ return &pitchSeed{
+ blocksegsBitstream: f[1],
+ blocksegs2idx: f[2],
+ blocksegsIx: f[3],
+ firstblockRange: f[4],
+ blocksegIdxDcmf: f[5],
+ deltaLagDcmfs: f[6],
+ blockTransitionDcmf: f[7],
+ }
+}
+
+// ecDecodeUniform decodes a uniform symbol in [0, n).
+func ecDecodeUniform(dec *RangeDecoder, n uint32) uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L34-L38
+ v := dec.Decode(n)
+ dec.Update(v, v+1, n)
+ return v
+}
+
+// decodeBlockseg: len = uniform(6)+1, then len pairs of (uniform(9), uniform(4)+1).
+func decodeBlockseg(dec *RangeDecoder) pitchBlockSeg {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L41-L57
+ length := int(ecDecodeUniform(dec, 6) + 1)
+ blocks := make([]int, length)
+ seglens := make([]int, length)
+ for j := 0; j < length; j++ {
+ blocks[j] = int(ecDecodeUniform(dec, 9))
+ seglens[j] = int(ecDecodeUniform(dec, 4) + 1)
+ }
+ return pitchBlockSeg{Nblocks: length, Blocks: blocks, Seglens: seglens}
+}
+
+// genBlocktracks expands each track's blockseg into per-subframe track + mean/deltas.
+func genBlocktracks(blocksegs []pitchBlockSeg, blocksegsIx [][2]int) []pitchBlockTrack {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L60-L86
+ out := make([]pitchBlockTrack, 0, pitchNumBlocktracks)
+ for trackIdx := 0; trackIdx < pitchNumBlocktracks; trackIdx++ {
+ seg := &blocksegs[blocksegsIx[trackIdx][0]]
+ var track [NumSubframes]int
+ segIdx := 0
+ var meanblock, trackdeltas float32
+ for b := 0; b < seg.Nblocks; b++ {
+ for k := 0; k < seg.Seglens[b]; k++ {
+ track[segIdx] = seg.Blocks[b]
+ segIdx++
+ }
+ meanblock += float32(seg.Blocks[b] * seg.Seglens[b])
+ if b != 0 {
+ d := seg.Blocks[b-1] - seg.Blocks[b]
+ if d < 0 {
+ d = -d
+ }
+ trackdeltas += float32(d)
+ }
+ }
+ meanblock /= float32(NumSubframes)
+ out = append(out, pitchBlockTrack{Track: track, Meanblock: meanblock, Trackdeltas: trackdeltas})
+ }
+ return out
+}
+
+// pitchDcmfToCmf is the integer expansion of a DCMF to a cumulative CDF of length len+1.
+func pitchDcmfToCmf(dcmf []byte) []uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L89-L109
+ n := len(dcmf)
+ cmf := make([]uint32, n+1)
+ var sum int64
+ for i := 0; i < n; i++ {
+ tmp := int32(dcmf[i]) + 1
+ tmp *= tmp
+ if tmp > 65535 {
+ tmp = 65535
+ }
+ cmf[i+1] = uint32(tmp)
+ sum += int64(tmp)
+ }
+ cmf[0] = 0
+ for i := 1; i <= n; i++ {
+ prev := int64(cmf[i-1])
+ add := int64(cmf[i])*(32767-int64(n))/sum + 1
+ cmf[i] = uint32(prev + add)
+ }
+ return cmf
+}
+
+func chunkPairs(b []byte) [][2]int {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L119-L128
+ out := make([][2]int, 0, len(b)/2)
+ for i := 0; i+1 < len(b); i += 2 {
+ out = append(out, [2]int{int(b[i]), int(b[i+1])})
+ }
+ return out
+}
+
+// contourWindowParts is the pitch lag/contour heap window built from the same seed
+// (port of smpl_pitch_seed.rs ContourWindowParts) — the tables Group D's pointer
+// chase reads, laid out by mem.go at the fixed WASM addresses.
+type contourWindowParts struct {
+ records [][2][]int // per contour: (blocks, seglens)
+ contourMap []byte // == blocksegs2idx
+ firstblockRange [][2]int
+ lagCdf []uint32 // dcmf_to_cmf(blockseg_idx_dcmf), 218
+ fracCmfs [][]uint32 // 3 × 320
+ deltaCmfs [][]uint32 // 9 × 10
+}
+
+// buildContourWindow re-decodes the blocksegs and expands the index maps + DCMFs.
+func buildContourWindow() *contourWindowParts {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L178-L209
+ s := loadPitchSeed()
+ dec := NewRangeDecoder(s.blocksegsBitstream)
+ records := make([][2][]int, 0, pitchNumBlocksegs)
+ for i := 0; i < pitchNumBlocksegs; i++ {
+ bs := decodeBlockseg(dec)
+ records = append(records, [2][]int{bs.Blocks, bs.Seglens})
+ }
+ w := &contourWindowParts{
+ records: records,
+ contourMap: s.blocksegs2idx,
+ firstblockRange: chunkPairs(s.firstblockRange),
+ lagCdf: pitchDcmfToCmf(s.blocksegIdxDcmf),
+ }
+ for i := 0; i+319 <= len(s.deltaLagDcmfs); i += 319 {
+ w.fracCmfs = append(w.fracCmfs, pitchDcmfToCmf(s.deltaLagDcmfs[i:i+319]))
+ }
+ for i := 0; i+pitchNumBlocks <= len(s.blockTransitionDcmf); i += pitchNumBlocks {
+ w.deltaCmfs = append(w.deltaCmfs, pitchDcmfToCmf(s.blockTransitionDcmf[i:i+pitchNumBlocks]))
+ }
+ return w
+}
+
+// buildPitchTablesFromSeed expands the embedded seed into the full PitchTables.
+func buildPitchTablesFromSeed() *PitchTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_pitch_seed.rs#L111-L157
+ s := loadPitchSeed()
+ dec := NewRangeDecoder(s.blocksegsBitstream)
+ blocksegs := make([]pitchBlockSeg, 0, pitchNumBlocksegs)
+ for i := 0; i < pitchNumBlocksegs; i++ {
+ blocksegs = append(blocksegs, decodeBlockseg(dec))
+ }
+ blocksegsIx := chunkPairs(s.blocksegsIx)
+ firstblockRange := chunkPairs(s.firstblockRange)
+ blocktracks := genBlocktracks(blocksegs, blocksegsIx)
+
+ blocksegs2idx := make([]int, len(s.blocksegs2idx))
+ for i, x := range s.blocksegs2idx {
+ blocksegs2idx[i] = int(x)
+ }
+ blocksegIdxCmf := pitchDcmfToCmf(s.blocksegIdxDcmf)
+ deltaLagCmfs := make([][]uint32, 0, 3)
+ for i := 0; i+319 <= len(s.deltaLagDcmfs); i += 319 {
+ deltaLagCmfs = append(deltaLagCmfs, pitchDcmfToCmf(s.deltaLagDcmfs[i:i+319]))
+ }
+ blockTransitionCmf := make([][]uint32, 0, pitchNumBlocks)
+ for i := 0; i+pitchNumBlocks <= len(s.blockTransitionDcmf); i += pitchNumBlocks {
+ blockTransitionCmf = append(blockTransitionCmf, pitchDcmfToCmf(s.blockTransitionDcmf[i:i+pitchNumBlocks]))
+ }
+
+ return &PitchTables{
+ Blocksegs: blocksegs,
+ Blocktracks: blocktracks,
+ Blocksegs2idx: blocksegs2idx,
+ BlocksegIdxCmf: blocksegIdxCmf,
+ DeltaLagCmfs: deltaLagCmfs,
+ BlocksegsIx: blocksegsIx,
+ FirstblockRange: firstblockRange,
+ BlockTransitionCmf: blockTransitionCmf,
+ }
+}
diff --git a/pkg/call/voip/media/mlow/postfilter.go b/pkg/call/voip/media/mlow/postfilter.go
new file mode 100644
index 00000000..897d37c8
--- /dev/null
+++ b/pkg/call/voip/media/mlow/postfilter.go
@@ -0,0 +1,534 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "math"
+ "sync"
+)
+
+// Postfilters: the excitation-domain harmonic comb (func 3524), the post-LPC HP
+// pitch-harmonic comb, and the per-packet harmonic postfilter. Validated
+// end-to-end and via the hp/harm postfilter raw vectors when implemented.
+
+// --- excitation-domain harmonic comb (WASM func 3524) ---
+
+// SmplPostfilterState is the persistent comb-postfilter state (pitch gain, env,
+// biquad/de-emphasis/resonator FIR state, smoothed autocorrelation, init/count/LCG).
+type SmplPostfilterState struct {
+ EnvState float32
+}
+
+// SmplCombPostfilter computes the n-sample contribution the caller ADDS into the
+// excitation.
+func SmplCombPostfilter(st *SmplPostfilterState, input []float32, n int, active bool, gain8 float32, nrgEnv [2]float32, out []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_postfilter.rs#L249-L443
+ // TODO
+ // agent suggestion: port smpl_comb_postfilter — per-subframe autocorrelation →
+ // resonator, de-emphasis FIR, env-shaped noise (LCG) add; carries biquad state.
+ // human input:
+ panic("mlow: SmplCombPostfilter not yet implemented (scaffold)")
+}
+
+// --- post-LPC HP (pitch-harmonic) comb ---
+
+var loEmph = [2]float32{1.0, -0.995}
+
+const (
+ hpPitchMAF = float32(0.1)
+ hpDefMAF = float32(0.1)
+ hpDefFcornerHz = float32(50.0)
+ lagChangeThreshold = float32(1.25)
+ hpPostfTransitionSpeed = float32(2.0)
+)
+
+var (
+ hpPitchARF = [2]float32{0.608057355, 0.070939485}
+ hpPitchARR = [2]float32{-2.187380512, 2.291030664}
+ hpDefARF = [2]float32{0.728508218, 0.476039848}
+ hpDefARR = [2]float32{-4.363803713, 8.441854006}
+)
+
+// HpPostfilterState is the post-LPC HP comb state (C HpPst). lagOld < 0 marks a
+// fresh/reset filter.
+type HpPostfilterState struct {
+ stateLoEmph1 float32
+ stateLoEmph2 float32
+ stateHp [4]float32 // [ma2 x[-1], x[-2], ar2 y[-1], y[-2]]
+ lagOld float32
+ xOld []float32
+ coefMA [3]float32
+ coefAR [3]float32
+}
+
+// NewHpPostfilterState allocates a fresh HP-postfilter state.
+func NewHpPostfilterState() *HpPostfilterState {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L46-L58
+ return &HpPostfilterState{lagOld: -1.0, xOld: make([]float32, SmplIntfLen)}
+}
+
+func cosApprox(x float32) float32 { return 1.0 - 0.5*x*x }
+
+// SmplPfFir3 is the 3-tap FIR with carried 2-sample input history (smpl_filt_ma2 general).
+func SmplPfFir3(input []float32, n int, coef [3]float32, state *[2]float32, out []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L68-L95
+ xm1 := state[0]
+ xm2 := state[1]
+ for i := 0; i < n; i++ {
+ var p1, p2 float32
+ if i >= 1 {
+ p1 = input[i-1]
+ } else {
+ p1 = xm1
+ }
+ if i >= 2 {
+ p2 = input[i-2]
+ } else if i == 1 {
+ p2 = xm1
+ } else {
+ p2 = xm2
+ }
+ out[i] = coef[0]*input[i] + coef[1]*p1 + coef[2]*p2
+ }
+ if n >= 2 {
+ state[0] = input[n-1]
+ state[1] = input[n-2]
+ } else if n == 1 {
+ state[1] = xm1
+ state[0] = input[0]
+ }
+}
+
+// pfFiltAR2: y[n] = in[n] - c1*y[n-1] - c2*y[n-2] (monic), 4-wide unrolled to match C rounding.
+func pfFiltAR2(input []float32, n int, c1, c2 float32, state *[2]float32, out []float32) {
+ ytmp0 := state[1]
+ ytmp1 := state[0]
+ ar1 := -c1
+ ar2 := -c2
+ ar1_2 := ar1 * ar1
+ ar1_3 := ar1 * ar1_2
+ ar1_4 := ar1 * ar1_3
+ imp1 := ar1
+ imp2 := ar1_2 + ar2
+ imp3 := ar1_3 + 2.0*ar1*ar2
+ imp4 := ar1_4 + ar2*ar2 + 3.0*ar1_2*ar2
+ ymp1 := ar2
+ ymp2 := ar2 * imp1
+ ymp3 := ar2 * imp2
+ ymp4 := ar2 * imp3
+ nn := 0
+ for nn+3 < n {
+ xtmp0 := input[nn]
+ xtmp1 := input[nn+1]
+ xtmp2 := input[nn+2]
+ out[nn+2] = xtmp2 + imp1*xtmp1 + imp2*xtmp0 + imp3*ytmp1 + ymp3*ytmp0
+ xtmp3 := input[nn+3]
+ out[nn+3] = xtmp3 + imp1*xtmp2 + imp2*xtmp1 + imp3*xtmp0 + imp4*ytmp1 + ymp4*ytmp0
+ out[nn] = xtmp0 + imp1*ytmp1 + ymp1*ytmp0
+ out[nn+1] = xtmp1 + imp1*xtmp0 + imp2*ytmp1 + ymp2*ytmp0
+ ytmp0 = out[nn+2]
+ ytmp1 = out[nn+3]
+ nn += 4
+ }
+ for nn < n {
+ out[nn] = input[nn] + ar1*ytmp1 + ar2*ytmp0
+ ytmp0 = ytmp1
+ ytmp1 = out[nn]
+ nn++
+ }
+ state[1] = ytmp0
+ state[0] = ytmp1
+}
+
+// pfFiltAR1: leaky integrator y[n] = x[n] - c1*y[n-1], 5-wide unrolled to match C rounding.
+func pfFiltAR1(input []float32, n int, c1 float32, state *float32, out []float32) {
+ ar1 := -c1
+ ar1_2 := ar1 * ar1
+ ar1_3 := ar1 * ar1_2
+ ar1_4 := ar1 * ar1_3
+ ar1_5 := ar1 * ar1_4
+ ytmp := *state
+ nn := 0
+ for nn+4 < n {
+ xtmp0 := input[nn]
+ xtmp1 := input[nn+1]
+ xtmp2 := input[nn+2]
+ xtmp3 := input[nn+3]
+ xtmp4 := input[nn+4]
+ out[nn+4] = xtmp4 + ar1*xtmp3 + ar1_2*xtmp2 + ar1_3*xtmp1 + ar1_4*xtmp0 + ar1_5*ytmp
+ out[nn] = xtmp0 + ar1*ytmp
+ out[nn+1] = xtmp1 + ar1*xtmp0 + ar1_2*ytmp
+ out[nn+2] = xtmp2 + ar1*xtmp1 + ar1_2*xtmp0 + ar1_3*ytmp
+ out[nn+3] = xtmp3 + ar1*xtmp2 + ar1_2*xtmp1 + ar1_3*xtmp0 + ar1_4*ytmp
+ ytmp = out[nn+4]
+ nn += 5
+ }
+ for nn < n {
+ ytmp = input[nn] + ytmp*ar1
+ out[nn] = ytmp
+ nn++
+ }
+ *state = ytmp
+}
+
+// pfFiltMA1: y[n] = x[n] + c1*x[n-1] (companion pre-emphasis).
+func pfFiltMA1(input []float32, n int, c1 float32, state *float32, out []float32) {
+ prev := *state
+ for i := n - 1; i >= 1; i-- {
+ out[i] = input[i] + c1*input[i-1]
+ }
+ if n > 0 {
+ out[0] = input[0] + c1*prev
+ *state = input[n-1]
+ }
+}
+
+// SmplGetHpCoefs returns the default fixed-corner ARMA2 biquad (coefMA, coefAR).
+func SmplGetHpCoefs(fcornerHz float32) (coefMA, coefAR [3]float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L188-L191
+ fc := fcornerHz
+ if fc < 5.0 {
+ fc = 5.0
+ }
+ if fc > 1500.0 {
+ fc = 1500.0
+ }
+ return smplCalcHPCoefs(hpDefMAF, hpDefARF, hpDefARR, fc/16000.0)
+}
+
+// SmplFiltArma2: MA2 numerator then AR2 denominator, shared 4-wide state.
+func SmplFiltArma2(input []float32, n int, coefMA, coefAR [3]float32, state *[4]float32, out []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L194-L211
+ tmp := make([]float32, n)
+ maSt := [2]float32{state[0], state[1]}
+ SmplPfFir3(input, n, coefMA, &maSt, tmp)
+ state[0] = maSt[0]
+ state[1] = maSt[1]
+ arSt := [2]float32{state[2], state[3]}
+ pfFiltAR2(tmp, n, coefAR[1], coefAR[2], &arSt, out)
+ state[2] = arSt[0]
+ state[3] = arSt[1]
+}
+
+// smplCalcHPCoefs builds the unity-DC comb biquad: AR resonance at the pitch angle
+// 2*pi*arf*f with radius 1+arr*f, then MA scaled for unity DC gain.
+func smplCalcHPCoefs(maf float32, arf, arr [2]float32, f float32) (coefMA, coefAR [3]float32) {
+ coefMA = [3]float32{1.0, -2.0 * cosApprox(2.0*smplPiF32*maf*f), 1.0}
+ far := arf[0]*f + arf[1]*f*f
+ rar := arr[0]*f + arr[1]*f*f
+ coefAR = [3]float32{
+ 1.0,
+ -2.0 * cosApprox(2.0*smplPiF32*far) * (1.0 + rar),
+ 1.0 + (2.0*rar + rar*rar),
+ }
+ sc := (1.0 - coefAR[1] + coefAR[2]) / (1.0 - coefMA[1] + coefMA[2])
+ coefMA[0] *= sc
+ coefMA[1] *= sc
+ coefMA[2] *= sc
+ return coefMA, coefAR
+}
+
+// newCoefs: voiced pitch curve when lag>0 (f=1/lag), else the default 50 Hz curve.
+func newCoefs(st *HpPostfilterState, lag float32) {
+ if lag > 0.0 {
+ st.coefMA, st.coefAR = smplCalcHPCoefs(hpPitchMAF, hpPitchARF, hpPitchARR, 1.0/lag)
+ } else {
+ fc := hpDefFcornerHz // already in [5,1500]
+ st.coefMA, st.coefAR = smplCalcHPCoefs(hpDefMAF, hpDefARF, hpDefARR, fc/16000.0)
+ }
+}
+
+// rampDn is the cos(omega)^2 down-ramp for the lag-change overlap-add.
+func rampDn() []float32 {
+ rampDnOnce.Do(func() {
+ dOmega := smplPiF32 / (2.0 * (float32(SmplIntfLen) + 1.0))
+ omega := dOmega
+ rampDnTab = make([]float32, SmplIntfLen)
+ for i := 0; i < SmplIntfLen; i++ {
+ rampDnTab[i] = float32(math.Pow(float64(float32(math.Cos(float64(omega)))), float64(hpPostfTransitionSpeed)))
+ omega += dOmega
+ }
+ })
+ return rampDnTab
+}
+
+var (
+ rampDnOnce sync.Once
+ rampDnTab []float32
+)
+
+// SmplHpPostfilter applies the post-LPC HP comb; lag is the frame's average pitch
+// lag (sum(l^2)/sum(l)), 0 for unvoiced.
+func SmplHpPostfilter(st *HpPostfilterState, xIn []float32, n int, lag float32, out []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harmcomb.rs#L265-L314
+ x := make([]float32, n)
+ pfFiltAR1(xIn, n, loEmph[1], &st.stateLoEmph1, x)
+
+ overlap := false
+ yOld := make([]float32, n)
+ if st.lagOld < 0.0 {
+ newCoefs(st, lag)
+ st.lagOld = lag
+ } else if lag > lagChangeThreshold*st.lagOld || lagChangeThreshold*lag < st.lagOld {
+ overlap = true
+ SmplFiltArma2(x, n, st.coefMA, st.coefAR, &st.stateHp, yOld)
+ newCoefs(st, lag)
+ st.lagOld = lag
+ xOld := append([]float32(nil), st.xOld...)
+ dummy := make([]float32, n)
+ SmplFiltArma2(xOld, n, st.coefMA, st.coefAR, &st.stateHp, dummy)
+ } else if lag != st.lagOld {
+ newCoefs(st, lag)
+ st.lagOld = lag
+ }
+ copy(st.xOld[:n], x[:n])
+
+ yTmp := make([]float32, n)
+ SmplFiltArma2(x, n, st.coefMA, st.coefAR, &st.stateHp, yTmp)
+
+ if overlap {
+ ramp := rampDn()
+ for i := 0; i < n; i++ {
+ yTmp[i] += (yOld[i] - yTmp[i]) * ramp[i]
+ }
+ }
+
+ pfFiltMA1(yTmp, n, loEmph[1], &st.stateLoEmph2, out)
+}
+
+// --- per-packet harmonic postfilter (smpl_harm_postfilter.c) ---
+
+const (
+ harmMaxFramesPerPacket = 6
+ harmMinPitchLag = 32
+ harmMaxPitchLag = 320
+ harmMaxpitchLen = 320
+ harmFBDelay = 8
+ harmLagSubfrLen = 40
+ harmDelay = 40 // = LAG_SUBFR_LEN
+ harmPitchNumSubframes = 8
+ harmFBStrength = float32(0.4734)
+ harmStrength = float32(0.6438)
+ harmCutoffHz = float32(4000.0)
+ harmNHarmCutoff = float32(6.3)
+ harmReductionFac = float32(0.0579)
+ harmLPFiltRes = 2500
+ harmStateCombLen = harmMaxpitchLen + SmplIntfLen*harmMaxFramesPerPacket + harmDelay
+ harmNumLPFilt = harmLPFiltRes/80 - harmLPFiltRes/harmMaxPitchLag + 1
+)
+
+func lagToFiltIx(lag int32) int {
+ d := lag + 30
+ if d < 80 {
+ d = 80
+ }
+ return int(int32(harmLPFiltRes)/d - int32(harmLPFiltRes)/int32(harmMaxPitchLag))
+}
+
+type harmTablesT struct {
+ lpFilters [][2*harmFBDelay + 1]float32
+}
+
+var (
+ harmTablesOnce sync.Once
+ harmTablesV harmTablesT
+)
+
+func harmTables() *harmTablesT {
+ harmTablesOnce.Do(func() {
+ var filtWin [harmFBDelay]float32
+ dOmega := (0.5 * smplPiF32) / (float32(harmFBDelay) + 1.0)
+ omega := dOmega
+ for i := 0; i < harmFBDelay; i++ {
+ filtWin[i] = float32(math.Cos(float64(omega))) / (float32(i) + 1.0)
+ omega += dOmega
+ }
+ harmTablesV.lpFilters = make([][2*harmFBDelay + 1]float32, harmNumLPFilt)
+ ixPrev := int32(-1)
+ for lag := int32(harmMinPitchLag); lag <= harmMaxPitchLag; lag++ {
+ ix := int32(lagToFiltIx(lag))
+ if ix != ixPrev {
+ harmCreateLPFilter(2.0*smplPiF32/float32(lag), &filtWin, &harmTablesV.lpFilters[ix])
+ ixPrev = ix
+ }
+ }
+ })
+ return &harmTablesV
+}
+
+func harmCreateLPFilter(omega0 float32, filtWin *[harmFBDelay]float32, blp *[2*harmFBDelay + 1]float32) {
+ omegaC := omega0 * harmNHarmCutoff
+ if lim := harmCutoffHz / 16000.0 * smplPiF32; lim < omegaC {
+ omegaC = lim
+ }
+ var sumB float32
+ omegaCSum := omegaC
+ for i := 0; i < harmFBDelay; i++ {
+ b := filtWin[i] * float32(math.Sin(float64(omegaCSum)))
+ omegaCSum += omegaC
+ blp[harmFBDelay+i+1] = b
+ blp[harmFBDelay-i-1] = b
+ sumB += 2.0 * b
+ }
+ blp[harmFBDelay] = omegaC
+ sumB += omegaC
+ sc := 1.0 / sumB
+ for k := range blp {
+ blp[k] *= sc
+ }
+}
+
+// HarmPostfilterState is the per-packet harmonic postfilter state (C HarmPst).
+type HarmPostfilterState struct {
+ state1 [2 * harmFBDelay]float32
+ lpcoefs [2*harmFBDelay + 1]float32
+ stateComb []float32
+ prevLag int32
+ prevDidFilter int32
+}
+
+// NewHarmPostfilterState allocates a fresh harmonic-postfilter state.
+func NewHarmPostfilterState() *HarmPostfilterState {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harm_postfilter.rs#L102-L112
+ return &HarmPostfilterState{stateComb: make([]float32, harmStateCombLen)}
+}
+
+func harmDotProd(a, b []float32, l int) float32 {
+ var r float32
+ for i := 0; i < l; i++ {
+ r += a[i] * b[i]
+ }
+ return r
+}
+
+func harmNrg(x []float32, n int) float32 {
+ var r float32
+ for i := 0; i < n; i++ {
+ r += x[i] * x[i]
+ }
+ return r
+}
+
+// harmFiltMA16Sym: 17-tap symmetric MA reading 16 samples of history from buf[xBase-16..].
+func harmFiltMA16Sym(buf []float32, xBase, n int, coef *[17]float32, out []float32) {
+ for nn := 0; nn < n; nn++ {
+ c := xBase + nn
+ res := buf[c-8] * coef[8]
+ for i := 0; i < 8; i++ {
+ res += coef[i] * (buf[c-i] + buf[c-16+i])
+ }
+ out[nn] = res
+ }
+}
+
+// harmPostfilterCore filters one 40-sample lag block (harm_postfilter_core).
+func harmPostfilterCore(lpcoefs *[2*harmFBDelay + 1]float32, comb []float32, combX int, futureSamples int32, lag int32, diff []float32, diffBase int, out []float32, outOff, l int, fbStrength float32, prevDidFilter *int32) {
+ tables := harmTables()
+ lagU := int(lag)
+ var xy float32
+ if lag > 0 {
+ lookforward := int32(l) + lag - futureSamples
+ if lookforward > 0 {
+ l2 := int(int32(l) - lookforward)
+ if l2 < 0 {
+ l2 = 0
+ }
+ for i := 0; i < l2; i++ {
+ out[outOff+i] = comb[combX+i-lagU] + comb[combX+i+lagU]
+ }
+ for i := 0; i < l-l2; i++ {
+ out[outOff+l2+i] = comb[combX+l2+i-lagU] + comb[combX+l2+i]
+ }
+ } else {
+ for i := 0; i < l; i++ {
+ out[outOff+i] = comb[combX+i-lagU] + comb[combX+i+lagU]
+ }
+ }
+ xy = harmDotProd(comb[combX:], out[outOff:], l)
+ }
+ if lag > 0 && xy > 0.0 {
+ xx := harmNrg(comb[combX:], l)
+ yy := 0.25 * harmNrg(out[outOff:], l)
+ denom := yy
+ if xx > denom {
+ denom = xx
+ }
+ strength := 0.5 * xy / denom
+ highLagReduction := 1.0 - harmReductionFac*(float32(lag-harmMinPitchLag)/float32(harmMaxPitchLag-harmMinPitchLag))
+ strength = strength * highLagReduction * harmStrength
+ for i := 0; i < l; i++ {
+ out[outOff+i] *= 0.5 * strength
+ }
+ for i := 0; i < l; i++ {
+ diff[diffBase+i] = out[outOff+i] + (-strength)*comb[combX+i]
+ }
+ kernel := tables.lpFilters[lagToFiltIx(lag)]
+ for k := 0; k < 2*harmFBDelay+1; k++ {
+ lpcoefs[k] = kernel[k] * fbStrength
+ }
+ coef17 := *lpcoefs
+ var yh [harmLagSubfrLen]float32
+ harmFiltMA16Sym(diff, diffBase, l, &coef17, yh[:])
+ for i := 0; i < l; i++ {
+ out[outOff+i] = yh[i] + comb[combX-harmFBDelay+i]
+ }
+ *prevDidFilter = 1
+ } else {
+ for i := 0; i < harmLagSubfrLen; i++ {
+ diff[diffBase+i] = 0.0
+ }
+ if *prevDidFilter != 0 {
+ coef17 := *lpcoefs
+ var yh [2 * harmFBDelay]float32
+ harmFiltMA16Sym(diff, diffBase, 2*harmFBDelay, &coef17, yh[:])
+ for i := 0; i < 2*harmFBDelay; i++ {
+ out[outOff+i] = yh[i] + comb[combX-harmFBDelay+i]
+ }
+ for i := 2 * harmFBDelay; i < l; i++ {
+ out[outOff+i] = comb[combX+harmFBDelay+i-2*harmFBDelay]
+ }
+ } else {
+ for i := 0; i < l; i++ {
+ out[outOff+i] = comb[combX-harmFBDelay+i]
+ }
+ }
+ *prevDidFilter = 0
+ }
+}
+
+// SmplHarmPostfilter applies the harmonic postfilter to a full packet IN PLACE. x is
+// xLen samples; lags are the per-40-block lags (nLags = packetlen/40);
+// normalizedBitrate is the packet average.
+func SmplHarmPostfilter(st *HarmPostfilterState, x []float32, xLen int, lags []float32, nLags int, normalizedBitrate float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_harm_postfilter.rs#L242-L299
+ const diffPrefix = 2 * harmFBDelay // 16 samples of history prefix
+ diff := make([]float32, SmplIntfLen+diffPrefix)
+
+ lag := st.prevLag
+ combCur := harmMaxPitchLag + harmDelay // current packet starts here
+ copy(st.stateComb[combCur:combCur+xLen], x[:xLen])
+
+ fbStrength := 1.0 - harmFBStrength*normalizedBitrate
+ offset1 := 0
+ lagCtr := 0
+ for lagCtr < nLags {
+ offset2 := 0
+ copy(diff[diffPrefix-16:diffPrefix], st.state1[:])
+ lagCtrEnd := lagCtr + harmPitchNumSubframes
+ if lagCtrEnd > nLags {
+ lagCtrEnd = nLags
+ }
+ for lagCtr < lagCtrEnd {
+ combX := harmMaxPitchLag + offset1
+ futureSamples := int32(harmDelay) + int32(xLen) - int32(offset1)
+ harmPostfilterCore(&st.lpcoefs, st.stateComb, combX, futureSamples, lag, diff, diffPrefix+offset2, x, offset1, harmLagSubfrLen, fbStrength, &st.prevDidFilter)
+ offset1 += harmLagSubfrLen
+ offset2 += harmLagSubfrLen
+ lag = int32(math.Round(float64(lags[lagCtr])))
+ lagCtr++
+ }
+ copy(st.state1[:], diff[diffPrefix+offset2-16:diffPrefix+offset2])
+ }
+
+ st.prevLag = lag
+ copy(st.stateComb[0:combCur], st.stateComb[xLen:xLen+combCur])
+}
diff --git a/pkg/call/voip/media/mlow/pulse.go b/pkg/call/voip/media/mlow/pulse.go
new file mode 100644
index 00000000..f0f11132
--- /dev/null
+++ b/pkg/call/voip/media/mlow/pulse.go
@@ -0,0 +1,234 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+// Excitation pulse decode (PVQ-style) for one internal frame: the total pulse
+// count, the recursive split across subframes, the per-position magnitudes, and the
+// signs — read straight from the range-coded bitstream against the heap-window ROM.
+
+// smplPulseCountByte is the static gain-helper table at rodata 0xe8990, indexed by
+// [config*3 + (p4+s1)]. Verbatim from the reference.
+var smplPulseCountByte = [8]uint8{80, 160, 160, 16, 32, 32, 0, 0}
+
+// Mem8Static reads the one static rodata table the pulse path needs (0xe8990..0xe8998);
+// every other address reads as 0.
+func Mem8Static(addr uint32) byte {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pulse.rs#L13-L19
+ if addr >= 0xe8990 && addr < 0xe8998 {
+ return smplPulseCountByte[addr-0xe8990]
+ }
+ return 0
+}
+
+// SmplPulseResult is the decoded excitation for one internal (20 ms) frame.
+type SmplPulseResult struct {
+ Pulses []int32 // signed pulse magnitudes per sample position (len = p2)
+ Subfr [4]int32 // per-subframe pulse counts
+ // Raw entropy symbols (for the encoder to replay byte-exactly): the per-position
+ // run-length magnitude symbols and the batched raw sign symbols, in read order.
+ MagRuns []int32
+ SignSyms []SmplRawSym
+}
+
+// DecodeSmplPulses decodes the pulse blocks of one internal frame. p2 = frame
+// samples (320), p3 = num subframes (4), p4 = regular flag (1), p6 = config (0/1),
+// s1 = LSF stage-1 selector.
+func DecodeSmplPulses(dec *RangeDecoder, _ *SmplMem, p2, p3, p4, p6, s1 int32) SmplPulseResult {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pulse.rs#L29-L206
+ n := p2
+ if n < 0 {
+ n = 0
+ }
+ res := SmplPulseResult{Pulses: make([]int32, n)}
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_pulse.rs#L26-L185 (seed cc-table rewire: count/split/runlen from CcTables)
+ cc := LoadCcTables()
+
+ idx := p4 + s1
+ bByte := int32(Mem8Static(0xe8990 + uint32(p6*3+idx)))
+ frameLen4k := bByte * p2 / 320
+ // ASSUMPTION: p3 (subframe count) is nonzero — always 4 on the 1:1 decode path.
+ // p3==0 divides by zero exactly as the reference does (a malformed-frame crash);
+ // we don't add a guard the reference lacks, to stay bit-faithful.
+ subfrLen16 := frameLen4k / p3
+ posPerSubfr := p2 / p3
+
+ // --- pulse COUNT ---
+ var total int32
+ if p6 != 0 {
+ // WB low-rate: the pulse-count CDF for this voicing class.
+ total = dec.DecodeCDF(cc.NPulseCount(idx))
+ } else {
+ // NB (config=0, our path): a TRIANGULAR prior over [0, frame_len4k].
+ l := uint32(frameLen4k)
+ triT := func(k uint32) uint32 {
+ a := (k + 2) * (l + 1)
+ b := ((k - 1) * (k + 131070)) >> 1
+ return (a - b) & 0xffff
+ }
+ ft := triT(l)
+ if ft == 0 {
+ ft = 1
+ }
+ val := dec.Decode(ft)
+ limit := uint32(frameLen4k) + 1
+ var prevCum uint32
+ var k uint32
+ for {
+ if k == limit {
+ break
+ }
+ cum := triT(k)
+ // found when prevCum <= val < cum (the cumulative-triangular interval).
+ if prevCum <= val && val < cum {
+ dec.Update(prevCum, cum, ft)
+ break
+ }
+ prevCum = cum
+ k++
+ }
+ total = int32(k)
+ }
+
+ // --- recursive binary SPLIT (p3==4 path) ---
+ var split [8]int32
+ if total != 0 {
+ sum := total - subfrLen16*2
+ if sum < 0 {
+ sum = 0
+ }
+ lo := total - 80
+ if lo < 0 {
+ lo = 0
+ }
+ if sum < lo {
+ // min_split2 >= min_split assert path; treat as parse error (zeroed subframes).
+ return res
+ }
+ hiBound := total - lo
+ if sum < hiBound {
+ // window the split CDF at (sum - lo); n entries from the table base.
+ sum += dec.DecodeCDF(cdfWindow(cc.SplitCmf(total), int(sum-lo), int((hiBound-sum)+2)))
+ }
+ if sum > 0 {
+ s0 := smplSplit3537(dec, cc, sum, subfrLen16)
+ split[0] = s0
+ split[1] = sum - s0
+ }
+ if sum < total {
+ s2 := smplSplit3537(dec, cc, total-sum, subfrLen16)
+ split[2] = s2
+ split[3] = (total - sum) - s2
+ }
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/543302e762ef36913b3e2fdf7f84510c43265272/wacore/src/voip/mlow/smpl_pulse.rs#L109-L113 (upstream corrupt-split guard)
+ // C smpl_pulse_coding zeroes the whole split (and n_pulses) on a corrupt -1
+ // from either half, rather than copying the sentinel into res.Subfr.
+ if split[0] == -1 || split[2] == -1 {
+ split = [8]int32{}
+ }
+ }
+
+ take := p3
+ if take < 0 {
+ take = 0
+ }
+ if take > 4 {
+ take = 4
+ }
+ copy(res.Subfr[:take], split[:take])
+
+ // --- MAGNITUDE block: per-subframe run-length pulse positions ---
+ posPer := posPerSubfr
+ var posList []int32
+ var magList []int32
+ pulseIdx := int32(-1)
+ for subfr := int32(0); subfr < p3; subfr++ {
+ cnt := split[subfr]
+ if cnt <= 0 {
+ continue
+ }
+ basePos := posPer * subfr
+ runPos := basePos
+ pos := posPer
+ c := cnt
+ k := int32(0)
+ for k < cnt {
+ if pos < 0 {
+ break // defensive: malformed frame must not drive a huge CDF length
+ }
+ oct := (pos + 7) / 8
+ // window the c-pulses run-length CDF by (max_samples - pos), reading pos+1 entries.
+ bucket := cc.Runlen(oct)
+ start := int(bucket.MaxSamples() - pos)
+ m := dec.DecodeCDF(cdfWindow(bucket.Cmf(c), start, int(pos+1)))
+ res.MagRuns = append(res.MagRuns, m)
+ if m > 0 || k == 0 {
+ pulseIdx++
+ runPos += m
+ posList = append(posList, runPos)
+ magList = append(magList, 1)
+ pos -= m
+ } else if pulseIdx >= 0 {
+ magList[pulseIdx]++
+ }
+ c--
+ k++
+ }
+ }
+
+ numPos := pulseIdx + 1
+
+ // --- SIGN block: batched uniform sign reads (1 bit per position) ---
+ if numPos > 0 {
+ p := int32(0)
+ for p <= pulseIdx {
+ nbits := numPos - p
+ if nbits >= 15 {
+ nbits = 15
+ }
+ if nbits <= 0 {
+ break
+ }
+ sym := dec.DecodeRawSymbol(uint32(nbits))
+ res.SignSyms = append(res.SignSyms, SmplRawSym{Sym: sym, Nbits: uint32(nbits)})
+ bitfield := sym << uint32(16-nbits)
+ end := p + nbits
+ for q := p; q < end; q++ {
+ sign := int32((bitfield>>14)&2) - 1 // +1 if MSB set else -1
+ magList[q] *= sign
+ bitfield <<= 1
+ }
+ p = end
+ }
+ }
+
+ // scatter signed magnitudes into the pulse vector at their absolute positions.
+ for i := int32(0); i < numPos; i++ {
+ pp := posList[i]
+ if pp >= 0 && int(pp) < len(res.Pulses) {
+ res.Pulses[pp] = magList[i]
+ }
+ }
+ return res
+}
+
+// smplSplit3537 splits count pulses across a range, returning the count assigned to
+// the first half (func 3537). The split CDF now comes from the seed-built CcTables.
+func smplSplit3537(dec *RangeDecoder, cc *CcTables, count, granularity int32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_pulse.rs#L208-L230
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/924eb2c15aa9ffc7362293c74b2888e171831434/wacore/src/voip/mlow/smpl_pulse.rs#L188-L201 (seed cc-table rewire: SplitCmf)
+ lo := count
+ if granularity < lo {
+ lo = granularity
+ }
+ minSplit := count - granularity
+ if minSplit < 0 {
+ minSplit = 0
+ }
+ if lo < minSplit {
+ return -1
+ }
+ if minSplit == lo {
+ return minSplit
+ }
+ n := int((lo - minSplit) + 2)
+ return dec.DecodeCDF(cdfWindow(cc.SplitCmf(count), int(minSplit), n)) + minSplit
+}
diff --git a/pkg/call/voip/media/mlow/rangecoder.go b/pkg/call/voip/media/mlow/rangecoder.go
new file mode 100644
index 00000000..629e4d47
--- /dev/null
+++ b/pkg/call/voip/media/mlow/rangecoder.go
@@ -0,0 +1,549 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math/bits"
+
+const (
+ ecSymBits = 8
+ ecCodeBits = 32
+ ecSymMax = 255
+ ecCodeTop = 1 << (ecCodeBits - 1)
+ ecCodeBot = ecCodeTop >> ecSymBits
+ ecCodeExtra = (ecCodeBits-2)%ecSymBits + 1
+ ecWindowSize = 32
+ ecUintBits = 8
+ ecCodeShift = ecCodeBits - ecSymBits - 1
+)
+
+// ilog is floor(log2(x))+1 for x>0 and 0 for x==0.
+func ilog(x uint32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L24-L26
+ return int32(bits.Len32(x))
+}
+
+func ecMini(a, b uint32) uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L29-L31
+ if a < b {
+ return a
+ }
+ return b
+}
+
+// RangeDecoder is the Opus/CELT range entropy decoder. Range-coded symbols are
+// read from the front of the buffer, raw bits from the back.
+type RangeDecoder struct {
+ buf []byte
+ storage uint32
+ endOffs uint32
+ endWindow uint32
+ nendBits int32
+ nbitsTotal int32
+ offs uint32
+ rng uint32
+ val uint32
+ ext uint32
+ rem int32
+ // Err is a sticky decode error (degenerate/malformed table or exhausted bits).
+ Err int32
+}
+
+// NewRangeDecoder initializes a decoder over buf.
+func NewRangeDecoder(buf []byte) *RangeDecoder {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L52-L72
+ d := &RangeDecoder{
+ buf: buf,
+ storage: uint32(len(buf)),
+ nbitsTotal: ecCodeBits + 1 - ((ecCodeBits-ecCodeExtra)/ecSymBits)*ecSymBits,
+ rng: 1 << ecCodeExtra,
+ }
+ d.rem = int32(d.readByte())
+ d.val = d.rng - 1 - uint32(d.rem>>(ecSymBits-ecCodeExtra))
+ d.normalize()
+ return d
+}
+
+func (d *RangeDecoder) readByte() uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L74-L82
+ if d.offs < d.storage {
+ b := d.buf[d.offs]
+ d.offs++
+ return uint32(b)
+ }
+ return 0
+}
+
+func (d *RangeDecoder) readByteFromEnd() uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L84-L91
+ if d.endOffs < d.storage {
+ d.endOffs++
+ return uint32(d.buf[d.storage-d.endOffs])
+ }
+ return 0
+}
+
+func (d *RangeDecoder) normalize() {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L93-L106
+ for d.rng <= ecCodeBot {
+ d.nbitsTotal += ecSymBits
+ d.rng <<= ecSymBits
+ sym0 := d.rem
+ d.rem = int32(d.readByte())
+ sym := (sym0<> (ecSymBits - ecCodeExtra)
+ d.val = (d.val<> bitsN
+ if d.ext == 0 {
+ d.Err = 1
+ d.ext = 1
+ return 0
+ }
+ s := d.val / d.ext
+ ft := uint32(1) << bitsN
+ return ft - ecMini(s+1, ft)
+}
+
+// DecodeRawSymbol decodes a uniform nbits-bit symbol directly off the range stream.
+func (d *RangeDecoder) DecodeRawSymbol(nbits uint32) uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L141-L145
+ sym := d.decodeBin(nbits)
+ d.Update(sym, sym+1, uint32(1)< 0 {
+ d.rng = d.ext * (fh - fl)
+ } else {
+ d.rng -= s
+ }
+ d.normalize()
+}
+
+// BitLogp decodes one bit with P(0) = 1/2^logp.
+func (d *RangeDecoder) BitLogp(logp uint32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L160-L173
+ r := d.rng
+ dv := d.val
+ s := r >> logp
+ var ret int32
+ if dv < s {
+ ret = 1
+ }
+ if ret == 0 {
+ d.val = dv - s
+ d.rng = r - s
+ } else {
+ d.rng = s
+ }
+ d.normalize()
+ return ret
+}
+
+// DecodeICDF decodes a symbol against an inverse-CDF table; ftb = log2(ft).
+func (d *RangeDecoder) DecodeICDF(icdf []byte, ftb uint32) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L176-L199
+ if len(icdf) == 0 {
+ d.Err = 1
+ return 0
+ }
+ s0 := d.rng
+ dv := d.val
+ r := s0 >> ftb
+ ret := int32(-1)
+ var t uint32
+ s := s0
+ for {
+ t = s
+ ret++
+ s = r * uint32(icdf[ret])
+ if dv >= s || int(ret) >= len(icdf)-1 {
+ break
+ }
+ }
+ d.val = dv - s
+ d.rng = t - s
+ d.normalize()
+ return ret
+}
+
+// DecodeCDF decodes a symbol against a uint16 cumulative CDF table; the effective
+// total is cdf[n-1]-cdf[0].
+func (d *RangeDecoder) DecodeCDF(cdf []uint16) int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L203-L226
+ n := len(cdf)
+ if n < 2 {
+ d.Err = 1
+ return 0
+ }
+ base := uint32(cdf[0])
+ if uint32(cdf[n-1]) <= base {
+ d.Err = 1
+ return 0
+ }
+ ft := uint32(cdf[n-1]) - base
+ fs := d.Decode(ft)
+ target := base + fs
+ k := 0
+ for k < n-1 {
+ if uint32(cdf[k+1]) > target {
+ break
+ }
+ k++
+ }
+ d.Update(uint32(cdf[k])-base, uint32(cdf[k+1])-base, ft)
+ return int32(k)
+}
+
+// BitsN reads n raw bits from the back of the buffer, LSB-first.
+func (d *RangeDecoder) BitsN(n uint32) uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L229-L248
+ window := d.endWindow
+ available := d.nendBits
+ if uint32(available) < n {
+ for {
+ window |= d.readByteFromEnd() << uint32(available)
+ available += ecSymBits
+ if uint32(available) > ecWindowSize-ecSymBits {
+ break
+ }
+ }
+ }
+ ret := window & ((uint32(1) << n) - 1)
+ window >>= n
+ available -= int32(n)
+ d.endWindow = window
+ d.nendBits = available
+ d.nbitsTotal += int32(n)
+ return ret
+}
+
+// DecodeUint decodes an integer uniformly distributed in [0, ft0) for ft0 > 1.
+func (d *RangeDecoder) DecodeUint(ft0 uint32) uint32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L251-L270
+ ft := ft0 - 1
+ ftb := ilog(ft)
+ if ftb > ecUintBits {
+ ftb -= ecUintBits
+ t := (ft >> uint32(ftb)) + 1
+ s := d.Decode(t)
+ d.Update(s, s+1, t)
+ v := (s << uint32(ftb)) | d.BitsN(uint32(ftb))
+ if v <= ft {
+ return v
+ }
+ d.Err = 1
+ return ft
+ }
+ ft++
+ s := d.Decode(ft)
+ d.Update(s, s+1, ft)
+ return s
+}
+
+// Decode64FineSym decodes the 64-symbol uniform fine-lag value.
+func (d *RangeDecoder) Decode64FineSym() int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L274-L285
+ d.ext = d.rng >> 6
+ if d.ext == 0 {
+ d.Err = 1
+ d.ext = 1
+ return 0
+ }
+ s := d.val / d.ext
+ sym := int64(63) - int64(s)
+ if sym < 0 {
+ sym = 0
+ } else if sym > 64 {
+ sym = 64
+ }
+ d.Update(uint32(sym), uint32(sym)+1, 64)
+ return int32(sym)
+}
+
+// Tell reports the number of bits consumed so far, rounded up.
+func (d *RangeDecoder) Tell() int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L289-L291
+ return d.nbitsTotal - ilog(d.rng)
+}
+
+// RangeEncoder is the Opus/CELT range entropy encoder, the exact inverse of
+// RangeDecoder. Range-coded symbols are written toward the front of the buffer,
+// raw bits toward the back; Done flushes and merges them.
+type RangeEncoder struct {
+ buf []byte
+ storage uint32
+ endOffs uint32
+ endWindow uint32
+ nendBits int32
+ nbitsTotal int32
+ offs uint32
+ rng uint32
+ val uint32
+ ext uint32
+ rem int32
+ err int32
+}
+
+// NewRangeEncoder allocates an encoder writing into a size-byte buffer.
+func NewRangeEncoder(size int) *RangeEncoder {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L313-L328
+ return &RangeEncoder{
+ buf: make([]byte, size),
+ storage: uint32(size),
+ nbitsTotal: ecCodeBits + 1,
+ rng: ecCodeTop,
+ rem: -1,
+ }
+}
+
+// Err returns the sticky encode error (-1 on failure).
+func (e *RangeEncoder) Err() int32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L330-L332
+ return e.err
+}
+
+func (e *RangeEncoder) writeByte(b uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L334-L341
+ if e.offs+e.endOffs < e.storage {
+ e.buf[e.offs] = byte(b)
+ e.offs++
+ } else {
+ e.err = -1
+ }
+}
+
+func (e *RangeEncoder) writeByteAtEnd(b uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L343-L350
+ if e.offs+e.endOffs < e.storage {
+ e.endOffs++
+ e.buf[e.storage-e.endOffs] = byte(b)
+ } else {
+ e.err = -1
+ }
+}
+
+func (e *RangeEncoder) carryOut(c int32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L352-L372
+ if uint32(c) != ecSymMax {
+ carry := c >> ecSymBits
+ if e.rem >= 0 {
+ e.writeByte(uint32(e.rem + carry))
+ }
+ if e.ext > 0 {
+ sym := uint32((ecSymMax + carry) & ecSymMax)
+ for {
+ e.writeByte(sym)
+ e.ext--
+ if e.ext == 0 {
+ break
+ }
+ }
+ }
+ e.rem = c & ecSymMax
+ } else {
+ e.ext++
+ }
+}
+
+func (e *RangeEncoder) normalize() {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L374-L381
+ for e.rng <= ecCodeBot {
+ e.carryOut(int32(e.val >> ecCodeShift))
+ e.val = (e.val << ecSymBits) & (ecCodeTop - 1)
+ e.rng <<= ecSymBits
+ e.nbitsTotal += ecSymBits
+ }
+}
+
+// Encode encodes the symbol with cumulative range [fl, fh) out of ft.
+func (e *RangeEncoder) Encode(fl, fh, ft uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L383-L398
+ if ft == 0 {
+ e.err = -1
+ return
+ }
+ r := e.rng / ft
+ if fl > 0 {
+ e.val += e.rng - r*(ft-fl)
+ e.rng = r * (fh - fl)
+ } else {
+ e.rng -= r * (ft - fh)
+ }
+ e.normalize()
+}
+
+// BitLogp encodes one bit with P(0) = 1/2^logp.
+func (e *RangeEncoder) BitLogp(val int32, logp uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L400-L412
+ r := e.rng
+ l := e.val
+ s := r >> logp
+ r2 := r - s
+ if val != 0 {
+ e.val = l + r2
+ e.rng = s
+ } else {
+ e.rng = r2
+ }
+ e.normalize()
+}
+
+// EncodeICDF encodes symbol s against an inverse-CDF table; ftb = log2(ft).
+func (e *RangeEncoder) EncodeICDF(s int32, icdf []byte, ftb uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L414-L428
+ r := e.rng >> ftb
+ if s > 0 {
+ e.val += e.rng - r*uint32(icdf[s-1])
+ e.rng = r * uint32(icdf[s-1]-icdf[s])
+ } else {
+ e.rng -= r * uint32(icdf[s])
+ }
+ e.normalize()
+}
+
+// EncodeCDF encodes symbol s against a uint16 cumulative CDF table.
+func (e *RangeEncoder) EncodeCDF(s int32, cdf []uint16) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L431-L448
+ n := len(cdf)
+ if n < 2 || s < 0 || int(s+1) >= n {
+ e.err = -1
+ return
+ }
+ base := uint32(cdf[0])
+ if uint32(cdf[n-1]) <= base {
+ e.err = -1
+ return
+ }
+ ft := uint32(cdf[n-1]) - base
+ e.Encode(uint32(cdf[s])-base, uint32(cdf[s+1])-base, ft)
+}
+
+// BitsN writes the low n bits of fl as raw bits toward the back of the buffer.
+func (e *RangeEncoder) BitsN(fl, n uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L451-L469
+ window := e.endWindow
+ used := e.nendBits
+ if used+int32(n) > ecWindowSize {
+ for {
+ e.writeByteAtEnd(window & ecSymMax)
+ window >>= ecSymBits
+ used -= ecSymBits
+ if used < ecSymBits {
+ break
+ }
+ }
+ }
+ window |= fl << uint32(used)
+ used += int32(n)
+ e.endWindow = window
+ e.nendBits = used
+ e.nbitsTotal += int32(n)
+}
+
+// EncodeUint encodes an integer uniformly distributed in [0, ft0).
+func (e *RangeEncoder) EncodeUint(fl, ft0 uint32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L471-L482
+ ft := ft0 - 1
+ ftb := ilog(ft)
+ if ftb > ecUintBits {
+ shift := uint32(ftb - ecUintBits)
+ t := (ft >> shift) + 1
+ e.Encode(fl>>shift, (fl>>shift)+1, t)
+ e.BitsN(fl&((uint32(1)<> uint32(l)
+ end := (e.val + msk) &^ msk
+ if (end | msk) >= e.val+e.rng {
+ l++
+ msk >>= 1
+ end = (e.val + msk) &^ msk
+ }
+ for l > 0 {
+ e.carryOut(int32(end >> ecCodeShift))
+ end = (end << ecSymBits) & (ecCodeTop - 1)
+ l -= ecSymBits
+ }
+ if e.rem >= 0 || e.ext > 0 {
+ e.carryOut(0)
+ }
+ window := e.endWindow
+ used := e.nendBits
+ for used >= ecSymBits {
+ e.writeByteAtEnd(window & ecSymMax)
+ window >>= ecSymBits
+ used -= ecSymBits
+ }
+ if e.err == 0 {
+ for i := e.offs; i < e.storage-e.endOffs; i++ {
+ e.buf[i] = 0
+ }
+ if used > 0 {
+ if e.endOffs >= e.storage-e.offs {
+ e.err = -1
+ } else {
+ e.buf[e.storage-e.endOffs-1] |= byte(window)
+ }
+ }
+ }
+}
+
+// Bytes returns the encoder's output buffer.
+func (e *RangeEncoder) Bytes() []byte {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L533-L535
+ return e.buf
+}
+
+// ConsumedLen reports the meaningful body length: front range bytes plus back
+// raw-bit bytes (the gap between is zero-fill padding).
+func (e *RangeEncoder) ConsumedLen() int {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/rangecoder.rs#L539-L541
+ return int(e.offs + e.endOffs)
+}
diff --git a/pkg/call/voip/media/mlow/red.go b/pkg/call/voip/media/mlow/red.go
new file mode 100644
index 00000000..73076eb6
--- /dev/null
+++ b/pkg/call/voip/media/mlow/red.go
@@ -0,0 +1,84 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "errors"
+
+ "github.com/rs/zerolog"
+)
+
+// MLow RED ("SplitRed") depacketization — the outermost wire layer of a WhatsApp
+// MLow RTP audio payload (WASM func 3819). OPTIONAL: applied only when the call
+// negotiated redundancy > 0; otherwise the RTP payload is a single bare MLow frame
+// and this MUST NOT run (a bare frame's high-bit-set first byte would misparse).
+
+// MlowFrame is one frame extracted from a SplitRed payload: raw MLow frame bytes
+// (TOC + body) plus RED metadata. Data is a subslice of the input payload (no copy).
+type MlowFrame struct {
+ Data []byte
+ TimeCode uint8
+ IsMain bool
+}
+
+var (
+ ErrPktSizeZero = errors.New("mlow red: packet size zero")
+ ErrHeaderTooShort = errors.New("mlow red: header too short")
+ ErrRedundantTooShort = errors.New("mlow red: redundant block too short")
+ ErrMainTooShort = errors.New("mlow red: main frame too short")
+)
+
+// DepackSplitRed parses a SplitRed RED packet into its frames (redundant blocks in
+// header order, then the main frame last). Only call when RED was negotiated.
+func DepackSplitRed(p []byte, log ...zerolog.Logger) ([]MlowFrame, error) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/red.rs#L32-L95
+ lg := pickLog(log)
+ n := len(p)
+ if n == 0 {
+ lg.Debug().Msg("red depack: empty packet")
+ return nil, ErrPktSizeZero
+ }
+ lg.Trace().Int("packet_bytes", n).Msg("red depack")
+ type redBlock struct {
+ code uint8
+ size uint8
+ }
+ var red []redBlock
+ cur := 0
+ rem := n
+ for {
+ if rem == 0 {
+ return nil, ErrHeaderTooShort
+ }
+ b0 := p[cur]
+ if b0 < 0x80 {
+ // main marker (high bit clear) terminates the header run
+ if rem <= 1 {
+ return nil, ErrMainTooShort
+ }
+ break
+ }
+ if rem <= 2 {
+ return nil, ErrRedundantTooShort
+ }
+ size := p[cur+1]
+ if int(size)+2 >= rem {
+ return nil, ErrRedundantTooShort
+ }
+ red = append(red, redBlock{code: b0 & 0x7f, size: size})
+ cur += 2
+ rem -= int(size) + 2
+ }
+
+ mainCode := p[cur] & 0x7f
+ cur++
+
+ frames := make([]MlowFrame, 0, len(red)+1)
+ for _, r := range red {
+ frames = append(frames, MlowFrame{Data: p[cur : cur+int(r.size)], TimeCode: r.code, IsMain: false})
+ cur += int(r.size)
+ }
+ mainSize := rem - 1 // total - header_size - sum(redundant sizes)
+ frames = append(frames, MlowFrame{Data: p[cur : cur+mainSize], TimeCode: mainCode, IsMain: true})
+ lg.Trace().Int("redundant_blocks", len(red)).Int("main_bytes", mainSize).Int("total_frames", len(frames)).Msg("red depack: done")
+ return frames, nil
+}
diff --git a/pkg/call/voip/media/mlow/synth.go b/pkg/call/voip/media/mlow/synth.go
new file mode 100644
index 00000000..99a0d609
--- /dev/null
+++ b/pkg/call/voip/media/mlow/synth.go
@@ -0,0 +1,624 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import (
+ "math"
+
+ "github.com/rs/zerolog"
+)
+
+// Low-band synthesis: NLSF reconstruction, NLSF→LPC, gain linearization, LTP/ACB
+// excitation prediction, and the per-internal-frame synthesis that turns decoded
+// parameters into PCM. Validated end-to-end via the decoder module.
+
+const (
+ SmplOrder = 16
+ SmplSubfrLen = 80 // 5 ms @ 16 kHz
+ SmplIntfLen = 320 // 20 ms internal frame
+ SmplSubfrCount = 4
+ SmplLtpHist = 728
+)
+
+const (
+ smplPiF32 = float32(3.1415927410125)
+ smplNLSFWeightWMax = float32(999.9999)
+ smplNLSFWeightEps = float32(0.0009999999)
+ smplStabilizeMaxLoop = 1000
+ smplStabilizeEps = float32(9.5367431640625e-07)
+)
+
+const (
+ gLTP = float32(0.949999988079071)
+ smplFracStateLen = 728
+ ltpHistLen = SmplLtpHist + SmplIntfLen + 64
+)
+
+// smplFIR16 is the 16-tap symmetric fractional-delay interpolation FIR (WASM mem
+// 0xe8780, func 3523/3507).
+var smplFIR16 = [16]float32{
+ -0.000006392598606907995,
+ 0.00011064113641623408,
+ -0.0009153038263320923,
+ 0.0048477197997272015,
+ -0.018698347732424736,
+ 0.05759090930223465,
+ -0.15997476875782013,
+ 0.617045521736145,
+ 0.6170454621315002,
+ -0.15997475385665894,
+ 0.05759090557694435,
+ -0.018698347732424736,
+ 0.0048477197997272015,
+ -0.0009153038263320923,
+ 0.00011064114369219169,
+ -0.0000063925981521606445,
+}
+
+// --- NLSF reconstruction / synthesis tables ---
+
+// SmplSynthTables is the runtime synthesis table set (the smpl_synth_tables dump).
+type SmplSynthTables struct {
+ Valtables [][][][][]float32 // [stage1][config][grid][coeff][sym]
+ Centroids [][][]float32 // [stage1][grid][16]
+ Matrices [][][][]float32 // [stage1][grid][row][col]
+ MinSpacing [][]float32 // [stage1][17]
+ Grid16W [][]float32
+ Grid16Alpha []float32
+ Grid16Matrices [][][]float32 // [sig][config][256]
+}
+
+// LoadSmplSynthTables returns the runtime synthesis tables, built from the embedded
+// seed ROM (lsf_seed.bin) and shared read-only.
+func LoadSmplSynthTables() *SmplSynthTables {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L97-L104
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/dbf10066a15f5c8c83c27908ad4284873331e1a4/wacore/src/voip/mlow/smpl_synth.rs#L71-L73 (seed rewire: build from lsf_seed.bin)
+ return loadLsfBuilt().synth
+}
+
+// smplNLSFLaroiaWeights: inverse-gap weights w[k] = invgap[k] + invgap[k+1] (silk_NLSF_VQ_weights_laroia).
+func smplNLSFLaroiaWeights(nlsf, out []float32) {
+ var inv [SmplOrder + 1]float32
+ clamp := func(gap float32) float32 {
+ if gap > smplNLSFWeightEps {
+ return 1.0 / gap
+ }
+ return smplNLSFWeightWMax
+ }
+ inv[0] = clamp(nlsf[0])
+ prev := nlsf[0]
+ for k := 1; k < SmplOrder; k++ {
+ inv[k] = clamp(nlsf[k] - prev)
+ prev = nlsf[k]
+ }
+ inv[SmplOrder] = clamp(smplPiF32 - nlsf[SmplOrder-1])
+ for k := 0; k < SmplOrder; k++ {
+ out[k] = inv[k] + inv[k+1]
+ }
+}
+
+// smplNLSFDecorr: out[r] = sum_c mat[c*16 + r] * vec[c] (column-major decorrelation matrix).
+func smplNLSFDecorr(mat, vec, out []float32) {
+ var scr [SmplOrder]float32
+ v0 := vec[0]
+ for r := 0; r < SmplOrder; r++ {
+ scr[r] = v0 * mat[r]
+ }
+ for c := 1; c < SmplOrder; c++ {
+ v := vec[c]
+ base := c * SmplOrder
+ for r := 0; r < SmplOrder; r++ {
+ scr[r] += mat[base+r] * v
+ }
+ }
+ copy(out[:SmplOrder], scr[:])
+}
+
+// smplStabilizeNLSF enforces minimum spacing + ordering in the margin domain (silk_NLSF_stabilize).
+func smplStabilizeNLSF(nlsf, minSpacing []float32) {
+ const L = SmplOrder
+ var marg [L + 1]float32
+ marg[0] = nlsf[0] - minSpacing[0]
+ for i := 1; i < L; i++ {
+ marg[i] = nlsf[i] - nlsf[i-1] - minSpacing[i]
+ }
+ marg[L] = smplPiF32 - nlsf[L-1] - minSpacing[L]
+ argmin := func() (float32, int) {
+ m := marg[0]
+ idx := 0
+ for i := 1; i < L+1; i++ {
+ if marg[i] < m {
+ m = marg[i]
+ idx = i
+ }
+ }
+ return m, idx
+ }
+ min, sel := argmin()
+ loopN := 0
+ for min < 0.0 {
+ d := float32(loopN)*smplStabilizeEps - min
+ if sel == 0 {
+ marg[0] += d
+ marg[1] -= d
+ } else if sel == L {
+ marg[L] += d
+ marg[L-1] -= d
+ } else {
+ marg[sel] += d
+ half := d * 0.5
+ marg[sel-1] -= half
+ marg[sel+1] -= half
+ }
+ m, s := argmin()
+ min = m
+ sel = s
+ if min < 0.0 {
+ loopN++
+ if loopN == smplStabilizeMaxLoop {
+ break
+ }
+ }
+ }
+ nlsf[0] = minSpacing[0] + marg[0]
+ run := nlsf[0]
+ for i := 1; i < L; i++ {
+ run = run + marg[i] + minSpacing[i]
+ nlsf[i] = run
+ }
+}
+
+// SmplReconstructNLSF rebuilds the quantized NLSF from the stage indices and the
+// previous frame's NLSF (the envelope the decoder synthesizes from).
+func SmplReconstructNLSF(t *SmplSynthTables, stage1, config, grid int, stage2 *[16]int32, prevNLSF []float32) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L176-L234
+ val := t.Valtables[stage1][config][grid]
+ var resid [SmplOrder]float32
+ for k := 0; k < SmplOrder; k++ {
+ sym := stage2[k]
+ if sym >= 0 && int(sym) < len(val[k]) {
+ resid[k] = val[k][sym]
+ }
+ }
+
+ out := make([]float32, SmplOrder)
+ if grid == 16 {
+ // grid==16: interpolate base between prevNLSF and the inverted grid16 base table.
+ var base [SmplOrder]float32
+ baseTbl := t.Grid16W[1-stage1]
+ alpha := t.Grid16Alpha[stage1]
+ for k := 0; k < SmplOrder; k++ {
+ var pv float32
+ if k < len(prevNLSF) {
+ pv = prevNLSF[k]
+ }
+ base[k] = pv + alpha*(baseTbl[k]-pv)
+ }
+ var w [SmplOrder]float32
+ smplNLSFLaroiaWeights(base[:], w[:])
+ for i := range w {
+ w[i] = float32(math.Sqrt(float64(w[i])))
+ }
+ var decorr [SmplOrder]float32
+ smplNLSFDecorr(t.Grid16Matrices[stage1][config], resid[:], decorr[:])
+ for k := 0; k < SmplOrder; k++ {
+ out[k] = base[k] + decorr[k]/w[k]
+ }
+ smplStabilizeNLSF(out, t.MinSpacing[stage1])
+ return out
+ }
+
+ // matrix case (grid < 16): NLSF[r] = 2*centroid[r] + sum_c mat[c][r]*resid[c].
+ cent := t.Centroids[stage1][grid]
+ mat := t.Matrices[stage1][grid]
+ for r := 0; r < SmplOrder; r++ {
+ acc := 2.0 * cent[r]
+ for c := 0; c < SmplOrder; c++ {
+ acc += mat[c][r] * resid[c]
+ }
+ out[r] = acc
+ }
+ smplStabilizeNLSF(out, t.MinSpacing[stage1])
+ return out
+}
+
+// SmplNLSF2A converts NLSF to the monic LPC coefficient vector A[0..16] (a[0]=1).
+func SmplNLSF2A(nlsf []float32) []float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L293-L311
+ // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth
+ // path reconstructs a tone at correlation 0.89). Correlation-bounded, not
+ // bit-exact — there is no isolated vector for this WASM-domain alt synth path.
+ order := len(nlsf)
+ half := order / 2
+ cosv := make([]float64, order)
+ for i, x := range nlsf {
+ cosv[i] = math.Cos(float64(x))
+ }
+ p := make([]float64, half+1)
+ q := make([]float64, half+1)
+ smplNLSFPoly(p, cosv, half, 0)
+ smplNLSFPoly(q, cosv, half, 1)
+
+ a := make([]float32, order+1)
+ a[0] = 1.0
+ for k := 0; k < half; k++ {
+ pt := p[k+1] + p[k]
+ qt := q[k+1] - q[k]
+ a[k+1] = float32(0.5 * (pt + qt))
+ a[order-k] = float32(0.5 * (pt - qt))
+ }
+ return a
+}
+
+func smplNLSFPoly(out, cosv []float64, half, parity int) {
+ out[0] = 1.0
+ out[1] = -2.0 * cosv[parity]
+ for k := 1; k < half; k++ {
+ c := -2.0 * cosv[2*k+parity]
+ out[k+1] = 2.0*out[k-1] + c*out[k]
+ for n := k; n > 1; n-- {
+ out[n] += out[n-2] + c*out[n-1]
+ }
+ out[1] += c
+ }
+}
+
+// smplLPCSynthesis: out[n] = ex[n] - sum_{j=1..16} a[j]*out[n-j]; state holds the
+// previous order outputs, carried across subframes/frames, updated in place.
+func smplLPCSynthesis(ex, a, out, state []float32) {
+ order := SmplOrder
+ for n := 0; n < len(ex); n++ {
+ acc := float64(ex[n])
+ for j := 1; j <= order; j++ {
+ var prev float64
+ if n >= j {
+ prev = float64(out[n-j])
+ } else {
+ prev = float64(state[order+n-j])
+ }
+ acc -= float64(a[j]) * prev
+ }
+ out[n] = float32(acc)
+ }
+ if len(out) >= order {
+ copy(state[:order], out[len(out)-order:])
+ }
+}
+
+// SmplGainLin maps the quantized log-gain to a linear gain (fast pow2 bit-cast).
+func SmplGainLin(gainQ int32) float64 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L350-L362
+ // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth
+ // path reconstructs a tone at correlation 0.89). Correlation-bounded, not
+ // bit-exact — there is no isolated vector for this WASM-domain alt synth path.
+ y := float32(gainQ)*6.103515625e-05*0.10000000149011612*27749388.0 + 1064866816.0
+ var i int32
+ if y < 2147483648.0 && y > -2147483648.0 {
+ i = int32(y)
+ } else {
+ i = -2147483648
+ }
+ f := math.Float32frombits(uint32(i)) - 3.1622775509276835e-09
+ if f < 0.0 {
+ f = 0.0
+ }
+ return float64(f)
+}
+
+func smplFloorF32(x float32) float32 {
+ i := int32(x)
+ if float32(i) > x {
+ i--
+ }
+ return float32(i)
+}
+
+// SmplLTPFracGain maps the normalized LTP gain to the fractional gain.
+func SmplLTPFracGain(normGain float64) float32 {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L482-L484
+ // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth
+ // path reconstructs a tone at correlation 0.89). Correlation-bounded, not
+ // bit-exact — there is no isolated vector for this WASM-domain alt synth path.
+ return float32(normGain)*-0.16999998688697815 + 0.3499999940395355
+}
+
+// smplFir8: 8-tap symmetric FIR16 application, in-place over sig (in==out overlap;
+// f32 accumulation order matches the WASM).
+func smplFir8(sig []float32, inBase, outBase, cnt int32) {
+ for jj := int32(0); jj < cnt; jj++ {
+ var acc float32
+ for i := int32(0); i < 8; i++ {
+ acc += (sig[inBase+jj+i] + sig[inBase+jj+15-i]) * smplFIR16[i]
+ }
+ sig[outBase+jj] = acc
+ }
+}
+
+// smplFracLTP: fractional LTP + interpolation (func 3523). Reads sig backward from
+// sigEnd, writes two regions per subframe into out (len 2*numSubfr*40); mutates sig.
+func smplFracLTP(lag []float32, numSubfr int32, sig []float32, sigEnd, stateLen int32, out []float32) {
+ lb := sigEnd - (40*numSubfr - stateLen)
+ for sf := int32(0); sf < numSubfr; sf++ {
+ fl := smplFloorF32(lag[sf])
+ intLag := int32(fl)
+ if float32(intLag) == lag[sf] {
+ for k := int32(0); k < 40; k++ {
+ sig[lb+k] = sig[lb+k-intLag]
+ }
+ for k := int32(0); k < 40; k++ {
+ out[sf*40+k] = sig[lb+k]
+ out[(numSubfr+sf)*40+k] = sig[lb+k-intLag-1] + sig[lb+k-intLag+1]
+ }
+ } else {
+ b := (numSubfr + sf) * 40
+ for k := int32(0); k < 40; k++ {
+ out[b+k] = sig[lb-intLag-1+k] + sig[lb-intLag+1+k]
+ }
+ var l10 float32
+ for j := int32(0); j < 16; j++ {
+ l10 += sig[lb-9-intLag+j] * smplFIR16[j]
+ }
+ smplFir8(sig, lb-intLag-8, lb, 40)
+ var l11 float32
+ for j := int32(0); j < 16; j++ {
+ l11 += sig[lb+32-intLag+j] * smplFIR16[j]
+ }
+ for k := int32(0); k < 40; k++ {
+ out[sf*40+k] = sig[lb+k]
+ }
+ out[b] = l10 + sig[lb+1]
+ for k := int32(0); k < 38; k++ {
+ out[b+1+k] = sig[lb+k] + sig[lb+2+k]
+ }
+ out[b+39] = l11 + sig[lb+38]
+ }
+ lb += 40
+ }
+}
+
+// smplExcGainApply: per-subframe LTP gain-apply (func 3522).
+func smplExcGainApply(subLen int, input []float32, st *SmplExcGainState, out []float32, gain float32) {
+ if gain != 0.0 {
+ s5 := st.S1
+ s6 := (s5 + s5) + st.S0
+ d := st.S0 - s5
+ absD := absF32(d)
+ absS6 := absF32(s6)
+ mn := absD + gain
+ if absS6 < mn {
+ mn = absS6
+ }
+ t := d * mn / (absD + 1e-12)
+ st.S1 = (s6 - t) / 3.0
+ st.S0 = (2.0*t + s6) / 3.0
+ }
+ if subLen == 0 {
+ return
+ }
+ s0 := st.S0
+ for n := 0; n < subLen; n++ {
+ out[n] = s0 * input[n]
+ }
+ s1 := st.S1
+ for n := 0; n < subLen; n++ {
+ out[n] += s1 * input[subLen+n]
+ }
+}
+
+// --- low-band synthesis (WASM func 3597 core) ---
+
+// SmplExcGainState is the 2-tap excitation-gain smoother state.
+type SmplExcGainState struct {
+ S0 float32
+ S1 float32
+}
+
+// SmplPitchSynth carries the per-internal-frame pitch synthesis inputs.
+type SmplPitchSynth struct {
+ Voiced bool
+ LagSubfr [4]float64
+ NormGain float64
+}
+
+// SmplFrameSynth is the cross-internal-frame low-band synthesis state: LPC state and
+// the LTP/excitation history plus the gain smoother. (The reference also carries
+// Region-1 and HP postfilter state for paths gated off by SMPL_TAIL_REGION1 /
+// SMPL_HP_POSTFILTER — those gated blocks are not ported here; they would need the
+// postfilter module's state types.)
+type SmplFrameSynth struct {
+ lpcState [SmplOrder]float32
+ ltpHist []float32
+ gst SmplExcGainState
+}
+
+// NewSmplFrameSynth allocates a zeroed low-band synthesis state.
+func NewSmplFrameSynth() *SmplFrameSynth {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L528-L538
+ return &SmplFrameSynth{ltpHist: make([]float32, ltpHistLen)}
+}
+
+// SmplLTPSubframePred runs the fractional LTP prediction for one 80-sample subframe,
+// writing predOut from the history at the fractional lag (func 3523 + func 3522).
+func SmplLTPSubframePred(hist []float32, histPos int32, lagF, gainFrac float32, gst *SmplExcGainState, predOut []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L487-L506
+ // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth
+ // path reconstructs a tone at correlation 0.89). Correlation-bounded, not
+ // bit-exact — there is no isolated vector for this WASM-domain alt synth path.
+ var fracOut [2 * 2 * 40]float32
+ lags := []float32{lagF, lagF}
+ smplFracLTP(lags, 2, hist, histPos-648, smplFracStateLen, fracOut[:])
+ smplExcGainApply(SmplSubfrLen, fracOut[:], gst, predOut, gainFrac)
+}
+
+// SynthInternalFrame synthesizes one internal (20 ms) frame, returning the PCM
+// signal and the reconstructed nlsf (which becomes the next frame's prevNLSF).
+func SynthInternalFrame(
+ t *SmplSynthTables,
+ st *SmplFrameSynth,
+ stage1, config, grid int,
+ stage2 *[16]int32,
+ prevNLSF []float32,
+ pulses []int32,
+ gainQ *[4]int32,
+ pitch *SmplPitchSynth,
+ log ...zerolog.Logger,
+) (signal []float32, nlsf []float32) {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_synth.rs#L543-L662
+ lg := pickLog(log)
+ lg.Trace().Int("stage1", stage1).Int("grid", grid).Int("pulses_len", len(pulses)).Int("prev_nlsf_len", len(prevNLSF)).Msg("synth internal frame")
+ // Exercised end-to-end by TestEncodeRoundTripsATone (the encoder shadow-synth
+ // path reconstructs a tone at correlation 0.89). Correlation-bounded, not
+ // bit-exact — there is no isolated vector for this WASM-domain alt synth path.
+ // The reference's Region-1 excitation comb and post-LPC HP postfilter are gated off
+ // (SMPL_TAIL_REGION1 / SMPL_HP_POSTFILTER == false) and need the postfilter
+ // module; those gated blocks are omitted here, matching the vector-capture config.
+ nlsf = SmplReconstructNLSF(t, stage1, config, grid, stage2, prevNLSF)
+ a := SmplNLSF2A(nlsf)
+
+ subGain := func(sf int) float64 {
+ gq := int32(0)
+ if sf < len(gainQ) {
+ gq = gainQ[sf]
+ }
+ return SmplGainLin(gq) * float64(SmplSubfrLen)
+ }
+
+ ex := make([]float32, SmplIntfLen)
+ for n := 0; n < SmplIntfLen; n++ {
+ ex[n] = float32(float64(pulses[n]) * subGain(n/SmplSubfrLen))
+ }
+ hist := st.ltpHist
+
+ if pitch.Voiced {
+ gainFrac := SmplLTPFracGain(pitch.NormGain)
+ predOut := make([]float32, SmplSubfrLen)
+ st.gst = SmplExcGainState{}
+ for sf := 0; sf < SmplSubfrCount; sf++ {
+ lagF := float32(pitch.LagSubfr[sf])
+ intLag := int32(lagF)
+ if intLag <= 0 {
+ from := sf * SmplSubfrLen
+ to := (sf + 1) * SmplSubfrLen
+ copy(hist[SmplLtpHist+from:SmplLtpHist+to], ex[from:to])
+ continue
+ }
+ exBase := sf * SmplSubfrLen
+ histPos := int32(SmplLtpHist + exBase)
+ if intLag > 0 && int(intLag) < SmplSubfrLen {
+ for n := int(intLag); n < SmplSubfrLen; n++ {
+ ex[exBase+n] += gLTP * ex[exBase+n-int(intLag)]
+ }
+ }
+ SmplLTPSubframePred(hist, histPos, lagF, gainFrac, &st.gst, predOut)
+ for n := 0; n < SmplSubfrLen; n++ {
+ ex[exBase+n] += predOut[n]
+ }
+ copy(hist[int(histPos):int(histPos)+SmplSubfrLen], ex[exBase:exBase+SmplSubfrLen])
+ }
+ } else {
+ copy(hist[SmplLtpHist:SmplLtpHist+SmplIntfLen], ex)
+ }
+
+ out := make([]float32, SmplIntfLen)
+ smplLPCSynthesis(ex, a, out, st.lpcState[:])
+
+ // roll the LTP history forward by one internal frame; clear the forward margin.
+ copy(hist[0:], hist[SmplIntfLen:SmplLtpHist+SmplIntfLen])
+ for i := SmplLtpHist + SmplIntfLen; i < ltpHistLen; i++ {
+ hist[i] = 0.0
+ }
+ return out, nlsf
+}
+
+// (The C-float CELP synthesis — CelpDecParams / CelpDecState / SynthFrame — lives in
+// celpdec.go.)
+
+// --- unvoiced residual-energy quantizer (smpl_quant_nrg_res.c) ---
+
+// NrgResQuant is the quantized residual-energy result; DbqQ14 is what the decoder
+// reads as gainQ.
+type NrgResQuant struct {
+ FrameQi int32
+ ShapeQi int32
+ DbqQ14 [4]int32
+}
+
+const (
+ smplResNrgBias = float32(3.1622776e-9)
+ smplResNrgMinDB = float32(-85.0)
+ smplResNrgMaxDB = float32(0.0)
+ smplNrgStepDBQ14_4 = int32(16686)
+ smplResNrgShapeCBN4 = 98
+)
+
+// nrgresShapeCB4Q10 is nrgres_shape_CB_4_Q10 (98 vectors x 4 subframes), verbatim.
+var nrgresShapeCB4Q10 = [smplResNrgShapeCBN4 * 4]int16{
+ -2515, -2238, 2632, 2121, 790, 3973, -2872, -1891, -533, 2847, 1453, -3767, -6174, -402, 2668, 3908,
+ -1623, -1458, 153, 2928, -1254, 3197, -476, -1467, 1803, -1086, 270, -987, 1952, -66, -1257, -629,
+ 161, 19, -85, -96, 4833, 3147, -105, -7875, -1320, 1377, -1156, 1099, 3398, -2247, 1485, -2637,
+ -3031, 2756, 1841, -1566, -1487, 2202, -2668, 1954, 5518, -5344, 522, -696, 8400, -3123, -6235, 958,
+ 5152, -2444, -2811, 102, 2513, -82, 1181, -3612, -561, -197, -1074, 1832, -294, -1250, -1839, 3383,
+ 5126, 522, -782, -4866, -7760, -5178, -1840, 14779, -1119, 6007, -1489, -3399, -4567, -2543, 1855, 5255,
+ 53, -1626, 67, 1506, -12256, -7706, -1982, 21943, 3549, -969, -1096, -1484, -10824, 2981, 2204, 5639,
+ -229, 1106, 945, -1821, -9237, 10157, 1616, -2537, 4916, -199, -2177, -2540, 6673, 984, -3355, -4302,
+ -7130, -4677, 8925, 2882, 445, 2762, -348, -2859, -196, -1859, 1761, 294, 2725, -2093, -966, 334,
+ -3908, -308, 3675, 541, 735, 890, -2516, 891, 504, 1631, -1157, -977, -17817, 2119, 7104, 8594,
+ -2056, 1897, -198, 356, 292, -4544, -287, 4538, -1455, -304, 603, 1156, -18259, -12643, 15247, 15655,
+ 4177, 1778, -1815, -4140, 1425, 576, -294, -1707, -1301, 5132, 2838, -6669, -4727, -3148, -905, 8781,
+ -650, 152, -4654, 5152, 13746, 2320, -6259, -9807, -1356, 396, 3789, -2829, 2337, 1947, -29, -4256,
+ 6033, 820, -5730, -1123, -1795, 1091, 1080, -377, 2208, -1921, -3314, 3027, 9688, 5218, -3754, -11152,
+ 3814, -3941, -6183, 6310, -1017, -2391, 4393, -984, 10944, -1182, -5011, -4751, -4640, 7201, -218, -2343,
+ -1278, 4720, -4212, 770, 2777, 1333, -5944, 1833, -16066, 8107, 5165, 2795, 2530, -5020, 6073, -3582,
+ -2111, -7534, 4575, 5070, -8702, -3762, 4050, 8414, 1335, -997, -1567, 1229, 9348, 1534, -3959, -6922,
+ 2440, 1153, -2175, -1418, -2715, -4538, -4478, 11730, 569, -885, 2032, -1716, 3529, -91, -3218, -219,
+ 2157, -4121, 191, 1772, -2123, -1968, -1355, 5446, 1475, -354, 3651, -4772, 1654, -3521, 2726, -859,
+ 2393, 6820, -2958, -6255, -3861, 1365, 1177, 1319, 7614, -1638, -2789, -3187, -3628, -2635, 6902, -639,
+ 1925, 2295, -1451, -2769, -3683, 4517, -981, 147, -1260, -529, 2339, -550, 3013, 639, -1050, -2602,
+ 3651, 1959, -3218, -2391, 6267, 3124, -2926, -6464, -8180, 3900, 4191, 89, -3372, -611, 1042, 2941,
+ -2510, 856, -925, 2579, -11667, -8436, 10605, 9498, 6427, -2733, 1887, -5581, 1581, -1722, -328, 469,
+ 2011, 1989, -3606, -394, -1014, 2197, -1200, 17, 1544, -2555, 765, 247, 1188, -183, 1966, -2972,
+ -6057, 3480, -2284, 4860, -25659, 8466, 8891, 8303,
+}
+
+// QuantNrgRes4 quantizes the 4-subframe residual-energy vector (smpl_quant_nrg_res, num_subfr==4).
+func QuantNrgRes4(nrgres *[4]float32) NrgResQuant {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_nrgres.rs#L61-L101
+ // Exercised by TestEncodeRoundTripsATone (the encoder unvoiced candidate quantizes
+ // the per-subframe residual energy through this). Correlation-bounded e2e.
+ var nrgresDB [4]float32
+ var frameDB float32
+ for i := 0; i < 4; i++ {
+ v := 10.0 * float32(math.Log10(float64(nrgres[i]+smplResNrgBias)))
+ if v > smplResNrgMaxDB {
+ v = smplResNrgMaxDB
+ }
+ nrgresDB[i] = v
+ frameDB += v
+ }
+ frameDB /= 4.0
+ scQ14 := float32(1.0) / float32(int32(1)<<14)
+ frameQi := int32(math.Round(float64((frameDB - smplResNrgMinDB) / (scQ14 * float32(smplNrgStepDBQ14_4)))))
+ frameDbqQ14 := frameQi * smplNrgStepDBQ14_4
+ frameDbqQ14 += int32(smplResNrgMinDB) * (1 << 14)
+ for i := 0; i < 4; i++ {
+ nrgresDB[i] -= float32(frameDbqQ14) * scQ14
+ }
+ scQ10 := float32(1.0) / float32(int32(1)<<10)
+ bestRD := float32(1e30)
+ qi := 0
+ for n := 0; n < smplResNrgShapeCBN4; n++ {
+ var rd float32
+ for i := 0; i < 4; i++ {
+ d := nrgresDB[i] - float32(nrgresShapeCB4Q10[n*4+i])*scQ10
+ rd += d * d
+ }
+ if rd < bestRD {
+ qi = n
+ bestRD = rd
+ }
+ }
+ var dbqQ14 [4]int32
+ for i := 0; i < 4; i++ {
+ dbqQ14[i] = frameDbqQ14 + int32(nrgresShapeCB4Q10[qi*4+i])*16
+ }
+ return NrgResQuant{FrameQi: frameQi, ShapeQi: int32(qi), DbqQ14: dbqQ14}
+}
diff --git a/pkg/call/voip/media/mlow/toc.go b/pkg/call/voip/media/mlow/toc.go
new file mode 100644
index 00000000..1ce6c7bd
--- /dev/null
+++ b/pkg/call/voip/media/mlow/toc.go
@@ -0,0 +1,76 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "github.com/rs/zerolog"
+
+// SmplTOC is the decoded first byte of an inbound MLow frame: how to interpret
+// the rest of the frame, or that it is a standard Opus packet to route elsewhere.
+type SmplTOC struct {
+ StdOpus bool
+ SID bool
+ VAD bool
+ SampleRate int
+ FrameMs int
+ Voiced bool
+ Active bool
+ Flag2 bool
+ Flag0 bool
+}
+
+// standardOpusFrameMs returns the frame duration (ms) of a standard Opus packet
+// from the config field b>>3 (RFC 6716 Table 2). 2.5 ms is rounded up to 3.
+func standardOpusFrameMs(b byte) int {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/toc.rs#L25-L39
+ config := b >> 3
+ switch {
+ case config < 12: // SILK NB/MB/WB
+ return []int{10, 20, 40, 60}[config&3]
+ case config < 16: // Hybrid
+ return []int{10, 20}[(config-12)&1]
+ default:
+ switch config & 3 {
+ case 0:
+ return 3 // 2.5 ms rounded up
+ case 1:
+ return 5
+ case 2:
+ return 10
+ default:
+ return 20
+ }
+ }
+}
+
+// ParseSmplTOC decodes the TOC byte at the head of an inbound MLow frame.
+func ParseSmplTOC(b byte, log ...zerolog.Logger) SmplTOC {
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/674e85164b35ca19115dfebcf605708d15951ee7/wacore/src/voip/mlow/toc.rs#L43-L87
+ lg := pickLog(log)
+ if b&0xC0 == 0xC0 {
+ lg.Trace().Uint8("toc_byte", b).Bool("std_opus", true).Msg("parse toc: standard-Opus packet")
+ return SmplTOC{
+ StdOpus: true,
+ SampleRate: 16000,
+ FrameMs: standardOpusFrameMs(b),
+ }
+ }
+ bit1 := (b>>1)&1 != 0
+ vad := (b>>6)&1 != 0
+ sampleRate := 16000
+ if b&0x20 != 0 {
+ sampleRate = 32000
+ }
+ toc := SmplTOC{
+ SID: b>>7 != 0,
+ VAD: vad,
+ SampleRate: sampleRate,
+ FrameMs: []int{10, 20, 60, 120}[(b>>3)&3],
+ Voiced: vad && bit1,
+ Active: vad || bit1,
+ Flag2: (b>>2)&1 != 0,
+ Flag0: b&1 != 0,
+ }
+ lg.Trace().Uint8("toc_byte", b).Bool("sid", toc.SID).Bool("vad", toc.VAD).
+ Bool("voiced", toc.Voiced).Bool("active", toc.Active).Int("frame_ms", toc.FrameMs).
+ Int("sample_rate", toc.SampleRate).Msg("parse toc")
+ return toc
+}
diff --git a/pkg/call/voip/media/mlow/vad.go b/pkg/call/voip/media/mlow/vad.go
new file mode 100644
index 00000000..0e3ec581
--- /dev/null
+++ b/pkg/call/voip/media/mlow/vad.go
@@ -0,0 +1,393 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../../LICENSE-WACALLS.
+package mlow
+
+import "math/bits"
+
+// SILK VAD (smpl_vad.c): per-internal-frame speech-activity probability and the
+// coded_as_active_voice flag. Faithful fixed-point port of smpl_VAD_GetSA_Q8_c +
+// GetNoiseLevels + the 2-band allpass filterbank + the per-packet hangover. Runs on
+// raw int16 input PCM at 16 kHz, 320 samples per internal frame.
+//
+// Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/ed12f359a086b28e807ba236f0977af1000859fe/wacore/src/voip/mlow/smpl_vad.rs#L1-L538
+
+// ---- SILK fixed-point primitives ----
+
+const (
+ silkInt32Max = int32(0x7FFFFFFF)
+ // silkInt16Max is defined in lpc.go (32767); reused here.
+ silkInt16Min = int32(-0x8000)
+ silkUint8Max = int32(0xFF)
+)
+
+func sat16(a int32) int32 {
+ if a < silkInt16Min {
+ return silkInt16Min
+ }
+ if a > silkInt16Max {
+ return silkInt16Max
+ }
+ return a
+}
+
+func smulwb(a, b int32) int32 { return int32((int64(a) * int64(int16(b))) >> 16) }
+func smlawb(a, b, c int32) int32 {
+ return int32(int64(a) + ((int64(b) * int64(int16(c))) >> 16))
+}
+func smulww(a, b int32) int32 { return int32((int64(a) * int64(b)) >> 16) }
+func smulbb(a, b int32) int32 { return int32(int16(a)) * int32(int16(b)) }
+func smlabb(a, b, c int32) int32 {
+ return a + int32(int16(b))*int32(int16(c))
+}
+
+func addPosSat32(a, b int32) int32 {
+ if (uint32(a)+uint32(b))&0x80000000 != 0 {
+ return silkInt32Max
+ }
+ return int32(uint32(a) + uint32(b))
+}
+
+func div32(a, b int32) int32 { return a / b }
+
+func clz32(x int32) int32 { return int32(bits.LeadingZeros32(uint32(x))) }
+
+// ror32: rotate right by rot (RotateLeft32 with -k rotates right).
+func ror32(a32, rot int32) int32 {
+ return int32(bits.RotateLeft32(uint32(a32), -int(rot&31)))
+}
+
+func clzFrac(inp int32) (int32, int32) {
+ lz := clz32(inp)
+ fracQ7 := ror32(inp, 24-lz) & 0x7f
+ return lz, fracQ7
+}
+
+// lin2log: approximation of 128 * log2().
+func lin2log(inLin int32) int32 {
+ lz, fracQ7 := clzFrac(inLin)
+ return smlawb(fracQ7, fracQ7*(128-fracQ7), 179) + ((31 - lz) << 7)
+}
+
+func sqrtApprox(x int32) int32 {
+ if x <= 0 {
+ return 0
+ }
+ lz, fracQ7 := clzFrac(x)
+ var y int32
+ if lz&1 != 0 {
+ y = 32768
+ } else {
+ y = 46214
+ }
+ y >>= lz >> 1
+ return smlawb(y, y, smulbb(213, fracQ7))
+}
+
+// sigmQ15: piecewise-linear sigmoid approximation.
+func sigmQ15(inQ5 int32) int32 {
+ slope := [6]int32{237, 153, 73, 30, 12, 7}
+ pos := [6]int32{16384, 23955, 28861, 31213, 32178, 32548}
+ neg := [6]int32{16384, 8812, 3906, 1554, 589, 219}
+ if inQ5 < 0 {
+ inQ5 = -inQ5
+ if inQ5 >= 6*32 {
+ return 0
+ }
+ ind := inQ5 >> 5
+ return neg[ind] - smulbb(slope[ind], inQ5&0x1F)
+ }
+ if inQ5 >= 6*32 {
+ return 32767
+ }
+ ind := inQ5 >> 5
+ return pos[ind] + smulbb(slope[ind], inQ5&0x1F)
+}
+
+// rshiftRound: silk_RSHIFT_ROUND.
+func rshiftRound(a, shift int32) int32 {
+ if shift == 1 {
+ return (a >> 1) + (a & 1)
+ }
+ return ((a >> (shift - 1)) + 1) >> 1
+}
+
+// ---- VAD constants ----
+
+const (
+ vadNBands = 4
+ vadInternalSubframesLog2 = 2
+ vadInternalSubframes = 1 << vadInternalSubframesLog2
+ vadNoiseLevelSmoothCoefQ16 = 1024
+ vadNoiseLevelsBias = 50
+ vadNegativeOffsetQ5 = 128
+ vadSnrFactorQ16 = 45000
+ aFB120 = 3894 << 1
+ aFB121 = -29322
+ speechActivityDtxThresQ8 = 12 // SILK_FIX_CONST(0.05, 8)
+)
+
+var tiltWeights = [vadNBands]int32{30000, 6000, -12000, -12000}
+
+// SmplVadState is the persistent SILK VAD state, carried across packets.
+type SmplVadState struct {
+ anaState [2]int32
+ anaState1 [2]int32
+ anaState2 [2]int32
+ xnrgSubfr [vadNBands]int32
+ nl [vadNBands]int32
+ invNl [vadNBands]int32
+ noiseLevelBias [vadNBands]int32
+ counter int32
+ hpState int32
+ noiseLvlUpdateSpeed int32
+ nonBinariness int32
+ highpassSharpness int32
+ remainingDtxHangover int32
+ hangoverMs int32
+}
+
+type vadType int
+
+const (
+ vadActive vadType = iota
+ vadInactive
+ vadHangover
+)
+
+// VadPacketResult is the VAD output for one 60 ms packet.
+type VadPacketResult struct {
+ VadResults [3]float32
+ CodedAsActiveVoice bool
+}
+
+// NewSmplVadState initializes the VAD (smpl_VAD_Init).
+func NewSmplVadState() *SmplVadState {
+ s := &SmplVadState{counter: 15, remainingDtxHangover: 60, hangoverMs: 60}
+ for b := 0; b < vadNBands; b++ {
+ bias := vadNoiseLevelsBias / (int32(b) + 1)
+ if bias < 1 {
+ bias = 1
+ }
+ s.noiseLevelBias[b] = bias
+ s.nl[b] = 100 * bias
+ s.invNl[b] = silkInt32Max / s.nl[b]
+ }
+ return s
+}
+
+// filtHP: first-order ARMA HP filter with zero at DC, in place over len samples.
+func (s *SmplVadState) filtHP(x []int32, bQ16, aNegQ16 int32, length int) {
+ for i := 0; i < length; i++ {
+ inval := smulwb(bQ16, x[i])
+ outval := sat16(s.hpState - inval)
+ s.hpState = smlawb(inval, aNegQ16, outval)
+ x[i] = outval
+ }
+}
+
+// anaFiltBank1: 2-band split via first-order allpass filters. Writes low band to
+// outL[0..n/2] and high band to outH[0..n/2]; s is the carried 2-element state.
+func anaFiltBank1(inp []int32, s *[2]int32, outL, outH []int32, n int) {
+ n2 := n >> 1
+ for k := 0; k < n2; k++ {
+ in32 := inp[2*k] << 10
+ y := in32 - s[0]
+ x := smlawb(y, y, aFB121)
+ out1 := s[0] + x
+ s[0] = in32 + x
+
+ in32 = inp[2*k+1] << 10
+ y = in32 - s[1]
+ x = smulwb(y, aFB120)
+ out2 := s[1] + x
+ s[1] = in32 + x
+
+ outL[k] = sat16(rshiftRound(out2+out1, 11))
+ outH[k] = sat16(rshiftRound(out2-out1, 11))
+ }
+}
+
+// anaFiltBank1Inplace: in-place 2-band split — reads x[0..n], writes low band to
+// x[0..n/2] and high band to x[hiOff..hiOff+n/2].
+func anaFiltBank1Inplace(x []int32, hiOff int, s *[2]int32, n int) {
+ n2 := n >> 1
+ for k := 0; k < n2; k++ {
+ in32 := x[2*k] << 10
+ y := in32 - s[0]
+ xx := smlawb(y, y, aFB121)
+ out1 := s[0] + xx
+ s[0] = in32 + xx
+
+ in32 = x[2*k+1] << 10
+ y = in32 - s[1]
+ xx = smulwb(y, aFB120)
+ out2 := s[1] + xx
+ s[1] = in32 + xx
+
+ x[hiOff+k] = sat16(rshiftRound(out2-out1, 11))
+ x[k] = sat16(rshiftRound(out2+out1, 11))
+ }
+}
+
+// getNoiseLevels: smpl_VAD_GetNoiseLevels.
+func (s *SmplVadState) getNoiseLevels(pX *[vadNBands]int32) {
+ var minCoef int32
+ if s.counter < 1000 {
+ minCoef = div32(silkInt16Max, (s.counter>>4)+1)
+ s.counter++
+ }
+ for b := 0; b < vadNBands; b++ {
+ nl := s.nl[b]
+ nrg := addPosSat32(pX[b], s.noiseLevelBias[b])
+ invNrg := div32(silkInt32Max, nrg)
+ var coef int32
+ switch {
+ case nrg > (nl << 3):
+ coef = vadNoiseLevelSmoothCoefQ16 >> 3
+ case nrg < nl:
+ coef = vadNoiseLevelSmoothCoefQ16
+ default:
+ coef = smulwb(smulww(invNrg, nl), vadNoiseLevelSmoothCoefQ16<<1)
+ }
+ coef = (coef * (100 + s.noiseLvlUpdateSpeed)) / 100
+ if coef < minCoef {
+ coef = minCoef
+ }
+ s.invNl[b] = smlawb(s.invNl[b], invNrg-s.invNl[b], coef)
+ v := div32(silkInt32Max, s.invNl[b])
+ if v > 0x00FFFFFF {
+ v = 0x00FFFFFF
+ }
+ s.nl[b] = v
+ }
+}
+
+// getSAQ8: smpl_VAD_GetSA_Q8_c — speech_activity_Q8 for one framelen-sample frame.
+func (s *SmplVadState) getSAQ8(pIn []int32, framelen int) int32 {
+ decFl1 := framelen >> 1
+ decFl2 := framelen >> 2
+ decFl3 := framelen >> 3
+
+ var xOffset [vadNBands]int
+ xOffset[0] = 0
+ xOffset[1] = decFl3 + decFl2
+ xOffset[2] = xOffset[1] + decFl3
+ xOffset[3] = xOffset[2] + decFl2
+ xTotal := xOffset[3] + decFl1
+ x := make([]int32, xTotal)
+
+ anaFiltBank1(pIn, &s.anaState, x[:xOffset[3]], x[xOffset[3]:], framelen)
+ anaFiltBank1Inplace(x, xOffset[2], &s.anaState1, decFl1)
+ anaFiltBank1Inplace(x, xOffset[1], &s.anaState2, decFl2)
+
+ // HP filter on the lowest band, -3 dB @ 66 Hz.
+ aNegQ16 := int32(53084)
+ aNegQ16 = (aNegQ16 * (100 - s.highpassSharpness)) / 100
+ bQ16 := (65536 + aNegQ16) / 2
+ s.filtHP(x[:decFl3], bQ16, aNegQ16, decFl3)
+
+ // Energy in each band.
+ var xnrg [vadNBands]int32
+ for b := 0; b < vadNBands; b++ {
+ shift := vadNBands - b
+ if shift > vadNBands-1 {
+ shift = vadNBands - 1
+ }
+ dec := framelen >> shift
+ decSubfrLen := dec >> vadInternalSubframesLog2
+ decSubfrOffset := 0
+ xnrg[b] = s.xnrgSubfr[b]
+ var sumSquared int32
+ for sub := 0; sub < vadInternalSubframes; sub++ {
+ sumSquared = 0
+ for i := 0; i < decSubfrLen; i++ {
+ xTmp := x[xOffset[b]+i+decSubfrOffset] >> 3
+ sumSquared = smlabb(sumSquared, xTmp, xTmp)
+ }
+ if sub < vadInternalSubframes-1 {
+ xnrg[b] = addPosSat32(xnrg[b], sumSquared)
+ } else {
+ xnrg[b] = addPosSat32(xnrg[b], sumSquared>>1)
+ }
+ decSubfrOffset += decSubfrLen
+ }
+ s.xnrgSubfr[b] = sumSquared
+ }
+
+ s.getNoiseLevels(&xnrg)
+
+ // Signal-plus-noise to noise ratio.
+ var sumSquared int32
+ var inputTilt int32
+ for b := 0; b < vadNBands; b++ {
+ speechNrg := xnrg[b] - s.nl[b]
+ if speechNrg > 0 {
+ var ratioQ8 int32
+ if (xnrg[b] & -0x00800000) == 0 { // 0xFF800000 as int32
+ ratioQ8 = div32(xnrg[b]<<8, s.nl[b]+1)
+ } else {
+ ratioQ8 = div32(xnrg[b], (s.nl[b]>>8)+1)
+ }
+ snrQ7 := lin2log(ratioQ8) - 8*128
+ sumSquared = smlabb(sumSquared, snrQ7, snrQ7)
+ if speechNrg < (1 << 20) {
+ snrQ7 = smulwb(sqrtApprox(speechNrg)<<6, snrQ7)
+ }
+ inputTilt = smlawb(inputTilt, tiltWeights[b], snrQ7)
+ }
+ }
+ sumSquared = div32(sumSquared, vadNBands)
+ pSnrDbQ7 := int32(int16(3 * sqrtApprox(sumSquared)))
+
+ vadSnrFactorQ16 := (int32(vadSnrFactorQ16) * (150 - s.nonBinariness)) / 150
+ saQ15 := sigmQ15(smulwb(vadSnrFactorQ16, pSnrDbQ7) - vadNegativeOffsetQ5)
+
+ _ = inputTilt
+ r := saQ15 >> 7
+ if r > silkUint8Max {
+ r = silkUint8Max
+ }
+ return r
+}
+
+// ProcessPacket processes one 60 ms packet (3 internal frames of framelen int16 samples).
+func (s *SmplVadState) ProcessPacket(pcmI16 []int16, framelen int) VadPacketResult {
+ const framesPerPacket = 3
+ const packetMs = 60
+ var vadResults [3]float32
+ // Source of truth: https://github.com/oxidezap/whatsapp-rust/blob/543302e762ef36913b3e2fdf7f84510c43265272/wacore/src/voip/mlow/smpl_vad.rs#L406-L412 (upstream short-packet guard)
+ // Reject a short capture buffer up front so the fixed-stride frame loop can't
+ // index out of range (mirrors the C VAD's short-packet guard).
+ if len(pcmI16) < framesPerPacket*framelen {
+ return VadPacketResult{}
+ }
+ var vt [3]vadType
+ for i := 0; i < framesPerPacket; i++ {
+ t := i * framelen
+ frame := make([]int32, framelen)
+ for j := 0; j < framelen; j++ {
+ frame[j] = int32(pcmI16[t+j])
+ }
+ saQ8 := s.getSAQ8(frame, framelen)
+ vadResults[i] = float32(saQ8) / 256.0
+ if saQ8 > speechActivityDtxThresQ8 {
+ vt[i] = vadActive
+ } else {
+ vt[i] = vadInactive
+ }
+ }
+
+ codedAsActiveVoice := false
+ for i := range vt {
+ if vt[i] == vadActive {
+ s.remainingDtxHangover = s.hangoverMs
+ } else if s.remainingDtxHangover > 0 {
+ vt[i] = vadHangover
+ s.remainingDtxHangover -= packetMs / framesPerPacket
+ }
+ if vt[i] != vadInactive {
+ codedAsActiveVoice = true
+ }
+ }
+
+ return VadPacketResult{VadResults: vadResults, CodedAsActiveVoice: codedAsActiveVoice}
+}
diff --git a/pkg/call/voip/media/mlow_codec.go b/pkg/call/voip/media/mlow_codec.go
new file mode 100644
index 00000000..2a65a4c3
--- /dev/null
+++ b/pkg/call/voip/media/mlow_codec.go
@@ -0,0 +1,74 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "fmt"
+ "sync"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/media/mlow"
+)
+
+type mlowCodec struct {
+ mu sync.Mutex
+ enc *mlow.MlowEncoder
+ dec *mlow.MlowDecoder
+ closed bool
+}
+
+func NewMLowCodec(opts CodecOptions) (Codec, error) {
+ _ = opts
+ return &mlowCodec{enc: mlow.NewMlowEncoder(), dec: mlow.NewMlowDecoder()}, nil
+}
+
+func (c *mlowCodec) Encode(pcm []float32) ([]byte, error) {
+ if len(pcm) == 0 {
+ return nil, nil
+ }
+ if len(pcm) != MLowFrameSize {
+ return nil, fmt.Errorf("%w: got %d samples, want %d", ErrInvalidPCMFrame, len(pcm), MLowFrameSize)
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed || c.enc == nil {
+ return nil, ErrCodecClosed
+ }
+ input := append([]float32(nil), pcm...)
+ defer zeroFloat32(input)
+ encoded, err := c.enc.Encode(input)
+ if err != nil {
+ return nil, fmt.Errorf("encode MLow frame: %w", err)
+ }
+ return append([]byte(nil), encoded...), nil
+}
+
+func (c *mlowCodec) Decode(frame []byte) ([]float32, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed || c.dec == nil {
+ return nil, ErrCodecClosed
+ }
+ input := append([]byte(nil), frame...)
+ defer zeroBytes(input)
+ decoded := c.dec.Decode(input)
+ return NormalizeFrame(decoded, MLowFrameSize), nil
+}
+
+func (c *mlowCodec) FrameSize() int { return MLowFrameSize }
+func (c *mlowCodec) SampleRate() int { return MLowSampleRate }
+
+func (c *mlowCodec) Close() {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ c.closed = true
+ c.enc = nil
+ c.dec = nil
+ c.mu.Unlock()
+}
+
+func zeroFloat32(values []float32) {
+ for index := range values {
+ values[index] = 0
+ }
+}
diff --git a/pkg/call/voip/media/mlow_codec_test.go b/pkg/call/voip/media/mlow_codec_test.go
new file mode 100644
index 00000000..3db45cb5
--- /dev/null
+++ b/pkg/call/voip/media/mlow_codec_test.go
@@ -0,0 +1,83 @@
+package media
+
+import (
+ "errors"
+ "math"
+ "sync"
+ "testing"
+)
+
+func TestMLowCodecAdapterRoundtrip(t *testing.T) {
+ codec, err := NewMLowCodec(DefaultCodecOptions)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer codec.Close()
+ frame := make([]float32, MLowFrameSize)
+ for i := range frame {
+ frame[i] = 0.25 * float32(math.Sin(2*math.Pi*440*float64(i)/MLowSampleRate))
+ }
+ encoded, err := codec.Encode(frame)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(encoded) == 0 {
+ t.Fatal("encoded frame is empty")
+ }
+ decoded, err := codec.Decode(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(decoded) != MLowFrameSize {
+ t.Fatalf("decoded %d samples, want %d", len(decoded), MLowFrameSize)
+ }
+}
+
+func TestMLowCodecPLCAndValidation(t *testing.T) {
+ codec, err := NewMLowCodec(DefaultCodecOptions)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plc, err := codec.Decode(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(plc) != MLowFrameSize {
+ t.Fatalf("PLC returned %d samples", len(plc))
+ }
+ if _, err = codec.Encode(make([]float32, 12)); !errors.Is(err, ErrInvalidPCMFrame) {
+ t.Fatalf("expected ErrInvalidPCMFrame, got %v", err)
+ }
+ codec.Close()
+ if _, err = codec.Decode(nil); !errors.Is(err, ErrCodecClosed) {
+ t.Fatalf("expected ErrCodecClosed, got %v", err)
+ }
+}
+
+func TestMLowCodecSerializesConcurrentUse(t *testing.T) {
+ codec, err := NewMLowCodec(DefaultCodecOptions)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer codec.Close()
+ frame := make([]float32, MLowFrameSize)
+ var wg sync.WaitGroup
+ for worker := 0; worker < 4; worker++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ for attempt := 0; attempt < 4; attempt++ {
+ encoded, encodeErr := codec.Encode(frame)
+ if encodeErr != nil {
+ t.Errorf("encode: %v", encodeErr)
+ return
+ }
+ if _, decodeErr := codec.Decode(encoded); decodeErr != nil {
+ t.Errorf("decode: %v", decodeErr)
+ return
+ }
+ }
+ }()
+ }
+ wg.Wait()
+}
diff --git a/pkg/call/voip/media/packet_registry.go b/pkg/call/voip/media/packet_registry.go
new file mode 100644
index 00000000..63d6a36e
--- /dev/null
+++ b/pkg/call/voip/media/packet_registry.go
@@ -0,0 +1,504 @@
+package media
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/types"
+)
+
+var (
+ ErrPacketSessionNotReady = errors.New("RTP/SRTP packet session is not ready")
+ ErrNonRTPFrame = errors.New("relay frame is not RTP/SRTP")
+)
+
+type PacketSource interface {
+ RelayData(instanceID, callID string) (*core.RelayData, bool)
+ State(instanceID, callID string) (*call_state.Info, bool)
+ SRTPKeying(instanceID, callID, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error)
+}
+
+type packetSRTPCandidate struct {
+ receiveJID string
+ session *SRTPSession
+}
+
+type packetSession struct {
+ mu sync.RWMutex
+
+ srtpCandidates []packetSRTPCandidate
+ activeCandidate int
+ receiveObserved bool
+ rtp *RTPSession
+ selfSSRC uint32
+ peerSSRC uint32
+ peerObserved bool
+}
+
+func newPacketSession(sendKeying, receiveKeying core.SRTPKeyingMaterial, selfSSRC, peerSSRC uint32) (*packetSession, error) {
+ return newPacketSessionCandidates([]packetSRTPCandidateKeying{{receiveJID: "", send: sendKeying, receive: receiveKeying}}, selfSSRC, peerSSRC)
+}
+
+type packetSRTPCandidateKeying struct {
+ receiveJID string
+ send core.SRTPKeyingMaterial
+ receive core.SRTPKeyingMaterial
+}
+
+func newPacketSessionCandidates(keyings []packetSRTPCandidateKeying, selfSSRC, peerSSRC uint32) (*packetSession, error) {
+ if selfSSRC == 0 || peerSSRC == 0 {
+ return nil, fmt.Errorf("RTP SSRC values must be non-zero")
+ }
+ if len(keyings) == 0 {
+ return nil, fmt.Errorf("at least one SRTP receive candidate is required")
+ }
+
+ candidates := make([]packetSRTPCandidate, 0, len(keyings))
+ for _, keying := range keyings {
+ srtp, err := NewSRTPSession(keying.send, keying.receive, core.SRTPSendAuthTagLen, core.SRTPRecvAuthTagLen)
+ if err != nil {
+ for index := range candidates {
+ candidates[index].session.Close()
+ }
+ return nil, err
+ }
+ candidates = append(candidates, packetSRTPCandidate{receiveJID: keying.receiveJID, session: srtp})
+ }
+
+ rtp, err := NewWhatsAppOpusRTPSession(selfSSRC)
+ if err != nil {
+ for index := range candidates {
+ candidates[index].session.Close()
+ }
+ return nil, err
+ }
+ return &packetSession{
+ srtpCandidates: candidates,
+ rtp: rtp,
+ selfSSRC: selfSSRC,
+ peerSSRC: peerSSRC,
+ }, nil
+}
+
+func (s *packetSession) protectOpus(payload []byte, durationSamples uint32, marker bool) ([]byte, error) {
+ if s == nil {
+ return nil, ErrPacketSessionNotReady
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ if len(s.srtpCandidates) == 0 || s.srtpCandidates[0].session == nil || s.rtp == nil {
+ return nil, ErrPacketSessionNotReady
+ }
+ packet := s.rtp.CreatePacketWithDuration(payload, durationSamples, marker)
+ defer packet.Wipe()
+ return s.srtpCandidates[0].session.Protect(packet)
+}
+
+func (s *packetSession) unprotect(frame []byte) (*RTPPacket, uint32, uint32, bool, string, string, bool, error) {
+ if s == nil {
+ return nil, 0, 0, false, "", "", false, ErrPacketSessionNotReady
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if len(s.srtpCandidates) == 0 {
+ return nil, 0, 0, false, "", "", false, ErrPacketSessionNotReady
+ }
+
+ previous, actual, first, err := s.peerSSRCCandidate(frame)
+ if err != nil {
+ return nil, previous, actual, false, "", "", false, err
+ }
+
+ order := make([]int, 0, len(s.srtpCandidates))
+ if s.activeCandidate >= 0 && s.activeCandidate < len(s.srtpCandidates) {
+ order = append(order, s.activeCandidate)
+ }
+ for index := range s.srtpCandidates {
+ if index != s.activeCandidate {
+ order = append(order, index)
+ }
+ }
+
+ var packet *RTPPacket
+ var authErrors []error
+ selected := -1
+ for _, index := range order {
+ candidate := s.srtpCandidates[index]
+ if candidate.session == nil {
+ continue
+ }
+ packet, err = candidate.session.Unprotect(frame)
+ if err == nil {
+ selected = index
+ break
+ }
+ if !isSRTPAuthenticationFailure(err) {
+ return nil, previous, actual, false, "", "", false, err
+ }
+ authErrors = append(authErrors, fmt.Errorf("receive_jid=%s: %w", candidate.receiveJID, err))
+ }
+ if selected < 0 {
+ return nil, previous, actual, false, "", "", false,
+ fmt.Errorf("SRTP authentication failed for %d receive key candidates: %w", len(authErrors), errors.Join(authErrors...))
+ }
+
+ if packet.Header.SSRC != actual {
+ got := packet.Header.SSRC
+ packet.Wipe()
+ return nil, previous, actual, false, "", "", false, fmt.Errorf("authenticated RTP SSRC mismatch: header=%d frame=%d", got, actual)
+ }
+ if packet.Header.PayloadType != core.PayloadTypeWhatsAppOpus {
+ got := packet.Header.PayloadType
+ packet.Wipe()
+ return nil, previous, actual, false, "", "", false, fmt.Errorf("unexpected RTP payload type: %d", got)
+ }
+
+ previousReceiveJID := ""
+ if s.receiveObserved && s.activeCandidate >= 0 && s.activeCandidate < len(s.srtpCandidates) {
+ previousReceiveJID = s.srtpCandidates[s.activeCandidate].receiveJID
+ }
+ selectedReceiveJID := s.srtpCandidates[selected].receiveJID
+ receiveChanged := !s.receiveObserved || selected != s.activeCandidate
+ s.activeCandidate = selected
+ s.receiveObserved = true
+
+ ssrcChanged := false
+ if first {
+ previous, ssrcChanged = s.commitPeerSSRC(actual)
+ }
+ return packet, previous, actual, ssrcChanged, previousReceiveJID, selectedReceiveJID, receiveChanged, nil
+}
+
+func isSRTPAuthenticationFailure(err error) bool {
+ var srtpErr *SRTPError
+ return errors.As(err, &srtpErr) && srtpErr.Type == SRTPErrAuthFailed
+}
+
+func (s *packetSession) close() {
+ if s == nil {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ for index := range s.srtpCandidates {
+ if s.srtpCandidates[index].session != nil {
+ s.srtpCandidates[index].session.Close()
+ }
+ s.srtpCandidates[index].session = nil
+ s.srtpCandidates[index].receiveJID = ""
+ }
+ s.srtpCandidates = nil
+ s.activeCandidate = 0
+ s.receiveObserved = false
+ s.rtp = nil
+ s.selfSSRC = 0
+ s.peerSSRC = 0
+ s.peerObserved = false
+}
+
+type PacketRegistry struct {
+ mu sync.RWMutex
+ source PacketSource
+ clients map[string]*whatsmeow.Client
+ sessions map[string]map[string]*packetSession
+ onRTP func(instanceID, callID string, packet *RTPPacket)
+ onPeerSSRC func(instanceID, callID string, previous, actual uint32)
+}
+
+func NewPacketRegistry(source PacketSource) *PacketRegistry {
+ return &PacketRegistry{
+ source: source,
+ clients: make(map[string]*whatsmeow.Client),
+ sessions: make(map[string]map[string]*packetSession),
+ }
+}
+
+func (r *PacketRegistry) SetOnRTP(callback func(instanceID, callID string, packet *RTPPacket)) {
+ r.mu.Lock()
+ r.onRTP = callback
+ r.mu.Unlock()
+}
+
+func (r *PacketRegistry) SetOnPeerSSRC(callback func(instanceID, callID string, previous, actual uint32)) {
+ r.mu.Lock()
+ r.onPeerSSRC = callback
+ r.mu.Unlock()
+}
+
+func (r *PacketRegistry) Attach(instanceID string, client *whatsmeow.Client) {
+ if r == nil || instanceID == "" || client == nil {
+ return
+ }
+ r.mu.Lock()
+ previous := r.clients[instanceID]
+ r.clients[instanceID] = client
+ if previous != nil && previous != client {
+ sessions := r.sessions[instanceID]
+ delete(r.sessions, instanceID)
+ r.mu.Unlock()
+ closePacketSessions(sessions)
+ attachPeerCallKeyObserver(r, instanceID, client)
+ return
+ }
+ r.mu.Unlock()
+ attachPeerCallKeyObserver(r, instanceID, client)
+}
+
+func (r *PacketRegistry) Prepare(instanceID, callID string) error {
+ if r == nil || r.source == nil {
+ return ErrPacketSessionNotReady
+ }
+ r.mu.RLock()
+ client := r.clients[instanceID]
+ if calls := r.sessions[instanceID]; calls != nil && calls[callID] != nil {
+ r.mu.RUnlock()
+ return nil
+ }
+ r.mu.RUnlock()
+ if client == nil {
+ return fmt.Errorf("packet runtime is not attached for instance %s", instanceID)
+ }
+
+ state, ok := r.source.State(instanceID, callID)
+ if !ok || state == nil {
+ return fmt.Errorf("call %s has no private state", callID)
+ }
+ relayData, ok := r.source.RelayData(instanceID, callID)
+ if !ok || relayData == nil {
+ return fmt.Errorf("call %s has no relay data", callID)
+ }
+ defer core.ZeroRelayData(relayData)
+
+ ownJID := ownClientJID(client)
+ peerJID, err := types.ParseJID(state.PeerJID)
+ if err != nil || ownJID.IsEmpty() || peerJID.IsEmpty() {
+ return fmt.Errorf("resolve RTP participants for call %s", callID)
+ }
+ creatorJID, _ := types.ParseJID(state.CallCreator)
+ selfDevice, peerDevice := selectCallDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID, creatorJID)
+ selfSSRC, err := GenerateSecureSSRC(callID, selfDevice, 0)
+ if err != nil {
+ return err
+ }
+ peerSSRC, err := GenerateSecureSSRC(callID, peerDevice, 0)
+ if err != nil {
+ return err
+ }
+
+ receiveJIDs := receiveSRTPJIDCandidates(state, peerDevice)
+ return r.PrepareWithDeviceCandidates(instanceID, callID, selfDevice, receiveJIDs, selfSSRC, peerSSRC)
+}
+
+func receiveSRTPJIDCandidates(state *call_state.Info, relayPeerDevice string) []string {
+ if state == nil {
+ return uniqueDeviceJIDs(relayPeerDevice)
+ }
+ peerAccount := ensureDeviceJIDString(state.PeerJID)
+ creator := ""
+ if state.Direction == core.CallDirectionIncoming {
+ creator = ensureDeviceJIDString(state.CallCreator)
+ return uniqueDeviceJIDs(relayPeerDevice, creator, peerAccount)
+ }
+ return uniqueDeviceJIDs(peerAccount, relayPeerDevice)
+}
+
+func uniqueDeviceJIDs(values ...string) []string {
+ seen := make(map[string]struct{}, len(values))
+ output := make([]string, 0, len(values))
+ for _, value := range values {
+ value = ensureDeviceJIDString(value)
+ if value == "" {
+ continue
+ }
+ if _, exists := seen[value]; exists {
+ continue
+ }
+ seen[value] = struct{}{}
+ output = append(output, value)
+ }
+ return output
+}
+
+func (r *PacketRegistry) PrepareWithDevices(instanceID, callID, selfDeviceJID, peerDeviceJID string, selfSSRC, peerSSRC uint32) error {
+ return r.PrepareWithDeviceCandidates(instanceID, callID, selfDeviceJID, []string{peerDeviceJID}, selfSSRC, peerSSRC)
+}
+
+func (r *PacketRegistry) PrepareWithDeviceCandidates(instanceID, callID, selfDeviceJID string, receiveJIDs []string, selfSSRC, peerSSRC uint32) error {
+ if r == nil || r.source == nil {
+ return ErrPacketSessionNotReady
+ }
+ receiveJIDs = uniqueDeviceJIDs(receiveJIDs...)
+ if len(receiveJIDs) == 0 {
+ return fmt.Errorf("call %s has no SRTP receive JID candidates", callID)
+ }
+
+ keyings, err := buildPacketSRTPCandidates(r, instanceID, callID, selfDeviceJID, receiveJIDs)
+ if err != nil {
+ return err
+ }
+ defer wipePacketCandidateKeyings(keyings)
+
+ candidate, err := newPacketSessionCandidates(keyings, selfSSRC, peerSSRC)
+ if err != nil {
+ return err
+ }
+
+ r.mu.Lock()
+ calls := r.sessions[instanceID]
+ if calls == nil {
+ calls = make(map[string]*packetSession)
+ r.sessions[instanceID] = calls
+ }
+ previous := calls[callID]
+ calls[callID] = candidate
+ r.mu.Unlock()
+ if previous != nil {
+ previous.close()
+ }
+
+ labels := make([]string, 0, len(keyings))
+ for _, keying := range keyings {
+ labels = append(labels, keying.receiveJID)
+ }
+ slog.Info("WhatsApp SRTP receive candidates prepared",
+ "instance", instanceID,
+ "call_id", callID,
+ "self_jid", selfDeviceJID,
+ "receive_jids", labels,
+ )
+ return nil
+}
+
+func (r *PacketRegistry) ProtectOpus(instanceID, callID string, payload []byte, durationSamples uint32, marker bool) ([]byte, error) {
+ session, err := r.packetSession(instanceID, callID, true)
+ if err != nil {
+ return nil, err
+ }
+ return session.protectOpus(payload, durationSamples, marker)
+}
+
+func (r *PacketRegistry) Unprotect(instanceID, callID string, frame []byte) (*RTPPacket, error) {
+ if len(frame) < 2 || frame[0]&0xc0 != 0x80 {
+ return nil, ErrNonRTPFrame
+ }
+ session, err := r.packetSession(instanceID, callID, true)
+ if err != nil {
+ return nil, err
+ }
+ packet, previous, actual, ssrcChanged, previousReceiveJID, selectedReceiveJID, receiveChanged, err := session.unprotect(frame)
+ if err != nil {
+ return nil, err
+ }
+ if receiveChanged {
+ slog.Info("WhatsApp SRTP receive key selected",
+ "instance", instanceID,
+ "call_id", callID,
+ "previous_receive_jid", previousReceiveJID,
+ "receive_jid", selectedReceiveJID,
+ )
+ }
+ if ssrcChanged {
+ r.mu.RLock()
+ callback := r.onPeerSSRC
+ r.mu.RUnlock()
+ if callback != nil {
+ callback(instanceID, callID, previous, actual)
+ }
+ }
+ return packet, nil
+}
+
+func (r *PacketRegistry) Handle(instanceID, callID string, frame []byte) error {
+ packet, err := r.Unprotect(instanceID, callID, frame)
+ if err != nil {
+ return err
+ }
+ defer packet.Wipe()
+ r.mu.RLock()
+ callback := r.onRTP
+ r.mu.RUnlock()
+ if callback != nil {
+ callback(instanceID, callID, packet)
+ }
+ return nil
+}
+
+func (r *PacketRegistry) packetSession(instanceID, callID string, lazyPrepare bool) (*packetSession, error) {
+ r.mu.RLock()
+ calls := r.sessions[instanceID]
+ session := calls[callID]
+ r.mu.RUnlock()
+ if session != nil {
+ return session, nil
+ }
+ if lazyPrepare {
+ if err := r.Prepare(instanceID, callID); err != nil {
+ return nil, err
+ }
+ r.mu.RLock()
+ session = r.sessions[instanceID][callID]
+ r.mu.RUnlock()
+ if session != nil {
+ return session, nil
+ }
+ }
+ return nil, ErrPacketSessionNotReady
+}
+
+func (r *PacketRegistry) Remove(instanceID, callID string) {
+ if r == nil {
+ return
+ }
+ r.mu.Lock()
+ calls := r.sessions[instanceID]
+ session := calls[callID]
+ delete(calls, callID)
+ if len(calls) == 0 {
+ delete(r.sessions, instanceID)
+ }
+ r.mu.Unlock()
+ if session != nil {
+ session.close()
+ }
+ removePeerCallKey(r, instanceID, callID)
+}
+
+func (r *PacketRegistry) Close(instanceID string) {
+ if r == nil {
+ return
+ }
+ detachPeerCallKeyObserver(r, instanceID)
+ r.mu.Lock()
+ delete(r.clients, instanceID)
+ sessions := r.sessions[instanceID]
+ delete(r.sessions, instanceID)
+ r.mu.Unlock()
+ closePacketSessions(sessions)
+}
+
+func closePacketSessions(sessions map[string]*packetSession) {
+ for callID, session := range sessions {
+ if session != nil {
+ session.close()
+ }
+ delete(sessions, callID)
+ }
+}
+
+func ownClientJID(client *whatsmeow.Client) types.JID {
+ if client == nil {
+ return types.JID{}
+ }
+ socket := wa.NewSocket(client)
+ jid := socket.OwnLID()
+ if jid.IsEmpty() {
+ jid = socket.OwnPN()
+ }
+ return jid
+}
diff --git a/pkg/call/voip/media/packet_registry_candidates_test.go b/pkg/call/voip/media/packet_registry_candidates_test.go
new file mode 100644
index 00000000..cfc18d2b
--- /dev/null
+++ b/pkg/call/voip/media/packet_registry_candidates_test.go
@@ -0,0 +1,162 @@
+package media
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestPacketRegistryFallsBackToAuthenticatedReceiveJID(t *testing.T) {
+ callKey := bytes.Repeat([]byte{0x31}, 32)
+ registry := NewPacketRegistry(&fakePacketSource{callKey: callKey})
+ const (
+ instanceID = "instance-candidates"
+ callID = "call-candidates"
+ selfDevice = "self:3@lid"
+ wrongPeer = "peer:99@hosted.lid"
+ actualPeer = "peer:0@lid"
+ selfSSRC = uint32(0x10101010)
+ peerSSRC = uint32(0x20202020)
+ )
+
+ if err := registry.PrepareWithDeviceCandidates(
+ instanceID,
+ callID,
+ selfDevice,
+ []string{wrongPeer, actualPeer},
+ selfSSRC,
+ peerSSRC,
+ ); err != nil {
+ t.Fatal(err)
+ }
+ defer registry.Close(instanceID)
+
+ peerSend, err := DerivePerJIDSRTPKey(callKey, actualPeer)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSend.Wipe()
+ peerReceive, err := DerivePerJIDSRTPKey(callKey, selfDevice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerReceive.Wipe()
+ peerSession, err := NewSRTPSession(peerSend, peerReceive, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSession.Close()
+
+ peerRTP, err := NewWhatsAppOpusRTPSession(peerSSRC)
+ if err != nil {
+ t.Fatal(err)
+ }
+ frame, err := peerSession.Protect(peerRTP.CreatePacket([]byte("authenticated peer audio"), true))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ packet, err := registry.Unprotect(instanceID, callID, frame)
+ if err != nil {
+ t.Fatalf("expected fallback receive key to authenticate packet: %v", err)
+ }
+ defer packet.Wipe()
+ if !bytes.Equal(packet.Payload, []byte("authenticated peer audio")) {
+ t.Fatalf("unexpected payload: %q", packet.Payload)
+ }
+
+ session, err := registry.packetSession(instanceID, callID, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.mu.RLock()
+ selected := session.srtpCandidates[session.activeCandidate].receiveJID
+ observed := session.receiveObserved
+ session.mu.RUnlock()
+ if !observed || selected != actualPeer {
+ t.Fatalf("unexpected selected receive JID: observed=%v selected=%s", observed, selected)
+ }
+}
+
+func TestPacketRegistryAcceptsPeerProvidedCallKey(t *testing.T) {
+ originalCallKey := bytes.Repeat([]byte{0x41}, 32)
+ remoteCallKey := bytes.Repeat([]byte{0x52}, 32)
+ registry := NewPacketRegistry(&fakePacketSource{callKey: originalCallKey})
+ const (
+ instanceID = "instance-peer-key"
+ callID = "call-peer-key"
+ selfDevice = "self:3@lid"
+ peerDevice = "peer:0@lid"
+ selfSSRC = uint32(0x30303030)
+ peerSSRC = uint32(0x40404040)
+ )
+ acceptedPeer := types.NewJID("peer", types.HiddenUserServer)
+
+ peerCallKeyObservers.Lock()
+ peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{
+ instanceID: {
+ keys: map[string]storedPeerCallKey{
+ callID: {key: append([]byte(nil), remoteCallKey...), peers: []types.JID{acceptedPeer}},
+ },
+ },
+ }
+ peerCallKeyObservers.Unlock()
+ defer registry.Close(instanceID)
+
+ if err := registry.PrepareWithDeviceCandidates(
+ instanceID,
+ callID,
+ selfDevice,
+ []string{peerDevice},
+ selfSSRC,
+ peerSSRC,
+ ); err != nil {
+ t.Fatal(err)
+ }
+
+ peerSend, err := DerivePerJIDSRTPKey(remoteCallKey, peerDevice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSend.Wipe()
+ peerReceive, err := DerivePerJIDSRTPKey(originalCallKey, selfDevice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerReceive.Wipe()
+ peerSession, err := NewSRTPSession(peerSend, peerReceive, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSession.Close()
+
+ peerRTP, err := NewWhatsAppOpusRTPSession(peerSSRC)
+ if err != nil {
+ t.Fatal(err)
+ }
+ frame, err := peerSession.Protect(peerRTP.CreatePacket([]byte("peer-key audio"), true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ packet, err := registry.Unprotect(instanceID, callID, frame)
+ if err != nil {
+ t.Fatalf("expected peer-provided call key to authenticate packet: %v", err)
+ }
+ defer packet.Wipe()
+ if string(packet.Payload) != "peer-key audio" {
+ t.Fatalf("unexpected payload: %q", packet.Payload)
+ }
+
+ session, err := registry.packetSession(instanceID, callID, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ session.mu.RLock()
+ selected := session.srtpCandidates[session.activeCandidate].receiveJID
+ session.mu.RUnlock()
+ if selected != peerDevice+" (peer-key)" {
+ t.Fatalf("unexpected peer-key candidate selected: %s", selected)
+ }
+}
diff --git a/pkg/call/voip/media/packet_registry_race_test.go b/pkg/call/voip/media/packet_registry_race_test.go
new file mode 100644
index 00000000..3d91e485
--- /dev/null
+++ b/pkg/call/voip/media/packet_registry_race_test.go
@@ -0,0 +1,36 @@
+package media
+
+import (
+ "bytes"
+ "sync"
+ "testing"
+)
+
+func TestPacketRegistryConcurrentProtectAndRemove(t *testing.T) {
+ registry := NewPacketRegistry(&fakePacketSource{callKey: bytes.Repeat([]byte{0x8d}, 32)})
+ const (
+ instanceID = "race-instance"
+ callID = "race-call"
+ )
+ if err := registry.PrepareWithDevices(instanceID, callID, "self@lid", "peer@lid", 303, 404); err != nil {
+ t.Fatal(err)
+ }
+
+ var wait sync.WaitGroup
+ wait.Add(2)
+ go func() {
+ defer wait.Done()
+ for index := 0; index < 200; index++ {
+ _, _ = registry.ProtectOpus(instanceID, callID, []byte{byte(index)}, 960, index == 0)
+ }
+ }()
+ go func() {
+ defer wait.Done()
+ for index := 0; index < 20; index++ {
+ registry.Remove(instanceID, callID)
+ _ = registry.PrepareWithDevices(instanceID, callID, "self@lid", "peer@lid", 303, 404)
+ }
+ }()
+ wait.Wait()
+ registry.Close(instanceID)
+}
diff --git a/pkg/call/voip/media/packet_registry_refresh.go b/pkg/call/voip/media/packet_registry_refresh.go
new file mode 100644
index 00000000..8d2e518d
--- /dev/null
+++ b/pkg/call/voip/media/packet_registry_refresh.go
@@ -0,0 +1,26 @@
+package media
+
+// dropSession removes only the RTP/SRTP packet state for a call. Unlike
+// Remove, it deliberately preserves a peer-provided call key so the session can
+// be rebuilt immediately after a CallAccept carrying a new payload.
+func (r *PacketRegistry) dropSession(instanceID, callID string) {
+ if r == nil || instanceID == "" || callID == "" {
+ return
+ }
+
+ r.mu.Lock()
+ calls := r.sessions[instanceID]
+ var session *packetSession
+ if calls != nil {
+ session = calls[callID]
+ delete(calls, callID)
+ if len(calls) == 0 {
+ delete(r.sessions, instanceID)
+ }
+ }
+ r.mu.Unlock()
+
+ if session != nil {
+ session.close()
+ }
+}
diff --git a/pkg/call/voip/media/packet_registry_refresh_test.go b/pkg/call/voip/media/packet_registry_refresh_test.go
new file mode 100644
index 00000000..5737c98c
--- /dev/null
+++ b/pkg/call/voip/media/packet_registry_refresh_test.go
@@ -0,0 +1,47 @@
+package media
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestDropSessionPreservesPeerCallKeyUntilFinalRemove(t *testing.T) {
+ registry := NewPacketRegistry(&fakePacketSource{callKey: bytes.Repeat([]byte{0x41}, 32)})
+ const (
+ instanceID = "refresh-instance"
+ callID = "refresh-call"
+ )
+ if err := registry.PrepareWithDevices(instanceID, callID, "self:1@lid", "peer:2@lid", 101, 202); err != nil {
+ t.Fatal(err)
+ }
+
+ peer := types.NewJID("peer", types.HiddenUserServer)
+ peerCallKeyObservers.Lock()
+ peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{
+ instanceID: {
+ keys: map[string]storedPeerCallKey{
+ callID: {key: bytes.Repeat([]byte{0x52}, 32), peers: []types.JID{peer}},
+ },
+ },
+ }
+ peerCallKeyObservers.Unlock()
+ defer detachPeerCallKeyObserver(registry, instanceID)
+
+ registry.dropSession(instanceID, callID)
+ if _, err := registry.packetSession(instanceID, callID, false); !errors.Is(err, ErrPacketSessionNotReady) {
+ t.Fatalf("packet session remained after refresh drop: %v", err)
+ }
+ key, peers, ok := peerCallKey(registry, instanceID, callID)
+ if !ok || len(key) != 32 || len(peers) != 1 || peers[0].String() != peer.String() {
+ t.Fatalf("peer key was not preserved: ok=%v key=%d peers=%#v", ok, len(key), peers)
+ }
+ zeroBytes(key)
+
+ registry.Remove(instanceID, callID)
+ if _, _, ok = peerCallKey(registry, instanceID, callID); ok {
+ t.Fatal("final Remove did not wipe the peer call key")
+ }
+}
diff --git a/pkg/call/voip/media/packet_registry_test.go b/pkg/call/voip/media/packet_registry_test.go
new file mode 100644
index 00000000..a671c462
--- /dev/null
+++ b/pkg/call/voip/media/packet_registry_test.go
@@ -0,0 +1,207 @@
+package media
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+type fakePacketSource struct {
+ callKey []byte
+}
+
+func (f *fakePacketSource) RelayData(string, string) (*core.RelayData, bool) {
+ return nil, false
+}
+
+func (f *fakePacketSource) State(string, string) (*call_state.Info, bool) {
+ return nil, false
+}
+
+func (f *fakePacketSource) SRTPKeying(_, _, selfDeviceJID, peerDeviceJID string) (core.SRTPKeyingMaterial, core.SRTPKeyingMaterial, error) {
+ send, err := DerivePerJIDSRTPKey(f.callKey, selfDeviceJID)
+ if err != nil {
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, err
+ }
+ receive, err := DerivePerJIDSRTPKey(f.callKey, peerDeviceJID)
+ if err != nil {
+ send.Wipe()
+ return core.SRTPKeyingMaterial{}, core.SRTPKeyingMaterial{}, err
+ }
+ return send, receive, nil
+}
+
+func TestPacketRegistryProtectsAndUnprotectsOpus(t *testing.T) {
+ callKey := bytes.Repeat([]byte{0x5a}, 32)
+ source := &fakePacketSource{callKey: callKey}
+ registry := NewPacketRegistry(source)
+ const (
+ instanceID = "instance-1"
+ callID = "call-1"
+ selfDevice = "5511000000000:1@lid"
+ peerDevice = "5511999999999:2@lid"
+ selfSSRC = uint32(0x11223344)
+ peerSSRC = uint32(0x55667788)
+ )
+ if err := registry.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, peerSSRC); err != nil {
+ t.Fatal(err)
+ }
+ defer registry.Close(instanceID)
+
+ peerSend, err := DerivePerJIDSRTPKey(callKey, peerDevice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSend.Wipe()
+ peerReceive, err := DerivePerJIDSRTPKey(callKey, selfDevice)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerReceive.Wipe()
+ peerSession, err := NewSRTPSession(peerSend, peerReceive, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSession.Close()
+ peerRTP, err := NewWhatsAppOpusRTPSession(peerSSRC)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ incomingPayload := []byte("peer opus frame")
+ incomingFrame, err := peerSession.Protect(peerRTP.CreatePacket(incomingPayload, true))
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := registry.Unprotect(instanceID, callID, incomingFrame)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !bytes.Equal(decoded.Payload, incomingPayload) || decoded.Header.SSRC != peerSSRC {
+ t.Fatalf("unexpected decoded packet: ssrc=%d payload=%q", decoded.Header.SSRC, decoded.Payload)
+ }
+ decoded.Wipe()
+
+ outgoingPayload := []byte("local opus frame")
+ outgoingFrame, err := registry.ProtectOpus(instanceID, callID, outgoingPayload, 960, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ peerDecoded, err := peerSession.Unprotect(outgoingFrame)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerDecoded.Wipe()
+ if !bytes.Equal(peerDecoded.Payload, outgoingPayload) || peerDecoded.Header.SSRC != selfSSRC {
+ t.Fatalf("unexpected peer packet: ssrc=%d payload=%q", peerDecoded.Header.SSRC, peerDecoded.Payload)
+ }
+}
+
+func TestPacketRegistryHandleInvokesCallbackAndRejectsNonRTP(t *testing.T) {
+ callKey := bytes.Repeat([]byte{0x6b}, 32)
+ registry := NewPacketRegistry(&fakePacketSource{callKey: callKey})
+ const (
+ instanceID = "instance-2"
+ callID = "call-2"
+ selfDevice = "self:1@lid"
+ peerDevice = "peer:2@lid"
+ selfSSRC = uint32(101)
+ peerSSRC = uint32(202)
+ )
+ if err := registry.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, peerSSRC); err != nil {
+ t.Fatal(err)
+ }
+ defer registry.Close(instanceID)
+
+ peerSend, _ := DerivePerJIDSRTPKey(callKey, peerDevice)
+ peerReceive, _ := DerivePerJIDSRTPKey(callKey, selfDevice)
+ defer peerSend.Wipe()
+ defer peerReceive.Wipe()
+ peerSession, err := NewSRTPSession(peerSend, peerReceive, 4, 4)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer peerSession.Close()
+ peerRTP, _ := NewWhatsAppOpusRTPSession(peerSSRC)
+ frame, err := peerSession.Protect(peerRTP.CreatePacket([]byte{1, 2, 3}, false))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ called := false
+ registry.SetOnRTP(func(gotInstance, gotCall string, packet *RTPPacket) {
+ called = true
+ if gotInstance != instanceID || gotCall != callID || !bytes.Equal(packet.Payload, []byte{1, 2, 3}) {
+ t.Fatalf("unexpected callback data: %s %s %v", gotInstance, gotCall, packet.Payload)
+ }
+ })
+ if err = registry.Handle(instanceID, callID, frame); err != nil {
+ t.Fatal(err)
+ }
+ if !called {
+ t.Fatal("RTP callback was not invoked")
+ }
+ if err = registry.Handle(instanceID, callID, []byte{0x00, 0x01}); !errors.Is(err, ErrNonRTPFrame) {
+ t.Fatalf("expected non-RTP error, got %v", err)
+ }
+}
+
+func TestPacketRegistryAdoptsFirstAuthenticatedPeerSSRC(t *testing.T) {
+ callKey := bytes.Repeat([]byte{0x7c}, 32)
+ registry := NewPacketRegistry(&fakePacketSource{callKey: callKey})
+ const (
+ instanceID = "instance"
+ callID = "call"
+ selfDevice = "self:1@lid"
+ peerDevice = "peer:2@lid"
+ selfSSRC = uint32(11)
+ predicted = uint32(22)
+ actual = uint32(99)
+ )
+ if err := registry.PrepareWithDevices(instanceID, callID, selfDevice, peerDevice, selfSSRC, predicted); err != nil {
+ t.Fatal(err)
+ }
+ defer registry.Close(instanceID)
+
+ peerSend, _ := DerivePerJIDSRTPKey(callKey, peerDevice)
+ peerReceive, _ := DerivePerJIDSRTPKey(callKey, selfDevice)
+ defer peerSend.Wipe()
+ defer peerReceive.Wipe()
+ peerSession, _ := NewSRTPSession(peerSend, peerReceive, 4, 4)
+ defer peerSession.Close()
+ actualRTP, _ := NewWhatsAppOpusRTPSession(actual)
+ frame, err := peerSession.Protect(actualRTP.CreatePacket([]byte{1}, false))
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ var gotPrevious, gotActual uint32
+ registry.SetOnPeerSSRC(func(_, _ string, previous, observed uint32) {
+ gotPrevious, gotActual = previous, observed
+ })
+ packet, err := registry.Unprotect(instanceID, callID, frame)
+ if err != nil {
+ t.Fatalf("first authenticated SSRC should be adopted: %v", err)
+ }
+ packet.Wipe()
+ if gotPrevious != predicted || gotActual != actual {
+ t.Fatalf("unexpected SSRC callback: previous=%d actual=%d", gotPrevious, gotActual)
+ }
+
+ session, err := registry.packetSession(instanceID, callID, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ otherFrame := make([]byte, 12)
+ otherFrame[0] = 0x80
+ otherFrame[8] = 0
+ otherFrame[9] = 0
+ otherFrame[10] = 0
+ otherFrame[11] = 100
+ if _, _, _, err = session.peerSSRCCandidate(otherFrame); err == nil {
+ t.Fatal("expected later peer SSRC changes to be rejected")
+ }
+}
diff --git a/pkg/call/voip/media/peer_call_key.go b/pkg/call/voip/media/peer_call_key.go
new file mode 100644
index 00000000..0595046e
--- /dev/null
+++ b/pkg/call/voip/media/peer_call_key.go
@@ -0,0 +1,391 @@
+package media
+
+import (
+ "context"
+ "crypto/sha256"
+ "crypto/subtle"
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+const peerCallKeyDecryptTimeout = 5 * time.Second
+
+type storedPeerCallKey struct {
+ key []byte
+ peers []types.JID
+}
+
+type peerCallKeyObserver struct {
+ client *whatsmeow.Client
+ handlerID uint32
+ keys map[string]storedPeerCallKey
+}
+
+var peerCallKeyObservers = struct {
+ sync.Mutex
+ registries map[*PacketRegistry]map[string]*peerCallKeyObserver
+}{registries: make(map[*PacketRegistry]map[string]*peerCallKeyObserver)}
+
+func attachPeerCallKeyObserver(registry *PacketRegistry, instanceID string, client *whatsmeow.Client) {
+ if registry == nil || instanceID == "" || client == nil {
+ return
+ }
+
+ observer := &peerCallKeyObserver{client: client, keys: make(map[string]storedPeerCallKey)}
+
+ // Reserve the instance slot before registering the callback. This serializes
+ // rapid reconnects: a later client replaces the earlier reservation, while
+ // duplicate attaches of the same client become no-ops.
+ peerCallKeyObservers.Lock()
+ instances := peerCallKeyObservers.registries[registry]
+ if instances == nil {
+ instances = make(map[string]*peerCallKeyObserver)
+ peerCallKeyObservers.registries[registry] = instances
+ }
+ previous := instances[instanceID]
+ if previous != nil && previous.client == client {
+ peerCallKeyObservers.Unlock()
+ return
+ }
+ instances[instanceID] = observer
+ peerCallKeyObservers.Unlock()
+
+ if previous != nil {
+ if previous.client != nil && previous.handlerID != 0 {
+ previous.client.RemoveEventHandler(previous.handlerID)
+ }
+ wipePeerObserver(previous)
+ }
+
+ handlerID := client.AddEventHandler(func(rawEvent interface{}) {
+ switch event := rawEvent.(type) {
+ case *events.CallAccept:
+ capturePeerCallKey(registry, instanceID, client, event)
+ case *events.CallReject:
+ removePeerCallKeyForClient(registry, instanceID, client, event.CallID)
+ case *events.CallTerminate:
+ removePeerCallKeyForClient(registry, instanceID, client, event.CallID)
+ case *events.Disconnected:
+ clearPeerCallKeysForClient(registry, instanceID, client)
+ case *events.LoggedOut:
+ clearPeerCallKeysForClient(registry, instanceID, client)
+ }
+ })
+
+ peerCallKeyObservers.Lock()
+ instances = peerCallKeyObservers.registries[registry]
+ if instances == nil || instances[instanceID] != observer {
+ peerCallKeyObservers.Unlock()
+ client.RemoveEventHandler(handlerID)
+ wipePeerObserver(observer)
+ return
+ }
+ observer.handlerID = handlerID
+ peerCallKeyObservers.Unlock()
+}
+
+func capturePeerCallKey(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, event *events.CallAccept) {
+ if registry == nil || client == nil || event == nil || event.CallID == "" || event.Data == nil {
+ return
+ }
+ peerCandidates := callKeyPeerCandidates(event)
+ if len(peerCandidates) == 0 {
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), peerCallKeyDecryptTimeout)
+ defer cancel()
+ key, decryptedPeer, err := decryptPeerCallKey(ctx, wa.NewSocket(client), event.Data, peerCandidates)
+ if err != nil || len(key) != 32 {
+ slog.Debug("WhatsApp peer call key not available",
+ "instance", instanceID,
+ "call_id", event.CallID,
+ "candidates", len(peerCandidates),
+ "err", err,
+ )
+ return
+ }
+ defer zeroBytes(key)
+ peerCandidates = uniqueCallKeyPeers(append([]types.JID{decryptedPeer}, peerCandidates...)...)
+
+ peerCallKeyObservers.Lock()
+ observer := peerCallKeyObservers.registries[registry][instanceID]
+ if observer == nil || observer.client != client {
+ peerCallKeyObservers.Unlock()
+ return
+ }
+ if previous, exists := observer.keys[event.CallID]; exists {
+ if equalPeerCallKeys(previous.key, key) && equalCallKeyPeers(previous.peers, peerCandidates) {
+ peerCallKeyObservers.Unlock()
+ return
+ }
+ zeroBytes(previous.key)
+ }
+ observer.keys[event.CallID] = storedPeerCallKey{
+ key: append([]byte(nil), key...),
+ peers: append([]types.JID(nil), peerCandidates...),
+ }
+ peerCallKeyObservers.Unlock()
+
+ // A relay may have pre-connected on CallPreAccept. Rebuild any early packet
+ // session now so the first post-accept RTP frame can use the peer-provided key.
+ registry.dropSession(instanceID, event.CallID)
+ if err = registry.Prepare(instanceID, event.CallID); err != nil {
+ slog.Debug("defer peer call-key SRTP refresh", "instance", instanceID, "call_id", event.CallID, "err", err)
+ return
+ }
+ slog.Info("WhatsApp peer call key applied",
+ "instance", instanceID,
+ "call_id", event.CallID,
+ "peer", decryptedPeer.String(),
+ "candidates", len(peerCandidates),
+ )
+}
+
+func callKeyPeerCandidates(event *events.CallAccept) []types.JID {
+ if event == nil {
+ return nil
+ }
+ return uniqueCallKeyPeers(event.From, event.CallCreator, event.CallCreatorAlt)
+}
+
+func uniqueCallKeyPeers(values ...types.JID) []types.JID {
+ seen := make(map[string]struct{}, len(values))
+ output := make([]types.JID, 0, len(values))
+ for _, value := range values {
+ if value.IsEmpty() {
+ continue
+ }
+ identity := value.String()
+ if _, exists := seen[identity]; exists {
+ continue
+ }
+ seen[identity] = struct{}{}
+ output = append(output, value)
+ }
+ return output
+}
+
+func decryptPeerCallKey(ctx context.Context, socket core.VoipSocket, node *waBinary.Node, peers []types.JID) ([]byte, types.JID, error) {
+ if socket == nil || node == nil || len(peers) == 0 {
+ return nil, types.JID{}, fmt.Errorf("peer call-key inputs are incomplete")
+ }
+ attemptErrors := make([]error, 0, len(peers))
+ for _, peer := range peers {
+ key, err := signaling.DecryptCallKeyInNode(ctx, socket, node, peer)
+ if err == nil {
+ return key, peer, nil
+ }
+ attemptErrors = append(attemptErrors, fmt.Errorf("peer=%s: %w", peer.String(), err))
+ }
+ return nil, types.JID{}, fmt.Errorf("decrypt peer call key with %d candidates: %w", len(peers), errors.Join(attemptErrors...))
+}
+
+func peerCallKey(registry *PacketRegistry, instanceID, callID string) ([]byte, []types.JID, bool) {
+ peerCallKeyObservers.Lock()
+ defer peerCallKeyObservers.Unlock()
+ observer := peerCallKeyObservers.registries[registry][instanceID]
+ if observer == nil {
+ return nil, nil, false
+ }
+ stored, ok := observer.keys[callID]
+ if !ok || len(stored.key) != 32 || len(stored.peers) == 0 {
+ return nil, nil, false
+ }
+ return append([]byte(nil), stored.key...), append([]types.JID(nil), stored.peers...), true
+}
+
+func removePeerCallKey(registry *PacketRegistry, instanceID, callID string) {
+ removePeerCallKeyMatchingClient(registry, instanceID, nil, callID)
+}
+
+func removePeerCallKeyForClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, callID string) {
+ if client == nil {
+ return
+ }
+ removePeerCallKeyMatchingClient(registry, instanceID, client, callID)
+}
+
+func removePeerCallKeyMatchingClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client, callID string) {
+ if registry == nil || instanceID == "" || callID == "" {
+ return
+ }
+ peerCallKeyObservers.Lock()
+ observer := peerCallKeyObservers.registries[registry][instanceID]
+ if observer != nil && (client == nil || observer.client == client) {
+ if stored, ok := observer.keys[callID]; ok {
+ zeroBytes(stored.key)
+ delete(observer.keys, callID)
+ }
+ }
+ peerCallKeyObservers.Unlock()
+}
+
+func clearPeerCallKeys(registry *PacketRegistry, instanceID string) {
+ clearPeerCallKeysMatchingClient(registry, instanceID, nil)
+}
+
+func clearPeerCallKeysForClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client) {
+ if client == nil {
+ return
+ }
+ clearPeerCallKeysMatchingClient(registry, instanceID, client)
+}
+
+func clearPeerCallKeysMatchingClient(registry *PacketRegistry, instanceID string, client *whatsmeow.Client) {
+ if registry == nil || instanceID == "" {
+ return
+ }
+ peerCallKeyObservers.Lock()
+ observer := peerCallKeyObservers.registries[registry][instanceID]
+ if observer != nil && (client == nil || observer.client == client) {
+ for callID, stored := range observer.keys {
+ zeroBytes(stored.key)
+ delete(observer.keys, callID)
+ }
+ }
+ peerCallKeyObservers.Unlock()
+}
+
+func detachPeerCallKeyObserver(registry *PacketRegistry, instanceID string) {
+ if registry == nil || instanceID == "" {
+ return
+ }
+ peerCallKeyObservers.Lock()
+ instances := peerCallKeyObservers.registries[registry]
+ observer := instances[instanceID]
+ delete(instances, instanceID)
+ if len(instances) == 0 {
+ delete(peerCallKeyObservers.registries, registry)
+ }
+ peerCallKeyObservers.Unlock()
+ if observer != nil {
+ if observer.client != nil && observer.handlerID != 0 {
+ observer.client.RemoveEventHandler(observer.handlerID)
+ }
+ wipePeerObserver(observer)
+ }
+}
+
+func wipePeerObserver(observer *peerCallKeyObserver) {
+ if observer == nil {
+ return
+ }
+ for callID, stored := range observer.keys {
+ zeroBytes(stored.key)
+ delete(observer.keys, callID)
+ }
+ observer.client = nil
+ observer.handlerID = 0
+}
+
+func buildPacketSRTPCandidates(
+ registry *PacketRegistry,
+ instanceID, callID, selfDeviceJID string,
+ receiveJIDs []string,
+) ([]packetSRTPCandidateKeying, error) {
+ if registry == nil || registry.source == nil {
+ return nil, ErrPacketSessionNotReady
+ }
+
+ peerKey, acceptedPeers, hasPeerKey := peerCallKey(registry, instanceID, callID)
+ defer zeroBytes(peerKey)
+ candidateJIDs := append([]string(nil), receiveJIDs...)
+ if hasPeerKey {
+ peerJIDs := make([]string, 0, len(acceptedPeers)*2+len(receiveJIDs))
+ for _, peer := range acceptedPeers {
+ peerJIDs = append(peerJIDs, peer.String(), ensureDeviceJIDString(peer.String()))
+ }
+ peerJIDs = append(peerJIDs, candidateJIDs...)
+ candidateJIDs = uniqueDeviceJIDs(peerJIDs...)
+ }
+
+ keyings := make([]packetSRTPCandidateKeying, 0, len(candidateJIDs)+len(receiveJIDs))
+ seenMaterial := make(map[[sha256.Size]byte]struct{})
+ appendCandidate := func(receiveJID string, send, receive core.SRTPKeyingMaterial) {
+ identity := packetKeyingFingerprint(receive)
+ if _, exists := seenMaterial[identity]; exists {
+ send.Wipe()
+ receive.Wipe()
+ return
+ }
+ seenMaterial[identity] = struct{}{}
+ keyings = append(keyings, packetSRTPCandidateKeying{receiveJID: receiveJID, send: send, receive: receive})
+ }
+
+ if hasPeerKey {
+ for _, receiveJID := range candidateJIDs {
+ send, originalReceive, err := registry.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID)
+ if err != nil {
+ wipePacketCandidateKeyings(keyings)
+ return nil, fmt.Errorf("derive send keying for peer call key: %w", err)
+ }
+ originalReceive.Wipe()
+ peerReceive, err := DerivePerJIDSRTPKey(peerKey, receiveJID)
+ if err != nil {
+ send.Wipe()
+ wipePacketCandidateKeyings(keyings)
+ return nil, fmt.Errorf("derive peer receive keying for %s: %w", receiveJID, err)
+ }
+ appendCandidate(receiveJID+" (peer-key)", send, peerReceive)
+ }
+ }
+
+ for _, receiveJID := range receiveJIDs {
+ send, receive, err := registry.source.SRTPKeying(instanceID, callID, selfDeviceJID, receiveJID)
+ if err != nil {
+ wipePacketCandidateKeyings(keyings)
+ return nil, fmt.Errorf("derive SRTP candidate %s: %w", receiveJID, err)
+ }
+ appendCandidate(receiveJID, send, receive)
+ }
+ if len(keyings) == 0 {
+ return nil, fmt.Errorf("call %s produced no unique SRTP key candidates", callID)
+ }
+ return keyings, nil
+}
+
+func packetKeyingFingerprint(keying core.SRTPKeyingMaterial) [sha256.Size]byte {
+ hash := sha256.New()
+ _, _ = hash.Write([]byte{byte(len(keying.MasterKey))})
+ _, _ = hash.Write(keying.MasterKey)
+ _, _ = hash.Write([]byte{byte(len(keying.MasterSalt))})
+ _, _ = hash.Write(keying.MasterSalt)
+ var output [sha256.Size]byte
+ copy(output[:], hash.Sum(nil))
+ return output
+}
+
+func wipePacketCandidateKeyings(keyings []packetSRTPCandidateKeying) {
+ for index := range keyings {
+ keyings[index].send.Wipe()
+ keyings[index].receive.Wipe()
+ }
+}
+
+func equalPeerCallKeys(left, right []byte) bool {
+ return len(left) == len(right) && subtle.ConstantTimeCompare(left, right) == 1
+}
+
+func equalCallKeyPeers(left, right []types.JID) bool {
+ if len(left) != len(right) {
+ return false
+ }
+ for index := range left {
+ if left[index].String() != right[index].String() {
+ return false
+ }
+ }
+ return true
+}
diff --git a/pkg/call/voip/media/peer_call_key_test.go b/pkg/call/voip/media/peer_call_key_test.go
new file mode 100644
index 00000000..17de827c
--- /dev/null
+++ b/pkg/call/voip/media/peer_call_key_test.go
@@ -0,0 +1,106 @@
+package media
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "go.mau.fi/whatsmeow"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+func TestCallKeyPeerCandidatesPreservesOrderAndDeduplicates(t *testing.T) {
+ from := types.NewJID("5511999999999", types.HiddenUserServer)
+ creator := types.NewJID("5511888888888", types.HiddenUserServer)
+ alt := types.NewJID("5511777777777", types.DefaultUserServer)
+ event := &events.CallAccept{}
+ event.From = from
+ event.CallCreator = creator
+ event.CallCreatorAlt = alt
+
+ got := callKeyPeerCandidates(event)
+ if len(got) != 3 {
+ t.Fatalf("unexpected candidate count: %d (%#v)", len(got), got)
+ }
+ if got[0].String() != from.String() || got[1].String() != creator.String() || got[2].String() != alt.String() {
+ t.Fatalf("unexpected candidate order: %#v", got)
+ }
+
+ duplicated := uniqueCallKeyPeers(from, from, creator, creator)
+ if len(duplicated) != 2 {
+ t.Fatalf("duplicate peers were not removed: %#v", duplicated)
+ }
+}
+
+func TestPacketKeyingFingerprintIsStableAndSeparatesMaterials(t *testing.T) {
+ first := core.SRTPKeyingMaterial{
+ MasterKey: bytes.Repeat([]byte{0x11}, 16),
+ MasterSalt: bytes.Repeat([]byte{0x22}, 14),
+ }
+ same := core.SRTPKeyingMaterial{
+ MasterKey: append([]byte(nil), first.MasterKey...),
+ MasterSalt: append([]byte(nil), first.MasterSalt...),
+ }
+ different := core.SRTPKeyingMaterial{
+ MasterKey: bytes.Repeat([]byte{0x11}, 16),
+ MasterSalt: bytes.Repeat([]byte{0x23}, 14),
+ }
+
+ if packetKeyingFingerprint(first) != packetKeyingFingerprint(same) {
+ t.Fatal("equal keying material produced different fingerprints")
+ }
+ if packetKeyingFingerprint(first) == packetKeyingFingerprint(different) {
+ t.Fatal("different keying material produced the same fingerprint")
+ }
+}
+
+func TestEqualCallKeyPeersIsOrderSensitive(t *testing.T) {
+ first := types.NewJID("first", types.HiddenUserServer)
+ second := types.NewJID("second", types.HiddenUserServer)
+ if !equalCallKeyPeers([]types.JID{first, second}, []types.JID{first, second}) {
+ t.Fatal("equal peer lists were not recognized")
+ }
+ if equalCallKeyPeers([]types.JID{first, second}, []types.JID{second, first}) {
+ t.Fatal("different peer priority order was treated as equal")
+ }
+}
+
+func TestStaleClientCannotDeleteReplacementPeerKeys(t *testing.T) {
+ registry := NewPacketRegistry(&fakePacketSource{callKey: bytes.Repeat([]byte{0x31}, 32)})
+ const (
+ instanceID = "replacement-instance"
+ callID = "replacement-call"
+ )
+ staleClient := &whatsmeow.Client{}
+ activeClient := &whatsmeow.Client{}
+ peer := types.NewJID("5511888888888", types.HiddenUserServer)
+
+ peerCallKeyObservers.Lock()
+ peerCallKeyObservers.registries[registry] = map[string]*peerCallKeyObserver{
+ instanceID: {
+ client: activeClient,
+ keys: map[string]storedPeerCallKey{
+ callID: {
+ key: bytes.Repeat([]byte{0x52}, 32),
+ peers: []types.JID{peer},
+ },
+ },
+ },
+ }
+ peerCallKeyObservers.Unlock()
+ defer detachPeerCallKeyObserver(registry, instanceID)
+
+ removePeerCallKeyForClient(registry, instanceID, staleClient, callID)
+ clearPeerCallKeysForClient(registry, instanceID, staleClient)
+ key, peers, ok := peerCallKey(registry, instanceID, callID)
+ if !ok || len(key) != 32 || len(peers) != 1 || peers[0].String() != peer.String() {
+ t.Fatalf("stale client removed replacement key: ok=%v key=%d peers=%#v", ok, len(key), peers)
+ }
+ zeroBytes(key)
+
+ removePeerCallKeyForClient(registry, instanceID, activeClient, callID)
+ if _, _, ok = peerCallKey(registry, instanceID, callID); ok {
+ t.Fatal("active client failed to remove its own peer key")
+ }
+}
diff --git a/pkg/call/voip/media/post_accept.go b/pkg/call/voip/media/post_accept.go
new file mode 100644
index 00000000..189d8866
--- /dev/null
+++ b/pkg/call/voip/media/post_accept.go
@@ -0,0 +1,265 @@
+package media
+
+import (
+ "context"
+ "fmt"
+ "sync"
+ "time"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+const (
+ postAcceptSignalingTimeout = 5 * time.Second
+ postAcceptProgressTTL = 10 * time.Minute
+)
+
+var postAcceptRetryDelays = []time.Duration{
+ 200 * time.Millisecond,
+ 750 * time.Millisecond,
+ 2 * time.Second,
+}
+
+var schedulePostAcceptRetry = func(delay time.Duration, callback func()) {
+ time.AfterFunc(delay, callback)
+}
+
+type postAcceptSocket interface {
+ ResolveLIDForPN(context.Context, types.JID) types.JID
+ SendNode(context.Context, waBinary.Node) error
+}
+
+type postAcceptProgressKey struct {
+ session *relaySession
+ callID string
+}
+
+type postAcceptProgress struct {
+ running bool
+ transportSent bool
+ muteSent bool
+ failedAttempts int
+ retryScheduled bool
+ expiresAt time.Time
+}
+
+type postAcceptProgressTracker struct {
+ sync.Mutex
+ calls map[postAcceptProgressKey]*postAcceptProgress
+}
+
+var outgoingPostAcceptProgress = postAcceptProgressTracker{
+ calls: make(map[postAcceptProgressKey]*postAcceptProgress),
+}
+
+var newPostAcceptSocket = func(client *whatsmeow.Client) postAcceptSocket {
+ return wa.NewSocket(client)
+}
+
+func (t *postAcceptProgressTracker) begin(session *relaySession, callID string) (postAcceptProgress, bool) {
+ if t == nil || session == nil || callID == "" {
+ return postAcceptProgress{}, false
+ }
+
+ now := time.Now()
+ key := postAcceptProgressKey{session: session, callID: callID}
+ t.Lock()
+ defer t.Unlock()
+ if t.calls == nil {
+ t.calls = make(map[postAcceptProgressKey]*postAcceptProgress)
+ }
+ for existingKey, progress := range t.calls {
+ if progress != nil && !progress.running && !progress.expiresAt.IsZero() && !progress.expiresAt.After(now) {
+ delete(t.calls, existingKey)
+ }
+ }
+
+ progress := t.calls[key]
+ if progress == nil {
+ progress = &postAcceptProgress{}
+ t.calls[key] = progress
+ }
+ if progress.running || progress.retryScheduled || (progress.transportSent && progress.muteSent) {
+ return postAcceptProgress{}, false
+ }
+ progress.running = true
+ progress.expiresAt = time.Time{}
+ return *progress, true
+}
+
+func (t *postAcceptProgressTracker) retryDue(session *relaySession, callID string) bool {
+ if t == nil || session == nil || callID == "" {
+ return false
+ }
+ key := postAcceptProgressKey{session: session, callID: callID}
+ t.Lock()
+ defer t.Unlock()
+ progress := t.calls[key]
+ if progress == nil || !progress.retryScheduled || progress.running || (progress.transportSent && progress.muteSent) {
+ return false
+ }
+ progress.retryScheduled = false
+ return true
+}
+
+func (t *postAcceptProgressTracker) finish(
+ session *relaySession,
+ callID string,
+ result postAcceptProgress,
+ failed bool,
+) (retryDelay time.Duration, scheduleRetry, exhausted bool) {
+ if t == nil || session == nil || callID == "" {
+ return 0, false, false
+ }
+ key := postAcceptProgressKey{session: session, callID: callID}
+ expiresAt := time.Now().Add(postAcceptProgressTTL)
+
+ t.Lock()
+ progress := t.calls[key]
+ if progress == nil {
+ t.Unlock()
+ return 0, false, false
+ }
+ progress.running = false
+ progress.transportSent = result.transportSent
+ progress.muteSent = result.muteSent
+ progress.expiresAt = expiresAt
+
+ if progress.transportSent && progress.muteSent {
+ progress.failedAttempts = 0
+ progress.retryScheduled = false
+ } else if failed && !progress.retryScheduled {
+ if progress.failedAttempts < len(postAcceptRetryDelays) {
+ retryDelay = postAcceptRetryDelays[progress.failedAttempts]
+ progress.failedAttempts++
+ progress.retryScheduled = true
+ scheduleRetry = true
+ } else {
+ exhausted = true
+ }
+ }
+ t.Unlock()
+
+ time.AfterFunc(postAcceptProgressTTL, func() {
+ t.Lock()
+ defer t.Unlock()
+ current := t.calls[key]
+ if current != nil && !current.running && !current.expiresAt.After(expiresAt) {
+ delete(t.calls, key)
+ }
+ })
+ return retryDelay, scheduleRetry, exhausted
+}
+
+func (t *postAcceptProgressTracker) reset() {
+ if t == nil {
+ return
+ }
+ t.Lock()
+ t.calls = make(map[postAcceptProgressKey]*postAcceptProgress)
+ t.Unlock()
+}
+
+// sendOutgoingPostAccept completes the signaling sequence used by WhatsApp
+// after the remote party accepts an outgoing call. The relay connection may
+// start in parallel; these stanzas must not block media startup.
+//
+// WhatsApp can emit duplicate CallAccept events. Progress is therefore tracked
+// per relay session and call: a successful transport announcement is not sent
+// again when only the mute synchronization needs retrying. Transient send
+// failures are retried internally, so recovery does not depend on WhatsApp
+// emitting another CallAccept event.
+func (s *relaySession) sendOutgoingPostAccept(callID string) {
+ if s == nil || s.source == nil || callID == "" {
+ return
+ }
+ state, ok := s.source.State(s.instanceID, callID)
+ if !ok || state == nil || state.Direction != core.CallDirectionOutgoing {
+ return
+ }
+
+ s.mu.Lock()
+ client := s.client
+ s.mu.Unlock()
+ if client == nil {
+ return
+ }
+
+ peer, err := types.ParseJID(state.PeerJID)
+ if err != nil || peer.IsEmpty() {
+ if err == nil {
+ err = fmt.Errorf("peer JID is empty")
+ }
+ s.log.Warn("WhatsApp post-accept signaling skipped", "instance", s.instanceID, "call_id", callID, "err", err)
+ return
+ }
+ creator, err := types.ParseJID(state.CallCreator)
+ if err != nil || creator.IsEmpty() {
+ if err == nil {
+ err = fmt.Errorf("creator JID is empty")
+ }
+ s.log.Warn("WhatsApp post-accept signaling skipped", "instance", s.instanceID, "call_id", callID, "err", err)
+ return
+ }
+
+ socket := newPostAcceptSocket(client)
+ if socket == nil {
+ s.log.Warn("WhatsApp post-accept signaling skipped", "instance", s.instanceID, "call_id", callID, "err", "nil socket adapter")
+ return
+ }
+
+ progress, acquired := outgoingPostAcceptProgress.begin(s, callID)
+ if !acquired {
+ return
+ }
+ failed := false
+ defer func() {
+ delay, shouldRetry, exhausted := outgoingPostAcceptProgress.finish(s, callID, progress, failed)
+ if shouldRetry {
+ s.log.Debug("WhatsApp post-accept signaling retry scheduled",
+ "instance", s.instanceID,
+ "call_id", callID,
+ "delay", delay,
+ )
+ schedulePostAcceptRetry(delay, func() {
+ if outgoingPostAcceptProgress.retryDue(s, callID) {
+ s.sendOutgoingPostAccept(callID)
+ }
+ })
+ } else if exhausted {
+ s.log.Warn("WhatsApp post-accept signaling retries exhausted",
+ "instance", s.instanceID,
+ "call_id", callID,
+ "transport_sent", progress.transportSent,
+ "mute_sent", progress.muteSent,
+ )
+ }
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), postAcceptSignalingTimeout)
+ defer cancel()
+ peer = socket.ResolveLIDForPN(ctx, peer)
+
+ if !progress.transportSent {
+ if err = socket.SendNode(ctx, signaling.BuildPostAcceptTransportStanza(peer, creator, callID)); err != nil {
+ failed = true
+ s.log.Warn("WhatsApp post-accept transport failed", "instance", s.instanceID, "call_id", callID, "err", err)
+ return
+ }
+ progress.transportSent = true
+ }
+ if !progress.muteSent {
+ if err = socket.SendNode(ctx, signaling.BuildMuteV2Stanza(peer, creator, callID, 0)); err != nil {
+ failed = true
+ s.log.Warn("WhatsApp post-accept mute sync failed", "instance", s.instanceID, "call_id", callID, "err", err)
+ return
+ }
+ progress.muteSent = true
+ }
+ s.log.Info("WhatsApp post-accept media signaling sent", "instance", s.instanceID, "call_id", callID, "peer", peer.String())
+}
diff --git a/pkg/call/voip/media/post_accept_idempotency_test.go b/pkg/call/voip/media/post_accept_idempotency_test.go
new file mode 100644
index 00000000..2bf93ac7
--- /dev/null
+++ b/pkg/call/voip/media/post_accept_idempotency_test.go
@@ -0,0 +1,210 @@
+package media
+
+import (
+ "context"
+ "errors"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+type fakePostAcceptSocket struct {
+ resolve func(context.Context, types.JID) types.JID
+ send func(context.Context, waBinary.Node) error
+}
+
+func (f *fakePostAcceptSocket) ResolveLIDForPN(ctx context.Context, peer types.JID) types.JID {
+ if f != nil && f.resolve != nil {
+ return f.resolve(ctx, peer)
+ }
+ return peer
+}
+
+func (f *fakePostAcceptSocket) SendNode(ctx context.Context, node waBinary.Node) error {
+ if f != nil && f.send != nil {
+ return f.send(ctx, node)
+ }
+ return nil
+}
+
+func installPostAcceptTestSocket(t *testing.T, socket postAcceptSocket) {
+ t.Helper()
+ previousFactory := newPostAcceptSocket
+ previousScheduler := schedulePostAcceptRetry
+ previousDelays := append([]time.Duration(nil), postAcceptRetryDelays...)
+ outgoingPostAcceptProgress.reset()
+ newPostAcceptSocket = func(*whatsmeow.Client) postAcceptSocket {
+ return socket
+ }
+ // Execute retries synchronously so tests prove the state machine without
+ // sleeping or leaving timers alive after cleanup.
+ schedulePostAcceptRetry = func(_ time.Duration, callback func()) {
+ callback()
+ }
+ t.Cleanup(func() {
+ newPostAcceptSocket = previousFactory
+ schedulePostAcceptRetry = previousScheduler
+ postAcceptRetryDelays = previousDelays
+ outgoingPostAcceptProgress.reset()
+ })
+}
+
+func newPostAcceptTestSession(t *testing.T, callID string) *relaySession {
+ t.Helper()
+ state := call_state.NewOutgoing(
+ callID,
+ "5511888888888:2@s.whatsapp.net",
+ "5511999999999:1@s.whatsapp.net",
+ core.CallMediaTypeAudio,
+ )
+ source := &fakeNegotiationSource{state: state}
+ session := newTestRelaySession(source, &fakeRelayTransport{})
+ session.client = &whatsmeow.Client{}
+ return session
+}
+
+func postAcceptChildTag(node waBinary.Node) string {
+ children := node.GetChildren()
+ if len(children) == 0 {
+ return ""
+ }
+ return children[0].Tag
+}
+
+func TestOutgoingPostAcceptSuppressesDuplicateAccept(t *testing.T) {
+ transportCalls := 0
+ muteCalls := 0
+ installPostAcceptTestSocket(t, &fakePostAcceptSocket{
+ send: func(_ context.Context, node waBinary.Node) error {
+ switch postAcceptChildTag(node) {
+ case "transport":
+ transportCalls++
+ case "mute_v2":
+ muteCalls++
+ }
+ return nil
+ },
+ })
+
+ session := newPostAcceptTestSession(t, "call-duplicate-accept")
+ session.sendOutgoingPostAccept("call-duplicate-accept")
+ session.sendOutgoingPostAccept("call-duplicate-accept")
+
+ if transportCalls != 1 || muteCalls != 1 {
+ t.Fatalf("duplicate accept resent signaling: transport=%d mute=%d", transportCalls, muteCalls)
+ }
+}
+
+func TestOutgoingPostAcceptAutomaticallyRetriesOnlyFailedMuteStage(t *testing.T) {
+ transportCalls := 0
+ muteCalls := 0
+ installPostAcceptTestSocket(t, &fakePostAcceptSocket{
+ send: func(_ context.Context, node waBinary.Node) error {
+ switch postAcceptChildTag(node) {
+ case "transport":
+ transportCalls++
+ case "mute_v2":
+ muteCalls++
+ if muteCalls == 1 {
+ return errors.New("temporary mute sync failure")
+ }
+ }
+ return nil
+ },
+ })
+
+ session := newPostAcceptTestSession(t, "call-mute-retry")
+ // No duplicate CallAccept is injected: the internal scheduler must recover.
+ session.sendOutgoingPostAccept("call-mute-retry")
+
+ if transportCalls != 1 {
+ t.Fatalf("successful transport stage was resent: %d", transportCalls)
+ }
+ if muteCalls != 2 {
+ t.Fatalf("failed mute stage was not automatically retried once: %d", muteCalls)
+ }
+}
+
+func TestOutgoingPostAcceptStopsAfterRetryBudget(t *testing.T) {
+ transportCalls := 0
+ muteCalls := 0
+ installPostAcceptTestSocket(t, &fakePostAcceptSocket{
+ send: func(_ context.Context, node waBinary.Node) error {
+ switch postAcceptChildTag(node) {
+ case "transport":
+ transportCalls++
+ return errors.New("persistent transport failure")
+ case "mute_v2":
+ muteCalls++
+ }
+ return nil
+ },
+ })
+
+ session := newPostAcceptTestSession(t, "call-retry-budget")
+ session.sendOutgoingPostAccept("call-retry-budget")
+
+ wantAttempts := 1 + len(postAcceptRetryDelays)
+ if transportCalls != wantAttempts {
+ t.Fatalf("unexpected retry count: got=%d want=%d", transportCalls, wantAttempts)
+ }
+ if muteCalls != 0 {
+ t.Fatalf("mute stage ran before transport succeeded: %d", muteCalls)
+ }
+}
+
+func TestOutgoingPostAcceptSerializesConcurrentAccepts(t *testing.T) {
+ var transportCalls atomic.Int32
+ var muteCalls atomic.Int32
+ transportStarted := make(chan struct{})
+ releaseTransport := make(chan struct{})
+ var startOnce sync.Once
+
+ installPostAcceptTestSocket(t, &fakePostAcceptSocket{
+ send: func(_ context.Context, node waBinary.Node) error {
+ switch postAcceptChildTag(node) {
+ case "transport":
+ transportCalls.Add(1)
+ startOnce.Do(func() { close(transportStarted) })
+ <-releaseTransport
+ case "mute_v2":
+ muteCalls.Add(1)
+ }
+ return nil
+ },
+ })
+
+ session := newPostAcceptTestSession(t, "call-concurrent-accept")
+ primaryDone := make(chan struct{})
+ go func() {
+ session.sendOutgoingPostAccept("call-concurrent-accept")
+ close(primaryDone)
+ }()
+ <-transportStarted
+
+ var duplicates sync.WaitGroup
+ for range 16 {
+ duplicates.Add(1)
+ go func() {
+ defer duplicates.Done()
+ session.sendOutgoingPostAccept("call-concurrent-accept")
+ }()
+ }
+ duplicates.Wait()
+ close(releaseTransport)
+ <-primaryDone
+
+ if got := transportCalls.Load(); got != 1 {
+ t.Fatalf("concurrent accepts sent transport %d times", got)
+ }
+ if got := muteCalls.Load(); got != 1 {
+ t.Fatalf("concurrent accepts sent mute %d times", got)
+ }
+}
diff --git a/pkg/call/voip/media/relay_broadcast.go b/pkg/call/voip/media/relay_broadcast.go
new file mode 100644
index 00000000..3cee1125
--- /dev/null
+++ b/pkg/call/voip/media/relay_broadcast.go
@@ -0,0 +1,41 @@
+package media
+
+import "fmt"
+
+// Broadcast sends one already-framed packet to every open relay connection for
+// the selected call. The caller retains ownership of the supplied buffer.
+func (r *RelayRegistry) Broadcast(instanceID, callID string, data []byte) error {
+ if r == nil {
+ return fmt.Errorf("relay registry is nil")
+ }
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session == nil {
+ return fmt.Errorf("relay runtime is not attached for instance %s", instanceID)
+ }
+
+ session.mu.Lock()
+ relay := session.transports[callID]
+ session.mu.Unlock()
+ if relay == nil {
+ return fmt.Errorf("call %s has no relay transport", callID)
+ }
+ return relay.Broadcast(data)
+}
+
+func (r *RelayRegistry) HasConnection(instanceID, callID string) bool {
+ if r == nil {
+ return false
+ }
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session == nil {
+ return false
+ }
+ session.mu.Lock()
+ relay := session.transports[callID]
+ session.mu.Unlock()
+ return relay != nil && relay.HasConnection()
+}
diff --git a/pkg/call/voip/media/relay_registry.go b/pkg/call/voip/media/relay_registry.go
new file mode 100644
index 00000000..1912bbf6
--- /dev/null
+++ b/pkg/call/voip/media/relay_registry.go
@@ -0,0 +1,505 @@
+package media
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "sync"
+ "time"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ call_transport "github.com/evolution-foundation/evolution-go/pkg/call/voip/transport"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wa"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+ "go.mau.fi/whatsmeow/types/events"
+)
+
+// NegotiationSource exposes private call state without exposing keys or relay
+// tokens to the public runtime or HTTP layer.
+type NegotiationSource interface {
+ RelayData(instanceID, callID string) (*core.RelayData, bool)
+ State(instanceID, callID string) (*call_state.Info, bool)
+ CaptureRelayNode(instanceID, callID string, node *waBinary.Node)
+ EnsureRemoteAccepted(instanceID, callID string) error
+ MarkMediaConnected(instanceID, callID string) error
+}
+
+type RelayFactory func(log *slog.Logger) call_transport.RelayTransport
+
+type relaySession struct {
+ mu sync.Mutex
+ instanceID string
+ client *whatsmeow.Client
+ handlerID uint32
+ source NegotiationSource
+ factory RelayFactory
+ log *slog.Logger
+ transports map[string]call_transport.RelayTransport
+ configuring map[string]bool
+ started map[string]bool
+ connected map[string]bool
+ activated map[string]bool
+ ownJID func() types.JID
+ onConnected func(instanceID, callID string)
+ onPacket func(instanceID, callID string, packet []byte)
+ onRemoved func(instanceID, callID string)
+ onCleanup func(instanceID string)
+}
+
+func newRelaySession(instanceID string, client *whatsmeow.Client, source NegotiationSource, factory RelayFactory, log *slog.Logger) *relaySession {
+ if log == nil {
+ log = slog.Default()
+ }
+ if factory == nil {
+ factory = call_transport.NewRelayTransport
+ }
+ session := &relaySession{
+ instanceID: instanceID,
+ client: client,
+ source: source,
+ factory: factory,
+ log: log,
+ transports: make(map[string]call_transport.RelayTransport),
+ configuring: make(map[string]bool),
+ started: make(map[string]bool),
+ connected: make(map[string]bool),
+ activated: make(map[string]bool),
+ }
+ session.ownJID = func() types.JID {
+ if client == nil {
+ return types.JID{}
+ }
+ socket := wa.NewSocket(client)
+ jid := socket.OwnLID()
+ if jid.IsEmpty() {
+ jid = socket.OwnPN()
+ }
+ return jid
+ }
+ if client != nil {
+ session.handlerID = client.AddEventHandler(session.handleEvent)
+ }
+ return session
+}
+
+func (s *relaySession) usesClient(client *whatsmeow.Client) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return client != nil && s.client == client
+}
+
+func (s *relaySession) handleEvent(rawEvent interface{}) {
+ switch event := rawEvent.(type) {
+ case *events.CallPreAccept:
+ // Pre-connect the relays while the peer is still ringing, but do not mark
+ // media active until the final CallAccept advances private state.
+ s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data)
+ go s.startLogged(event.CallID)
+ case *events.CallAccept:
+ _ = s.source.EnsureRemoteAccepted(s.instanceID, event.CallID)
+ s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data)
+ go s.sendOutgoingPostAccept(event.CallID)
+ go s.startLogged(event.CallID)
+ go s.activateIfReady(event.CallID)
+ case *events.CallTransport:
+ s.source.CaptureRelayNode(s.instanceID, event.CallID, event.Data)
+ go s.startLogged(event.CallID)
+ go s.activateIfReady(event.CallID)
+ case *events.CallReject:
+ s.remove(event.CallID)
+ case *events.CallTerminate:
+ s.remove(event.CallID)
+ case *events.Disconnected:
+ s.cleanup()
+ case *events.LoggedOut:
+ s.cleanup()
+ }
+}
+
+func (s *relaySession) startLogged(callID string) {
+ if err := s.start(callID); err != nil {
+ s.log.Warn("WhatsApp relay setup failed", "instance", s.instanceID, "call_id", callID, "err", err)
+ }
+}
+
+func (s *relaySession) start(callID string) error {
+ if callID == "" || s.source == nil {
+ return nil
+ }
+ state, ok := s.source.State(s.instanceID, callID)
+ if !ok || state == nil {
+ return fmt.Errorf("call %s has no private relay state", callID)
+ }
+ if state.StateData.State != core.CallStateRinging && state.StateData.State != core.CallStateConnecting {
+ return nil
+ }
+ relayData, ok := s.source.RelayData(s.instanceID, callID)
+ if !ok || relayData == nil {
+ return fmt.Errorf("call %s has no relay data", callID)
+ }
+ defer core.ZeroRelayData(relayData)
+ configs := call_transport.BuildRelayConfigs(relayData.Endpoints)
+ if len(configs) == 0 {
+ return fmt.Errorf("call %s has no usable relay endpoints", callID)
+ }
+ defer call_transport.ZeroRelayConfigs(configs)
+
+ s.mu.Lock()
+ if s.started[callID] {
+ relay := s.transports[callID]
+ connected := s.connected[callID] || (relay != nil && relay.HasConnection())
+ if connected {
+ s.connected[callID] = true
+ }
+ s.mu.Unlock()
+ if connected {
+ s.activateIfReady(callID)
+ }
+ return nil
+ }
+ if s.configuring[callID] {
+ s.mu.Unlock()
+ return nil
+ }
+ s.configuring[callID] = true
+ s.started[callID] = true
+ relay := s.factory(s.log)
+ s.transports[callID] = relay
+ s.mu.Unlock()
+
+ setupSucceeded := false
+ defer func() {
+ s.mu.Lock()
+ delete(s.configuring, callID)
+ if !setupSucceeded {
+ delete(s.started, callID)
+ delete(s.connected, callID)
+ delete(s.activated, callID)
+ if s.transports[callID] == relay {
+ delete(s.transports, callID)
+ }
+ }
+ s.mu.Unlock()
+ if !setupSucceeded && relay != nil {
+ relay.Cleanup()
+ }
+ }()
+
+ ownJID := types.JID{}
+ if s.ownJID != nil {
+ ownJID = s.ownJID()
+ }
+ peerJID, err := types.ParseJID(state.PeerJID)
+ if err != nil || peerJID.IsEmpty() || ownJID.IsEmpty() {
+ return fmt.Errorf("resolve SSRC participants for call %s", callID)
+ }
+ creatorJID, _ := types.ParseJID(state.CallCreator)
+ selfDevice, peerDevice := selectCallDeviceJIDs(relayData.ParticipantJIDs, ownJID, peerJID, creatorJID)
+ selfSSRC, err := GenerateSecureSSRC(callID, selfDevice, 0)
+ if err != nil {
+ return err
+ }
+ peerSSRC, err := GenerateSecureSSRC(callID, peerDevice, 0)
+ if err != nil {
+ return err
+ }
+
+ s.log.Info("WhatsApp relay media participants resolved",
+ "instance", s.instanceID,
+ "call_id", callID,
+ "self_device", selfDevice,
+ "peer_device", peerDevice,
+ "self_ssrc", selfSSRC,
+ "peer_ssrc", peerSSRC,
+ "participants", len(relayData.ParticipantJIDs),
+ "relays", len(configs),
+ )
+
+ var retryOnce sync.Once
+ var firstFrame sync.Once
+ relay.SetSSRC(selfSSRC)
+ relay.SetSubscriptionSSRC(peerSSRC)
+ relay.SetOnConnected(func(_ string, _ int) {
+ s.mu.Lock()
+ s.connected[callID] = true
+ s.mu.Unlock()
+ retryOnce.Do(func() {
+ go retryRelaySubscriptions(relay)
+ })
+ s.activateIfReady(callID)
+ })
+ relay.SetOnReceive(func(packet []byte) {
+ firstFrame.Do(func() {
+ rtpCandidate := len(packet) >= 12 && packet[0]&0xc0 == 0x80
+ payloadType := uint8(0)
+ if len(packet) >= 2 {
+ payloadType = packet[1] & 0x7f
+ }
+ s.log.Info("WhatsApp relay first inbound frame",
+ "instance", s.instanceID,
+ "call_id", callID,
+ "bytes", len(packet),
+ "rtp_candidate", rtpCandidate,
+ "payload_type", payloadType,
+ )
+ })
+ s.mu.Lock()
+ callback := s.onPacket
+ s.mu.Unlock()
+ if callback != nil {
+ callback(s.instanceID, callID, append([]byte(nil), packet...))
+ }
+ })
+
+ if err := relay.ConfigureRelays(configs); err != nil {
+ if errors.Is(err, call_transport.ErrSCTPUnavailable) {
+ return nil
+ }
+ return fmt.Errorf("configure relays for call %s: %w", callID, err)
+ }
+ setupSucceeded = true
+ if relay.HasConnection() {
+ s.mu.Lock()
+ s.connected[callID] = true
+ s.mu.Unlock()
+ s.activateIfReady(callID)
+ }
+ return nil
+}
+
+func (s *relaySession) activateIfReady(callID string) {
+ if s == nil || s.source == nil || callID == "" {
+ return
+ }
+
+ // Reserve activation before consulting mutable call state. Multiple relays
+ // may connect at nearly the same time, but only one goroutine may transition
+ // and notify the media pipeline.
+ s.mu.Lock()
+ if !s.connected[callID] || s.activated[callID] {
+ s.mu.Unlock()
+ return
+ }
+ s.activated[callID] = true
+ callback := s.onConnected
+ s.mu.Unlock()
+
+ state, ok := s.source.State(s.instanceID, callID)
+ if !ok || state == nil || state.StateData.State != core.CallStateConnecting {
+ s.mu.Lock()
+ s.activated[callID] = false
+ s.mu.Unlock()
+ return
+ }
+ if err := s.source.MarkMediaConnected(s.instanceID, callID); err != nil {
+ s.mu.Lock()
+ s.activated[callID] = false
+ s.mu.Unlock()
+ s.log.Debug("defer WhatsApp media activation", "instance", s.instanceID, "call_id", callID, "err", err)
+ return
+ }
+ if callback != nil {
+ callback(s.instanceID, callID)
+ }
+}
+
+func retryRelaySubscriptions(relay call_transport.RelayTransport) {
+ if relay == nil {
+ return
+ }
+ for _, delay := range []time.Duration{
+ 50 * time.Millisecond,
+ 150 * time.Millisecond,
+ 500 * time.Millisecond,
+ 3 * time.Second,
+ } {
+ timer := time.NewTimer(delay)
+ <-timer.C
+ if !relay.HasConnection() {
+ return
+ }
+ relay.ResendSubscriptions()
+ }
+}
+
+// selectDeviceJIDs is retained for compatibility with existing tests and
+// callers. New call setup uses selectCallDeviceJIDs so call-creator and
+// non-matching LID device participants are handled correctly.
+func selectDeviceJIDs(participants []string, ownJID, peerJID types.JID) (string, string) {
+ return selectCallDeviceJIDs(participants, ownJID, peerJID, types.JID{})
+}
+
+func (s *relaySession) remove(callID string) {
+ s.mu.Lock()
+ relay := s.transports[callID]
+ delete(s.transports, callID)
+ delete(s.configuring, callID)
+ delete(s.started, callID)
+ delete(s.connected, callID)
+ delete(s.activated, callID)
+ callback := s.onRemoved
+ s.mu.Unlock()
+ if relay != nil {
+ relay.Cleanup()
+ }
+ if callback != nil && callID != "" {
+ callback(s.instanceID, callID)
+ }
+}
+
+func (s *relaySession) cleanup() {
+ s.mu.Lock()
+ transports := make([]call_transport.RelayTransport, 0, len(s.transports))
+ for callID, relay := range s.transports {
+ transports = append(transports, relay)
+ delete(s.transports, callID)
+ }
+ s.configuring = make(map[string]bool)
+ s.started = make(map[string]bool)
+ s.connected = make(map[string]bool)
+ s.activated = make(map[string]bool)
+ callback := s.onCleanup
+ s.mu.Unlock()
+ for _, relay := range transports {
+ relay.Cleanup()
+ }
+ if callback != nil {
+ callback(s.instanceID)
+ }
+}
+
+func (s *relaySession) close() {
+ s.mu.Lock()
+ client := s.client
+ handlerID := s.handlerID
+ s.client = nil
+ s.handlerID = 0
+ s.mu.Unlock()
+ if client != nil && handlerID != 0 {
+ client.RemoveEventHandler(handlerID)
+ }
+ s.cleanup()
+}
+
+// RelayRegistry owns one relay-event session per Evolution instance.
+type RelayRegistry struct {
+ mu sync.RWMutex
+ source NegotiationSource
+ factory RelayFactory
+ log *slog.Logger
+ sessions map[string]*relaySession
+ onConnected func(instanceID, callID string)
+ onPacket func(instanceID, callID string, packet []byte)
+ onRemoved func(instanceID, callID string)
+ onCleanup func(instanceID string)
+}
+
+func NewRelayRegistry(source NegotiationSource, factory RelayFactory, log *slog.Logger) *RelayRegistry {
+ if log == nil {
+ log = slog.Default()
+ }
+ return &RelayRegistry{
+ source: source,
+ factory: factory,
+ log: log,
+ sessions: make(map[string]*relaySession),
+ }
+}
+
+func (r *RelayRegistry) SetOnConnected(callback func(instanceID, callID string)) {
+ r.mu.Lock()
+ r.onConnected = callback
+ for _, session := range r.sessions {
+ session.onConnected = callback
+ }
+ r.mu.Unlock()
+}
+
+func (r *RelayRegistry) SetOnPacket(callback func(instanceID, callID string, packet []byte)) {
+ r.mu.Lock()
+ r.onPacket = callback
+ for _, session := range r.sessions {
+ session.onPacket = callback
+ }
+ r.mu.Unlock()
+}
+
+func (r *RelayRegistry) SetOnRemoved(callback func(instanceID, callID string)) {
+ r.mu.Lock()
+ r.onRemoved = callback
+ for _, session := range r.sessions {
+ session.onRemoved = callback
+ }
+ r.mu.Unlock()
+}
+
+func (r *RelayRegistry) SetOnCleanup(callback func(instanceID string)) {
+ r.mu.Lock()
+ r.onCleanup = callback
+ for _, session := range r.sessions {
+ session.onCleanup = callback
+ }
+ r.mu.Unlock()
+}
+
+func (r *RelayRegistry) Attach(instanceID string, client *whatsmeow.Client) {
+ if instanceID == "" || client == nil {
+ return
+ }
+ r.mu.RLock()
+ current := r.sessions[instanceID]
+ if current != nil && current.usesClient(client) {
+ r.mu.RUnlock()
+ return
+ }
+ r.mu.RUnlock()
+
+ candidate := newRelaySession(instanceID, client, r.source, r.factory, r.log)
+ r.mu.Lock()
+ candidate.onConnected = r.onConnected
+ candidate.onPacket = r.onPacket
+ candidate.onRemoved = r.onRemoved
+ candidate.onCleanup = r.onCleanup
+ previous := r.sessions[instanceID]
+ r.sessions[instanceID] = candidate
+ r.mu.Unlock()
+ if previous != nil {
+ previous.close()
+ }
+}
+
+func (r *RelayRegistry) Start(instanceID, callID string) error {
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session == nil {
+ return fmt.Errorf("relay runtime is not attached for instance %s", instanceID)
+ }
+ err := session.start(callID)
+ if err != nil {
+ r.log.Warn("WhatsApp relay setup failed", "instance", instanceID, "call_id", callID, "err", err)
+ }
+ return err
+}
+
+func (r *RelayRegistry) Remove(instanceID, callID string) {
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session != nil {
+ session.remove(callID)
+ }
+}
+
+func (r *RelayRegistry) Close(instanceID string) {
+ r.mu.Lock()
+ session := r.sessions[instanceID]
+ delete(r.sessions, instanceID)
+ r.mu.Unlock()
+ if session != nil {
+ session.close()
+ }
+}
diff --git a/pkg/call/voip/media/relay_registry_test.go b/pkg/call/voip/media/relay_registry_test.go
new file mode 100644
index 00000000..f90253e4
--- /dev/null
+++ b/pkg/call/voip/media/relay_registry_test.go
@@ -0,0 +1,239 @@
+package media
+
+import (
+ "errors"
+ "log/slog"
+ "testing"
+
+ call_state "github.com/evolution-foundation/evolution-go/pkg/call/voip/call"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ call_transport "github.com/evolution-foundation/evolution-go/pkg/call/voip/transport"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+type fakeNegotiationSource struct {
+ state *call_state.Info
+ relayData *core.RelayData
+ connected int
+ captureHits int
+}
+
+func (f *fakeNegotiationSource) RelayData(_, _ string) (*core.RelayData, bool) {
+ if f.relayData == nil {
+ return nil, false
+ }
+ return core.CloneRelayData(f.relayData), true
+}
+func (f *fakeNegotiationSource) State(_, _ string) (*call_state.Info, bool) {
+ if f.state == nil {
+ return nil, false
+ }
+ return f.state.Clone(), true
+}
+func (f *fakeNegotiationSource) CaptureRelayNode(_, _ string, _ *waBinary.Node) {
+ f.captureHits++
+}
+func (f *fakeNegotiationSource) EnsureRemoteAccepted(_, _ string) error {
+ if f.state.StateData.State == core.CallStateRinging {
+ return f.state.Apply(call_state.Transition{Type: call_state.TransitionRemoteAccepted})
+ }
+ return nil
+}
+func (f *fakeNegotiationSource) MarkMediaConnected(_, _ string) error {
+ if err := f.state.Apply(call_state.Transition{Type: call_state.TransitionMediaConnected}); err != nil {
+ return err
+ }
+ f.connected++
+ return nil
+}
+
+type fakeRelayTransport struct {
+ ssrc uint32
+ subscriptionSSRC uint32
+ configs []call_transport.RelayConfig
+ onConnected func(string, int)
+ onReceive func([]byte)
+ configureErr error
+ cleaned bool
+ connected bool
+}
+
+func (f *fakeRelayTransport) SetSSRC(ssrc uint32) { f.ssrc = ssrc }
+func (f *fakeRelayTransport) SetSubscriptionSSRC(ssrc uint32) { f.subscriptionSSRC = ssrc }
+func (f *fakeRelayTransport) SetOnConnected(callback func(string, int)) { f.onConnected = callback }
+func (f *fakeRelayTransport) SetOnReceive(callback func([]byte)) { f.onReceive = callback }
+func (f *fakeRelayTransport) ResendSubscriptions() {}
+func (f *fakeRelayTransport) ConfigureRelays(configs []call_transport.RelayConfig) error {
+ f.configs = append([]call_transport.RelayConfig(nil), configs...)
+ return f.configureErr
+}
+func (f *fakeRelayTransport) Broadcast([]byte) error { return nil }
+func (f *fakeRelayTransport) HasConnection() bool { return f.connected }
+func (f *fakeRelayTransport) ConnectedCount() int {
+ if f.connected {
+ return 1
+ }
+ return 0
+}
+func (f *fakeRelayTransport) Cleanup() { f.cleaned = true }
+
+func newTestRelaySession(source *fakeNegotiationSource, transport *fakeRelayTransport) *relaySession {
+ return &relaySession{
+ instanceID: "instance-1",
+ source: source,
+ factory: func(*slog.Logger) call_transport.RelayTransport { return transport },
+ log: slog.Default(),
+ transports: make(map[string]call_transport.RelayTransport),
+ configuring: make(map[string]bool),
+ started: make(map[string]bool),
+ connected: make(map[string]bool),
+ activated: make(map[string]bool),
+ ownJID: func() types.JID {
+ return types.NewJID("5511999999999", types.DefaultUserServer)
+ },
+ }
+}
+
+func testRelayData() *core.RelayData {
+ return &core.RelayData{
+ Endpoints: []core.RelayEndpoint{{
+ IP: "203.0.113.10",
+ Port: 3480,
+ Protocol: 0,
+ Key: "relay-password",
+ RawToken: []byte{1, 2, 3},
+ }},
+ ParticipantJIDs: []string{
+ "5511999999999:7@s.whatsapp.net",
+ "5511888888888:9@s.whatsapp.net",
+ },
+ }
+}
+
+func TestRelaySessionConfiguresTransportAndMarksMediaConnected(t *testing.T) {
+ state := call_state.NewOutgoing(
+ "call-1",
+ "5511888888888:2@s.whatsapp.net",
+ "5511999999999:1@s.whatsapp.net",
+ core.CallMediaTypeAudio,
+ )
+ if err := state.Apply(call_state.Transition{Type: call_state.TransitionOfferSent}); err != nil {
+ t.Fatal(err)
+ }
+ if err := state.Apply(call_state.Transition{Type: call_state.TransitionRemoteAccepted}); err != nil {
+ t.Fatal(err)
+ }
+
+ source := &fakeNegotiationSource{state: state, relayData: testRelayData()}
+ fakeTransport := &fakeRelayTransport{}
+ session := newTestRelaySession(source, fakeTransport)
+ connectedCallback := 0
+ session.onConnected = func(_, _ string) { connectedCallback++ }
+
+ if err := session.start("call-1"); err != nil {
+ t.Fatal(err)
+ }
+ if fakeTransport.ssrc == 0 || fakeTransport.subscriptionSSRC == 0 {
+ t.Fatalf("SSRCs were not configured: self=%d peer=%d", fakeTransport.ssrc, fakeTransport.subscriptionSSRC)
+ }
+ if len(fakeTransport.configs) != 1 || fakeTransport.configs[0].IP != "203.0.113.10" {
+ t.Fatalf("unexpected relay configs: %#v", fakeTransport.configs)
+ }
+ if fakeTransport.onConnected == nil {
+ t.Fatal("connected callback was not installed")
+ }
+ fakeTransport.connected = true
+ fakeTransport.onConnected("203.0.113.10", 3480)
+ fakeTransport.onConnected("203.0.113.11", 3480)
+ if source.state.StateData.State != core.CallStateActive || source.connected != 1 || connectedCallback != 1 {
+ t.Fatalf("media connection was not propagated once: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback)
+ }
+}
+
+func TestRelaySessionPreconnectsWithoutActivatingBeforeAccept(t *testing.T) {
+ state := call_state.NewOutgoing(
+ "call-preconnect",
+ "5511888888888:2@s.whatsapp.net",
+ "5511999999999:1@s.whatsapp.net",
+ core.CallMediaTypeAudio,
+ )
+ if err := state.Apply(call_state.Transition{Type: call_state.TransitionOfferSent}); err != nil {
+ t.Fatal(err)
+ }
+
+ source := &fakeNegotiationSource{state: state, relayData: testRelayData()}
+ fakeTransport := &fakeRelayTransport{}
+ session := newTestRelaySession(source, fakeTransport)
+ connectedCallback := 0
+ session.onConnected = func(_, _ string) { connectedCallback++ }
+
+ if err := session.start("call-preconnect"); err != nil {
+ t.Fatal(err)
+ }
+ fakeTransport.connected = true
+ fakeTransport.onConnected("203.0.113.10", 3480)
+ if source.state.StateData.State != core.CallStateRinging || source.connected != 0 || connectedCallback != 0 {
+ t.Fatalf("preconnect activated media too early: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback)
+ }
+
+ if err := source.EnsureRemoteAccepted("instance-1", "call-preconnect"); err != nil {
+ t.Fatal(err)
+ }
+ session.activateIfReady("call-preconnect")
+ if source.state.StateData.State != core.CallStateActive || source.connected != 1 || connectedCallback != 1 {
+ t.Fatalf("media was not activated after accept: state=%s source=%d callback=%d", source.state.StateData.State, source.connected, connectedCallback)
+ }
+}
+
+func TestRelaySessionTreatsDisabledTransportAsNoop(t *testing.T) {
+ state := call_state.NewIncoming("call-2", "5511888888888@s.whatsapp.net", "5511888888888@s.whatsapp.net", core.CallMediaTypeAudio)
+ if err := state.Apply(call_state.Transition{Type: call_state.TransitionLocalAccepted}); err != nil {
+ t.Fatal(err)
+ }
+ source := &fakeNegotiationSource{
+ state: state,
+ relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{
+ IP: "203.0.113.11", Protocol: 0, Key: "key", RawToken: []byte{4, 5, 6},
+ }}},
+ }
+ fakeTransport := &fakeRelayTransport{configureErr: call_transport.ErrSCTPUnavailable}
+ session := newTestRelaySession(source, fakeTransport)
+ if err := session.start("call-2"); err != nil {
+ t.Fatal(err)
+ }
+ if !fakeTransport.cleaned {
+ t.Fatal("disabled transport was not cleaned up")
+ }
+ if _, exists := session.transports["call-2"]; exists {
+ t.Fatal("disabled transport remained registered")
+ }
+}
+
+func TestRelaySessionReturnsRealConfigurationErrors(t *testing.T) {
+ state := call_state.NewIncoming("call-3", "5511888888888@s.whatsapp.net", "5511888888888@s.whatsapp.net", core.CallMediaTypeAudio)
+ _ = state.Apply(call_state.Transition{Type: call_state.TransitionLocalAccepted})
+ source := &fakeNegotiationSource{
+ state: state,
+ relayData: &core.RelayData{Endpoints: []core.RelayEndpoint{{
+ IP: "203.0.113.12", Protocol: 0, Key: "key", RawToken: []byte{7},
+ }}},
+ }
+ expected := errors.New("setup failed")
+ fakeTransport := &fakeRelayTransport{configureErr: expected}
+ session := newTestRelaySession(source, fakeTransport)
+ if err := session.start("call-3"); !errors.Is(err, expected) {
+ t.Fatalf("expected setup error, got %v", err)
+ }
+}
+
+func TestSelectDeviceJIDsPrefersParticipantDevices(t *testing.T) {
+ self, peer := selectDeviceJIDs(
+ []string{"5511999999999:7@s.whatsapp.net", "5511888888888:9@s.whatsapp.net"},
+ types.NewJID("5511999999999", types.DefaultUserServer),
+ types.NewJID("5511888888888", types.HiddenUserServer),
+ )
+ if self != "5511999999999:7@s.whatsapp.net" || peer != "5511888888888:9@s.whatsapp.net" {
+ t.Fatalf("unexpected participant selection: self=%s peer=%s", self, peer)
+ }
+}
diff --git a/pkg/call/voip/media/relay_subscription.go b/pkg/call/voip/media/relay_subscription.go
new file mode 100644
index 00000000..cfffb014
--- /dev/null
+++ b/pkg/call/voip/media/relay_subscription.go
@@ -0,0 +1,33 @@
+package media
+
+import "fmt"
+
+func (s *relaySession) updatePeerSSRC(callID string, ssrc uint32) error {
+ if s == nil || callID == "" || ssrc == 0 {
+ return fmt.Errorf("invalid peer SSRC update")
+ }
+ s.mu.Lock()
+ relay := s.transports[callID]
+ s.mu.Unlock()
+ if relay == nil {
+ return fmt.Errorf("relay transport for call %s is not ready", callID)
+ }
+ relay.SetSubscriptionSSRC(ssrc)
+ relay.ResendSubscriptions()
+ return nil
+}
+
+// UpdatePeerSSRC replaces the negotiation-derived relay subscription with the
+// SSRC authenticated from the first real remote RTP frame.
+func (r *RelayRegistry) UpdatePeerSSRC(instanceID, callID string, ssrc uint32) error {
+ if r == nil {
+ return fmt.Errorf("relay registry is not ready")
+ }
+ r.mu.RLock()
+ session := r.sessions[instanceID]
+ r.mu.RUnlock()
+ if session == nil {
+ return fmt.Errorf("relay runtime is not attached for instance %s", instanceID)
+ }
+ return session.updatePeerSSRC(callID, ssrc)
+}
diff --git a/pkg/call/voip/media/rtp.go b/pkg/call/voip/media/rtp.go
new file mode 100644
index 00000000..ba5bb5f7
--- /dev/null
+++ b/pkg/call/voip/media/rtp.go
@@ -0,0 +1,316 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "crypto/rand"
+ "encoding/binary"
+ "fmt"
+ "sync"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+const (
+ rtpVersion uint8 = 2
+ rtpMinHeaderSize = 12
+ maxCSRCCount = 15
+
+ // WhatsApp audio RTP uses the RFC 5285 one-byte extension profile even when
+ // the current packet carries no extension elements. Native clients include
+ // this empty DEBE block on outbound voice packets.
+ whatsAppRTPDEBEProfile uint16 = 0xbede
+)
+
+type RTPHeader struct {
+ Version uint8
+ Padding bool
+ Extension bool
+ Marker bool
+ PayloadType uint8
+ SequenceNumber uint16
+ Timestamp uint32
+ SSRC uint32
+ CSRC []uint32
+ ExtensionProfile uint16
+ ExtensionData []byte
+}
+
+func NewRTPHeader(payloadType uint8, sequence uint16, timestamp, ssrc uint32) *RTPHeader {
+ return &RTPHeader{
+ Version: rtpVersion,
+ PayloadType: payloadType,
+ SequenceNumber: sequence,
+ Timestamp: timestamp,
+ SSRC: ssrc,
+ }
+}
+
+func (h *RTPHeader) encodedSize() (int, error) {
+ if h == nil {
+ return 0, fmt.Errorf("RTP header is nil")
+ }
+ if h.Version == 0 {
+ h.Version = rtpVersion
+ }
+ if h.Version != rtpVersion {
+ return 0, fmt.Errorf("invalid RTP version: %d", h.Version)
+ }
+ if len(h.CSRC) > maxCSRCCount {
+ return 0, fmt.Errorf("too many RTP CSRC entries: %d", len(h.CSRC))
+ }
+ if h.PayloadType > 127 {
+ return 0, fmt.Errorf("invalid RTP payload type: %d", h.PayloadType)
+ }
+ if h.Extension {
+ if len(h.ExtensionData)%4 != 0 {
+ return 0, fmt.Errorf("RTP extension length must be a multiple of four: %d", len(h.ExtensionData))
+ }
+ if len(h.ExtensionData)/4 > int(^uint16(0)) {
+ return 0, fmt.Errorf("RTP extension is too large: %d", len(h.ExtensionData))
+ }
+ }
+
+ size := rtpMinHeaderSize + len(h.CSRC)*4
+ if h.Extension {
+ size += 4 + len(h.ExtensionData)
+ }
+ return size, nil
+}
+
+func (h *RTPHeader) MarshalTo(buffer []byte) (int, error) {
+ size, err := h.encodedSize()
+ if err != nil {
+ return 0, err
+ }
+ if len(buffer) < size {
+ return 0, fmt.Errorf("buffer too small for RTP header: got %d, need %d", len(buffer), size)
+ }
+
+ buffer[0] = (h.Version&0x03)<<6 | boolBit(h.Padding)<<5 | boolBit(h.Extension)<<4 | byte(len(h.CSRC)&0x0f)
+ buffer[1] = boolBit(h.Marker)<<7 | (h.PayloadType & 0x7f)
+ binary.BigEndian.PutUint16(buffer[2:4], h.SequenceNumber)
+ binary.BigEndian.PutUint32(buffer[4:8], h.Timestamp)
+ binary.BigEndian.PutUint32(buffer[8:12], h.SSRC)
+
+ offset := rtpMinHeaderSize
+ for _, source := range h.CSRC {
+ binary.BigEndian.PutUint32(buffer[offset:offset+4], source)
+ offset += 4
+ }
+ if h.Extension {
+ binary.BigEndian.PutUint16(buffer[offset:offset+2], h.ExtensionProfile)
+ binary.BigEndian.PutUint16(buffer[offset+2:offset+4], uint16(len(h.ExtensionData)/4))
+ copy(buffer[offset+4:offset+4+len(h.ExtensionData)], h.ExtensionData)
+ }
+ return size, nil
+}
+
+func ParseRTPHeader(buffer []byte) (*RTPHeader, int, error) {
+ if len(buffer) < rtpMinHeaderSize {
+ return nil, 0, fmt.Errorf("buffer too small for RTP header: %d", len(buffer))
+ }
+ version := (buffer[0] >> 6) & 0x03
+ if version != rtpVersion {
+ return nil, 0, fmt.Errorf("invalid RTP version: %d", version)
+ }
+
+ csrcCount := int(buffer[0] & 0x0f)
+ offset := rtpMinHeaderSize + csrcCount*4
+ if len(buffer) < offset {
+ return nil, 0, fmt.Errorf("truncated RTP CSRC list")
+ }
+
+ header := &RTPHeader{
+ Version: version,
+ Padding: buffer[0]&0x20 != 0,
+ Extension: buffer[0]&0x10 != 0,
+ Marker: buffer[1]&0x80 != 0,
+ PayloadType: buffer[1] & 0x7f,
+ SequenceNumber: binary.BigEndian.Uint16(buffer[2:4]),
+ Timestamp: binary.BigEndian.Uint32(buffer[4:8]),
+ SSRC: binary.BigEndian.Uint32(buffer[8:12]),
+ CSRC: make([]uint32, 0, csrcCount),
+ }
+
+ cursor := rtpMinHeaderSize
+ for index := 0; index < csrcCount; index++ {
+ header.CSRC = append(header.CSRC, binary.BigEndian.Uint32(buffer[cursor:cursor+4]))
+ cursor += 4
+ }
+ if header.Extension {
+ if len(buffer) < cursor+4 {
+ return nil, 0, fmt.Errorf("truncated RTP extension header")
+ }
+ header.ExtensionProfile = binary.BigEndian.Uint16(buffer[cursor : cursor+2])
+ extensionLength := int(binary.BigEndian.Uint16(buffer[cursor+2:cursor+4])) * 4
+ cursor += 4
+ if len(buffer) < cursor+extensionLength {
+ return nil, 0, fmt.Errorf("truncated RTP extension data")
+ }
+ header.ExtensionData = append([]byte(nil), buffer[cursor:cursor+extensionLength]...)
+ cursor += extensionLength
+ }
+ return header, cursor, nil
+}
+
+type RTPPacket struct {
+ Header *RTPHeader
+ Payload []byte
+ PaddingSize uint8
+}
+
+func (p *RTPPacket) Marshal() ([]byte, error) {
+ if p == nil || p.Header == nil {
+ return nil, fmt.Errorf("RTP packet or header is nil")
+ }
+ headerSize, err := p.Header.encodedSize()
+ if err != nil {
+ return nil, err
+ }
+ paddingSize := int(p.PaddingSize)
+ if p.Header.Padding && paddingSize == 0 {
+ return nil, fmt.Errorf("RTP padding flag is set without padding bytes")
+ }
+ if !p.Header.Padding && paddingSize != 0 {
+ return nil, fmt.Errorf("RTP padding bytes require the padding flag")
+ }
+
+ output := make([]byte, headerSize+len(p.Payload)+paddingSize)
+ if _, err = p.Header.MarshalTo(output); err != nil {
+ return nil, err
+ }
+ copy(output[headerSize:], p.Payload)
+ if paddingSize > 0 {
+ output[len(output)-1] = byte(paddingSize)
+ }
+ return output, nil
+}
+
+func ParseRTPPacket(buffer []byte) (*RTPPacket, error) {
+ header, headerSize, err := ParseRTPHeader(buffer)
+ if err != nil {
+ return nil, err
+ }
+ if len(buffer) < headerSize {
+ return nil, fmt.Errorf("invalid RTP header size")
+ }
+ payloadEnd := len(buffer)
+ paddingSize := 0
+ if header.Padding {
+ if payloadEnd == headerSize {
+ return nil, fmt.Errorf("RTP padding flag set on empty payload")
+ }
+ paddingSize = int(buffer[payloadEnd-1])
+ if paddingSize == 0 || paddingSize > payloadEnd-headerSize {
+ return nil, fmt.Errorf("invalid RTP padding size: %d", paddingSize)
+ }
+ payloadEnd -= paddingSize
+ }
+ return &RTPPacket{
+ Header: header,
+ Payload: append([]byte(nil), buffer[headerSize:payloadEnd]...),
+ PaddingSize: uint8(paddingSize),
+ }, nil
+}
+
+func (p *RTPPacket) Wipe() {
+ if p == nil {
+ return
+ }
+ zeroBytes(p.Payload)
+ p.Payload = nil
+ if p.Header != nil {
+ zeroBytes(p.Header.ExtensionData)
+ p.Header.ExtensionData = nil
+ p.Header.CSRC = nil
+ }
+ p.Header = nil
+ p.PaddingSize = 0
+}
+
+type RTPSession struct {
+ mu sync.Mutex
+ ssrc uint32
+ payloadType uint8
+ sequenceNumber uint16
+ timestamp uint32
+ samplesPerPacket uint32
+ extension bool
+ extensionProfile uint16
+}
+
+func NewRTPSession(ssrc uint32, payloadType uint8, samplesPerPacket uint32) (*RTPSession, error) {
+ if ssrc == 0 {
+ return nil, fmt.Errorf("RTP SSRC must be non-zero")
+ }
+ if payloadType > 127 {
+ return nil, fmt.Errorf("invalid RTP payload type: %d", payloadType)
+ }
+ if samplesPerPacket == 0 {
+ return nil, fmt.Errorf("samples per RTP packet must be non-zero")
+ }
+ sequence, err := randomUint16()
+ if err != nil {
+ return nil, err
+ }
+ timestamp, err := randomUint32()
+ if err != nil {
+ return nil, err
+ }
+ return &RTPSession{
+ ssrc: ssrc,
+ payloadType: payloadType,
+ sequenceNumber: sequence,
+ timestamp: timestamp,
+ samplesPerPacket: samplesPerPacket,
+ }, nil
+}
+
+func NewWhatsAppOpusRTPSession(ssrc uint32) (*RTPSession, error) {
+ session, err := NewRTPSession(ssrc, core.PayloadTypeWhatsAppOpus, 960)
+ if err != nil {
+ return nil, err
+ }
+ session.extension = true
+ session.extensionProfile = whatsAppRTPDEBEProfile
+ return session, nil
+}
+
+func (s *RTPSession) CreatePacket(payload []byte, marker bool) *RTPPacket {
+ return s.CreatePacketWithDuration(payload, s.samplesPerPacket, marker)
+}
+
+func (s *RTPSession) CreatePacketWithDuration(payload []byte, durationSamples uint32, marker bool) *RTPPacket {
+ s.mu.Lock()
+ header := NewRTPHeader(s.payloadType, s.sequenceNumber, s.timestamp, s.ssrc)
+ header.Marker = marker
+ header.Extension = s.extension
+ header.ExtensionProfile = s.extensionProfile
+ s.sequenceNumber++
+ s.timestamp += durationSamples
+ s.mu.Unlock()
+ return &RTPPacket{Header: header, Payload: append([]byte(nil), payload...)}
+}
+
+func boolBit(value bool) byte {
+ if value {
+ return 1
+ }
+ return 0
+}
+
+func randomUint16() (uint16, error) {
+ var buffer [2]byte
+ if _, err := rand.Read(buffer[:]); err != nil {
+ return 0, fmt.Errorf("generate RTP sequence: %w", err)
+ }
+ return binary.BigEndian.Uint16(buffer[:]), nil
+}
+
+func randomUint32() (uint32, error) {
+ var buffer [4]byte
+ if _, err := rand.Read(buffer[:]); err != nil {
+ return 0, fmt.Errorf("generate RTP timestamp: %w", err)
+ }
+ return binary.BigEndian.Uint32(buffer[:]), nil
+}
diff --git a/pkg/call/voip/media/rtp_observation.go b/pkg/call/voip/media/rtp_observation.go
new file mode 100644
index 00000000..8bd81034
--- /dev/null
+++ b/pkg/call/voip/media/rtp_observation.go
@@ -0,0 +1,49 @@
+package media
+
+import (
+ "encoding/binary"
+ "errors"
+ "fmt"
+)
+
+var ErrSelfRTPFrame = errors.New("relay frame belongs to the local RTP sender")
+
+// relayRTPSSRC reads the clear RTP header carried inside an SRTP frame. The
+// payload is encrypted, but the RTP version and SSRC remain available before
+// authentication/decryption and can be used to correct relay subscriptions.
+func relayRTPSSRC(frame []byte) (uint32, bool) {
+ if len(frame) < 12 || frame[0]&0xc0 != 0x80 {
+ return 0, false
+ }
+ ssrc := binary.BigEndian.Uint32(frame[8:12])
+ return ssrc, ssrc != 0
+}
+
+// peerSSRCCandidate validates whether a relay frame can belong to the remote
+// stream. The first non-local SSRC is allowed as a candidate, but it is only
+// committed after SRTP authentication succeeds.
+func (s *packetSession) peerSSRCCandidate(frame []byte) (previous, actual uint32, first bool, err error) {
+ if s == nil {
+ return 0, 0, false, ErrPacketSessionNotReady
+ }
+ actual, ok := relayRTPSSRC(frame)
+ if !ok {
+ return 0, 0, false, ErrNonRTPFrame
+ }
+ if actual == s.selfSSRC {
+ return s.peerSSRC, actual, false, ErrSelfRTPFrame
+ }
+ if s.peerObserved && actual != s.peerSSRC {
+ return s.peerSSRC, actual, false, fmt.Errorf("unexpected RTP SSRC: got %d, want %d", actual, s.peerSSRC)
+ }
+ return s.peerSSRC, actual, !s.peerObserved, nil
+}
+
+func (s *packetSession) commitPeerSSRC(actual uint32) (previous uint32, changed bool) {
+ previous = s.peerSSRC
+ if !s.peerObserved {
+ s.peerSSRC = actual
+ s.peerObserved = true
+ }
+ return previous, previous != actual
+}
diff --git a/pkg/call/voip/media/rtp_srtp_test.go b/pkg/call/voip/media/rtp_srtp_test.go
new file mode 100644
index 00000000..81562033
--- /dev/null
+++ b/pkg/call/voip/media/rtp_srtp_test.go
@@ -0,0 +1,241 @@
+package media
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+func testKeying(t *testing.T, fill byte, jid string) core.SRTPKeyingMaterial {
+ t.Helper()
+ material, err := DerivePerJIDSRTPKey(bytes.Repeat([]byte{fill}, 32), jid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return material
+}
+
+func TestDerivePerJIDSRTPKey(t *testing.T) {
+ callKey := bytes.Repeat([]byte{0xab}, 32)
+ first, err := DerivePerJIDSRTPKey(callKey, "5511999999999:3@lid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer first.Wipe()
+ second, err := DerivePerJIDSRTPKey(callKey, "5511999999999:3@lid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer second.Wipe()
+ other, err := DerivePerJIDSRTPKey(callKey, "5511999999999:4@lid")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer other.Wipe()
+
+ if len(first.MasterKey) != 16 || len(first.MasterSalt) != 14 {
+ t.Fatalf("unexpected keying lengths: key=%d salt=%d", len(first.MasterKey), len(first.MasterSalt))
+ }
+ if !bytes.Equal(first.MasterKey, second.MasterKey) || !bytes.Equal(first.MasterSalt, second.MasterSalt) {
+ t.Fatal("per-device derivation is not deterministic")
+ }
+ if bytes.Equal(first.MasterKey, other.MasterKey) && bytes.Equal(first.MasterSalt, other.MasterSalt) {
+ t.Fatal("different device JIDs produced identical keying material")
+ }
+ if _, err = DerivePerJIDSRTPKey(callKey[:31], "device@lid"); err == nil {
+ t.Fatal("expected invalid call-key length error")
+ }
+ if _, err = DerivePerJIDSRTPKey(callKey, ""); err == nil {
+ t.Fatal("expected empty device JID error")
+ }
+}
+
+func TestRTPPacketRoundTripWithExtensionAndPadding(t *testing.T) {
+ header := NewRTPHeader(core.PayloadTypeWhatsAppOpus, 0x1234, 0xdeadbeef, 0xcafebabe)
+ header.Marker = true
+ header.Extension = true
+ header.ExtensionProfile = 0xbede
+ header.ExtensionData = []byte{1, 2, 3, 4, 5, 6, 7, 8}
+ header.CSRC = []uint32{0x01020304, 0x05060708}
+ header.Padding = true
+ packet := &RTPPacket{Header: header, Payload: []byte{9, 8, 7, 6}, PaddingSize: 4}
+
+ encoded, err := packet.Marshal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := ParseRTPPacket(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer decoded.Wipe()
+ if decoded.Header.SequenceNumber != header.SequenceNumber || decoded.Header.Timestamp != header.Timestamp || decoded.Header.SSRC != header.SSRC {
+ t.Fatalf("header mismatch: %+v", decoded.Header)
+ }
+ if !bytes.Equal(decoded.Header.ExtensionData, header.ExtensionData) || !bytes.Equal(decoded.Payload, packet.Payload) {
+ t.Fatal("RTP extension or payload mismatch")
+ }
+ if decoded.PaddingSize != 4 || len(decoded.Header.CSRC) != 2 {
+ t.Fatalf("unexpected padding or CSRC count: padding=%d csrc=%d", decoded.PaddingSize, len(decoded.Header.CSRC))
+ }
+}
+
+func TestRTPRejectsMalformedFrames(t *testing.T) {
+ if _, err := ParseRTPPacket([]byte{0x80}); err == nil {
+ t.Fatal("expected short RTP frame error")
+ }
+ header := NewRTPHeader(120, 1, 2, 3)
+ header.Extension = true
+ header.ExtensionData = []byte{1, 2, 3}
+ if _, err := (&RTPPacket{Header: header, Payload: []byte{1}}).Marshal(); err == nil {
+ t.Fatal("expected unaligned extension error")
+ }
+ header = NewRTPHeader(120, 1, 2, 3)
+ header.Padding = true
+ if _, err := (&RTPPacket{Header: header, Payload: []byte{1}}).Marshal(); err == nil {
+ t.Fatal("expected missing padding-size error")
+ }
+}
+
+func TestSRTPRoundTripAndAuthentication(t *testing.T) {
+ self := testKeying(t, 0x11, "self:0@lid")
+ peer := testKeying(t, 0x11, "peer:0@lid")
+ defer self.Wipe()
+ defer peer.Wipe()
+
+ sender, err := NewSRTPSession(self, peer, core.SRTPSendAuthTagLen, core.SRTPRecvAuthTagLen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer sender.Close()
+ receiver, err := NewSRTPSession(peer, self, core.SRTPRecvAuthTagLen, core.SRTPSendAuthTagLen)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer receiver.Close()
+
+ rtp, err := NewWhatsAppOpusRTPSession(0xaabbccdd)
+ if err != nil {
+ t.Fatal(err)
+ }
+ payload := bytes.Repeat([]byte{0x42}, 40)
+ packet := rtp.CreatePacket(payload, true)
+ protected, err := sender.Protect(packet)
+ if err != nil {
+ t.Fatal(err)
+ }
+ plain, err := receiver.Unprotect(protected)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer plain.Wipe()
+ if !bytes.Equal(plain.Payload, payload) || plain.Header.SSRC != packet.Header.SSRC {
+ t.Fatal("SRTP roundtrip mismatch")
+ }
+
+ tampered := append([]byte(nil), protected...)
+ tampered[len(tampered)-1] ^= 0xff
+ _, err = receiver.Unprotect(tampered)
+ var srtpErr *SRTPError
+ if !errors.As(err, &srtpErr) || srtpErr.Type != SRTPErrAuthFailed {
+ t.Fatalf("expected authentication error, got %v", err)
+ }
+ _, err = receiver.Unprotect(protected)
+ if !errors.As(err, &srtpErr) || srtpErr.Type != SRTPErrReplay {
+ t.Fatalf("expected replay error, got %v", err)
+ }
+}
+
+func TestSRTPAcceptsAuthenticatedOutOfOrderPackets(t *testing.T) {
+ self := testKeying(t, 0x22, "self:0@lid")
+ peer := testKeying(t, 0x22, "peer:0@lid")
+ defer self.Wipe()
+ defer peer.Wipe()
+ sender, _ := NewSRTPSession(self, peer, 4, 4)
+ receiver, _ := NewSRTPSession(peer, self, 4, 4)
+ defer sender.Close()
+ defer receiver.Close()
+
+ first := &RTPPacket{Header: NewRTPHeader(120, 100, 1000, 55), Payload: []byte("first")}
+ second := &RTPPacket{Header: NewRTPHeader(120, 101, 1960, 55), Payload: []byte("second")}
+ protectedFirst, err := sender.Protect(first)
+ if err != nil {
+ t.Fatal(err)
+ }
+ protectedSecond, err := sender.Protect(second)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decodedSecond, err := receiver.Unprotect(protectedSecond)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decodedSecond.Wipe()
+ decodedFirst, err := receiver.Unprotect(protectedFirst)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer decodedFirst.Wipe()
+ if string(decodedFirst.Payload) != "first" {
+ t.Fatalf("unexpected out-of-order payload: %q", decodedFirst.Payload)
+ }
+}
+
+func TestSRTPSequenceRollover(t *testing.T) {
+ self := testKeying(t, 0x33, "self:0@lid")
+ peer := testKeying(t, 0x33, "peer:0@lid")
+ defer self.Wipe()
+ defer peer.Wipe()
+ sender, _ := NewSRTPSession(self, peer, 4, 4)
+ receiver, _ := NewSRTPSession(peer, self, 4, 4)
+ defer sender.Close()
+ defer receiver.Close()
+
+ before := &RTPPacket{Header: NewRTPHeader(120, 0xffff, 1, 99), Payload: []byte{1}}
+ after := &RTPPacket{Header: NewRTPHeader(120, 0, 2, 99), Payload: []byte{2}}
+ protectedBefore, err := sender.Protect(before)
+ if err != nil {
+ t.Fatal(err)
+ }
+ protectedAfter, err := sender.Protect(after)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decodedBefore, err := receiver.Unprotect(protectedBefore)
+ if err != nil {
+ t.Fatal(err)
+ }
+ decodedBefore.Wipe()
+ decodedAfter, err := receiver.Unprotect(protectedAfter)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer decodedAfter.Wipe()
+ if !bytes.Equal(decodedAfter.Payload, []byte{2}) {
+ t.Fatalf("unexpected rollover payload: %v", decodedAfter.Payload)
+ }
+}
+
+func TestSRTPRejectsSendIndexReuseAndClosedContext(t *testing.T) {
+ self := testKeying(t, 0x44, "self:0@lid")
+ peer := testKeying(t, 0x44, "peer:0@lid")
+ defer self.Wipe()
+ defer peer.Wipe()
+ session, err := NewSRTPSession(self, peer, 4, 4)
+ if err != nil {
+ t.Fatal(err)
+ }
+ packet := &RTPPacket{Header: NewRTPHeader(120, 7, 1, 1), Payload: []byte{1}}
+ if _, err = session.Protect(packet); err != nil {
+ t.Fatal(err)
+ }
+ if _, err = session.Protect(packet); err == nil {
+ t.Fatal("expected duplicate send sequence to be rejected")
+ }
+ session.Close()
+ if _, err = session.Protect(&RTPPacket{Header: NewRTPHeader(120, 8, 2, 1), Payload: []byte{2}}); err == nil {
+ t.Fatal("expected closed session error")
+ }
+}
diff --git a/pkg/call/voip/media/rtp_whatsapp_extension_test.go b/pkg/call/voip/media/rtp_whatsapp_extension_test.go
new file mode 100644
index 00000000..fc7d2192
--- /dev/null
+++ b/pkg/call/voip/media/rtp_whatsapp_extension_test.go
@@ -0,0 +1,29 @@
+package media
+
+import "testing"
+
+func TestWhatsAppOpusSessionUsesDEBEExtension(t *testing.T) {
+ session, err := NewWhatsAppOpusRTPSession(1234)
+ if err != nil {
+ t.Fatal(err)
+ }
+ packet := session.CreatePacket([]byte{1, 2, 3}, true)
+ if packet.Header == nil || !packet.Header.Extension {
+ t.Fatal("expected RTP extension")
+ }
+ if packet.Header.ExtensionProfile != whatsAppRTPDEBEProfile {
+ t.Fatalf("unexpected profile: %x", packet.Header.ExtensionProfile)
+ }
+ encoded, err := packet.Marshal()
+ if err != nil {
+ t.Fatal(err)
+ }
+ decoded, err := ParseRTPPacket(encoded)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer decoded.Wipe()
+ if !decoded.Header.Extension || decoded.Header.ExtensionProfile != whatsAppRTPDEBEProfile {
+ t.Fatal("extension did not survive RTP round trip")
+ }
+}
diff --git a/pkg/call/voip/media/srtp.go b/pkg/call/voip/media/srtp.go
new file mode 100644
index 00000000..767b9913
--- /dev/null
+++ b/pkg/call/voip/media/srtp.go
@@ -0,0 +1,392 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hmac"
+ "crypto/sha1"
+ "crypto/subtle"
+ "encoding/binary"
+ "fmt"
+ "sync"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+type SRTPErrorType string
+
+const (
+ SRTPErrPacketTooShort SRTPErrorType = "packet_too_short"
+ SRTPErrAuthFailed SRTPErrorType = "auth_failed"
+ SRTPErrReplay SRTPErrorType = "replay"
+ SRTPErrEncryption SRTPErrorType = "encryption"
+ SRTPErrDecryption SRTPErrorType = "decryption"
+ SRTPErrInvalidKeying SRTPErrorType = "invalid_keying"
+ SRTPErrClosed SRTPErrorType = "closed"
+)
+
+type SRTPError struct {
+ Type SRTPErrorType
+ Msg string
+}
+
+func (e *SRTPError) Error() string {
+ if e == nil {
+ return "srtp error"
+ }
+ return fmt.Sprintf("srtp %s: %s", e.Type, e.Msg)
+}
+
+type SRTPContext struct {
+ mu sync.Mutex
+
+ sessionKey []byte
+ sessionSalt []byte
+ authKey []byte
+ authTagLen int
+
+ initialized bool
+ highestIndex uint64
+ replayWindow uint64
+ closed bool
+}
+
+func NewSRTPContext(keying core.SRTPKeyingMaterial, authTagLen int) (*SRTPContext, error) {
+ if len(keying.MasterKey) != srtpMasterKeyLength {
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master key length is %d", len(keying.MasterKey))}
+ }
+ if len(keying.MasterSalt) != srtpMasterSaltLength {
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master salt length is %d", len(keying.MasterSalt))}
+ }
+ if authTagLen <= 0 {
+ authTagLen = core.SRTPAuthTagLen
+ }
+ if authTagLen > sha1.Size {
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("auth tag length is %d", authTagLen)}
+ }
+
+ sessionKey, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelEncryption, srtpMasterKeyLength)
+ if err != nil {
+ return nil, err
+ }
+ authKey, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelAuth, sha1.Size)
+ if err != nil {
+ zeroBytes(sessionKey)
+ return nil, err
+ }
+ sessionSalt, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelSalt, srtpMasterSaltLength)
+ if err != nil {
+ zeroBytes(sessionKey)
+ zeroBytes(authKey)
+ return nil, err
+ }
+ return &SRTPContext{
+ sessionKey: sessionKey,
+ sessionSalt: sessionSalt,
+ authKey: authKey,
+ authTagLen: authTagLen,
+ }, nil
+}
+
+func (c *SRTPContext) Protect(packet *RTPPacket) ([]byte, error) {
+ if packet == nil || packet.Header == nil {
+ return nil, &SRTPError{Type: SRTPErrEncryption, Msg: "RTP packet or header is nil"}
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return nil, &SRTPError{Type: SRTPErrClosed, Msg: "context is closed"}
+ }
+
+ index := estimatePacketIndex(packet.Header.SequenceNumber, c.highestIndex, c.initialized)
+ if c.initialized && index <= c.highestIndex {
+ return nil, &SRTPError{Type: SRTPErrEncryption, Msg: "non-monotonic RTP sequence would reuse an SRTP index"}
+ }
+
+ plain, err := packet.Marshal()
+ if err != nil {
+ return nil, &SRTPError{Type: SRTPErrEncryption, Msg: err.Error()}
+ }
+ defer zeroBytes(plain)
+
+ _, headerSize, err := ParseRTPHeader(plain)
+ if err != nil {
+ return nil, &SRTPError{Type: SRTPErrEncryption, Msg: err.Error()}
+ }
+ output := make([]byte, len(plain)+c.authTagLen)
+ copy(output[:headerSize], plain[:headerSize])
+
+ iv := c.generateIV(packet.Header.SSRC, index)
+ if err = aesCTRXOR(c.sessionKey, iv, plain[headerSize:], output[headerSize:len(plain)]); err != nil {
+ zeroBytes(output)
+ return nil, &SRTPError{Type: SRTPErrEncryption, Msg: err.Error()}
+ }
+ zeroBytes(iv)
+
+ roc := uint32(index >> 16)
+ tag := c.computeAuthTag(output[:len(plain)], roc)
+ copy(output[len(plain):], tag)
+ zeroBytes(tag)
+
+ c.highestIndex = index
+ c.initialized = true
+ return output, nil
+}
+
+func (c *SRTPContext) Unprotect(data []byte) (*RTPPacket, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ return nil, &SRTPError{Type: SRTPErrClosed, Msg: "context is closed"}
+ }
+ if len(data) < rtpMinHeaderSize+c.authTagLen {
+ return nil, &SRTPError{Type: SRTPErrPacketTooShort, Msg: fmt.Sprintf("packet is %d bytes", len(data))}
+ }
+
+ header, headerSize, err := ParseRTPHeader(data)
+ if err != nil {
+ return nil, &SRTPError{Type: SRTPErrDecryption, Msg: err.Error()}
+ }
+ ciphertextEnd := len(data) - c.authTagLen
+ if ciphertextEnd <= headerSize {
+ return nil, &SRTPError{Type: SRTPErrPacketTooShort, Msg: "packet has no encrypted RTP payload"}
+ }
+
+ index := estimatePacketIndex(header.SequenceNumber, c.highestIndex, c.initialized)
+ roc := uint32(index >> 16)
+ expectedTag := c.computeAuthTag(data[:ciphertextEnd], roc)
+ receivedTag := data[ciphertextEnd:]
+ if len(expectedTag) != len(receivedTag) || subtle.ConstantTimeCompare(expectedTag, receivedTag) != 1 {
+ zeroBytes(expectedTag)
+ return nil, &SRTPError{Type: SRTPErrAuthFailed, Msg: "authentication tag mismatch"}
+ }
+ zeroBytes(expectedTag)
+
+ if c.isReplay(index) {
+ return nil, &SRTPError{Type: SRTPErrReplay, Msg: "packet index was already received or is outside the replay window"}
+ }
+
+ plain := make([]byte, ciphertextEnd)
+ copy(plain[:headerSize], data[:headerSize])
+ iv := c.generateIV(header.SSRC, index)
+ if err = aesCTRXOR(c.sessionKey, iv, data[headerSize:ciphertextEnd], plain[headerSize:]); err != nil {
+ zeroBytes(iv)
+ zeroBytes(plain)
+ return nil, &SRTPError{Type: SRTPErrDecryption, Msg: err.Error()}
+ }
+ zeroBytes(iv)
+
+ packet, err := ParseRTPPacket(plain)
+ zeroBytes(plain)
+ if err != nil {
+ return nil, &SRTPError{Type: SRTPErrDecryption, Msg: err.Error()}
+ }
+ c.commitReceivedIndex(index)
+ return packet, nil
+}
+
+func (c *SRTPContext) SetAuthenticationKeying(keying core.SRTPKeyingMaterial) error {
+ if len(keying.MasterKey) != srtpMasterKeyLength || len(keying.MasterSalt) != srtpMasterSaltLength {
+ return &SRTPError{Type: SRTPErrInvalidKeying, Msg: "invalid authentication keying material"}
+ }
+ authKey, err := deriveSRTPKey(keying.MasterKey, keying.MasterSalt, core.SRTPLabelAuth, sha1.Size)
+ if err != nil {
+ return err
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.closed {
+ zeroBytes(authKey)
+ return &SRTPError{Type: SRTPErrClosed, Msg: "context is closed"}
+ }
+ zeroBytes(c.authKey)
+ c.authKey = authKey
+ return nil
+}
+
+func (c *SRTPContext) Close() {
+ if c == nil {
+ return
+ }
+ c.mu.Lock()
+ if !c.closed {
+ zeroBytes(c.sessionKey)
+ zeroBytes(c.sessionSalt)
+ zeroBytes(c.authKey)
+ c.sessionKey = nil
+ c.sessionSalt = nil
+ c.authKey = nil
+ c.highestIndex = 0
+ c.replayWindow = 0
+ c.initialized = false
+ c.closed = true
+ }
+ c.mu.Unlock()
+}
+
+func (c *SRTPContext) generateIV(ssrc uint32, index uint64) []byte {
+ iv := make([]byte, aes.BlockSize)
+ copy(iv, c.sessionSalt)
+ var ssrcBuffer [4]byte
+ binary.BigEndian.PutUint32(ssrcBuffer[:], ssrc)
+ for offset := 0; offset < len(ssrcBuffer); offset++ {
+ iv[4+offset] ^= ssrcBuffer[offset]
+ }
+ var indexBuffer [8]byte
+ binary.BigEndian.PutUint64(indexBuffer[:], index)
+ for offset := 0; offset < 6; offset++ {
+ iv[8+offset] ^= indexBuffer[2+offset]
+ }
+ return iv
+}
+
+func (c *SRTPContext) computeAuthTag(data []byte, roc uint32) []byte {
+ mac := hmac.New(sha1.New, c.authKey)
+ _, _ = mac.Write(data)
+ var rocBuffer [4]byte
+ binary.BigEndian.PutUint32(rocBuffer[:], roc)
+ _, _ = mac.Write(rocBuffer[:])
+ return append([]byte(nil), mac.Sum(nil)[:c.authTagLen]...)
+}
+
+func (c *SRTPContext) isReplay(index uint64) bool {
+ if !c.initialized || index > c.highestIndex {
+ return false
+ }
+ delta := c.highestIndex - index
+ if delta >= 64 {
+ return true
+ }
+ return c.replayWindow&(uint64(1)< c.highestIndex {
+ shift := index - c.highestIndex
+ if shift >= 64 {
+ c.replayWindow = 1
+ } else {
+ c.replayWindow = (c.replayWindow << shift) | 1
+ }
+ c.highestIndex = index
+ return
+ }
+ delta := c.highestIndex - index
+ c.replayWindow |= uint64(1) << delta
+}
+
+func estimatePacketIndex(sequence uint16, highest uint64, initialized bool) uint64 {
+ if !initialized {
+ return uint64(sequence)
+ }
+ roc := uint32(highest >> 16)
+ lastSequence := uint16(highest)
+ guessedROC := roc
+ if lastSequence < 0x8000 {
+ if int(sequence)-int(lastSequence) > 0x8000 && roc > 0 {
+ guessedROC = roc - 1
+ }
+ } else if int(lastSequence)-int(sequence) > 0x8000 {
+ guessedROC = roc + 1
+ }
+ return (uint64(guessedROC) << 16) | uint64(sequence)
+}
+
+type SRTPSession struct {
+ send *SRTPContext
+ recv *SRTPContext
+}
+
+func NewSRTPSession(sendKey, receiveKey core.SRTPKeyingMaterial, sendAuthLen, receiveAuthLen int) (*SRTPSession, error) {
+ sendContext, err := NewSRTPContext(sendKey, sendAuthLen)
+ if err != nil {
+ return nil, err
+ }
+ receiveContext, err := NewSRTPContext(receiveKey, receiveAuthLen)
+ if err != nil {
+ sendContext.Close()
+ return nil, err
+ }
+ return &SRTPSession{send: sendContext, recv: receiveContext}, nil
+}
+
+func (s *SRTPSession) Protect(packet *RTPPacket) ([]byte, error) {
+ if s == nil || s.send == nil {
+ return nil, &SRTPError{Type: SRTPErrClosed, Msg: "send context is unavailable"}
+ }
+ return s.send.Protect(packet)
+}
+
+func (s *SRTPSession) Unprotect(data []byte) (*RTPPacket, error) {
+ if s == nil || s.recv == nil {
+ return nil, &SRTPError{Type: SRTPErrClosed, Msg: "receive context is unavailable"}
+ }
+ return s.recv.Unprotect(data)
+}
+
+func (s *SRTPSession) SetSendAuthenticationKeying(keying core.SRTPKeyingMaterial) error {
+ if s == nil || s.send == nil {
+ return &SRTPError{Type: SRTPErrClosed, Msg: "send context is unavailable"}
+ }
+ return s.send.SetAuthenticationKeying(keying)
+}
+
+func (s *SRTPSession) Close() {
+ if s == nil {
+ return
+ }
+ if s.send != nil {
+ s.send.Close()
+ }
+ if s.recv != nil {
+ s.recv.Close()
+ }
+ s.send = nil
+ s.recv = nil
+}
+
+func deriveSRTPKey(masterKey, masterSalt []byte, label byte, length int) ([]byte, error) {
+ if len(masterKey) != srtpMasterKeyLength {
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master key length is %d", len(masterKey))}
+ }
+ if len(masterSalt) != srtpMasterSaltLength {
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("master salt length is %d", len(masterSalt))}
+ }
+ if length <= 0 {
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: fmt.Sprintf("derived key length is %d", length)}
+ }
+ iv := make([]byte, aes.BlockSize)
+ copy(iv, masterSalt)
+ iv[7] ^= label
+ output := make([]byte, length)
+ if err := aesCTRXOR(masterKey, iv, make([]byte, length), output); err != nil {
+ zeroBytes(iv)
+ zeroBytes(output)
+ return nil, &SRTPError{Type: SRTPErrInvalidKeying, Msg: err.Error()}
+ }
+ zeroBytes(iv)
+ return output, nil
+}
+
+func aesCTRXOR(key, iv, source, destination []byte) error {
+ if len(iv) != aes.BlockSize {
+ return fmt.Errorf("invalid AES CTR IV length: %d", len(iv))
+ }
+ if len(source) != len(destination) {
+ return fmt.Errorf("AES CTR source and destination lengths differ")
+ }
+ block, err := aes.NewCipher(key)
+ if err != nil {
+ return err
+ }
+ cipher.NewCTR(block, iv).XORKeyStream(destination, source)
+ return nil
+}
diff --git a/pkg/call/voip/media/ssrc.go b/pkg/call/voip/media/ssrc.go
new file mode 100644
index 00000000..ee36bdb0
--- /dev/null
+++ b/pkg/call/voip/media/ssrc.go
@@ -0,0 +1,31 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package media
+
+import (
+ "crypto/hkdf"
+ "crypto/sha256"
+ "encoding/binary"
+ "fmt"
+)
+
+// GenerateSecureSSRC deterministically derives a WhatsApp media SSRC from the
+// call ID, device JID and stream counter.
+func GenerateSecureSSRC(callID, deviceJID string, counter uint32) (uint32, error) {
+ if callID == "" {
+ return 0, fmt.Errorf("call ID is empty")
+ }
+ if deviceJID == "" {
+ return 0, fmt.Errorf("device JID is empty")
+ }
+ salt := make([]byte, 4)
+ binary.LittleEndian.PutUint32(salt, counter)
+ output, err := hkdf.Key(sha256.New, []byte(callID), salt, deviceJID, 4)
+ if err != nil {
+ return 0, fmt.Errorf("derive SSRC: %w", err)
+ }
+ ssrc := binary.LittleEndian.Uint32(output)
+ if ssrc == 0 {
+ return 0, fmt.Errorf("derived SSRC is zero")
+ }
+ return ssrc, nil
+}
diff --git a/pkg/call/voip/media/ssrc_test.go b/pkg/call/voip/media/ssrc_test.go
new file mode 100644
index 00000000..9e5125a8
--- /dev/null
+++ b/pkg/call/voip/media/ssrc_test.go
@@ -0,0 +1,41 @@
+package media
+
+import "testing"
+
+func TestGenerateSecureSSRCIsDeterministic(t *testing.T) {
+ first, err := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ second, err := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if first != second {
+ t.Fatalf("SSRC is not deterministic: %d != %d", first, second)
+ }
+}
+
+func TestGenerateSecureSSRCChangesWithInputs(t *testing.T) {
+ base, err := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 0)
+ if err != nil {
+ t.Fatal(err)
+ }
+ counter, _ := GenerateSecureSSRC("call-123", "5511999999999:1@s.whatsapp.net", 1)
+ peer, _ := GenerateSecureSSRC("call-123", "5511888888888:1@s.whatsapp.net", 0)
+ otherCall, _ := GenerateSecureSSRC("call-456", "5511999999999:1@s.whatsapp.net", 0)
+ for name, value := range map[string]uint32{"counter": counter, "peer": peer, "call": otherCall} {
+ if value == base {
+ t.Fatalf("%s input did not change SSRC", name)
+ }
+ }
+}
+
+func TestGenerateSecureSSRCValidatesInputs(t *testing.T) {
+ if _, err := GenerateSecureSSRC("", "device", 0); err == nil {
+ t.Fatal("expected empty call ID error")
+ }
+ if _, err := GenerateSecureSSRC("call", "", 0); err == nil {
+ t.Fatal("expected empty device JID error")
+ }
+}
diff --git a/pkg/call/voip/signaling/build.go b/pkg/call/voip/signaling/build.go
new file mode 100644
index 00000000..2043caf8
--- /dev/null
+++ b/pkg/call/voip/signaling/build.go
@@ -0,0 +1,208 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package signaling
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+var (
+ capabilityOffer = []byte{0x01, 0x05, 0xf7, 0x09, 0xe4, 0xbb, 0x07}
+ capabilityPreaccept = []byte{0x01, 0x05, 0xff, 0x09, 0xe4, 0xbb, 0x07}
+)
+
+func BuildOfferStanza(ctx context.Context, socket core.VoipSocket, callID string, callKey []byte, peer types.JID, video bool) (waBinary.Node, error) {
+ creator := socket.OwnLID()
+ if creator.IsEmpty() {
+ creator = socket.OwnPN()
+ }
+ if creator.IsEmpty() {
+ return waBinary.Node{}, fmt.Errorf("whatsapp client has no own JID")
+ }
+
+ resolvedPeer := socket.ResolveLIDForPN(ctx, peer)
+ devices, err := socket.GetUSyncDevices(ctx, []types.JID{resolvedPeer})
+ if err != nil {
+ return waBinary.Node{}, fmt.Errorf("get peer devices: %w", err)
+ }
+ if len(devices) == 0 {
+ return waBinary.Node{}, fmt.Errorf("no WhatsApp devices found for %s", peer.String())
+ }
+ if err := socket.AssertSessions(ctx, devices, false); err != nil {
+ return waBinary.Node{}, fmt.Errorf("assert sessions: %w", err)
+ }
+
+ participants, includeIdentity, err := socket.CreateParticipantNodes(
+ ctx,
+ devices,
+ callKey,
+ waBinary.Attrs{"count": "0"},
+ )
+ if err != nil {
+ return waBinary.Node{}, fmt.Errorf("encrypt call key: %w", err)
+ }
+
+ content := make([]waBinary.Node, 0, 8)
+ if token, tokenErr := socket.GetTCToken(ctx, wanode.MustJID(wanode.CleanJID(resolvedPeer.String()))); tokenErr == nil && len(token) > 0 {
+ content = append(content, waBinary.Node{Tag: "privacy", Content: token})
+ }
+ content = append(content,
+ waBinary.Node{Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "8000"}},
+ waBinary.Node{Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "16000"}},
+ )
+ if video {
+ content = append(content, waBinary.Node{Tag: "video", Attrs: waBinary.Attrs{
+ "enc": "vp8",
+ "dec": "vp8",
+ "orientation": "0",
+ "screen_width": "1920",
+ "screen_height": "1080",
+ "device_orientation": "0",
+ }})
+ }
+ content = append(content,
+ waBinary.Node{Tag: "net", Attrs: waBinary.Attrs{"medium": "3"}},
+ waBinary.Node{Tag: "capability", Attrs: waBinary.Attrs{"ver": "1"}, Content: capabilityOffer},
+ waBinary.Node{Tag: "destination", Content: participants},
+ waBinary.Node{Tag: "encopt", Attrs: waBinary.Attrs{"keygen": "2"}},
+ )
+ if includeIdentity {
+ if identity, ok := socket.AccountDeviceIdentityNode(); ok {
+ content = append(content, identity)
+ }
+ }
+
+ return waBinary.Node{
+ Tag: "call",
+ Attrs: waBinary.Attrs{"to": resolvedPeer, "id": GenerateCallStanzaID()},
+ Content: []waBinary.Node{{
+ Tag: "offer",
+ Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator},
+ Content: content,
+ }},
+ }, nil
+}
+
+// BuildPreacceptStanza acknowledges an incoming offer while the local user is
+// deciding whether to accept it.
+func BuildPreacceptStanza(peer types.JID, callID string, creator types.JID) waBinary.Node {
+ return waBinary.Node{
+ Tag: "call",
+ Attrs: waBinary.Attrs{"to": peer, "id": GenerateCallStanzaID()},
+ Content: []waBinary.Node{{
+ Tag: "preaccept",
+ Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator},
+ Content: []waBinary.Node{
+ {Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "16000"}},
+ {Tag: "encopt", Attrs: waBinary.Attrs{"keygen": "2"}},
+ {Tag: "capability", Attrs: waBinary.Attrs{"ver": "1"}, Content: capabilityPreaccept},
+ },
+ }},
+ }
+}
+
+// BuildAcceptStanza encrypts the incoming call key back to the initiating
+// device and constructs the explicit call acceptance node.
+func BuildAcceptStanza(
+ ctx context.Context,
+ socket core.VoipSocket,
+ callID string,
+ callKey []byte,
+ peer types.JID,
+ creator types.JID,
+ video bool,
+) (waBinary.Node, error) {
+ if len(callKey) != 32 {
+ return waBinary.Node{}, fmt.Errorf("invalid call key length: %d", len(callKey))
+ }
+ if peer.IsEmpty() || creator.IsEmpty() {
+ return waBinary.Node{}, fmt.Errorf("peer and call creator are required")
+ }
+ if err := socket.AssertSessions(ctx, []types.JID{creator}, true); err != nil {
+ return waBinary.Node{}, fmt.Errorf("assert creator session: %w", err)
+ }
+
+ participants, includeIdentity, err := socket.CreateParticipantNodes(
+ ctx,
+ []types.JID{creator},
+ callKey,
+ waBinary.Attrs{"count": "0"},
+ )
+ if err != nil {
+ return waBinary.Node{}, fmt.Errorf("encrypt accept key: %w", err)
+ }
+ encrypted := extractEncryptedNode(participants)
+ if encrypted == nil {
+ return waBinary.Node{}, fmt.Errorf("participant encryption did not produce an enc node")
+ }
+
+ content := []waBinary.Node{
+ {Tag: "audio", Attrs: waBinary.Attrs{"enc": "opus", "rate": "16000"}},
+ {Tag: "net", Attrs: waBinary.Attrs{"medium": "3"}},
+ *encrypted,
+ {Tag: "encopt", Attrs: waBinary.Attrs{"keygen": "2"}},
+ }
+ if includeIdentity {
+ if identity, ok := socket.AccountDeviceIdentityNode(); ok {
+ content = append(content, identity)
+ }
+ }
+ if video {
+ content = append(content, waBinary.Node{Tag: "video", Attrs: waBinary.Attrs{"enc": "vp8"}})
+ }
+
+ return waBinary.Node{
+ Tag: "call",
+ Attrs: waBinary.Attrs{
+ "to": wanode.MustJID(wanode.CleanJID(peer.String())),
+ "id": GenerateCallStanzaID(),
+ },
+ Content: []waBinary.Node{{
+ Tag: "accept",
+ Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator},
+ Content: content,
+ }},
+ }, nil
+}
+
+func extractEncryptedNode(nodes []waBinary.Node) *waBinary.Node {
+ for index := range nodes {
+ if nodes[index].Tag == "enc" {
+ return &nodes[index]
+ }
+ children := nodes[index].GetChildren()
+ for childIndex := range children {
+ if children[childIndex].Tag == "enc" {
+ return &children[childIndex]
+ }
+ }
+ }
+ return nil
+}
+
+func BuildTerminateStanza(peer types.JID, callID string, creator types.JID) waBinary.Node {
+ return wrap(peer, waBinary.Node{
+ Tag: "terminate",
+ Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator},
+ })
+}
+
+func BuildRejectStanza(peer types.JID, callID string, creator types.JID) waBinary.Node {
+ return wrap(peer, waBinary.Node{
+ Tag: "reject",
+ Attrs: waBinary.Attrs{"call-id": callID, "call-creator": creator},
+ })
+}
+
+func wrap(to types.JID, inner waBinary.Node) waBinary.Node {
+ return waBinary.Node{
+ Tag: "call",
+ Attrs: waBinary.Attrs{"to": to, "id": GenerateCallStanzaID()},
+ Content: []waBinary.Node{inner},
+ }
+}
diff --git a/pkg/call/voip/signaling/build_test.go b/pkg/call/voip/signaling/build_test.go
new file mode 100644
index 00000000..079423eb
--- /dev/null
+++ b/pkg/call/voip/signaling/build_test.go
@@ -0,0 +1,194 @@
+package signaling
+
+import (
+ "context"
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+type fakeSocket struct {
+ own types.JID
+ devices []types.JID
+ decryptedKey []byte
+}
+
+func (f *fakeSocket) OwnPN() types.JID { return f.own }
+func (f *fakeSocket) OwnLID() types.JID { return types.JID{} }
+func (f *fakeSocket) AccountDeviceIdentityNode() (waBinary.Node, bool) {
+ return waBinary.Node{Tag: "device-identity"}, true
+}
+func (f *fakeSocket) SendNode(context.Context, waBinary.Node) error { return nil }
+func (f *fakeSocket) Query(context.Context, waBinary.Node) (*waBinary.Node, error) {
+ return nil, nil
+}
+func (f *fakeSocket) GetUSyncDevices(context.Context, []types.JID) ([]types.JID, error) {
+ return f.devices, nil
+}
+func (f *fakeSocket) AssertSessions(context.Context, []types.JID, bool) error { return nil }
+func (f *fakeSocket) CreateParticipantNodes(_ context.Context, devices []types.JID, _ []byte, _ waBinary.Attrs) ([]waBinary.Node, bool, error) {
+ device := types.JID{}
+ if len(devices) > 0 {
+ device = devices[0]
+ }
+ return []waBinary.Node{{
+ Tag: "to",
+ Attrs: waBinary.Attrs{"jid": device},
+ Content: []waBinary.Node{{
+ Tag: "enc",
+ Attrs: waBinary.Attrs{"type": "msg"},
+ Content: []byte{1, 2, 3},
+ }},
+ }}, true, nil
+}
+func (f *fakeSocket) DecryptCallKey(context.Context, types.JID, *waBinary.Node) ([]byte, error) {
+ return append([]byte(nil), f.decryptedKey...), nil
+}
+func (f *fakeSocket) GetTCToken(context.Context, types.JID) ([]byte, error) { return nil, nil }
+func (f *fakeSocket) ResolveLIDForPN(_ context.Context, jid types.JID) types.JID { return jid }
+
+func TestGenerateCallKey(t *testing.T) {
+ key, err := GenerateCallKey()
+ if err != nil {
+ t.Fatalf("GenerateCallKey() error = %v", err)
+ }
+ if len(key) != 32 {
+ t.Fatalf("GenerateCallKey() length = %d, want 32", len(key))
+ }
+}
+
+func TestBuildOfferStanza(t *testing.T) {
+ own := types.NewJID("5511000000000", types.DefaultUserServer)
+ peer := types.NewJID("5511999999999", types.DefaultUserServer)
+ device := types.NewJID("5511999999999", types.DefaultUserServer)
+ socket := &fakeSocket{own: own, devices: []types.JID{device}}
+
+ node, err := BuildOfferStanza(context.Background(), socket, "CALL-123", make([]byte, 32), peer, false)
+ if err != nil {
+ t.Fatalf("BuildOfferStanza() error = %v", err)
+ }
+ if node.Tag != "call" {
+ t.Fatalf("root tag = %q, want call", node.Tag)
+ }
+ if to, ok := node.Attrs["to"].(types.JID); !ok || to != peer {
+ t.Fatalf("root to = %#v, want %s", node.Attrs["to"], peer.String())
+ }
+
+ rootChildren := wanode.NodeChildren(&node)
+ if len(rootChildren) != 1 || rootChildren[0].Tag != "offer" {
+ t.Fatalf("unexpected root children: %#v", rootChildren)
+ }
+ offer := rootChildren[0]
+ if wanode.AttrString(offer.Attrs, "call-id") != "CALL-123" {
+ t.Fatalf("call-id = %q", wanode.AttrString(offer.Attrs, "call-id"))
+ }
+ if creator, ok := offer.Attrs["call-creator"].(types.JID); !ok || creator != own {
+ t.Fatalf("call creator = %#v, want %s", offer.Attrs["call-creator"], own.String())
+ }
+
+ var audio16, destination, identity bool
+ for _, child := range wanode.NodeChildren(&offer) {
+ switch child.Tag {
+ case "audio":
+ if wanode.AttrString(child.Attrs, "rate") == "16000" {
+ audio16 = true
+ }
+ case "destination":
+ destination = len(wanode.NodeChildren(&child)) == 1
+ case "device-identity":
+ identity = true
+ }
+ }
+ if !audio16 || !destination || !identity {
+ t.Fatalf("offer missing required nodes: audio16=%v destination=%v identity=%v", audio16, destination, identity)
+ }
+}
+
+func TestBuildOfferRequiresDevices(t *testing.T) {
+ socket := &fakeSocket{own: types.NewJID("5511000000000", types.DefaultUserServer)}
+ peer := types.NewJID("5511999999999", types.DefaultUserServer)
+ if _, err := BuildOfferStanza(context.Background(), socket, "CALL-123", make([]byte, 32), peer, false); err == nil {
+ t.Fatal("BuildOfferStanza() expected error when no peer devices are available")
+ }
+}
+
+func TestBuildPreacceptStanza(t *testing.T) {
+ peer := types.NewJID("5511999999999", types.DefaultUserServer)
+ creator := types.NewJID("5511999999999", types.HiddenUserServer)
+ node := BuildPreacceptStanza(peer, "CALL-IN", creator)
+ children := wanode.NodeChildren(&node)
+ if len(children) != 1 || children[0].Tag != "preaccept" {
+ t.Fatalf("unexpected preaccept node: %#v", children)
+ }
+ if wanode.AttrString(children[0].Attrs, "call-id") != "CALL-IN" {
+ t.Fatalf("unexpected call id: %s", wanode.AttrString(children[0].Attrs, "call-id"))
+ }
+}
+
+func TestBuildAcceptStanza(t *testing.T) {
+ own := types.NewJID("5511000000000", types.DefaultUserServer)
+ peer := types.NewJID("5511999999999", types.DefaultUserServer)
+ creator := types.NewJID("5511999999999", types.HiddenUserServer)
+ socket := &fakeSocket{own: own, devices: []types.JID{creator}}
+
+ node, err := BuildAcceptStanza(context.Background(), socket, "CALL-IN", make([]byte, 32), peer, creator, true)
+ if err != nil {
+ t.Fatalf("BuildAcceptStanza() error = %v", err)
+ }
+ children := wanode.NodeChildren(&node)
+ if len(children) != 1 || children[0].Tag != "accept" {
+ t.Fatalf("unexpected accept node: %#v", children)
+ }
+ var encrypted, video bool
+ for _, child := range wanode.NodeChildren(&children[0]) {
+ if child.Tag == "enc" {
+ encrypted = true
+ }
+ if child.Tag == "video" {
+ video = true
+ }
+ }
+ if !encrypted || !video {
+ t.Fatalf("accept missing nodes: encrypted=%v video=%v", encrypted, video)
+ }
+}
+
+func TestDecryptCallKeyInNode(t *testing.T) {
+ peer := types.NewJID("5511999999999", types.DefaultUserServer)
+ key := make([]byte, 32)
+ for index := range key {
+ key[index] = byte(index + 1)
+ }
+ socket := &fakeSocket{decryptedKey: key}
+ offer := &waBinary.Node{
+ Tag: "offer",
+ Content: []waBinary.Node{{
+ Tag: "destination",
+ Content: []waBinary.Node{{
+ Tag: "to",
+ Content: []waBinary.Node{{
+ Tag: "enc",
+ Attrs: waBinary.Attrs{"type": "msg"},
+ Content: []byte{9},
+ }},
+ }},
+ }},
+ }
+
+ decrypted, err := DecryptCallKeyInNode(context.Background(), socket, offer, peer)
+ if err != nil {
+ t.Fatalf("DecryptCallKeyInNode() error = %v", err)
+ }
+ if len(decrypted) != 32 || decrypted[31] != 32 {
+ t.Fatalf("unexpected decrypted key: %v", decrypted)
+ }
+}
+
+func TestNodeContainsVideo(t *testing.T) {
+ offer := &waBinary.Node{Tag: "offer", Content: []waBinary.Node{{Tag: "video"}}}
+ if !NodeContainsVideo(offer) {
+ t.Fatal("expected video offer to be detected")
+ }
+}
diff --git a/pkg/call/voip/signaling/callkey.go b/pkg/call/voip/signaling/callkey.go
new file mode 100644
index 00000000..bfc1de6c
--- /dev/null
+++ b/pkg/call/voip/signaling/callkey.go
@@ -0,0 +1,71 @@
+// Package signaling builds and parses WhatsApp protocol nodes.
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package signaling
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/proto/waE2E"
+ "go.mau.fi/whatsmeow/types"
+ "google.golang.org/protobuf/proto"
+)
+
+func GenerateCallID() string {
+ buffer := make([]byte, 16)
+ _, _ = rand.Read(buffer)
+ return strings.ToUpper(hex.EncodeToString(buffer))
+}
+
+func GenerateCallStanzaID() string {
+ buffer := make([]byte, 16)
+ _, _ = rand.Read(buffer)
+ return strings.ToUpper(hex.EncodeToString(buffer))
+}
+
+func GenerateCallKey() ([]byte, error) {
+ key := make([]byte, 32)
+ if _, err := rand.Read(key); err != nil {
+ return nil, fmt.Errorf("generate call key: %w", err)
+ }
+ return key, nil
+}
+
+func EncodeCallKeyMessage(callKey []byte) ([]byte, error) {
+ message := &waE2E.Message{Call: &waE2E.Call{CallKey: callKey}}
+ return proto.Marshal(message)
+}
+
+func DecodeCallKeyPlaintext(plaintext []byte) ([]byte, error) {
+ var message waE2E.Message
+ if err := proto.Unmarshal(plaintext, &message); err != nil {
+ return nil, err
+ }
+ key := message.GetCall().GetCallKey()
+ if len(key) != 32 {
+ return nil, fmt.Errorf("invalid call key: expected 32 bytes, got %d", len(key))
+ }
+ return key, nil
+}
+
+// DecryptCallKeyInNode finds the encrypted call key in an incoming offer and
+// decrypts it through the currently authenticated whatsmeow Signal session.
+func DecryptCallKeyInNode(ctx context.Context, socket core.VoipSocket, inner *waBinary.Node, peer types.JID) ([]byte, error) {
+ encrypted := findEncryptedCallKeyNode(inner)
+ if encrypted == nil {
+ return nil, fmt.Errorf("incoming call offer does not contain an encrypted call key")
+ }
+ key, err := socket.DecryptCallKey(ctx, peer, encrypted)
+ if err != nil {
+ return nil, fmt.Errorf("decrypt incoming call key: %w", err)
+ }
+ if len(key) != 32 {
+ return nil, fmt.Errorf("invalid decrypted call key length: %d", len(key))
+ }
+ return key, nil
+}
diff --git a/pkg/call/voip/signaling/parse.go b/pkg/call/voip/signaling/parse.go
new file mode 100644
index 00000000..6a45f9e3
--- /dev/null
+++ b/pkg/call/voip/signaling/parse.go
@@ -0,0 +1,51 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package signaling
+
+import (
+ "strings"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ waBinary "go.mau.fi/whatsmeow/binary"
+)
+
+// NodeContainsVideo reports whether a call protocol node advertises video.
+func NodeContainsVideo(node *waBinary.Node) bool {
+ if node == nil {
+ return false
+ }
+ if strings.EqualFold(node.Tag, "video") {
+ return true
+ }
+ for key := range node.Attrs {
+ keyLower := strings.ToLower(key)
+ valueString := strings.ToLower(strings.TrimSpace(wanode.AttrString(node.Attrs, key)))
+ if (keyLower == "media" || keyLower == "type") && valueString == "video" {
+ return true
+ }
+ }
+ children := wanode.NodeChildren(node)
+ for index := range children {
+ if NodeContainsVideo(&children[index]) {
+ return true
+ }
+ }
+ return false
+}
+
+func findEncryptedCallKeyNode(inner *waBinary.Node) *waBinary.Node {
+ if inner == nil {
+ return nil
+ }
+ children := wanode.NodeChildren(inner)
+ for index := range children {
+ if children[index].Tag == "enc" && wanode.HasAttr(children[index].Attrs, "type") {
+ return &children[index]
+ }
+ }
+ for index := range children {
+ if found := findEncryptedCallKeyNode(&children[index]); found != nil {
+ return found
+ }
+ }
+ return nil
+}
diff --git a/pkg/call/voip/signaling/post_accept.go b/pkg/call/voip/signaling/post_accept.go
new file mode 100644
index 00000000..9710031a
--- /dev/null
+++ b/pkg/call/voip/signaling/post_accept.go
@@ -0,0 +1,100 @@
+package signaling
+
+import (
+ "fmt"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+// BuildPostAcceptTransportStanza announces the relay media path after the
+// remote party accepts an outgoing call. WhatsApp clients use message type 1
+// and candidate round 1 at this stage of the negotiation.
+func BuildPostAcceptTransportStanza(peer, creator types.JID, callID string) waBinary.Node {
+ return waBinary.Node{
+ Tag: "call",
+ Attrs: waBinary.Attrs{
+ "to": wanode.MustJID(wanode.CleanJID(peer.String())),
+ "id": GenerateCallStanzaID(),
+ },
+ Content: []waBinary.Node{{
+ Tag: "transport",
+ Attrs: waBinary.Attrs{
+ "call-id": callID,
+ "call-creator": creator,
+ "transport-message-type": "1",
+ "p2p-cand-round": "1",
+ },
+ Content: []waBinary.Node{{
+ Tag: "net",
+ Attrs: waBinary.Attrs{"medium": "2", "protocol": "0"},
+ }},
+ }},
+ }
+}
+
+// BuildMuteV2Stanza synchronizes the initial microphone state with the remote
+// WhatsApp device after media negotiation.
+func BuildMuteV2Stanza(peer, creator types.JID, callID string, muteState int) waBinary.Node {
+ return waBinary.Node{
+ Tag: "call",
+ Attrs: waBinary.Attrs{
+ "to": peer,
+ "id": GenerateCallStanzaID(),
+ },
+ Content: []waBinary.Node{{
+ Tag: "mute_v2",
+ Attrs: waBinary.Attrs{
+ "call-id": callID,
+ "call-creator": creator,
+ "mute-state": fmt.Sprintf("%d", muteState),
+ },
+ }},
+ }
+}
+
+// BuildAcceptReceiptStanza builds the device receipt expected after a remote
+// CallAccept. acceptMessageID MUST be the original ID from the outer incoming
+// stanza. It must never be generated locally or replaced by callID.
+//
+// The currently pinned whatsmeow event API does not expose that outer ID, so
+// this helper is intentionally not wired into media signaling until the source
+// event can provide it without reflection or unsafe access.
+func BuildAcceptReceiptStanza(
+ peer types.JID,
+ acceptMessageID, callID string,
+ creator, own types.JID,
+) (waBinary.Node, error) {
+ if peer.IsEmpty() {
+ return waBinary.Node{}, fmt.Errorf("accept receipt peer JID is empty")
+ }
+ if own.IsEmpty() {
+ return waBinary.Node{}, fmt.Errorf("accept receipt own JID is empty")
+ }
+ if creator.IsEmpty() {
+ return waBinary.Node{}, fmt.Errorf("accept receipt creator JID is empty")
+ }
+ if acceptMessageID == "" {
+ return waBinary.Node{}, fmt.Errorf("accept receipt requires original stanza ID")
+ }
+ if callID == "" {
+ return waBinary.Node{}, fmt.Errorf("accept receipt call ID is empty")
+ }
+
+ return waBinary.Node{
+ Tag: "receipt",
+ Attrs: waBinary.Attrs{
+ "to": peer,
+ "id": acceptMessageID,
+ "from": own,
+ },
+ Content: []waBinary.Node{{
+ Tag: "accept",
+ Attrs: waBinary.Attrs{
+ "call-id": callID,
+ "call-creator": creator,
+ },
+ }},
+ }, nil
+}
diff --git a/pkg/call/voip/signaling/post_accept_test.go b/pkg/call/voip/signaling/post_accept_test.go
new file mode 100644
index 00000000..62529a57
--- /dev/null
+++ b/pkg/call/voip/signaling/post_accept_test.go
@@ -0,0 +1,122 @@
+package signaling
+
+import (
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ "go.mau.fi/whatsmeow/types"
+)
+
+func TestBuildPostAcceptTransportStanza(t *testing.T) {
+ peer := types.NewJID("5511999999999", types.HiddenUserServer)
+ creator := types.NewJID("5511000000000", types.HiddenUserServer)
+ node := BuildPostAcceptTransportStanza(peer, creator, "CALL-POST")
+
+ children := wanode.NodeChildren(&node)
+ if len(children) != 1 || children[0].Tag != "transport" {
+ t.Fatalf("unexpected transport stanza: %#v", children)
+ }
+ transport := children[0]
+ if wanode.AttrString(transport.Attrs, "call-id") != "CALL-POST" {
+ t.Fatalf("unexpected call ID: %s", wanode.AttrString(transport.Attrs, "call-id"))
+ }
+ if wanode.AttrString(transport.Attrs, "transport-message-type") != "1" {
+ t.Fatalf("unexpected transport type: %s", wanode.AttrString(transport.Attrs, "transport-message-type"))
+ }
+ if wanode.AttrString(transport.Attrs, "p2p-cand-round") != "1" {
+ t.Fatalf("unexpected candidate round: %s", wanode.AttrString(transport.Attrs, "p2p-cand-round"))
+ }
+ netChildren := wanode.NodeChildren(&transport)
+ if len(netChildren) != 1 || netChildren[0].Tag != "net" {
+ t.Fatalf("transport is missing net child: %#v", netChildren)
+ }
+ if wanode.AttrString(netChildren[0].Attrs, "protocol") != "0" {
+ t.Fatalf("unexpected transport protocol: %s", wanode.AttrString(netChildren[0].Attrs, "protocol"))
+ }
+}
+
+func TestBuildMuteV2Stanza(t *testing.T) {
+ peer := types.NewJID("5511999999999", types.HiddenUserServer)
+ creator := types.NewJID("5511000000000", types.HiddenUserServer)
+ node := BuildMuteV2Stanza(peer, creator, "CALL-MUTE", 0)
+
+ children := wanode.NodeChildren(&node)
+ if len(children) != 1 || children[0].Tag != "mute_v2" {
+ t.Fatalf("unexpected mute stanza: %#v", children)
+ }
+ if wanode.AttrString(children[0].Attrs, "mute-state") != "0" {
+ t.Fatalf("unexpected mute state: %s", wanode.AttrString(children[0].Attrs, "mute-state"))
+ }
+ if wanode.AttrString(children[0].Attrs, "call-id") != "CALL-MUTE" {
+ t.Fatalf("unexpected call ID: %s", wanode.AttrString(children[0].Attrs, "call-id"))
+ }
+}
+
+func TestBuildAcceptReceiptStanza(t *testing.T) {
+ peer := types.NewADJID("5511999999999", 0, 7)
+ creator := types.NewJID("5511999999999", types.HiddenUserServer)
+ own := types.NewADJID("5511000000000", 0, 3)
+
+ node, err := BuildAcceptReceiptStanza(peer, "ACCEPT-STANZA-ID", "CALL-RECEIPT", creator, own)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if node.Tag != "receipt" {
+ t.Fatalf("unexpected receipt tag: %s", node.Tag)
+ }
+ if got := wanode.AttrString(node.Attrs, "id"); got != "ACCEPT-STANZA-ID" {
+ t.Fatalf("receipt did not preserve outer stanza ID: %s", got)
+ }
+ if got, ok := node.Attrs["to"].(types.JID); !ok || got.String() != peer.String() {
+ t.Fatalf("unexpected receipt target: %#v", node.Attrs["to"])
+ }
+ if got, ok := node.Attrs["from"].(types.JID); !ok || got.String() != own.String() {
+ t.Fatalf("unexpected receipt sender: %#v", node.Attrs["from"])
+ }
+
+ children := wanode.NodeChildren(&node)
+ if len(children) != 1 || children[0].Tag != "accept" {
+ t.Fatalf("unexpected receipt content: %#v", children)
+ }
+ if got := wanode.AttrString(children[0].Attrs, "call-id"); got != "CALL-RECEIPT" {
+ t.Fatalf("unexpected receipt call ID: %s", got)
+ }
+ if got, ok := children[0].Attrs["call-creator"].(types.JID); !ok || got.String() != creator.String() {
+ t.Fatalf("unexpected receipt creator: %#v", children[0].Attrs["call-creator"])
+ }
+}
+
+func TestBuildAcceptReceiptStanzaRejectsSyntheticOrIncompleteInput(t *testing.T) {
+ peer := types.NewJID("5511999999999", types.HiddenUserServer)
+ creator := types.NewJID("5511999999999", types.HiddenUserServer)
+ own := types.NewJID("5511000000000", types.HiddenUserServer)
+
+ tests := []struct {
+ name string
+ peer types.JID
+ acceptMessageID string
+ callID string
+ creator types.JID
+ own types.JID
+ }{
+ {name: "missing outer stanza ID", peer: peer, callID: "CALL", creator: creator, own: own},
+ {name: "missing call ID", peer: peer, acceptMessageID: "ACCEPT", creator: creator, own: own},
+ {name: "missing peer", acceptMessageID: "ACCEPT", callID: "CALL", creator: creator, own: own},
+ {name: "missing creator", peer: peer, acceptMessageID: "ACCEPT", callID: "CALL", own: own},
+ {name: "missing own JID", peer: peer, acceptMessageID: "ACCEPT", callID: "CALL", creator: creator},
+ }
+
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ if _, err := BuildAcceptReceiptStanza(
+ test.peer,
+ test.acceptMessageID,
+ test.callID,
+ test.creator,
+ test.own,
+ ); err == nil {
+ t.Fatal("expected validation error")
+ }
+ })
+ }
+}
diff --git a/pkg/call/voip/signaling/relay.go b/pkg/call/voip/signaling/relay.go
new file mode 100644
index 00000000..5b107e15
--- /dev/null
+++ b/pkg/call/voip/signaling/relay.go
@@ -0,0 +1,256 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package signaling
+
+import (
+ "encoding/base64"
+ "sort"
+ "strconv"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/wanode"
+ waBinary "go.mau.fi/whatsmeow/binary"
+)
+
+// ParsedRelayAck contains the complete relay metadata returned in an offer ACK
+// or embedded in an incoming offer using WhatsApp's structured te2 encoding.
+type ParsedRelayAck struct {
+ Relays []core.RelayEndpoint
+ ParticipantJIDs []string
+ UUID string
+ SelfPID *int
+ PeerPID *int
+ HBHKey []byte
+}
+
+// ExtractRelayEndpoints parses the older attribute-based relay form:
+// . Some offers wrap candidates in a
+// element, so both layouts are supported.
+func ExtractRelayEndpoints(node *waBinary.Node) []core.RelayEndpoint {
+ var relays []core.RelayEndpoint
+
+ parseRelay := func(candidate *waBinary.Node) {
+ ip := wanode.AttrString(candidate.Attrs, "ip")
+ token := wanode.AttrString(candidate.Attrs, "token")
+ if ip == "" || token == "" {
+ return
+ }
+
+ key := firstAttr(candidate.Attrs, "relay-key", "relay_key", "key")
+ endpoint := core.RelayEndpoint{
+ IP: ip,
+ Port: firstAttrInt(candidate.Attrs, core.WARelayPort, "port"),
+ Token: token,
+ AuthToken: firstAttr(candidate.Attrs, "auth-token", "auth_token"),
+ Key: key,
+ RelayID: firstAttrInt(candidate.Attrs, 0, "relay-id", "relay_id"),
+ Protocol: firstAttrInt(candidate.Attrs, 0, "protocol"),
+ RelayName: firstAttr(candidate.Attrs, "relay-name", "relay_name"),
+ }
+ if value, ok := firstOptionalInt(candidate.Attrs, "c2r-rtt", "c2r_rtt"); ok {
+ endpoint.C2RRtt = &value
+ }
+ relays = append(relays, endpoint)
+ }
+
+ for _, childValue := range wanode.NodeChildren(node) {
+ child := childValue
+ switch child.Tag {
+ case "relay":
+ parseRelay(&child)
+ case "relays":
+ for _, relayValue := range wanode.NodeChildren(&child) {
+ relay := relayValue
+ if relay.Tag == "relay" {
+ parseRelay(&relay)
+ }
+ }
+ }
+ }
+
+ sortRelaysByRTT(relays)
+ return relays
+}
+
+// ParseRelayFromAck parses WhatsApp's structured relay response. Binary token
+// material is copied so callers never retain slices backed by a protocol node.
+func ParseRelayFromAck(node *waBinary.Node) ParsedRelayAck {
+ result := ParsedRelayAck{}
+ participantSeen := make(map[string]struct{})
+
+ addParticipant := func(jid string) {
+ if jid == "" {
+ return
+ }
+ if _, exists := participantSeen[jid]; exists {
+ return
+ }
+ participantSeen[jid] = struct{}{}
+ result.ParticipantJIDs = append(result.ParticipantJIDs, jid)
+ }
+
+ for _, childValue := range wanode.NodeChildren(node) {
+ child := childValue
+ if child.Tag == "user" {
+ for _, deviceValue := range wanode.NodeChildren(&child) {
+ device := deviceValue
+ if device.Tag == "device" {
+ addParticipant(wanode.AttrString(device.Attrs, "jid"))
+ }
+ }
+ }
+ if child.Tag != "relay" {
+ continue
+ }
+
+ result.UUID = wanode.AttrString(child.Attrs, "uuid")
+ if value, ok := firstOptionalInt(child.Attrs, "self_pid", "self-pid"); ok {
+ result.SelfPID = &value
+ }
+ if value, ok := firstOptionalInt(child.Attrs, "peer_pid", "peer-pid"); ok {
+ result.PeerPID = &value
+ }
+
+ relayChildren := wanode.NodeChildren(&child)
+ for _, relayChildValue := range relayChildren {
+ relayChild := relayChildValue
+ if relayChild.Tag == "participant" {
+ addParticipant(wanode.AttrString(relayChild.Attrs, "jid"))
+ }
+ }
+
+ var relayKey string
+ tokens := make(map[string]string)
+ authTokens := make(map[string]string)
+ rawTokens := make(map[string][]byte)
+ rawAuthTokens := make(map[string][]byte)
+
+ for _, relayChildValue := range relayChildren {
+ relayChild := relayChildValue
+ switch relayChild.Tag {
+ case "key":
+ if value := wanode.NodeBytes(&relayChild); value != nil {
+ relayKey = string(value)
+ }
+ case "hbh_key":
+ result.HBHKey = decodeHBHKey(wanode.NodeBytes(&relayChild))
+ case "token":
+ if value := wanode.NodeBytes(&relayChild); value != nil {
+ id := attrStringOr(relayChild.Attrs, "id", "0")
+ rawTokens[id] = append([]byte(nil), value...)
+ tokens[id] = base64.StdEncoding.EncodeToString(value)
+ }
+ case "auth_token":
+ if value := wanode.NodeBytes(&relayChild); value != nil {
+ id := attrStringOr(relayChild.Attrs, "id", "0")
+ rawAuthTokens[id] = append([]byte(nil), value...)
+ authTokens[id] = base64.StdEncoding.EncodeToString(value)
+ }
+ }
+ }
+
+ for _, relayChildValue := range relayChildren {
+ relayChild := relayChildValue
+ if relayChild.Tag != "te2" {
+ continue
+ }
+ address := wanode.NodeBytes(&relayChild)
+ if len(address) != 6 {
+ continue
+ }
+
+ tokenID := attrStringOr(relayChild.Attrs, "token_id", "0")
+ authTokenID := firstAttr(relayChild.Attrs, "auth_token_id", "auth-token-id")
+ endpoint := core.RelayEndpoint{
+ IP: ipv4String(address[:4]),
+ Port: int(address[4])<<8 | int(address[5]),
+ Token: tokens[tokenID],
+ AuthToken: authTokens[authTokenID],
+ RawToken: append([]byte(nil), rawTokens[tokenID]...),
+ RawAuthToken: append([]byte(nil), rawAuthTokens[authTokenID]...),
+ Key: relayKey,
+ RelayID: firstAttrInt(relayChild.Attrs, 0, "relay_id", "relay-id"),
+ Protocol: firstAttrInt(relayChild.Attrs, 0, "protocol"),
+ RelayName: firstAttr(relayChild.Attrs, "relay_name", "relay-name"),
+ AddressBytes: append([]byte(nil), address...),
+ AuthTokenID: authTokenID,
+ }
+ if endpoint.AuthTokenID == "" {
+ endpoint.AuthTokenID = tokenID
+ }
+ if value, ok := firstOptionalInt(relayChild.Attrs, "c2r_rtt", "c2r-rtt"); ok {
+ endpoint.C2RRtt = &value
+ }
+ result.Relays = append(result.Relays, endpoint)
+ }
+ }
+
+ sortRelaysByRTT(result.Relays)
+ return result
+}
+
+func decodeHBHKey(value []byte) []byte {
+ if len(value) == 30 {
+ return append([]byte(nil), value...)
+ }
+ decoded, err := base64.StdEncoding.DecodeString(string(value))
+ if err == nil && len(decoded) == 30 {
+ return append([]byte(nil), decoded...)
+ }
+ return nil
+}
+
+func attrStringOr(attrs waBinary.Attrs, key, fallback string) string {
+ if value := wanode.AttrString(attrs, key); value != "" {
+ return value
+ }
+ return fallback
+}
+
+func firstAttr(attrs waBinary.Attrs, keys ...string) string {
+ for _, key := range keys {
+ if value := wanode.AttrString(attrs, key); value != "" {
+ return value
+ }
+ }
+ return ""
+}
+
+func firstAttrInt(attrs waBinary.Attrs, fallback int, keys ...string) int {
+ for _, key := range keys {
+ if wanode.HasAttr(attrs, key) {
+ return wanode.AttrInt(attrs, key, fallback)
+ }
+ }
+ return fallback
+}
+
+func firstOptionalInt(attrs waBinary.Attrs, keys ...string) (int, bool) {
+ for _, key := range keys {
+ if wanode.HasAttr(attrs, key) {
+ return wanode.AttrInt(attrs, key, 0), true
+ }
+ }
+ return 0, false
+}
+
+func ipv4String(value []byte) string {
+ return strconv.Itoa(int(value[0])) + "." + strconv.Itoa(int(value[1])) + "." +
+ strconv.Itoa(int(value[2])) + "." + strconv.Itoa(int(value[3]))
+}
+
+func sortRelaysByRTT(relays []core.RelayEndpoint) {
+ sort.SliceStable(relays, func(left, right int) bool {
+ leftRTT := relays[left].C2RRtt
+ rightRTT := relays[right].C2RRtt
+ switch {
+ case leftRTT == nil && rightRTT == nil:
+ return false
+ case leftRTT == nil:
+ return false
+ case rightRTT == nil:
+ return true
+ default:
+ return *leftRTT < *rightRTT
+ }
+ })
+}
diff --git a/pkg/call/voip/signaling/relay_test.go b/pkg/call/voip/signaling/relay_test.go
new file mode 100644
index 00000000..b099eb60
--- /dev/null
+++ b/pkg/call/voip/signaling/relay_test.go
@@ -0,0 +1,159 @@
+package signaling
+
+import (
+ "bytes"
+ "encoding/base64"
+ "testing"
+
+ waBinary "go.mau.fi/whatsmeow/binary"
+)
+
+func TestParseRelayFromAck(t *testing.T) {
+ token := []byte{0x01, 0x02, 0x03}
+ authToken := []byte{0x04, 0x05, 0x06}
+ hbhKey := bytes.Repeat([]byte{0x07}, 30)
+
+ node := &waBinary.Node{
+ Tag: "ack",
+ Content: []waBinary.Node{
+ {
+ Tag: "user",
+ Content: []waBinary.Node{
+ {Tag: "device", Attrs: waBinary.Attrs{"jid": "self:1@s.whatsapp.net"}},
+ },
+ },
+ {
+ Tag: "relay",
+ Attrs: waBinary.Attrs{
+ "uuid": "relay-uuid",
+ "self_pid": "11",
+ "peer_pid": "22",
+ },
+ Content: []waBinary.Node{
+ {Tag: "participant", Attrs: waBinary.Attrs{"jid": "self:1@s.whatsapp.net"}},
+ {Tag: "participant", Attrs: waBinary.Attrs{"jid": "peer:2@s.whatsapp.net"}},
+ {Tag: "key", Content: []byte("relay-key")},
+ {Tag: "hbh_key", Content: hbhKey},
+ {Tag: "token", Attrs: waBinary.Attrs{"id": "token-1"}, Content: token},
+ {Tag: "auth_token", Attrs: waBinary.Attrs{"id": "auth-1"}, Content: authToken},
+ {
+ Tag: "te2",
+ Attrs: waBinary.Attrs{
+ "token_id": "token-1",
+ "auth_token_id": "auth-1",
+ "relay_id": "1",
+ "relay_name": "slow",
+ "protocol": "1",
+ "c2r_rtt": "40",
+ },
+ Content: []byte{1, 2, 3, 4, 0x0d, 0x98},
+ },
+ {
+ Tag: "te2",
+ Attrs: waBinary.Attrs{
+ "token_id": "token-1",
+ "auth_token_id": "auth-1",
+ "relay_id": "2",
+ "relay_name": "fast",
+ "protocol": "1",
+ "c2r_rtt": "10",
+ },
+ Content: []byte{5, 6, 7, 8, 0x0d, 0x99},
+ },
+ {Tag: "te2", Content: []byte{1, 2, 3}},
+ },
+ },
+ },
+ }
+
+ parsed := ParseRelayFromAck(node)
+ if parsed.UUID != "relay-uuid" {
+ t.Fatalf("UUID = %q", parsed.UUID)
+ }
+ if parsed.SelfPID == nil || *parsed.SelfPID != 11 {
+ t.Fatalf("SelfPID = %#v", parsed.SelfPID)
+ }
+ if parsed.PeerPID == nil || *parsed.PeerPID != 22 {
+ t.Fatalf("PeerPID = %#v", parsed.PeerPID)
+ }
+ if len(parsed.ParticipantJIDs) != 2 {
+ t.Fatalf("participants = %#v", parsed.ParticipantJIDs)
+ }
+ if len(parsed.Relays) != 2 {
+ t.Fatalf("relays = %#v", parsed.Relays)
+ }
+
+ fast := parsed.Relays[0]
+ if fast.IP != "5.6.7.8" || fast.Port != 3481 || fast.RelayName != "fast" {
+ t.Fatalf("fast relay = %#v", fast)
+ }
+ if fast.C2RRtt == nil || *fast.C2RRtt != 10 {
+ t.Fatalf("fast relay RTT = %#v", fast.C2RRtt)
+ }
+ if fast.Token != base64.StdEncoding.EncodeToString(token) {
+ t.Fatalf("token = %q", fast.Token)
+ }
+ if fast.AuthToken != base64.StdEncoding.EncodeToString(authToken) {
+ t.Fatalf("auth token = %q", fast.AuthToken)
+ }
+ if fast.Key != "relay-key" || fast.AuthTokenID != "auth-1" {
+ t.Fatalf("relay credentials metadata = %#v", fast)
+ }
+ if !bytes.Equal(parsed.HBHKey, hbhKey) {
+ t.Fatalf("HBH key mismatch")
+ }
+
+ // Parsed secrets must not alias protocol-node buffers.
+ token[0] = 0xff
+ authToken[0] = 0xff
+ hbhKey[0] = 0xff
+ if fast.RawToken[0] != 0x01 || fast.RawAuthToken[0] != 0x04 || parsed.HBHKey[0] != 0x07 {
+ t.Fatal("parsed relay material aliases input buffers")
+ }
+}
+
+func TestExtractRelayEndpoints(t *testing.T) {
+ node := &waBinary.Node{
+ Tag: "offer",
+ Content: []waBinary.Node{
+ {
+ Tag: "relays",
+ Content: []waBinary.Node{
+ {Tag: "relay", Attrs: waBinary.Attrs{
+ "ip": "10.0.0.2", "port": "4000", "token": "slow-token",
+ "relay_key": "key-2", "relay_id": "2", "c2r_rtt": "30",
+ }},
+ },
+ },
+ {Tag: "relay", Attrs: waBinary.Attrs{
+ "ip": "10.0.0.1", "token": "fast-token", "relay-key": "key-1",
+ "relay-id": "1", "relay-name": "fast", "c2r-rtt": "5",
+ }},
+ {Tag: "relay", Attrs: waBinary.Attrs{"ip": "10.0.0.3"}},
+ },
+ }
+
+ relays := ExtractRelayEndpoints(node)
+ if len(relays) != 2 {
+ t.Fatalf("relay count = %d", len(relays))
+ }
+ if relays[0].IP != "10.0.0.1" || relays[0].Port != 3480 || relays[0].RelayID != 1 {
+ t.Fatalf("first relay = %#v", relays[0])
+ }
+ if relays[1].IP != "10.0.0.2" || relays[1].Port != 4000 || relays[1].Key != "key-2" {
+ t.Fatalf("second relay = %#v", relays[1])
+ }
+}
+
+func TestParseRelayFromAckDecodesBase64HBHKey(t *testing.T) {
+ expected := bytes.Repeat([]byte{0x42}, 30)
+ node := &waBinary.Node{Tag: "ack", Content: []waBinary.Node{{
+ Tag: "relay",
+ Content: []waBinary.Node{{Tag: "hbh_key", Content: []byte(base64.StdEncoding.EncodeToString(expected))}},
+ }}}
+
+ parsed := ParseRelayFromAck(node)
+ if !bytes.Equal(parsed.HBHKey, expected) {
+ t.Fatalf("decoded HBH key = %x", parsed.HBHKey)
+ }
+}
diff --git a/pkg/call/voip/transport/factory_default.go b/pkg/call/voip/transport/factory_default.go
new file mode 100644
index 00000000..f7e326ea
--- /dev/null
+++ b/pkg/call/voip/transport/factory_default.go
@@ -0,0 +1,11 @@
+//go:build !voip_pion
+
+package transport
+
+import "log/slog"
+
+// NewRelayTransport returns the safe no-network implementation unless the
+// experimental voip_pion build tag is explicitly enabled.
+func NewRelayTransport(_ *slog.Logger) RelayTransport {
+ return NewDisabledRelayTransport()
+}
diff --git a/pkg/call/voip/transport/foundation_test.go b/pkg/call/voip/transport/foundation_test.go
new file mode 100644
index 00000000..434acb10
--- /dev/null
+++ b/pkg/call/voip/transport/foundation_test.go
@@ -0,0 +1,128 @@
+package transport
+
+import (
+ "bytes"
+ "encoding/binary"
+ "hash/crc32"
+ "strings"
+ "testing"
+)
+
+func TestVarintEncoding(t *testing.T) {
+ cases := []struct {
+ input uint64
+ want []byte
+ }{
+ {0, []byte{0x00}},
+ {127, []byte{0x7f}},
+ {128, []byte{0x80, 0x01}},
+ {300, []byte{0xac, 0x02}},
+ }
+ for _, testCase := range cases {
+ if got := encodeVarint(testCase.input); !bytes.Equal(got, testCase.want) {
+ t.Fatalf("encodeVarint(%d)=%x, want %x", testCase.input, got, testCase.want)
+ }
+ }
+}
+
+func TestSenderSubscriptions(t *testing.T) {
+ inner := []byte{0x18, 0x10, 0x28, 0x00, 0x30, 0x00}
+ want := append([]byte{0x0a, byte(len(inner))}, inner...)
+ if got := BuildSenderSubscriptions(0x10); !bytes.Equal(got, want) {
+ t.Fatalf("sender subscriptions mismatch: got=%x want=%x", got, want)
+ }
+}
+
+func TestSSRCSubscriptionListOmitsZeroValues(t *testing.T) {
+ withoutZero := BuildSSRCSubscriptionList([]uint32{100}, []uint32{200}, 1, 2)
+ withZero := BuildSSRCSubscriptionList([]uint32{0, 100}, []uint32{200, 0}, 1, 2)
+ if !bytes.Equal(withoutZero, withZero) {
+ t.Fatalf("zero SSRC changed payload: without=%x with=%x", withoutZero, withZero)
+ }
+}
+
+func TestSTUNBindingFingerprint(t *testing.T) {
+ subscriptions := BuildSenderSubscriptions(0x12345678)
+ message, err := BuildBindingRequestWithSubscriptions(nil, nil, subscriptions, true, true)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if binary.BigEndian.Uint32(message[4:8]) != stunMagicCookie {
+ t.Fatal("missing STUN magic cookie")
+ }
+ info := ParseSTUNResponse(message)
+ if info == nil || info.Method != "binding" || info.Class != "request" {
+ t.Fatalf("unexpected parsed binding request: %#v", info)
+ }
+ last := info.Attributes[len(info.Attributes)-1]
+ if last.TypeName != "FINGERPRINT" {
+ t.Fatalf("expected fingerprint last, got %s", last.TypeName)
+ }
+ fingerprintStart := len(message) - 8
+ want := crc32.ChecksumIEEE(message[:fingerprintStart]) ^ stunFingerprintXOR
+ got := binary.BigEndian.Uint32(message[len(message)-4:])
+ if got != want {
+ t.Fatalf("fingerprint mismatch: got=%08x want=%08x", got, want)
+ }
+}
+
+func TestAllocateRequestIncludesRelayAddress(t *testing.T) {
+ message, err := BuildAllocateForRelay([]byte{1}, []byte{2}, []byte("secret"), "127.0.0.1", 3480)
+ if err != nil {
+ t.Fatal(err)
+ }
+ info := ParseSTUNResponse(message)
+ if info == nil || info.Method != "allocate" {
+ t.Fatalf("unexpected allocation request: %#v", info)
+ }
+ var found bool
+ for _, attribute := range info.Attributes {
+ if attribute.TypeName == "XOR-RELAYED-ADDRESS" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("allocation request did not contain relay address")
+ }
+}
+
+func TestAllocateRequestRejectsInvalidAddress(t *testing.T) {
+ if _, err := BuildAllocateForRelay(nil, nil, nil, "not-an-ip", 3480); err == nil {
+ t.Fatal("expected invalid IP error")
+ }
+ if _, err := BuildAllocateForRelay(nil, nil, nil, "127.0.0.1", 70000); err == nil {
+ t.Fatal("expected invalid port error")
+ }
+}
+
+func TestPacketClassification(t *testing.T) {
+ ping, err := BuildWhatsAppPing()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !IsSTUNPacket(ping) || IsRTPPacket(ping) {
+ t.Fatalf("unexpected ping classification: %s", ClassifyPacket(ping))
+ }
+ if classification := ClassifyPacket(ping); !strings.Contains(classification, "wa-ping") {
+ t.Fatalf("unexpected ping description: %s", classification)
+ }
+
+ rtp := []byte{0x80, 120, 0x01, 0x02}
+ if !IsRTPPacket(rtp) || IsSTUNPacket(rtp) {
+ t.Fatalf("unexpected RTP classification: %s", ClassifyPacket(rtp))
+ }
+ if classification := ClassifyPacket(rtp); !strings.Contains(classification, "seq=258") {
+ t.Fatalf("unexpected RTP description: %s", classification)
+ }
+}
+
+func TestParseSTUNRejectsTruncatedMessage(t *testing.T) {
+ message, err := BuildBindingRequestWithSubscriptions(nil, nil, nil, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+ message = message[:len(message)-1]
+ if ParseSTUNResponse(message) != nil {
+ t.Fatal("expected truncated STUN message to be rejected")
+ }
+}
diff --git a/pkg/call/voip/transport/pion_relay.go b/pkg/call/voip/transport/pion_relay.go
new file mode 100644
index 00000000..5e41e40a
--- /dev/null
+++ b/pkg/call/voip/transport/pion_relay.go
@@ -0,0 +1,574 @@
+//go:build voip_pion
+
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package transport
+
+import (
+ "errors"
+ "fmt"
+ "log/slog"
+ "regexp"
+ "sync"
+ "time"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/pion/webrtc/v4"
+)
+
+const (
+ relayConnectionTimeout = 20 * time.Second
+ relayKeepaliveInterval = 1100 * time.Millisecond
+)
+
+type relayConnectionState uint8
+
+const (
+ relayStateConnecting relayConnectionState = iota
+ relayStateOpen
+ relayStateClosed
+ relayStateFailed
+)
+
+type pionRelayConnection struct {
+ mu sync.RWMutex
+ state relayConnectionState
+ pc *webrtc.PeerConnection
+ channel *webrtc.DataChannel
+ id string
+ info RelayConfig
+ localUfrag string
+ keepalive *time.Ticker
+ stopCh chan struct{}
+ stopOnce sync.Once
+}
+
+func (c *pionRelayConnection) isOpen() bool {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.state == relayStateOpen && c.channel != nil
+}
+
+func (c *pionRelayConnection) setOpen() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.state != relayStateConnecting {
+ return false
+ }
+ c.state = relayStateOpen
+ return true
+}
+
+func (c *pionRelayConnection) setTerminal(state relayConnectionState) bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.state == relayStateClosed || c.state == relayStateFailed {
+ return false
+ }
+ c.state = state
+ return true
+}
+
+// PionRelayTransport opens WhatsApp relay DataChannels when the voip_pion build
+// tag is enabled. The default build never includes this implementation.
+type PionRelayTransport struct {
+ mu sync.RWMutex
+ connections map[string]*pionRelayConnection
+ log *slog.Logger
+ ssrc uint32
+ subscriptionSSRC uint32
+ onConnected func(ip string, port int)
+ onReceive func(data []byte)
+}
+
+func NewPionRelayTransport(log *slog.Logger) *PionRelayTransport {
+ if log == nil {
+ log = slog.Default()
+ }
+ return &PionRelayTransport{
+ connections: make(map[string]*pionRelayConnection),
+ log: log,
+ }
+}
+
+// NewRelayTransport selects the real transport only in a voip_pion build.
+func NewRelayTransport(log *slog.Logger) RelayTransport {
+ return NewPionRelayTransport(log)
+}
+
+func (m *PionRelayTransport) SetSSRC(ssrc uint32) {
+ m.mu.Lock()
+ m.ssrc = ssrc
+ m.mu.Unlock()
+}
+
+func (m *PionRelayTransport) SetSubscriptionSSRC(ssrc uint32) {
+ m.mu.Lock()
+ m.subscriptionSSRC = ssrc
+ m.mu.Unlock()
+}
+
+func (m *PionRelayTransport) SetOnConnected(callback func(ip string, port int)) {
+ m.mu.Lock()
+ m.onConnected = callback
+ m.mu.Unlock()
+}
+
+func (m *PionRelayTransport) SetOnReceive(callback func(data []byte)) {
+ m.mu.Lock()
+ m.onReceive = callback
+ m.mu.Unlock()
+}
+
+func (m *PionRelayTransport) ResendSubscriptions() {
+ for _, connection := range m.connectionSnapshot() {
+ if connection.isOpen() {
+ m.sendSTUNRegistration(connection)
+ }
+ }
+}
+
+func relayConnectionID(ip string, port int, authTokenID string) string {
+ identity := fmt.Sprintf("%s:%d", ip, port)
+ if authTokenID != "" {
+ identity += "#" + authTokenID
+ }
+ return identity
+}
+
+func (m *PionRelayTransport) ConfigureRelays(relays []RelayConfig) error {
+ if len(relays) == 0 {
+ return fmt.Errorf("no relay configurations supplied")
+ }
+
+ var setupErrors []error
+ for _, relay := range relays {
+ config := cloneRelayConfig(relay)
+ if config.Port == 0 {
+ config.Port = core.WARelayPort
+ }
+ if config.IP == "" || config.Key == "" || len(config.RawToken) == 0 {
+ zeroRelayConfig(&config)
+ setupErrors = append(setupErrors, fmt.Errorf("relay configuration is missing IP, key or token"))
+ continue
+ }
+
+ identity := relayConnectionID(config.IP, config.Port, config.AuthTokenID)
+ connection := &pionRelayConnection{
+ state: relayStateConnecting,
+ id: identity,
+ info: config,
+ stopCh: make(chan struct{}),
+ }
+
+ m.mu.Lock()
+ if _, exists := m.connections[identity]; exists {
+ m.mu.Unlock()
+ zeroRelayConfig(&config)
+ continue
+ }
+ m.connections[identity] = connection
+ m.mu.Unlock()
+
+ if err := m.connectToRelay(connection); err != nil {
+ m.failConnection(connection)
+ setupErrors = append(setupErrors, fmt.Errorf("configure relay %s: %w", identity, err))
+ }
+ }
+ return errors.Join(setupErrors...)
+}
+
+func (m *PionRelayTransport) connectToRelay(connection *pionRelayConnection) error {
+ info := connection.info
+ m.log.Info("WhatsApp relay connecting", "id", connection.id, "name", info.Name)
+
+ peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{})
+ if err != nil {
+ return fmt.Errorf("create peer connection: %w", err)
+ }
+ connection.mu.Lock()
+ connection.pc = peerConnection
+ connection.mu.Unlock()
+
+ peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
+ m.log.Debug("WhatsApp relay ICE state", "id", connection.id, "state", state.String())
+ switch state {
+ case webrtc.ICEConnectionStateFailed, webrtc.ICEConnectionStateDisconnected:
+ m.failConnection(connection)
+ case webrtc.ICEConnectionStateClosed:
+ m.closeConnection(connection.id)
+ }
+ })
+
+ ordered := false
+ channel, err := peerConnection.CreateDataChannel("wa-web-call", &webrtc.DataChannelInit{Ordered: &ordered})
+ if err != nil {
+ return fmt.Errorf("create relay data channel: %w", err)
+ }
+ connection.mu.Lock()
+ connection.channel = channel
+ connection.mu.Unlock()
+
+ channel.OnOpen(func() {
+ if !connection.setOpen() {
+ return
+ }
+ m.sendSTUNRegistration(connection)
+ m.startKeepalive(connection)
+ m.mu.RLock()
+ callback := m.onConnected
+ m.mu.RUnlock()
+ if callback != nil {
+ callback(info.IP, info.Port)
+ }
+ })
+ channel.OnClose(func() { m.closeConnection(connection.id) })
+ channel.OnMessage(func(message webrtc.DataChannelMessage) {
+ m.mu.RLock()
+ callback := m.onReceive
+ m.mu.RUnlock()
+ if callback != nil {
+ callback(append([]byte(nil), message.Data...))
+ }
+ })
+
+ offer, err := peerConnection.CreateOffer(nil)
+ if err != nil {
+ return fmt.Errorf("create relay SDP offer: %w", err)
+ }
+ if err = peerConnection.SetLocalDescription(offer); err != nil {
+ return fmt.Errorf("set relay local description: %w", err)
+ }
+ connection.mu.Lock()
+ connection.localUfrag = extractFirst(relayUfragPattern, offer.SDP)
+ connection.mu.Unlock()
+
+ answer := modifySDPForRelay(offer.SDP, info)
+ if err = peerConnection.SetRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeAnswer, SDP: answer}); err != nil {
+ return fmt.Errorf("set relay remote description: %w", err)
+ }
+
+ go func() {
+ timer := time.NewTimer(relayConnectionTimeout)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ connection.mu.RLock()
+ connecting := connection.state == relayStateConnecting
+ connection.mu.RUnlock()
+ if connecting {
+ m.failConnection(connection)
+ }
+ case <-connection.stopCh:
+ }
+ }()
+ return nil
+}
+
+var (
+ relaySetupPattern = regexp.MustCompile(`a=setup:actpass`)
+ relayUfragLinePattern = regexp.MustCompile(`a=ice-ufrag:[^\r\n]+`)
+ relayPasswordPattern = regexp.MustCompile(`a=ice-pwd:[^\r\n]+`)
+ relayFingerprintPattern = regexp.MustCompile(`a=fingerprint:[^\r\n]+`)
+ relayMaxMessagePattern = regexp.MustCompile(`a=max-message-size:[^\r\n]+`)
+ relayICEOptionsPattern = regexp.MustCompile(`a=ice-options:[^\r\n]+\r?\n`)
+ relayCandidatePattern = regexp.MustCompile(`a=candidate:[^\r\n]+\r?\n`)
+ relayEndCandidatePattern = regexp.MustCompile(`a=end-of-candidates\r?\n?`)
+ relayUfragPattern = regexp.MustCompile(`a=ice-ufrag:([^\r\n]+)`)
+)
+
+func modifySDPForRelay(sdp string, info RelayConfig) string {
+ output := relaySetupPattern.ReplaceAllString(sdp, "a=setup:passive")
+ iceUfrag := info.AuthToken
+ if iceUfrag == "" {
+ iceUfrag = info.Token
+ }
+ output = relayUfragLinePattern.ReplaceAllString(output, "a=ice-ufrag:"+iceUfrag)
+ output = relayPasswordPattern.ReplaceAllString(output, "a=ice-pwd:"+info.Key)
+ output = relayFingerprintPattern.ReplaceAllString(output, "a=fingerprint:"+core.WADTLSFingerprint)
+ output = relayMaxMessagePattern.ReplaceAllString(output, "a=max-message-size:1500")
+ output = relayICEOptionsPattern.ReplaceAllString(output, "")
+ output = relayCandidatePattern.ReplaceAllString(output, "")
+ output = relayEndCandidatePattern.ReplaceAllString(output, "")
+ candidate := fmt.Sprintf("a=candidate:2 1 udp 2122262783 %s %d typ host generation 0 network-cost 5", info.IP, info.Port)
+ return output + candidate + "\r\na=end-of-candidates\r\n"
+}
+
+func extractFirst(pattern *regexp.Regexp, value string) string {
+ matches := pattern.FindStringSubmatch(value)
+ if len(matches) > 1 {
+ return matches[1]
+ }
+ return ""
+}
+
+func (m *PionRelayTransport) sendSTUNRegistration(connection *pionRelayConnection) {
+ connection.mu.RLock()
+ info := cloneRelayConfig(connection.info)
+ localUfrag := connection.localUfrag
+ open := connection.state == relayStateOpen && connection.channel != nil
+ connection.mu.RUnlock()
+ defer zeroRelayConfig(&info)
+ if !open {
+ return
+ }
+
+ remoteUfrag := info.AuthToken
+ if remoteUfrag == "" {
+ remoteUfrag = info.Token
+ }
+ if remoteUfrag == "" {
+ return
+ }
+
+ m.mu.RLock()
+ selfSSRC := m.ssrc
+ peerSSRC := m.subscriptionSSRC
+ m.mu.RUnlock()
+ subscriptionSSRC := peerSSRC
+ if subscriptionSSRC == 0 {
+ subscriptionSSRC = selfSSRC
+ }
+ if subscriptionSSRC == 0 {
+ return
+ }
+
+ subscriptions := BuildSenderSubscriptions(subscriptionSSRC)
+ hmacKey := []byte(info.Key)
+ sendBinding := func(username, key []byte, controlling, fingerprint bool) {
+ message, err := BuildBindingRequestWithSubscriptions(username, key, subscriptions, controlling, fingerprint)
+ if err == nil {
+ _ = m.sendRaw(connection, message)
+ }
+ }
+ if localUfrag != "" {
+ sendBinding([]byte(remoteUfrag+":"+localUfrag), hmacKey, true, true)
+ }
+ if info.Token != "" && info.Token != remoteUfrag && localUfrag != "" {
+ sendBinding([]byte(info.Token+":"+localUfrag), hmacKey, true, true)
+ }
+ sendBinding(nil, nil, false, false)
+
+ if len(info.RawToken) > 0 {
+ var peerSSRCs []uint32
+ if peerSSRC != 0 {
+ peerSSRCs = []uint32{peerSSRC}
+ }
+ ssrcList := BuildSSRCSubscriptionList([]uint32{selfSSRC}, peerSSRCs, 0, 0)
+ allocation, err := BuildAllocateForRelay(info.RawToken, ssrcList, hmacKey, info.IP, info.Port)
+ if err == nil {
+ _ = m.sendRaw(connection, allocation)
+ }
+ }
+
+ for _, delay := range []time.Duration{50, 150, 500, 3000} {
+ delay := delay * time.Millisecond
+ go func() {
+ timer := time.NewTimer(delay)
+ defer timer.Stop()
+ select {
+ case <-timer.C:
+ if connection.isOpen() {
+ m.sendSTUNRegistrationOnce(connection)
+ }
+ case <-connection.stopCh:
+ }
+ }()
+ }
+}
+
+func (m *PionRelayTransport) sendSTUNRegistrationOnce(connection *pionRelayConnection) {
+ connection.mu.RLock()
+ info := cloneRelayConfig(connection.info)
+ localUfrag := connection.localUfrag
+ connection.mu.RUnlock()
+ defer zeroRelayConfig(&info)
+
+ m.mu.RLock()
+ selfSSRC := m.ssrc
+ peerSSRC := m.subscriptionSSRC
+ m.mu.RUnlock()
+ subscriptionSSRC := peerSSRC
+ if subscriptionSSRC == 0 {
+ subscriptionSSRC = selfSSRC
+ }
+ if subscriptionSSRC == 0 {
+ return
+ }
+ remoteUfrag := info.AuthToken
+ if remoteUfrag == "" {
+ remoteUfrag = info.Token
+ }
+ if remoteUfrag == "" {
+ return
+ }
+ message, err := BuildBindingRequestWithSubscriptions([]byte(remoteUfrag+":"+localUfrag), []byte(info.Key), BuildSenderSubscriptions(subscriptionSSRC), true, true)
+ if err == nil {
+ _ = m.sendRaw(connection, message)
+ }
+}
+
+func (m *PionRelayTransport) startKeepalive(connection *pionRelayConnection) {
+ ping, err := BuildWhatsAppPing()
+ if err == nil {
+ _ = m.sendRaw(connection, ping)
+ }
+ ticker := time.NewTicker(relayKeepaliveInterval)
+ connection.mu.Lock()
+ connection.keepalive = ticker
+ connection.mu.Unlock()
+ go func() {
+ for {
+ select {
+ case <-ticker.C:
+ if !connection.isOpen() {
+ return
+ }
+ if ping, pingErr := BuildWhatsAppPing(); pingErr == nil {
+ _ = m.sendRaw(connection, ping)
+ }
+ case <-connection.stopCh:
+ return
+ }
+ }
+ }()
+}
+
+func (m *PionRelayTransport) sendRaw(connection *pionRelayConnection, data []byte) error {
+ connection.mu.RLock()
+ channel := connection.channel
+ open := connection.state == relayStateOpen && channel != nil
+ connection.mu.RUnlock()
+ if !open {
+ return fmt.Errorf("relay %s is not open", connection.id)
+ }
+ if err := channel.Send(data); err != nil {
+ return fmt.Errorf("send relay data: %w", err)
+ }
+ return nil
+}
+
+func (m *PionRelayTransport) Broadcast(data []byte) error {
+ var sendErrors []error
+ for _, connection := range m.connectionSnapshot() {
+ if !connection.isOpen() {
+ continue
+ }
+ if err := m.sendRaw(connection, data); err != nil {
+ sendErrors = append(sendErrors, err)
+ }
+ }
+ return errors.Join(sendErrors...)
+}
+
+func (m *PionRelayTransport) HasConnection() bool {
+ return m.ConnectedCount() > 0
+}
+
+func (m *PionRelayTransport) ConnectedCount() int {
+ count := 0
+ for _, connection := range m.connectionSnapshot() {
+ if connection.isOpen() {
+ count++
+ }
+ }
+ return count
+}
+
+func (m *PionRelayTransport) connectionSnapshot() []*pionRelayConnection {
+ m.mu.RLock()
+ defer m.mu.RUnlock()
+ connections := make([]*pionRelayConnection, 0, len(m.connections))
+ for _, connection := range m.connections {
+ connections = append(connections, connection)
+ }
+ return connections
+}
+
+func (m *PionRelayTransport) failConnection(connection *pionRelayConnection) {
+ if !connection.setTerminal(relayStateFailed) {
+ return
+ }
+ m.removeConnection(connection)
+ m.teardown(connection)
+}
+
+func (m *PionRelayTransport) closeConnection(identity string) {
+ m.mu.RLock()
+ connection := m.connections[identity]
+ m.mu.RUnlock()
+ if connection == nil || !connection.setTerminal(relayStateClosed) {
+ return
+ }
+ m.removeConnection(connection)
+ m.teardown(connection)
+}
+
+func (m *PionRelayTransport) removeConnection(connection *pionRelayConnection) {
+ m.mu.Lock()
+ if current := m.connections[connection.id]; current == connection {
+ delete(m.connections, connection.id)
+ }
+ m.mu.Unlock()
+}
+
+func (m *PionRelayTransport) teardown(connection *pionRelayConnection) {
+ connection.stopOnce.Do(func() { close(connection.stopCh) })
+ connection.mu.Lock()
+ ticker := connection.keepalive
+ channel := connection.channel
+ peerConnection := connection.pc
+ connection.keepalive = nil
+ connection.channel = nil
+ connection.pc = nil
+ zeroRelayConfig(&connection.info)
+ connection.mu.Unlock()
+ if ticker != nil {
+ ticker.Stop()
+ }
+ if channel != nil {
+ _ = channel.Close()
+ }
+ if peerConnection != nil {
+ _ = peerConnection.Close()
+ }
+}
+
+func (m *PionRelayTransport) Cleanup() {
+ m.mu.Lock()
+ connections := make([]*pionRelayConnection, 0, len(m.connections))
+ for _, connection := range m.connections {
+ connections = append(connections, connection)
+ }
+ m.connections = make(map[string]*pionRelayConnection)
+ m.ssrc = 0
+ m.subscriptionSSRC = 0
+ m.mu.Unlock()
+ for _, connection := range connections {
+ connection.setTerminal(relayStateClosed)
+ m.teardown(connection)
+ }
+}
+
+func cloneRelayConfig(config RelayConfig) RelayConfig {
+ clone := config
+ clone.RawToken = append([]byte(nil), config.RawToken...)
+ clone.RawAuthToken = append([]byte(nil), config.RawAuthToken...)
+ return clone
+}
+
+func zeroRelayConfig(config *RelayConfig) {
+ if config == nil {
+ return
+ }
+ zeroBytes(config.RawToken)
+ zeroBytes(config.RawAuthToken)
+ config.RawToken = nil
+ config.RawAuthToken = nil
+ config.Token = ""
+ config.AuthToken = ""
+ config.Key = ""
+ config.Name = ""
+ config.AuthTokenID = ""
+}
+
+var _ RelayTransport = (*PionRelayTransport)(nil)
diff --git a/pkg/call/voip/transport/pion_relay_test.go b/pkg/call/voip/transport/pion_relay_test.go
new file mode 100644
index 00000000..dc2a91f4
--- /dev/null
+++ b/pkg/call/voip/transport/pion_relay_test.go
@@ -0,0 +1,85 @@
+//go:build voip_pion
+
+package transport
+
+import (
+ "log/slog"
+ "strings"
+ "testing"
+)
+
+func TestPionFactorySelectsExperimentalTransport(t *testing.T) {
+ transport := NewRelayTransport(slog.Default())
+ if _, ok := transport.(*PionRelayTransport); !ok {
+ t.Fatalf("expected Pion relay transport, got %T", transport)
+ }
+}
+
+func TestModifySDPForRelay(t *testing.T) {
+ input := strings.Join([]string{
+ "v=0",
+ "a=setup:actpass",
+ "a=ice-ufrag:local-user",
+ "a=ice-pwd:local-password",
+ "a=fingerprint:sha-256 LOCAL",
+ "a=max-message-size:65536",
+ "a=ice-options:trickle",
+ "a=candidate:1 1 udp 1 10.0.0.1 9999 typ host",
+ "a=end-of-candidates",
+ "",
+ }, "\r\n")
+
+ output := modifySDPForRelay(input, RelayConfig{
+ IP: "203.0.113.9",
+ Port: 3480,
+ Token: "relay-token",
+ AuthToken: "relay-auth",
+ Key: "relay-password",
+ })
+
+ for _, expected := range []string{
+ "a=setup:passive",
+ "a=ice-ufrag:relay-auth",
+ "a=ice-pwd:relay-password",
+ "a=max-message-size:1500",
+ "203.0.113.9 3480 typ host",
+ } {
+ if !strings.Contains(output, expected) {
+ t.Fatalf("modified SDP does not contain %q:\n%s", expected, output)
+ }
+ }
+ for _, removed := range []string{"local-user", "local-password", "10.0.0.1 9999", "ice-options:trickle"} {
+ if strings.Contains(output, removed) {
+ t.Fatalf("modified SDP still contains %q:\n%s", removed, output)
+ }
+ }
+}
+
+func TestRelayConfigCloneAndZero(t *testing.T) {
+ original := RelayConfig{
+ Token: "token",
+ AuthToken: "auth",
+ RawToken: []byte{1, 2, 3},
+ RawAuthToken: []byte{4, 5, 6},
+ Key: "password",
+ }
+ clone := cloneRelayConfig(original)
+ clone.RawToken[0] = 99
+ if original.RawToken[0] != 1 {
+ t.Fatal("clone shares raw token storage")
+ }
+ zeroRelayConfig(&clone)
+ if clone.Token != "" || clone.AuthToken != "" || clone.Key != "" || clone.RawToken != nil || clone.RawAuthToken != nil {
+ t.Fatalf("relay config was not cleared: %#v", clone)
+ }
+}
+
+func TestPionTransportStartsDisconnected(t *testing.T) {
+ transport := NewPionRelayTransport(nil)
+ transport.SetSSRC(123)
+ transport.SetSubscriptionSSRC(456)
+ if transport.HasConnection() || transport.ConnectedCount() != 0 {
+ t.Fatal("new transport unexpectedly has a relay connection")
+ }
+ transport.Cleanup()
+}
diff --git a/pkg/call/voip/transport/relay.go b/pkg/call/voip/transport/relay.go
new file mode 100644
index 00000000..4b7d159d
--- /dev/null
+++ b/pkg/call/voip/transport/relay.go
@@ -0,0 +1,139 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package transport
+
+import (
+ "errors"
+ "fmt"
+ "sync"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+var ErrSCTPUnavailable = errors.New("WhatsApp SCTP relay transport is not enabled")
+
+type RelayConfig struct {
+ IP string
+ Port int
+ Token string
+ AuthToken string
+ RawAuthToken []byte
+ RawToken []byte
+ Key string
+ RelayID int
+ Name string
+ AuthTokenID string
+}
+
+// RelayTransport is the media-relay boundary used by the future CallManager.
+// Implementations may use Pion, another WebRTC stack, or a deterministic fake.
+type RelayTransport interface {
+ SetSSRC(ssrc uint32)
+ SetSubscriptionSSRC(ssrc uint32)
+ SetOnConnected(fn func(ip string, port int))
+ SetOnReceive(fn func(data []byte))
+ ResendSubscriptions()
+ ConfigureRelays(relays []RelayConfig) error
+ Broadcast(data []byte) error
+ HasConnection() bool
+ ConnectedCount() int
+ Cleanup()
+}
+
+// BuildRelayConfigs converts protocol candidates into independent SCTP configs.
+// Only UDP relay protocol 0 entries with credentials and a raw token are usable.
+func BuildRelayConfigs(endpoints []core.RelayEndpoint) []RelayConfig {
+ seen := make(map[string]struct{})
+ configs := make([]RelayConfig, 0, len(endpoints))
+ for _, endpoint := range endpoints {
+ if endpoint.Protocol != 0 || endpoint.IP == "" || endpoint.Key == "" || len(endpoint.RawToken) == 0 {
+ continue
+ }
+ port := endpoint.Port
+ if port == 0 {
+ port = core.WARelayPort
+ }
+ identity := fmt.Sprintf("%s:%d#%s", endpoint.IP, port, endpoint.AuthTokenID)
+ if _, exists := seen[identity]; exists {
+ continue
+ }
+ seen[identity] = struct{}{}
+
+ name := endpoint.RelayName
+ if name == "" {
+ name = endpoint.IP
+ }
+ configs = append(configs, RelayConfig{
+ IP: endpoint.IP,
+ Port: port,
+ Token: endpoint.Token,
+ AuthToken: endpoint.AuthToken,
+ RawAuthToken: append([]byte(nil), endpoint.RawAuthToken...),
+ RawToken: append([]byte(nil), endpoint.RawToken...),
+ Key: endpoint.Key,
+ RelayID: endpoint.RelayID,
+ Name: name,
+ AuthTokenID: endpoint.AuthTokenID,
+ })
+ }
+ return configs
+}
+
+func ZeroRelayConfigs(configs []RelayConfig) {
+ for index := range configs {
+ zeroBytes(configs[index].RawToken)
+ zeroBytes(configs[index].RawAuthToken)
+ configs[index].RawToken = nil
+ configs[index].RawAuthToken = nil
+ configs[index].Token = ""
+ configs[index].AuthToken = ""
+ configs[index].Key = ""
+ configs[index].Name = ""
+ configs[index].AuthTokenID = ""
+ }
+}
+
+// DisabledRelayTransport is the safe default until the Pion SCTP implementation
+// is connected. It preserves callbacks and SSRC configuration but opens no socket.
+type DisabledRelayTransport struct {
+ mu sync.RWMutex
+ ssrc uint32
+ subscriptionSSRC uint32
+ onConnected func(string, int)
+ onReceive func([]byte)
+}
+
+func NewDisabledRelayTransport() *DisabledRelayTransport { return &DisabledRelayTransport{} }
+func (d *DisabledRelayTransport) SetSSRC(ssrc uint32) {
+ d.mu.Lock()
+ d.ssrc = ssrc
+ d.mu.Unlock()
+}
+func (d *DisabledRelayTransport) SetSubscriptionSSRC(ssrc uint32) {
+ d.mu.Lock()
+ d.subscriptionSSRC = ssrc
+ d.mu.Unlock()
+}
+func (d *DisabledRelayTransport) SetOnConnected(fn func(string, int)) {
+ d.mu.Lock()
+ d.onConnected = fn
+ d.mu.Unlock()
+}
+func (d *DisabledRelayTransport) SetOnReceive(fn func([]byte)) {
+ d.mu.Lock()
+ d.onReceive = fn
+ d.mu.Unlock()
+}
+func (d *DisabledRelayTransport) ResendSubscriptions() {}
+func (d *DisabledRelayTransport) ConfigureRelays([]RelayConfig) error { return ErrSCTPUnavailable }
+func (d *DisabledRelayTransport) Broadcast([]byte) error { return ErrSCTPUnavailable }
+func (d *DisabledRelayTransport) HasConnection() bool { return false }
+func (d *DisabledRelayTransport) ConnectedCount() int { return 0 }
+func (d *DisabledRelayTransport) Cleanup() {}
+
+var _ RelayTransport = (*DisabledRelayTransport)(nil)
+
+func zeroBytes(value []byte) {
+ for index := range value {
+ value[index] = 0
+ }
+}
diff --git a/pkg/call/voip/transport/relay_test.go b/pkg/call/voip/transport/relay_test.go
new file mode 100644
index 00000000..da069f7a
--- /dev/null
+++ b/pkg/call/voip/transport/relay_test.go
@@ -0,0 +1,90 @@
+package transport
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+)
+
+func TestBuildRelayConfigsFiltersAndCopies(t *testing.T) {
+ token := []byte{1, 2, 3}
+ auth := []byte{4, 5, 6}
+ configs := BuildRelayConfigs([]core.RelayEndpoint{
+ {
+ IP: "10.0.0.1",
+ Port: 0,
+ Protocol: 0,
+ Key: "relay-key",
+ RawToken: token,
+ RawAuthToken: auth,
+ AuthTokenID: "a",
+ },
+ {
+ IP: "10.0.0.1",
+ Protocol: 0,
+ Key: "relay-key",
+ RawToken: []byte{9},
+ AuthTokenID: "a",
+ },
+ {
+ IP: "10.0.0.2",
+ Protocol: 1,
+ Key: "ignored",
+ RawToken: []byte{7},
+ },
+ {
+ IP: "10.0.0.3",
+ Protocol: 0,
+ RawToken: []byte{8},
+ },
+ })
+
+ if len(configs) != 1 {
+ t.Fatalf("config count = %d, want 1", len(configs))
+ }
+ if configs[0].Port != core.WARelayPort || configs[0].Name != "10.0.0.1" {
+ t.Fatalf("unexpected defaults: %+v", configs[0])
+ }
+ configs[0].RawToken[0] = 99
+ configs[0].RawAuthToken[0] = 99
+ if token[0] != 1 || auth[0] != 4 {
+ t.Fatal("relay config shares private buffers with protocol endpoint")
+ }
+}
+
+func TestZeroRelayConfigsOverwritesBuffers(t *testing.T) {
+ token := []byte{1, 2}
+ auth := []byte{3, 4}
+ configs := []RelayConfig{{
+ Token: "token",
+ AuthToken: "auth",
+ RawToken: token,
+ RawAuthToken: auth,
+ Key: "key",
+ }}
+ ZeroRelayConfigs(configs)
+ for _, buffer := range [][]byte{token, auth} {
+ for _, value := range buffer {
+ if value != 0 {
+ t.Fatalf("buffer was not overwritten: %v", buffer)
+ }
+ }
+ }
+ if configs[0].RawToken != nil || configs[0].RawAuthToken != nil || configs[0].Key != "" {
+ t.Fatal("relay config references were not cleared")
+ }
+}
+
+func TestDisabledRelayTransportFailsClosed(t *testing.T) {
+ transport := NewDisabledRelayTransport()
+ if !errors.Is(transport.ConfigureRelays(nil), ErrSCTPUnavailable) {
+ t.Fatal("disabled transport must reject relay configuration")
+ }
+ if !errors.Is(transport.Broadcast([]byte{1}), ErrSCTPUnavailable) {
+ t.Fatal("disabled transport must reject media writes")
+ }
+ if transport.HasConnection() || transport.ConnectedCount() != 0 {
+ t.Fatal("disabled transport reported a connection")
+ }
+}
diff --git a/pkg/call/voip/transport/stun.go b/pkg/call/voip/transport/stun.go
new file mode 100644
index 00000000..b9e4d134
--- /dev/null
+++ b/pkg/call/voip/transport/stun.go
@@ -0,0 +1,294 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package transport
+
+import (
+ "crypto/hmac"
+ "crypto/rand"
+ "crypto/sha1"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "hash/crc32"
+ "net"
+ "strings"
+)
+
+const (
+ stunMagicCookie = 0x2112a442
+ stunFingerprintXOR = 0x5354554e
+ stunBindingRequest = 0x0001
+ stunAllocateRequest = 0x0003
+ whatsAppPing = 0x0801
+
+ attrUsername = 0x0006
+ attrMessageIntegrity = 0x0008
+ attrXORRelayedAddress = 0x0016
+ attrPriority = 0x0024
+ attrSenderSubscriptions = 0x4000
+ attrSSRCList = 0x4024
+ attrICEControlling = 0x802a
+ attrFingerprint = 0x8028
+
+ defaultICEPriority = 16_777_215
+)
+
+func generateTransactionID() ([]byte, error) {
+ id := make([]byte, 12)
+ if _, err := rand.Read(id); err != nil {
+ return nil, fmt.Errorf("generate STUN transaction ID: %w", err)
+ }
+ return id, nil
+}
+
+func encodeAttribute(attributeType int, data []byte) []byte {
+ header := make([]byte, 4)
+ binary.BigEndian.PutUint16(header[0:], uint16(attributeType))
+ binary.BigEndian.PutUint16(header[2:], uint16(len(data)))
+ padding := (4 - (len(data) % 4)) % 4
+ output := append(header, data...)
+ return append(output, make([]byte, padding)...)
+}
+
+func buildSTUNMessage(messageType int, attributes, transactionID, integrityKey []byte, includeFingerprint bool) []byte {
+ attributesData := append([]byte(nil), attributes...)
+
+ if len(integrityKey) > 0 {
+ messageLengthForHMAC := len(attributesData) + 24
+ hmacHeader := make([]byte, 20)
+ binary.BigEndian.PutUint16(hmacHeader[0:], uint16(messageType))
+ binary.BigEndian.PutUint16(hmacHeader[2:], uint16(messageLengthForHMAC))
+ binary.BigEndian.PutUint32(hmacHeader[4:], stunMagicCookie)
+ copy(hmacHeader[8:], transactionID)
+
+ mac := hmac.New(sha1.New, integrityKey)
+ _, _ = mac.Write(hmacHeader)
+ _, _ = mac.Write(attributesData)
+ attributesData = append(attributesData, encodeAttribute(attrMessageIntegrity, mac.Sum(nil))...)
+ }
+
+ if includeFingerprint {
+ messageLengthForCRC := len(attributesData) + 8
+ crcHeader := make([]byte, 20)
+ binary.BigEndian.PutUint16(crcHeader[0:], uint16(messageType))
+ binary.BigEndian.PutUint16(crcHeader[2:], uint16(messageLengthForCRC))
+ binary.BigEndian.PutUint32(crcHeader[4:], stunMagicCookie)
+ copy(crcHeader[8:], transactionID)
+
+ crcInput := append(append([]byte(nil), crcHeader...), attributesData...)
+ fingerprint := crc32.ChecksumIEEE(crcInput) ^ stunFingerprintXOR
+ fingerprintBuffer := make([]byte, 4)
+ binary.BigEndian.PutUint32(fingerprintBuffer, fingerprint)
+ attributesData = append(attributesData, encodeAttribute(attrFingerprint, fingerprintBuffer)...)
+ }
+
+ header := make([]byte, 20)
+ binary.BigEndian.PutUint16(header[0:], uint16(messageType))
+ binary.BigEndian.PutUint16(header[2:], uint16(len(attributesData)))
+ binary.BigEndian.PutUint32(header[4:], stunMagicCookie)
+ copy(header[8:], transactionID)
+ return append(header, attributesData...)
+}
+
+func encodeXORRelayedAddress(ip string, port int) ([]byte, error) {
+ parsedIP := net.ParseIP(ip).To4()
+ if parsedIP == nil {
+ return nil, fmt.Errorf("relay IP %q is not IPv4", ip)
+ }
+ if port <= 0 || port > 65535 {
+ return nil, fmt.Errorf("relay port %d is invalid", port)
+ }
+
+ data := make([]byte, 8)
+ data[1] = 0x01
+ binary.BigEndian.PutUint16(data[2:], uint16(port)^uint16(stunMagicCookie>>16))
+ ipNumber := binary.BigEndian.Uint32(parsedIP)
+ binary.BigEndian.PutUint32(data[4:], ipNumber^stunMagicCookie)
+ return data, nil
+}
+
+// BuildAllocateForRelay builds the WhatsApp relay allocation request.
+func BuildAllocateForRelay(senderSubscriptions, ssrcList, hmacKey []byte, relayIP string, relayPort int) ([]byte, error) {
+ transactionID, err := generateTransactionID()
+ if err != nil {
+ return nil, err
+ }
+ parts := [][]byte{
+ encodeAttribute(attrSenderSubscriptions, senderSubscriptions),
+ encodeAttribute(attrSSRCList, ssrcList),
+ }
+ if relayIP != "" && relayPort != 0 {
+ address, addressErr := encodeXORRelayedAddress(relayIP, relayPort)
+ if addressErr != nil {
+ return nil, addressErr
+ }
+ parts = append(parts, encodeAttribute(attrXORRelayedAddress, address))
+ }
+ return buildSTUNMessage(stunAllocateRequest, concat(parts...), transactionID, hmacKey, false), nil
+}
+
+// BuildBindingRequestWithSubscriptions creates a STUN binding request carrying
+// WhatsApp sender subscriptions.
+func BuildBindingRequestWithSubscriptions(username, hmacKey, senderSubscriptions []byte, includeICEControlling, includeFingerprint bool) ([]byte, error) {
+ transactionID, err := generateTransactionID()
+ if err != nil {
+ return nil, err
+ }
+ var parts [][]byte
+ if len(username) > 0 {
+ parts = append(parts, encodeAttribute(attrUsername, username))
+ }
+ priority := make([]byte, 4)
+ binary.BigEndian.PutUint32(priority, defaultICEPriority)
+ parts = append(parts, encodeAttribute(attrPriority, priority))
+ if includeICEControlling {
+ tieBreaker := make([]byte, 8)
+ if _, err = rand.Read(tieBreaker); err != nil {
+ return nil, fmt.Errorf("generate ICE tie breaker: %w", err)
+ }
+ parts = append(parts, encodeAttribute(attrICEControlling, tieBreaker))
+ }
+ if len(senderSubscriptions) > 0 {
+ parts = append(parts, encodeAttribute(attrSenderSubscriptions, senderSubscriptions))
+ }
+ return buildSTUNMessage(stunBindingRequest, concat(parts...), transactionID, hmacKey, includeFingerprint), nil
+}
+
+// BuildWhatsAppPing returns the proprietary keepalive frame used by relays.
+func BuildWhatsAppPing() ([]byte, error) {
+ transactionID, err := generateTransactionID()
+ if err != nil {
+ return nil, err
+ }
+ header := make([]byte, 20)
+ binary.BigEndian.PutUint16(header[0:], whatsAppPing)
+ binary.BigEndian.PutUint32(header[4:], stunMagicCookie)
+ copy(header[8:], transactionID)
+ return header, nil
+}
+
+func IsSTUNPacket(data []byte) bool { return len(data) >= 2 && data[0]&0xc0 == 0 }
+func IsRTPPacket(data []byte) bool { return len(data) >= 2 && data[0]&0xc0 == 0x80 }
+
+type STUNAttribute struct {
+ Type int
+ TypeName string
+ Length int
+ Data []byte
+}
+
+type STUNResponseInfo struct {
+ RawType int
+ Method string
+ Class string
+ IsSuccess bool
+ IsError bool
+ ErrorCode int
+ ErrorReason string
+ StableRoutingConnID uint64
+ TransactionID string
+ Length int
+ Attributes []STUNAttribute
+}
+
+var stunAttributeNames = map[int]string{
+ 0x0001: "MAPPED-ADDRESS", 0x0006: "USERNAME", 0x0008: "MESSAGE-INTEGRITY",
+ 0x0009: "ERROR-CODE", 0x0016: "XOR-RELAYED-ADDRESS", 0x0020: "XOR-MAPPED-ADDRESS",
+ 0x0024: "PRIORITY", 0x4000: "SENDER-SUBSCRIPTIONS", 0x4001: "RECEIVER-SUBSCRIPTION",
+ 0x4002: "SUBSCRIPTION-ACK", 0x4024: "SSRC-LIST", 0x4033: "STABLE-ROUTING-CONN-ID",
+ 0x8028: "FINGERPRINT", 0x8029: "ICE-CONTROLLED", 0x802a: "ICE-CONTROLLING",
+}
+
+func ParseSTUNResponse(data []byte) *STUNResponseInfo {
+ if len(data) < 20 || binary.BigEndian.Uint32(data[4:]) != stunMagicCookie {
+ return nil
+ }
+
+ rawType := int(binary.BigEndian.Uint16(data[0:]))
+ messageLength := int(binary.BigEndian.Uint16(data[2:]))
+ if 20+messageLength > len(data) {
+ return nil
+ }
+ classNumber := (((rawType >> 8) & 0x1) << 1) | ((rawType >> 4) & 0x1)
+ classes := []string{"request", "indication", "success", "error"}
+ class := "unknown"
+ if classNumber < len(classes) {
+ class = classes[classNumber]
+ }
+ methodBits := ((rawType & 0x3e00) >> 2) | ((rawType & 0x00e0) >> 1) | (rawType & 0x000f)
+ method := map[int]string{0x001: "binding", 0x003: "allocate", 0x004: "refresh", 0x006: "send", 0x007: "data", 0x008: "create-permission", 0x009: "channel-bind"}[methodBits]
+ if method == "" {
+ method = "unknown"
+ }
+ if rawType == 0x0801 {
+ method = "wa-ping"
+ } else if rawType == 0x0802 {
+ method = "wa-pong"
+ }
+
+ info := &STUNResponseInfo{
+ RawType: rawType,
+ Method: method,
+ Class: class,
+ IsSuccess: class == "success",
+ IsError: class == "error",
+ TransactionID: hex.EncodeToString(data[8:20]),
+ Length: len(data),
+ }
+
+ for offset := 20; offset+4 <= 20+messageLength; {
+ attributeType := int(binary.BigEndian.Uint16(data[offset:]))
+ attributeLength := int(binary.BigEndian.Uint16(data[offset+2:]))
+ attributeEnd := offset + 4 + attributeLength
+ if attributeEnd > len(data) || attributeEnd > 20+messageLength {
+ return nil
+ }
+ attributeData := append([]byte(nil), data[offset+4:attributeEnd]...)
+ name := stunAttributeNames[attributeType]
+ if name == "" {
+ name = fmt.Sprintf("0x%04x", attributeType)
+ }
+ info.Attributes = append(info.Attributes, STUNAttribute{Type: attributeType, TypeName: name, Length: attributeLength, Data: attributeData})
+ if attributeType == 0x0009 && attributeLength >= 4 {
+ info.ErrorCode = int(attributeData[2]&0x07)*100 + int(attributeData[3])
+ if attributeLength > 4 {
+ info.ErrorReason = string(attributeData[4:])
+ }
+ }
+ if attributeType == 0x4033 && class == "success" && attributeLength == 8 {
+ info.StableRoutingConnID = binary.BigEndian.Uint64(attributeData)
+ }
+ offset = attributeEnd + ((4 - (attributeLength % 4)) % 4)
+ }
+ return info
+}
+
+func ClassifyPacket(data []byte) string {
+ if len(data) < 2 {
+ return fmt.Sprintf("tiny(%dB)", len(data))
+ }
+ switch (data[0] & 0xc0) >> 6 {
+ case 0:
+ if info := ParseSTUNResponse(data); info != nil {
+ result := fmt.Sprintf("STUN %s %s (0x%04x, %dB)", info.Method, info.Class, info.RawType, info.Length)
+ if len(info.Attributes) > 0 {
+ names := make([]string, len(info.Attributes))
+ for index, attribute := range info.Attributes {
+ names[index] = attribute.TypeName
+ }
+ result += " [" + strings.Join(names, ", ") + "]"
+ }
+ return result
+ }
+ return fmt.Sprintf("STUN? 0x%x (%dB)", int(data[0])<<8|int(data[1]), len(data))
+ case 2:
+ sequence := 0
+ if len(data) >= 4 {
+ sequence = int(binary.BigEndian.Uint16(data[2:4]))
+ }
+ return fmt.Sprintf("RTP/SRTP PT=%d M=%d seq=%d (%dB)", data[1]&0x7f, data[1]>>7, sequence, len(data))
+ case 1:
+ return fmt.Sprintf("DTLS? 0x%x (%dB)", data[0], len(data))
+ default:
+ return fmt.Sprintf("unknown 0x%x (%dB)", data[0], len(data))
+ }
+}
diff --git a/pkg/call/voip/transport/subscriptions.go b/pkg/call/voip/transport/subscriptions.go
new file mode 100644
index 00000000..e01432f9
--- /dev/null
+++ b/pkg/call/voip/transport/subscriptions.go
@@ -0,0 +1,70 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package transport
+
+func encodeVarint(value uint64) []byte {
+ var output []byte
+ for value > 0x7f {
+ output = append(output, byte((value&0x7f)|0x80))
+ value >>= 7
+ }
+ return append(output, byte(value&0x7f))
+}
+
+func encodeProtobufVarintField(fieldNumber int, value uint64) []byte {
+ tag := encodeVarint(uint64(fieldNumber << 3))
+ return append(tag, encodeVarint(value)...)
+}
+
+func encodeProtobufLengthDelimited(fieldNumber int, data []byte) []byte {
+ tag := encodeVarint(uint64((fieldNumber << 3) | 2))
+ output := append(tag, encodeVarint(uint64(len(data)))...)
+ return append(output, data...)
+}
+
+// BuildSenderSubscriptions creates the WhatsApp relay subscription protobuf
+// attached to STUN binding requests.
+func BuildSenderSubscriptions(ssrc uint32) []byte {
+ inner := concat(
+ encodeProtobufVarintField(3, uint64(ssrc)),
+ encodeProtobufVarintField(5, 0),
+ encodeProtobufVarintField(6, 0),
+ )
+ return encodeProtobufLengthDelimited(1, inner)
+}
+
+// BuildSSRCSubscriptionList creates the allocation payload for local and remote
+// media SSRCs. Zero SSRC values are omitted.
+func BuildSSRCSubscriptionList(selfSSRCs, peerSSRCs []uint32, selfPID, peerPID int) []byte {
+ var entries [][]byte
+ for _, ssrc := range selfSSRCs {
+ if ssrc == 0 {
+ continue
+ }
+ inner := concat(
+ encodeProtobufVarintField(1, uint64(selfPID)),
+ encodeProtobufVarintField(2, 1),
+ encodeProtobufVarintField(3, uint64(ssrc)),
+ )
+ entries = append(entries, encodeProtobufLengthDelimited(1, inner))
+ }
+ for _, ssrc := range peerSSRCs {
+ if ssrc == 0 {
+ continue
+ }
+ inner := concat(
+ encodeProtobufVarintField(1, uint64(peerPID)),
+ encodeProtobufVarintField(2, 1),
+ encodeProtobufVarintField(3, uint64(ssrc)),
+ )
+ entries = append(entries, encodeProtobufLengthDelimited(1, inner))
+ }
+ return concat(entries...)
+}
+
+func concat(parts ...[]byte) []byte {
+ var output []byte
+ for _, part := range parts {
+ output = append(output, part...)
+ }
+ return output
+}
diff --git a/pkg/call/voip/wa/socket.go b/pkg/call/voip/wa/socket.go
new file mode 100644
index 00000000..8f7017c9
--- /dev/null
+++ b/pkg/call/voip/wa/socket.go
@@ -0,0 +1,196 @@
+// Package wa adapts the Evolution whatsmeow client to the VoIP socket interface.
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package wa
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/core"
+ "github.com/evolution-foundation/evolution-go/pkg/call/voip/signaling"
+ "go.mau.fi/whatsmeow"
+ waBinary "go.mau.fi/whatsmeow/binary"
+ "go.mau.fi/whatsmeow/types"
+)
+
+const queryTimeout = 15 * time.Second
+
+type Socket struct {
+ client *whatsmeow.Client
+}
+
+func NewSocket(client *whatsmeow.Client) *Socket {
+ return &Socket{client: client}
+}
+
+var _ core.VoipSocket = (*Socket)(nil)
+
+func (s *Socket) dangerous() *whatsmeow.DangerousInternalClient {
+ return s.client.DangerousInternals()
+}
+
+func (s *Socket) OwnPN() types.JID { return s.dangerous().GetOwnID() }
+func (s *Socket) OwnLID() types.JID { return s.dangerous().GetOwnLID() }
+
+func (s *Socket) AccountDeviceIdentityNode() (waBinary.Node, bool) {
+ if s.client == nil || s.client.Store == nil || s.client.Store.Account == nil {
+ return waBinary.Node{}, false
+ }
+ return s.dangerous().MakeDeviceIdentityNode(), true
+}
+
+func (s *Socket) SendNode(ctx context.Context, node waBinary.Node) error {
+ if s.client == nil {
+ return fmt.Errorf("nil whatsmeow client")
+ }
+ // Incoming acceptance has an ACK. Register the waiter before sending, then
+ // drain it asynchronously so relay startup is not delayed by the query timer.
+ if isCallAcceptNode(node) {
+ return s.sendQueryAsync(ctx, node)
+ }
+ return s.dangerous().SendNode(ctx, node)
+}
+
+func (s *Socket) sendQueryAsync(ctx context.Context, node waBinary.Node) error {
+ id, _ := node.Attrs["id"].(string)
+ if id == "" {
+ return s.dangerous().SendNode(ctx, node)
+ }
+ dangerous := s.dangerous()
+ responseChannel := dangerous.WaitResponse(id)
+ if err := dangerous.SendNode(ctx, node); err != nil {
+ dangerous.CancelResponse(id, responseChannel)
+ return err
+ }
+ go func() {
+ timer := time.NewTimer(queryTimeout)
+ defer timer.Stop()
+ select {
+ case <-responseChannel:
+ case <-timer.C:
+ dangerous.CancelResponse(id, responseChannel)
+ }
+ }()
+ return nil
+}
+
+func isCallAcceptNode(node waBinary.Node) bool {
+ if node.Tag == "accept" {
+ return true
+ }
+ for _, child := range node.GetChildren() {
+ if child.Tag == "accept" {
+ return true
+ }
+ }
+ return false
+}
+
+func (s *Socket) Query(ctx context.Context, node waBinary.Node) (*waBinary.Node, error) {
+ id, _ := node.Attrs["id"].(string)
+ if id == "" {
+ if s.client == nil {
+ return nil, fmt.Errorf("nil whatsmeow client")
+ }
+ return nil, s.dangerous().SendNode(ctx, node)
+ }
+
+ dangerous := s.dangerous()
+ responseChannel := dangerous.WaitResponse(id)
+ if err := dangerous.SendNode(ctx, node); err != nil {
+ dangerous.CancelResponse(id, responseChannel)
+ return nil, err
+ }
+
+ timer := time.NewTimer(queryTimeout)
+ defer timer.Stop()
+ select {
+ case response := <-responseChannel:
+ return response, nil
+ case <-timer.C:
+ dangerous.CancelResponse(id, responseChannel)
+ return nil, nil
+ case <-ctx.Done():
+ dangerous.CancelResponse(id, responseChannel)
+ return nil, ctx.Err()
+ }
+}
+
+func (s *Socket) GetUSyncDevices(ctx context.Context, jids []types.JID) ([]types.JID, error) {
+ return s.client.GetUserDevices(ctx, jids)
+}
+
+func (s *Socket) AssertSessions(context.Context, []types.JID, bool) error {
+ // whatsmeow ensures Signal sessions while encrypting for the target devices.
+ return nil
+}
+
+func (s *Socket) CreateParticipantNodes(ctx context.Context, devices []types.JID, callKey []byte, attrs waBinary.Attrs) ([]waBinary.Node, bool, error) {
+ plaintext, err := signaling.EncodeCallKeyMessage(callKey)
+ if err != nil {
+ return nil, false, err
+ }
+ messageID := s.client.GenerateMessageID()
+ return s.dangerous().EncryptMessageForDevices(ctx, devices, messageID, plaintext, plaintext, attrs)
+}
+
+func (s *Socket) DecryptCallKey(ctx context.Context, from types.JID, encrypted *waBinary.Node) ([]byte, error) {
+ typeValue, _ := encrypted.Attrs["type"].(string)
+ plaintext, _, err := s.dangerous().DecryptDM(ctx, encrypted, from, typeValue == "pkmsg", time.Now())
+ if err != nil {
+ return nil, err
+ }
+ return signaling.DecodeCallKeyPlaintext(plaintext)
+}
+
+func (s *Socket) GetTCToken(ctx context.Context, jid types.JID) ([]byte, error) {
+ if s.client.Store == nil || s.client.Store.PrivacyTokens == nil {
+ return nil, nil
+ }
+ candidates := []types.JID{s.ResolveLIDForPN(ctx, jid).ToNonAD(), jid.ToNonAD()}
+ for _, candidate := range candidates {
+ if candidate.IsEmpty() {
+ continue
+ }
+ token, err := s.client.Store.PrivacyTokens.GetPrivacyToken(ctx, candidate)
+ if err != nil {
+ return nil, err
+ }
+ if token != nil && len(token.Token) > 0 {
+ return token.Token, nil
+ }
+ }
+ return nil, nil
+}
+
+func (s *Socket) ResolveLIDForPN(ctx context.Context, phoneNumber types.JID) types.JID {
+ if phoneNumber.Server == types.HiddenUserServer {
+ return phoneNumber
+ }
+ if s.client.Store != nil && s.client.Store.LIDs != nil {
+ if lid, err := s.client.Store.LIDs.GetLIDForPN(ctx, phoneNumber); err == nil && !lid.IsEmpty() {
+ return lid
+ }
+ }
+ if userInfo, err := s.client.GetUserInfo(ctx, []types.JID{phoneNumber}); err == nil {
+ if lid := userInfo[phoneNumber].LID; !lid.IsEmpty() {
+ return lid
+ }
+ }
+ return phoneNumber
+}
+
+// ResolvePNForLID converts an opaque LID back to the user's phone-number JID
+// when the WhatsApp store has learned the mapping.
+func (s *Socket) ResolvePNForLID(ctx context.Context, lid types.JID) types.JID {
+ if lid.IsEmpty() || lid.Server != types.HiddenUserServer {
+ return lid
+ }
+ if s.client != nil && s.client.Store != nil && s.client.Store.LIDs != nil {
+ if pn, err := s.client.Store.LIDs.GetPNForLID(ctx, lid.ToNonAD()); err == nil && !pn.IsEmpty() {
+ return pn
+ }
+ }
+ return lid
+}
diff --git a/pkg/call/voip/wanode/jid.go b/pkg/call/voip/wanode/jid.go
new file mode 100644
index 00000000..ba1e6fed
--- /dev/null
+++ b/pkg/call/voip/wanode/jid.go
@@ -0,0 +1,26 @@
+// Package wanode contains WhatsApp call-node and JID helpers.
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package wanode
+
+import (
+ "strings"
+
+ "go.mau.fi/whatsmeow/types"
+)
+
+func CleanJID(jid string) string {
+ if index := strings.Index(jid, ":"); index >= 0 {
+ if at := strings.Index(jid, "@"); at > index {
+ return jid[:index] + jid[at:]
+ }
+ }
+ return jid
+}
+
+func MustJID(value string) types.JID {
+ jid, err := types.ParseJID(value)
+ if err != nil {
+ return types.JID{}
+ }
+ return jid
+}
diff --git a/pkg/call/voip/wanode/nodeutil.go b/pkg/call/voip/wanode/nodeutil.go
new file mode 100644
index 00000000..87457483
--- /dev/null
+++ b/pkg/call/voip/wanode/nodeutil.go
@@ -0,0 +1,61 @@
+// Portions are derived from JotaDev66/WaCalls under the MIT license in ../LICENSE-WACALLS.
+package wanode
+
+import (
+ "fmt"
+ "strconv"
+
+ waBinary "go.mau.fi/whatsmeow/binary"
+)
+
+func NodeChildren(node *waBinary.Node) []waBinary.Node {
+ if node == nil {
+ return nil
+ }
+ children, _ := node.Content.([]waBinary.Node)
+ return children
+}
+
+func NodeBytes(node *waBinary.Node) []byte {
+ if node == nil {
+ return nil
+ }
+ value, _ := node.Content.([]byte)
+ return value
+}
+
+func AttrString(attrs waBinary.Attrs, key string) string {
+ value, ok := attrs[key]
+ if !ok || value == nil {
+ return ""
+ }
+ switch typed := value.(type) {
+ case string:
+ return typed
+ case fmt.Stringer:
+ return typed.String()
+ case int64:
+ return strconv.FormatInt(typed, 10)
+ case int:
+ return strconv.Itoa(typed)
+ case uint64:
+ return strconv.FormatUint(typed, 10)
+ case bool:
+ return strconv.FormatBool(typed)
+ default:
+ return fmt.Sprintf("%v", typed)
+ }
+}
+
+func AttrInt(attrs waBinary.Attrs, key string, fallback int) int {
+ value, err := strconv.Atoi(AttrString(attrs, key))
+ if err != nil {
+ return fallback
+ }
+ return value
+}
+
+func HasAttr(attrs waBinary.Attrs, key string) bool {
+ value, ok := attrs[key]
+ return ok && value != nil && AttrString(attrs, key) != ""
+}
diff --git a/pkg/routes/manager_v2.go b/pkg/routes/manager_v2.go
new file mode 100644
index 00000000..de112151
--- /dev/null
+++ b/pkg/routes/manager_v2.go
@@ -0,0 +1,12 @@
+package routes
+
+import "github.com/gin-gonic/gin"
+
+func registerManagerV2Routes(eng *gin.Engine) {
+ eng.Static("/manager-v2/assets", "./manager-v2/dist/assets")
+ serveIndex := func(c *gin.Context) {
+ c.File("manager-v2/dist/index.html")
+ }
+ eng.GET("/manager-v2", serveIndex)
+ eng.GET("/manager-v2/", serveIndex)
+}
diff --git a/pkg/routes/manager_v2_test.go b/pkg/routes/manager_v2_test.go
new file mode 100644
index 00000000..e721b32d
--- /dev/null
+++ b/pkg/routes/manager_v2_test.go
@@ -0,0 +1,29 @@
+package routes
+
+import (
+ "testing"
+
+ "github.com/gin-gonic/gin"
+)
+
+func TestRegisterManagerV2Routes(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ engine := gin.New()
+ registerManagerV2Routes(engine)
+
+ routes := make(map[string]bool)
+ for _, route := range engine.Routes() {
+ routes[route.Method+" "+route.Path] = true
+ }
+
+ for _, expected := range []string{
+ "GET /manager-v2",
+ "GET /manager-v2/",
+ "GET /manager-v2/assets/*filepath",
+ "HEAD /manager-v2/assets/*filepath",
+ } {
+ if !routes[expected] {
+ t.Fatalf("missing route %s; registered routes: %#v", expected, routes)
+ }
+ }
+}
diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go
index 8e026a87..0e2d6448 100644
--- a/pkg/routes/routes.go
+++ b/pkg/routes/routes.go
@@ -63,7 +63,7 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) {
c.Status(http.StatusNoContent)
})
- // Rotas para o gerenciador React (sem autenticação)
+ // Rotas para o gerenciador React legado (sem autenticação)
eng.Static("/assets", "./manager/dist/assets")
// Ajuste nas rotas do manager para suportar client-side routing do React
@@ -75,6 +75,9 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) {
c.File("manager/dist/index.html")
})
+ // Novo Manager V2. Mantém /manager intacto durante a migração gradual.
+ registerManagerV2Routes(eng)
+
eng.GET("/server/ok", r.serverHandler.ServerOk)
routes := eng.Group("/instance")
@@ -193,6 +196,13 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) {
{
routes.Use(r.authMiddleware.Auth)
{
+ routes.GET("/status", r.callHandler.Status)
+ routes.POST("/start", r.callHandler.StartCall)
+ routes.POST("/:callId/accept", r.callHandler.AcceptCall)
+ routes.POST("/:callId/webrtc", r.callHandler.CreateWebRTC)
+ routes.GET("/:callId/webrtc", r.callHandler.ListWebRTC)
+ routes.DELETE("/:callId/webrtc/:sessionId", r.callHandler.CloseWebRTC)
+ routes.DELETE("/:callId", r.callHandler.TerminateCall)
routes.POST("/reject", r.jidValidationMiddleware.ValidateNumberField(), r.callHandler.RejectCall)
}
}
diff --git a/pkg/whatsmeow/service/call_lifecycle.go b/pkg/whatsmeow/service/call_lifecycle.go
new file mode 100644
index 00000000..05f981ef
--- /dev/null
+++ b/pkg/whatsmeow/service/call_lifecycle.go
@@ -0,0 +1,21 @@
+package whatsmeow_service
+
+import "go.mau.fi/whatsmeow"
+
+// SetClientLifecycle injects the call coordinator without coupling the
+// WhatsApp service package to the call implementation.
+func (w *whatsmeowService) SetClientLifecycle(lifecycle ClientLifecycle) {
+ w.clientLifecycle = lifecycle
+}
+
+func (w whatsmeowService) attachCallClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool) {
+ if w.clientLifecycle != nil {
+ w.clientLifecycle.AttachClient(instanceID, client, prepareIncoming)
+ }
+}
+
+func (w whatsmeowService) detachCallClient(instanceID string) {
+ if w.clientLifecycle != nil {
+ w.clientLifecycle.DetachClient(instanceID)
+ }
+}
diff --git a/pkg/whatsmeow/service/whatsmeow.go b/pkg/whatsmeow/service/whatsmeow.go
index 366f0edb..f945d612 100644
--- a/pkg/whatsmeow/service/whatsmeow.go
+++ b/pkg/whatsmeow/service/whatsmeow.go
@@ -50,7 +50,13 @@ import (
"github.com/evolution-foundation/evolution-go/pkg/utils"
)
+type ClientLifecycle interface {
+ AttachClient(instanceID string, client *whatsmeow.Client, prepareIncoming bool)
+ DetachClient(instanceID string)
+}
+
type WhatsmeowService interface {
+ SetClientLifecycle(lifecycle ClientLifecycle)
StartClient(clientData *ClientData)
ConnectOnStartup(clientName string)
StartInstance(instanceId string) error
@@ -97,6 +103,7 @@ type whatsmeowService struct {
natsProducer producer_interfaces.Producer
loggerWrapper *logger_wrapper.LoggerManager
passkeyCeremony *ceremony.Store
+ clientLifecycle ClientLifecycle
}
type MyClient struct {
@@ -172,6 +179,7 @@ type ProxyConfig struct {
}
func (w whatsmeowService) ReconnectClient(instanceId string) error {
+ w.detachCallClient(instanceId)
w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Starting reconnection process - simulating restart", instanceId)
// Passo 1: Limpar conexão existente se houver
@@ -308,10 +316,11 @@ func (w whatsmeowService) StartClient(cd *ClientData) {
var deviceStore *store.Device
var err error
- if w.clientPointer[cd.Instance.Id] != nil {
- if w.clientPointer[cd.Instance.Id].IsConnected() {
+ if existing := w.clientPointer[cd.Instance.Id]; existing != nil {
+ if existing.IsConnected() {
return
}
+ w.detachCallClient(cd.Instance.Id)
}
var container *sqlstore.Container
@@ -502,6 +511,11 @@ func (w whatsmeowService) StartClient(cd *ClientData) {
// Armazena o MyClient no map para permitir atualizações posteriores
w.myClientPointer[cd.Instance.Id] = mycli
+ // Call monitoring starts with the WhatsApp client itself, before the
+ // connection can emit an incoming offer. Auto-reject instances keep only
+ // the public state tracker and do not decrypt/send preaccept.
+ w.attachCallClient(cd.Instance.Id, client, !cd.Instance.RejectCall)
+
if client.Store.ID != nil {
w.loggerWrapper.GetLogger(cd.Instance.Id).LogInfo("[%s] Already logged in with JID: %s", cd.Instance.Id, client.Store.ID.String())
err = client.Connect()
@@ -2756,6 +2770,7 @@ func (w whatsmeowService) UpdateInstanceAdvancedSettings(instanceId string) erro
}
func (w whatsmeowService) ClearInstanceCache(instanceId string, token string) error {
+ w.detachCallClient(instanceId)
w.loggerWrapper.GetLogger(instanceId).LogInfo("[%s] Clearing instance cache - Token: %s", instanceId, token)
// Limpar userInfoCache