# Call audio — implementation & usage guide (validated path) > Status: **authoritative implementation guide** for real two-way call audio. This is the > path validated in production on the sibling project (funnelchat-api) and confirmed by the > meowcaller author (Rajeh / purpshell). It **supersedes the WASM sidecar bridge** in > `whatsapp-web-call-integration.md` §3 for the audio path — the sidecar > (`wazmeow-voip-sidecar`, `@w3nder/whatsapp-voip-wasm`) is a `v0.1.0` scaffold that ships > placeholder SDP and is **not** the recommended way to get audio working. ## TL;DR wazmeow already has the **Go-native** call stack that actually carries media: `voip.Manager` (Dial/Answer/Reject/Hangup/Record/Status) + the vendored `go.mau.fi/whatsmeow/calls` engine + a **bidirectional PCM WebSocket** at `GET /call/{call_id}/stream` (`CallMediaStream`, `routes.go:293`). That socket carries **s16le 16 kHz mono PCM** both ways (`voip.FramesToS16LE` out, `voip.S16LEToFrames` in) — the exact substrate the engine decodes. The author's diagnosis of "no audio in the browser": **the media path is correct** (attach a `WAVRecorder` and the saved `.wav` is perfect) — the gap is **browser playback (Web Audio API)**. So the fix is **not** WebRTC and **not** the sidecar; it is: > **Stream the raw PCM over `/call/{id}/stream` and play it in the browser through an > AudioWorklet** (and capture the mic the same way). — meowcaller author. What is **missing** in wazmeow to make calls work end to end is listed in §2; the implementation recipe is §3; operator usage is §4. ## 1. Architecture (the validated path) ``` (control: dial/answer/reject/hangup/mute, token: ) Operator browser ──HTTPS──> wazmeow REST ──> voip.Manager ──> calls engine ──> WhatsApp relay │ │ (SRTP/MLow, 16 kHz) │ bidirectional s16le 16 kHz PCM │ └──────WSS /call/{call_id}/stream───────> wsAudioSink/Source ─┘ ▲ AudioWorklet plays inbound PCM ▼ AudioWorklet captures mic PCM ``` - **Control** = the existing REST endpoints (token header auth). - **Media** = the existing `/call/{call_id}/stream` WS. Inbound peer audio is written to the socket as s16le; the browser plays it via an AudioWorklet jitter buffer. The browser sends mic PCM (s16le 16 kHz) back; the engine plays it to the peer. - **Audio only flows after the call is answered** (outbound: the callee picks up; inbound: the operator answers). Before that there is no relay and therefore no audio — that is normal. ## 2. Gap audit — what is missing (vs. the production-proven funnelchat-api) | # | Gap | Where (wazmeow) | Symptom | Fix (mirror funnelchat-api) | |---|-----|-------------------|---------|------------------------------| | 1 | **Engine has no logger** | `call_engine.go` `attachCallEngine` → `calls.NewClient(mycli.WAClient)` (no `WithLogger`) | media path runs blind; no `subsystem:calls` log to debug offer/relay/SRTP/media | pass `calls.WithLogger(zerolog logger)`; gate level via env (e.g. `VOIP_LOG_LEVEL`, default info) | | 2 | **Engine not (re)attached on enable** | `attachCallEngine` runs only at connect; no reattach on restart | enabling calls on a running session leaves `callMgr` nil → dial 409 / "engine not enabled" | add a `reattachCallEngine` (teardown + attach on the live client) and call it from the restart/reconnect path | | 3 | **callId casing mismatch** | `voip/manager.go` map keyed raw: `m.calls[call.ID()]` (370), `m.calls[callID]` (284/378) | inbound webhook delivers UPPERCASE callId; `/answer`,`/{id}/stream`,etc. 404 ("rings but cannot answer") | normalize the map key + lookups with `strings.ToUpper` (a `callKey` helper) and return the normalized id from `Dial`/`Status` | | 4 | **No packet-loss concealment** | vendored `calls/engine_media.go` recv loop drops on `UnprotectAudio` fail | a lost/reordered packet is a hard audio gap | on unprotect-fail write a silence frame (`dec.Decode(nil)`) instead of `continue` (LOCAL DIVERGENCE comment) | | 5 | **No browser audio client** | `manager-wazmeow` has no call UI/audio; docs point at the WASM sidecar placeholder | the `/stream` WS exists server-side but nothing in the browser plays/streams PCM | implement the AudioWorklet PCM client (§3) against `/call/{call_id}/stream` | | 6 | **`/stream` WS browser auth** | `/call/{call_id}/stream` likely requires the `token:` header (middleware chain `c`) | browsers cannot set WS headers → cannot connect | accept the user token as a **query param** (`?token=`) on the `/stream` route (in addition to the header), or mint a short-lived scoped token | Items 1–4 are the same fixes already shipped to funnelchat-api (commits `9f13eedb` logger + reattach + callId, `95038dae` PLC). Item 5 is the AudioWorklet client (funnelchat-api `e4140210`). Item 6 is a small auth adjustment specific to wazmeow's header-based auth. ## 3. Implementation recipe ### 3.1 Backend (Go) — apply the four fixes 1. **Logger** — in `attachCallEngine`, build a `zerolog.Logger` (JSON to stderr, level from `VOIP_LOG_LEVEL`, default `info`) and pass `calls.WithLogger(logger)` to `calls.NewClient`. This surfaces: `offer sent`, `relay silent after allocate`, `RTP arrived but failed to unprotect`, `starting media`, `first RTP decoded from relay`, `selected audio codec`. 2. **Reattach** — add `reattachCallEngine` that, under the client lock, calls `teardownCallEngine` then `attachCallEngine` on the **existing** client, and invoke it whenever calls config is toggled or on `/restart` (otherwise a paired session needs a full process restart to pick up `calls_enabled`). 3. **callId** — add `func callKey(id string) string { return strings.ToUpper(id) }` and use it in `voip/manager.go` for `track`/`lookup`/`delete`/`StartRecording`/`Status` and the `Dial` return value, so the REST/WS id space matches the upper-cased webhook callId. 4. **PLC** — in the vendored `calls/engine_media.go` recv loop, on `UnprotectAudio` failure write `dec.Decode(nil)` (960-sample silence) to the sink instead of `continue` (keep a `LOCAL DIVERGENCE` comment — this file is synced from upstream/meowcaller). ### 3.2 Backend — allow query-param token on the stream WS `/call/{call_id}/stream` is bidirectional **s16le 16 kHz PCM** already. Make it browser-reachable by accepting `?token=` in addition to the `token:` header in the middleware/handler (browsers cannot set WS headers). No protocol change — same binary frames. ### 3.3 Browser (manager-wazmeow) — the AudioWorklet PCM client Mirror the funnelchat-api implementation (frontend-only): - `public/pcm-player-worklet.js` — jitter-buffered PCM playout (≈180 ms prebuffer, silence on underrun, bounded buffer). Inbound peer audio. - `public/pcm-recorder-worklet.js` — captures the mic per render quantum, posts frames to the main thread; emits silence when muted. - `lib/calls/pcm-stream-client.ts` — `AudioContext({ sampleRate: 16000 })` (linear-resampler fallback if the browser overrides the rate), `addModule` both worklets, `getUserMedia`, connect `WSS …/call/{id}/stream?token=…`, bridge: WS binary in → `s16leToFloat` → player; recorder frames → accumulate 960 samples → `floatToS16LE` → `ws.send`. Expose `setMuted`, `close`. - Wire into the call UI: on answer/dial, call `startPcmStream(callId)`; the worklet plays inbound and streams the mic — works on any page if mounted in a global provider. Frame = **960 samples (60 ms) @ 16 kHz mono**; s16le frame = 1920 bytes (matches `calls.FrameSamples` and `voip.FramesToS16LE`/`S16LEToFrames`). ## 4. How to use it (operator flow) 1. **Enable calls** for the user/session: `calls_enabled = true`, `call_inbound_mode = manual` (manual is required for the browser to drive audio — other modes let the engine answer server-side with no browser audio), then **reattach/reconnect** so the engine attaches (fix #2). Optionally point the call webhook at your app so inbound offers ring. 2. **Outbound:** `POST /call/dial {to}` → open the browser AudioWorklet stream against `/call/{call_id}/stream` → **the callee must answer** → two-way audio. 3. **Inbound:** the `v1.call.*` webhook / offer event rings the operator UI → `POST /call/answer` → open the stream → two-way audio. 4. **Mute** stops feeding mic PCM to the socket; **Hangup** tears down the call + the socket. ### Live debugging The engine logs only during a call. With the logger (fix #1) at `VOIP_LOG_LEVEL=debug`, watch: ``` kubectl logs deploy/ | grep -iE \ 'subsystem.:.calls|selected audio codec|relay silent|first RTP decoded|failed to unprotect|starting media|offer sent|inbound call|/call/.*/stream' ``` - `offer sent` then nothing → callee never answered (no relay) — not a bug, answer the call. - `starting media` + `first RTP decoded from relay` → audio is flowing in the backend; if the browser is still silent it's the Web Audio side (worklet not loaded / AudioContext suspended / WS not connected). - `failed to unprotect` recurring → keying/ROC/jitter; PLC (fix #4) keeps cadence. ## 5. Why not the WASM sidecar `whatsapp-web-call-integration.md` §3 proxies SDP/ICE to `wazmeow-voip-sidecar` (`@w3nder/whatsapp-voip-wasm`). That repo is a **scaffold (v0.1.0) shipping placeholder SDP** — it is not a working media path today, and it duplicates what the Go-native engine + `/stream` WS already do. For real audio, use the validated path above. Keep the sidecar endpoints only if a separate, non-meowcaller media backend is genuinely required. ## 6. Video calls (H.264 Annex-B) The same native engine supports **video** via two additional surfaces: - `POST /call/dial {phone, video:true}` — the offer advertises H.264 video capability. - `GET /call/{call_id}/video/stream` — bidirectional **H.264 Annex-B access units** over WebSocket. The engine forwards peer video to the client and client video to the peer. It does **not** encode/decode pixels; the browser/client must produce and consume H.264. - `GET /call/{call_id}/video/state` — Server-Sent Events for peer camera on/off, audio→video upgrade, and orientation. Implementation notes: - Open the video socket **in addition to** the PCM audio socket. - Send an IDR/keyframe immediately after connecting and at regular GOP intervals. - For browser clients, encode camera frames to H.264 (e.g. via WebCodecs `VideoEncoder`) and decode inbound Annex-B units with `VideoDecoder` before rendering to a `` or `