> ## Documentation Index
> Fetch the complete documentation index at: https://whatsapp-rust.jlucaso.com/llms.txt
> Use this file to discover all available pages before exploring further.

# VoIP Calls

> Place and answer end-to-end encrypted 1:1 voice and video calls with whatsapp-rust

## Overview

whatsapp-rust supports end-to-end encrypted 1:1 voice and video calls that interoperate with the official WhatsApp app. The full audio media path is implemented in pure Rust — encoding, E2E-SRTP encryption, relay transport, decryption, and decoding — while you supply mic capture and speaker playout (see Audio I/O below).

Video is **codec-neutral**: you hand the library complete H.264 Annex-B access units and it owns signaling, RTP packetization/reassembly, E2E-SRTP encryption, relay transport, and PLI/FIR-driven keyframe recovery. Encoding, decoding, capture, and display stay outside the library — the bundled CLI example drives them through `ffmpeg`/`ffplay`.

<Note>
  Voice and video calling are behind the optional `voip` feature flag. The default build is entirely unaffected — none of the codec or relay dependencies are compiled unless you opt in.
</Note>

## Enabling the feature

<Warning>
  The `voip` feature landed on `main` with [PR #918](https://github.com/oxidezap/whatsapp-rust/pull/918) (audio), [PR #1024](https://github.com/oxidezap/whatsapp-rust/pull/1024) (video), and [PR #1050](https://github.com/oxidezap/whatsapp-rust/pull/1050) (encoded audio + native Opus, and the feature split below) and will be included in the next published release. Until then, depend on the git source:
</Warning>

<Warning>
  [PR #1051](https://github.com/oxidezap/whatsapp-rust/pull/1051) changed `CallHandle::accept_video`'s signature and the shape of `CallEvent::VideoStateChanged` to close a race where a cancelled or superseded video-upgrade request could still attach a camera. If you're upgrading from before this PR, see [Peer initiates](#video-io) below and the `VideoUpgradeToken` field on the event.
</Warning>

```toml theme={null}
[dependencies]
whatsapp-rust = { git = "https://github.com/oxidezap/whatsapp-rust", features = ["voip"] }
async-channel = "2"  # needed to implement AudioSource / AudioSink / VideoSource / VideoSink
tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] }
```

Once a release that includes `voip` is published, you can switch to the version form:

```toml theme={null}
[dependencies]
whatsapp-rust = { version = "0.7", features = ["voip"] }  # update when released
async-channel = "2"
tokio = { version = "1.48", features = ["macros", "rt-multi-thread"] }
```

`voip` is a backward-compatible aggregate for `voip-mlow` + `voip-libopus` — both codec adapters linked in. If you only need one codec, or you exchange codec packets with an external encoder/decoder, pick a narrower feature instead:

| Feature        | Contents                                                                                                                                                                                |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `voip-runtime` | Tokio driver, relay transport, DTLS/SCTP — no audio codec. Internal base feature; you'll normally reach for `voip-encoded` or a codec feature below instead of naming this one directly |
| `voip-encoded` | `voip-runtime` plus the encoded-audio boundary (`encoded_audio`, `AudioFormat`) — bring your own codec, nothing extra linked                                                            |
| `voip-mlow`    | `voip-encoded` plus the bundled pure-Rust MLOW adapter                                                                                                                                  |
| `voip-libopus` | `voip-encoded` plus the optional libopus FFI adapter                                                                                                                                    |
| `voip`         | `voip-mlow` + `voip-libopus` (the full, backward-compatible profile)                                                                                                                    |

See the full table, including default/publish status, in [Installation](/installation#feature-flags). An application that already produces raw Opus packets (FFmpeg, libopus, a hardware encoder, …) needs only `voip-encoded` — no codec dependency is compiled in. See [Audio codecs](#audio-codecs) below.

## Answering an Incoming Call

`Event::IncomingCall` fires for all call-control stanzas (offer, preaccept, accept, reject, terminate, transport, relaylatency). Guard on `CallAction::Offer` before calling `accept()` — otherwise non-offer events return `CallError::NotAnOffer`:

```rust theme={null}
use whatsapp_rust::client::Client;
use whatsapp_rust::types::events::Event;
use whatsapp_rust::wacore::types::call::CallAction;

async fn on_event(client: &Client, event: Event) -> anyhow::Result<()> {
    match event {
        Event::IncomingCall(incoming) => {
            // Borrow the action so `incoming` is not partially moved before `.accept(&incoming)`.
            if let CallAction::Offer { is_video, .. } = &incoming.action {
                let mut call = client.voip()
                    .accept(&incoming)
                    .audio(mic_source, speaker_sink);
                if *is_video {
                    // Required to answer a video-from-the-start offer.
                    call = call.video(camera_source, video_sink);
                }
                // If `is_video` was false, `start()` accepts audio-only — video can still be
                // added later via `CallHandle::start_video`.
                let handle = call.start().await?;

                // Resolves when either side hangs up
                handle.wait_ended().await;
            }
        }
        _ => {}
    }
    Ok(())
}
```

<Note>
  `accept(...).start()` drives the **media plane only** (callKey decrypt → relay connect → engine). Sending `<preaccept>` and `<accept>` signaling stanzas to the peer is the caller's responsibility and must happen before or alongside `start()`. See `examples/voip-cli/src/main.rs` in the repository for a complete incoming-call handler that includes signaling.
</Note>

## Placing an Outgoing Call

```rust theme={null}
use whatsapp_rust::types::Jid;

let peer: Jid = "15551234567@s.whatsapp.net".parse()?;

let handle = client.voip()
    .call(&peer)
    .audio(mic_source, speaker_sink)
    .video(camera_source, video_sink) // omit for an audio-only call
    .start()
    .await?;

// End the call from your side: sends <terminate> to the peer and tears down local media.
// CallHandle exposes call_id() / peer_jid() / call_creator() for exactly this.
client.voip()
    .terminate(handle.call_id(), &handle.peer_jid(), handle.call_creator())
    .await?;

// Or wait for the remote side to hang up
handle.wait_ended().await;
```

<Note>
  If the callee has a stored [trusted-contact token](/api/tctoken), it's attached to the offer automatically, and a fresh token is issued to them in the background afterward if the sender-side bucket has rolled over — matching WhatsApp Web's `sendTcToken` in `StartCall.js`. This prevents 463 nacks on calls to privacy-restricted contacts and needs no action from the caller. Group-call initiation doesn't implement this yet.
</Note>

## Audio I/O

You supply the audio I/O by implementing the `AudioSource` and `AudioSink` traits. Both traits are channel-based — the library reads from a `Receiver` and writes decoded PCM to a `Sender`. The bundled `examples/voip-cli/src/main.rs` wires up [cpal](https://crates.io/crates/cpal)/PipeWire as a reference.

<Note>
  This PCM path requires the `voip-mlow` feature (included in the default `voip` aggregate) — the engine encodes/decodes through the bundled MLOW codec. Built with only `voip-libopus` or plain `voip-encoded`, calling `.audio(...)` compiles but fails at call setup ("PCM MLOW audio requires the `voip-mlow` feature"). Use `.encoded_audio(...)` instead in that configuration — see [Audio codecs](#audio-codecs).
</Note>

The CLI exposes three subcommands, each accepting a trailing `--video`:

| Subcommand                  | Description                                                                                                                                                                                                                                                                                                                                                                                           |
| --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `loopback [--video]`        | Mic → Opus → E2E-SRTP protect/unprotect → Opus → speaker. No WhatsApp connection — hear yourself processed by the full VoIP stack. `--video` replaces this with a separate, video-only loopback: an ffmpeg source is looped straight into an ffplay window with no E2E-SRTP step, unlike the audio path — it's a pipeline check for the capture/encode/render plumbing, not the video RTP/SRTP plane. |
| `listen [accept] [--video]` | Connect to WhatsApp and print incoming calls. Rejects by default; pass `accept` to auto-answer. `--video` implies `accept` too (there's no point answering with video and then rejecting), and the accepted call answers with video media.                                                                                                                                                            |
| `call <jid> [--video]`      | Connect to WhatsApp and place an outgoing call to the given JID. With `--video` it's a video call from the start.                                                                                                                                                                                                                                                                                     |

During a live call, single-key stdin commands (terminal only) work regardless of how the call started: `v` toggles video — upgrades to video, accepts a pending peer upgrade request, or downgrades back to audio — and `q` performs a signaled hangup.

<Note>
  `listen accept --video` auto-accepts a peer's mid-call video-upgrade request even when the call itself started as audio-only — not just on calls that were video from the start.
</Note>

To test the audio stack locally without a WhatsApp session:

```bash theme={null}
cargo run -p whatsapp-rust-voip-cli -- loopback
```

The `AudioSource` / `AudioSink` implementation from the example:

```rust theme={null}
use whatsapp_rust::voip::{AudioSource, AudioSink};
use async_channel;

struct MyMic { receiver: async_channel::Receiver<Vec<i16>> }
struct MySpeaker { sender: async_channel::Sender<Vec<i16>> }

impl AudioSource for MyMic {
    // Frames are 16 kHz wideband PCM (60 ms = 960 samples per Vec)
    fn frames(&self) -> async_channel::Receiver<Vec<i16>> {
        self.receiver.clone()
    }
}

impl AudioSink for MySpeaker {
    fn playout(&self) -> async_channel::Sender<Vec<i16>> {
        self.sender.clone()
    }
}
```

<Tip>
  If you already have bare `async_channel` endpoints, you can pass them directly — `Receiver<Vec<i16>>` implements `AudioSource` and `Sender<Vec<i16>>` implements `AudioSink` out of the box.
</Tip>

<Tip>
  The library owns the call key, relay handshake, codec, and crypto. You only provide mic input and speaker output.
</Tip>

## Audio codecs

The PCM path above is the simplest way in: you hand over 16 kHz mono samples and the library runs its bundled MLOW codec. If you already produce or consume complete codec packets — from FFmpeg, libopus, a hardware encoder, or another Opus implementation — you can bypass MLOW and exchange raw packets through `encoded_audio`, added in [PR #1050](https://github.com/oxidezap/whatsapp-rust/pull/1050).

```text theme={null}
PCM source ── MLOW adapter ──┐
                             ├── RTP + SRTP/WARP ── WhatsApp relay
encoded source/sink ─────────┘
```

The encoded boundary does not transcode — `AudioFormat` fixes the codec profile, RTP payload type, RTP clock, and 60 ms packet cadence for the whole call.

### Profiles

| Format                                 | Codec             |         PCM | RTP clock / step |  PT |
| -------------------------------------- | ----------------- | ----------: | ---------------: | --: |
| `AudioFormat::MLOW_16KHZ_60MS`         | MLOW              | 16 kHz mono |     16 kHz / 960 | 120 |
| `AudioFormat::OPUS_MLOW_16KHZ_60MS`    | Opus CELT-in-MLOW | 16 kHz mono |     16 kHz / 960 | 120 |
| `AudioFormat::OPUS_16KHZ_60MS`         | Native Opus       | 16 kHz mono |     16 kHz / 960 | 120 |
| `AudioFormat::OPUS_RFC7587_16KHZ_60MS` | Native Opus       | 16 kHz mono |    48 kHz / 2880 | 111 |
| `AudioFormat::OPUS_RFC7587_48KHZ_60MS` | Native Opus       | 48 kHz mono |    48 kHz / 2880 | 111 |

All current profiles signal `<audio enc="opus" rate="16000">` — the signaled rate alone doesn't pick the RTP profile. PT120/16 kHz is the production default; PT111/48 kHz is an explicit variant.

### Encoded API

The source sends one complete raw codec packet per `Bytes`, paced every 60 ms. The sink receives the decrypted packet plus its RTP metadata:

```rust theme={null}
use whatsapp_rust::bytes::Bytes;
use whatsapp_rust::voip::{AudioFormat, EncodedAudioFrame};

let (encoded_tx, encoded_rx) = async_channel::bounded::<Bytes>(3);
let (playout_tx, playout_rx) = async_channel::bounded::<EncodedAudioFrame>(3);

let call = client
    .voip()
    .call(&peer)
    .encoded_audio(AudioFormat::OPUS_16KHZ_60MS, encoded_rx, playout_tx)
    .start()
    .await?;

// Feed your encoder's output in: encoded_tx.send(packet).await?; (one raw Opus packet per Bytes)
// Drain decoded frames for playback: while let Ok(frame) = playout_rx.recv().await { play(frame.data) }
```

`AcceptCall` (from `client.voip().accept(&incoming)`) has the same `.encoded_audio(format, source, sink)` builder method alongside `.audio(source, sink)`.

<Warning>
  Container data isn't accepted. Ogg pages from `ffmpeg -f opus` must be demuxed first, and FFmpeg RTP output must have its RTP header stripped — this core builds and protects the WhatsApp RTP packet itself. The engine advances the RTP clock by the selected `AudioFormat`'s fixed step on every `Bytes` it receives, so the encoder must actually be configured for that format's 60 ms frame duration — pushing a shorter-duration `AVPacket` (e.g. libavcodec's default 20 ms Opus frames) straight through desyncs timestamps from real audio content. Once the encoder is configured for 60 ms frames, libavcodec integrations can hand each raw `AVPacket` through unmodified.
</Warning>

<Note>
  `CallHandle::set_muted` only affects the built-in PCM path (`.audio(...)`) — it gates the mic feed before encoding. For `.encoded_audio(...)` calls, packets you push through `encoded_tx` are forwarded to the engine as-is; mute by pausing your own encoder or withholding packets, not by calling `set_muted`.
</Note>

`OPUS_MLOW_16KHZ_60MS` requires CELT-only Opus for packets you send: run each outbound packet through `packetize_opus_for_mlow` before pushing it to `encoded_tx`. On receive, a peer using this profile can still fall back to proprietary MLOW, so check each `EncodedAudioFrame`'s `codec` field first — only run `depacketize_opus_from_mlow` when `codec == AudioCodec::Opus`; frames with `codec == AudioCodec::Mlow` need an external MLOW decoder instead. Both packetize/depacketize helpers only rewrite the packet header — SILK/Hybrid Opus, or arbitrary Opus↔MLOW conversion, needs full decode/re-encode, which these helpers don't do.

### Negotiation

MLOW capability index 31 controls which codec the peer sends. A native-Opus answer clears that bit and adds `encode.use_mlow_codec_v1=false` to select the peer's decoder for the reverse direction — clearing the capability bit and setting `use_mlow_codec_v1=false` are both required together for full-duplex native Opus.

`options.enable_48khz_rtp_clock` is a separate, independent setting: `true` selects PT111/48 kHz, `false` (the default) selects PT120/16 kHz.

An incoming call rejects a locally selected rate that's absent from the peer's offer, and a later incompatible preaccept/accept emits `CallEvent::AudioFormatMismatch` and terminates the call. Codec detection never overrides the negotiated RTP profile.

### CLI reference

The bundled CLI (`examples/voip-cli`) picks its codec from an environment variable:

```bash theme={null}
WA_AUDIO_CODEC=mlow cargo run -p whatsapp-rust-voip-cli --release -- listen accept
WA_AUDIO_CODEC=opus cargo run -p whatsapp-rust-voip-cli --release -- listen accept
```

`WA_AUDIO_PROFILE` is read only when `WA_AUDIO_CODEC=opus` (it has no effect under the `mlow` default): `WA_AUDIO_PROFILE=pt111` selects the 48 kHz RTP variant instead of the PT120 default, and `WA_AUDIO_PROFILE=mlow` selects the CELT-in-MLOW escape — set both together:

```bash theme={null}
WA_AUDIO_CODEC=opus WA_AUDIO_PROFILE=pt111 cargo run -p whatsapp-rust-voip-cli --release -- listen accept
```

## Video I/O

Video works the same way, one layer up: you supply `VideoSource` and `VideoSink` implementations that hand the library complete **H.264 Annex-B access units** (start codes included). The library never touches pixels — encoding and decoding are entirely your consumer's responsibility (the CLI example shells out to `ffmpeg`/`ffplay`).

```rust theme={null}
use whatsapp_rust::voip::{VideoSource, VideoSink, VideoFrame};
use async_channel;

struct MyCamera { receiver: async_channel::Receiver<Vec<u8>> }
struct MyDisplay { sender: async_channel::Sender<VideoFrame> }

impl VideoSource for MyCamera {
    // Each item is one complete H.264 Annex-B access unit.
    fn frames(&self) -> async_channel::Receiver<Vec<u8>> {
        self.receiver.clone()
    }

    // RTP clock increment between access units (90_000 / fps). Must be non-zero;
    // defaults to the 15 fps cadence if not overridden.
    fn rtp_timestamp_stride(&self) -> u32 {
        90_000 / 20 // 20 fps
    }
}

impl VideoSink for MyDisplay {
    fn playout(&self) -> async_channel::Sender<VideoFrame> {
        self.sender.clone()
    }
}
```

`VideoFrame` carries the reassembled peer access unit plus `keyframe` (safe point to (re)start a decoder) and `orientation` (from `<video device_orientation>`).

<Tip>
  Bare `async_channel` endpoints work directly here too — `Receiver<Vec<u8>>` implements `VideoSource` (15 fps default stride) and `Sender<VideoFrame>` implements `VideoSink`.
</Tip>

You can supply video up front with `.video(source, sink)` on the call builder (from-start video), or start/stop it mid-call on the `CallHandle`. The two mid-call flows below are mutually exclusive — you're either the side initiating the upgrade or the side responding to one, never both for the same transition.

**We initiate** — upgrade an audio-only call to video. `start_video` sends the upgrade request and returns immediately; the peer's acceptance arrives later, asynchronously:

```rust theme={null}
handle.start_video(camera_source, video_sink).await?;
```

<Note>
  Once the peer accepts, `whatsapp-rust`'s `<call>` stanza handler sends the standalone `Enabled` stanza and ungates your local video plane automatically. No further call is required.

  `handle.events()` still emits `CallEvent::VideoStateChanged { state: VideoState::UpgradeAccept, .. }` as a notification. Use it to update your UI if needed.

  Don't call `handle.announce_video_enabled()` in response because that sends a redundant second `Enabled`. Only use that method when you drive call signaling outside the standard handler.
</Note>

**Peer initiates** — respond to a `CallEvent::VideoStateChanged { state, upgrade_token, .. }` event where `state` is `VideoState::UpgradeRequest` (legacy) or `VideoState::UpgradeRequestV2`. Pass the event's `upgrade_token` straight to `accept_video` — it binds the accept to that exact peer request so a cancelled or superseded upgrade can't attach your camera:

```rust theme={null}
CallEvent::VideoStateChanged { state, upgrade_token: Some(token), .. } if state.is_upgrade_request() => {
    // Sends both the accept and the Enabled stanza in one call — no extra step needed on this side.
    handle.accept_video(token, camera_source, video_sink).await?;
}
```

<Note>
  `upgrade_token` is `None` when the transition was already auto-resolved by the signaling state machine (e.g. simultaneous local and peer upgrade requests) — there's nothing to accept in that case. If you call `accept_video` with a token from an upgrade that has since been cancelled, superseded, or expired (see the 5-second timeout below), it returns `CallError::VideoUpgradeExpired` instead of attaching the camera.
</Note>

**Either side** — stop sending our own video:

```rust theme={null}
handle.stop_video().await?; // Idempotent.
```

<Note>
  `stop_video` only stops the video **we** send. If the peer keeps sending, their plane and `is_video` stay up — it's no longer a full downgrade to audio-only for the call. To end video in both directions, each side calls `stop_video()` independently, or one side hangs up.
</Note>

<Note>
  An upgrade request you send with `start_video` auto-cancels after 5 seconds if the peer hasn't answered: the library sends `<video state=9>` (`VideoState::UpgradeCancelByTimeout`) and releases the local camera source/sink it had prepared. This mirrors the native app's upgrade timeout and means `start_video` isn't guaranteed to stay pending indefinitely — watch `CallEvent::VideoStateChanged` if you need to know when a request you initiated expires unanswered.
</Note>

<Tip>
  `handle.events()` clones share one underlying queue — they're competing consumers, not a broadcast. If your app already drains events on one loop (for relay/RTCP/audio events, say), react to `VideoStateChanged` there rather than spawning a second `handle.events()` consumer, or the two loops will race for the same messages.
</Tip>

<Note>
  The engine treats an authenticated peer PLI/FIR as a decoder resync boundary: dependent access units are withheld (without losing RTP/SRTP sequence state) until the next IDR packetizes successfully, then transmission resumes. `stop_video`/disable purges queued, unstarted access units while preserving any batch already on the wire, so reactivation always resumes at a complete IDR.
</Note>

## Call Handle

`start()` returns a `CallHandle` for controlling an active call:

| Method                                                                                       | Description                                                                                                                                                                                                                                                          |
| -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `handle.wait_ended().await`                                                                  | Resolves when either side hangs up                                                                                                                                                                                                                                   |
| `handle.hangup().await`                                                                      | Tears down local media only — **no peer signaling**                                                                                                                                                                                                                  |
| `client.voip().terminate(handle.call_id(), &handle.peer_jid(), handle.call_creator()).await` | Sends `<terminate>` to the peer **and** tears down local media                                                                                                                                                                                                       |
| `handle.call_id()`                                                                           | The call ID (needed for `terminate`)                                                                                                                                                                                                                                 |
| `handle.peer_jid()`                                                                          | The peer's JID (needed for `terminate`)                                                                                                                                                                                                                              |
| `handle.call_creator()`                                                                      | The call creator's JID (needed for `terminate`)                                                                                                                                                                                                                      |
| `handle.set_muted(true)`                                                                     | Mute or unmute the local microphone                                                                                                                                                                                                                                  |
| `handle.start_video(source, sink).await`                                                     | Upgrade to video (we initiate); sends the video-upgrade offer and returns immediately. The media plane enables once the peer accepts — see Video I/O above. Auto-cancels after 5s if unanswered                                                                      |
| `handle.accept_video(token, source, sink).await`                                             | Accept the peer's pending video-upgrade request. `token` is the `VideoUpgradeToken` from that request's `CallEvent::VideoStateChanged`; a stale token returns `CallError::VideoUpgradeExpired`                                                                       |
| `handle.announce_video_enabled().await`                                                      | Send the standalone `Enabled` stanza that completes a `start_video` upgrade. `whatsapp-rust`'s standard `<call>` handler already does this automatically on the peer's `UpgradeAccept` — only call it yourself if you're driving call signaling outside that handler |
| `handle.stop_video().await`                                                                  | Stop our own outbound video; idempotent. The peer's video plane is unaffected if they keep sending — see Video I/O above                                                                                                                                             |
| `handle.events()`                                                                            | Subscribe to engine events (relay allocate, audio/video state changes, RTCP, failures)                                                                                                                                                                               |

## Multi-Device Behavior

The library handles multi-device call scenarios automatically:

* **Companion answering** — if another linked device picks up, the library performs a recv-key rekey to that device.
* **Sibling dismiss** — if a sibling device declines or answers elsewhere, the call tears down cleanly on this device.
* **Offline missed calls** — missed-call surfacing for devices that were offline when the call arrived.

## Architecture: `CallEngine`

The core `CallEngine` lives in `wacore` and is **sans-IO** — it owns no socket, clock, or thread. You feed it relay packets, mic frames, camera access units, and timer ticks; it emits transmit packets, playout PCM/video frames, call events, and the next deadline.

<Warning>
  **Platform support depends on which crate and feature you use:**

  * `wacore` with `features = ["voip"]` — pure Rust (H.264 RTP media plane, SRTP crypto, encoded-audio boundary, `CallEngine`). No FFI, no bundled audio codec. Compiles to WASM and embedded targets (esp32).
  * `wacore` with `features = ["voip-mlow"]` — adds the pure-Rust MLOW codec on top of `voip`. Still no FFI; still compiles to WASM/esp32.
  * `whatsapp-rust` with `features = ["voip-encoded"]` — adds the Tokio async driver and webrtc-rs (DTLS/SCTP), no audio codec linked. **Does not compile on `wasm32` or `espidf`** — a `compile_error!` enforces this at build time.
  * `whatsapp-rust` with `features = ["voip-mlow"]` / `["voip-libopus"]` / `["voip"]` — `voip-encoded` plus the corresponding codec adapter(s). Same platform restriction as `voip-encoded`.
</Warning>

| Target                   | Supported crate                                                                                                                                                               |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tokio (servers, desktop) | `whatsapp-rust` with `voip`, or a narrower `voip-*` feature — see [Enabling the feature](#enabling-the-feature)                                                               |
| WebAssembly (browser)    | `wacore` with `voip` (+ `voip-mlow` for the bundled codec) and `js` (sans-IO only; `js` enables WASM-compatible random number generation — see [Installation](/installation)) |
| Embedded (esp32)         | `wacore` with `voip` (+ `voip-mlow` for the bundled codec), sans-IO only                                                                                                      |

## MLow Codec

WhatsApp's voice codec ("MLow") is a heavily modified Opus variant. whatsapp-rust includes a **pure Rust** port — no FFI, no C library. It compiles to WASM and embedded targets alongside the rest of the crate and is pinned by a byte-exact golden roundtrip test. It's one of several codec profiles the engine supports; see [Audio codecs](#audio-codecs) for the full list, native Opus, and the encoded-packet bypass.

The decoder operates at a single fixed point:

| Parameter       | Value                          |
| --------------- | ------------------------------ |
| Sample rate     | 16 kHz wideband                |
| Frame duration  | 60 ms (960 samples)            |
| Off-spec frames | Dropped (fail-loud, no desync) |

<Tip>
  MLOW is broadly compatible and pure Rust, but its analysis-by-synthesis encoder costs more CPU than native Opus. Native Opus avoids MLOW transcoding but a peer may still send proprietary MLOW, so an Opus-only application still needs an external MLOW decoder for that fallback (see `voip-mlow`'s `OPUS_MLOW_16KHZ_60MS` escape in [Audio codecs](#audio-codecs)).
</Tip>

## H.264 video plane

Unlike MLow, whatsapp-rust does **not** implement an H.264 encoder or decoder — the codec is owned by the consumer. What `wacore` does implement, in pure Rust, is the RTP media plane around it:

* **Packetization**: single-NAL and FU-A (fragmented) packet formats.
* **Reassembly**: single-NAL, STAP-A, and FU-A on receive, with sequence-aware fragment-loss handling and allocation caps.
* **Keyframe recovery**: an authenticated peer PLI/FIR is treated as a decoder resync boundary — dependent access units are withheld until the next IDR packetizes successfully, matching what the official app expects for recovery.
* **Backpressure**: a complete access unit is the unit of backpressure, so overload can't leave half an IDR on the wire.

WhatsApp's own encoder settings (for interop reference, not enforced by the library): H.264 Constrained Baseline, repeated SPS/PPS, adapting from a 15 fps low-bandwidth mode up to 1280×720 @ 20 fps / \~2 Mbps.

## Encryption

Call audio and video are end-to-end encrypted the WhatsApp way:

1. The call key arrives over the peer's Signal session.
2. E2E-SRTP keys are derived with HKDF + the libsrtp AES-CM KDF, per participant — the same master keys protect both the audio and video pipelines for that peer; only the SSRC, sequence number, and ROC state are kept separate per media stream.
3. Media is protected with AES-128-CTR and authenticated with a 4-byte WARP MESSAGE-INTEGRITY tag (HMAC-SHA1).
4. On the audio path only, an optional SFrame layer wraps inbound payloads the engine won't decode itself (e.g. a peer that sent GCM-wrapped Opus instead of MLow); video has no SFrame step.

On receive, the ROC is estimated per-packet via RFC 3711's guess-index, then the WARP tag is verified in constant time against that estimate — *before* the ROC is advanced. Committing an unauthenticated packet's index would let an on-path relay desync the receiver's keystream with just a couple of forged packets. A packet that fails authentication is rejected outright and never advances the ROC state, so it can't cause a persistent decode failure for subsequent legitimate frames. Per-sender SRTCP replay windows apply the same authenticate-before-commit rule to RTCP.

The relay never sees plaintext audio or video.

## Validation

The implementation is tested at multiple levels:

* **Byte-exact golden roundtrip** for the MLow codec
* **Known-answer-test vectors** for the E2E-SRTP crypto and the H.264 packetization/reassembly/recovery paths
* **In-tree loopback** DTLS/SCTP transport E2E test
* **Live tested** end-to-end against the real WhatsApp app over 3G/WiFi/5G, including outbound video from a real V4L2 webcam

## Roadmap

1:1 audio and video are the foundation. Natural follow-ups tracked upstream:

* **Group calls** — the signaling and SFrame/SRTP key management generalize to multi-party; the engine is already participant-aware.
* **Deeper codec coverage** — inband FEC, PLC, CNG, and low-bitrate operating points for audio.
* **Embedded demo** — the sans-IO core already builds for esp32.

## Next Steps

* [Installation](/installation) — enable the `voip` feature flag
* [Advanced: Signal Protocol](/advanced/signal-protocol) — how call keys are derived from Signal sessions
