Skip to main content

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.
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.

Enabling the feature

The voip feature landed on main with PR #918 (audio), PR #1024 (video), and PR #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:
PR #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 below and the VideoUpgradeToken field on the event.
Once a release that includes voip is published, you can switch to the version form:
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: See the full table, including default/publish status, in Installation. 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 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:
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.

Placing an Outgoing Call

If the callee has a stored trusted-contact token, 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.

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/PipeWire as a reference.
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.
The CLI exposes three subcommands, each accepting a trailing --video: 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.
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.
To test the audio stack locally without a WhatsApp session:
The AudioSource / AudioSink implementation from the example:
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.
The library owns the call key, relay handshake, codec, and crypto. You only provide mic input and speaker output.

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.
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

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:
AcceptCall (from client.voip().accept(&incoming)) has the same .encoded_audio(format, source, sink) builder method alongside .audio(source, sink).
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.
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.
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:
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:

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).
VideoFrame carries the reassembled peer access unit plus keyframe (safe point to (re)start a decoder) and orientation (from <video device_orientation>).
Bare async_channel endpoints work directly here too — Receiver<Vec<u8>> implements VideoSource (15 fps default stride) and Sender<VideoFrame> implements VideoSink.
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:
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.
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:
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.
Either side — stop sending our own video:
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.
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.
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.
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.

Call Handle

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

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.
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.

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 for the full list, native Opus, and the encoded-packet bypass. The decoder operates at a single fixed point:
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).

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