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 throughffmpeg/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
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 theAudioSource 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.--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.AudioSource / AudioSink implementation from the example:
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 throughencoded_audio, added in PR #1050.
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 perBytes, 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).
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 addsencode.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 supplyVideoSource 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>).
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.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.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.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.
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:H.264 video plane
Unlike MLow, whatsapp-rust does not implement an H.264 encoder or decoder — the codec is owned by the consumer. Whatwacore 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.
Encryption
Call audio and video are end-to-end encrypted the WhatsApp way:- The call key arrives over the peer’s Signal session.
- 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.
- Media is protected with AES-128-CTR and authenticated with a 4-byte WARP MESSAGE-INTEGRITY tag (HMAC-SHA1).
- 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.
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 — enable the
voipfeature flag - Advanced: Signal Protocol — how call keys are derived from Signal sessions