Skip to main content

Overview

whatsapp-rust supports end-to-end encrypted voice and video calls that interoperate with the official WhatsApp app — 1:1 calls, ad-hoc and group-bound group calls, and reusable call links with waiting rooms. 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.
PR #1130 added group calls and call links on top of the 1:1 flow below — no separate feature flag, and no change to the 1:1 API. See Group calls and Call links.
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 ships in the published crate as of 0.7.0 — no git source needed:
AudioSource / AudioSink / VideoSource / VideoSink are channel-based, and the examples below build those channels through whatsapp_rust::async_channel — the re-export, so you don’t have to add or version-pin async-channel yourself.
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.
PR #1351 fixed CallHandle::hangup() and CallHandle::set_muted() silently never reaching the peer. hangup() is renamed to hangup_local(), still local-only and silent. A new terminate() sends <terminate> before tearing down. Replace handle.hangup().await with handle.terminate().await to end a live call, or with handle.hangup_local().await if the call is already over for the peer. set_muted() is now async and returns Result<(), CallError>, so add .await? at call sites — it announces the new mute state over the wire instead of only flipping a local flag. See Call Handle and Call Termination below.
PR #1111 fixed a codec-selection bug where a peer outside the use_mlow_codec_v1 rollout could go completely silent with nothing logged: RTP payload type 120 carries either MLow or native Opus, and the engine used to trust the payload type alone instead of the negotiation. Codec choice is now read from the peer’s negotiated capability, with a content probe corroborating only where negotiation can’t tell, and a call with no viable decoder now reports CallEvent::AudioSilent { dominant_reason: AudioSilenceReason::NoDecoderForNegotiatedCodec, .. } instead of just going quiet. See Negotiation and Media stats and silence detection below.
PR #1385 added CallHandle::request_peer_keyframe(urgency), so a 1:1 call can ask the peer to recover a dropped inbound access unit instead of waiting on its next scheduled keyframe — see H.264 video plane below.
voip is a backward-compatible aggregate for voip-mlow + voip-libopus + voip-relay-native — both codec adapters plus the native relay dialer, linked in. If you only need one codec, exchange codec packets with an external encoder/decoder, or supply your own way onto the media wire, pick narrower features 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.
PR #1364 split the former voip-runtime in two. If you depend on voip-mlow, voip-libopus, or voip-encoded directly — rather than the voip aggregate — your build now needs voip-relay-native added explicitly to keep the built-in UDP/DTLS/SCTP relay dialer. Without it, those features still compile fine; every call just fails at setup with CallEvent::MediaSetupFailed("no relay media transport...") unless you install your own transport with Client::set_relay_transport_provider. voip itself already included the native dialer and is unaffected.

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:
PR #1128 moved callee signaling into accept(...).start() itself. Before this PR, start() drove only the media plane and the application had to send <preaccept>/<accept> itself; see the note below for current behavior.
This is the 1:1 path only. A group offer’s CallAction::Offer also carries group_jid: Some(...) and would otherwise be caught by the same is_video, .. match below — guard on group_jid: None here and dispatch Some(...) to Receiving a group call invite instead.
accept(...).start() drives the full callee flow: it sends <preaccept>, decrypts the offer’s callKey, sends <accept>, then connects the relay and spawns the engine — in that order. You don’t build or send either signaling stanza yourself anymore. This is deliberately not automatic preaccept-on-offer: no <preaccept> or <accept> call-signaling stanza is sent until your application calls accept(...).start(), so ringing UI, do-not-disturb, and other answer policy stay entirely under your control. (The router still auto-acks the incoming offer stanza itself — see Events — that’s transport-level acknowledgment, not call-signaling.)
start() can fail with two errors specific to this flow. CallError::VideoNotOffered is returned if you call .video(...) but the incoming offer’s is_video was false — add video later with CallHandle::start_video instead (see Video I/O). CallError::CallEndedDuringSetup is returned if the peer terminates or supersedes the call while start() is still decrypting the callKey or connecting the relay.

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 calls handle.terminate(), warning if only part of a multi-device fan-out was confirmed.
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’s local mic-gating only applies to the built-in PCM path (.audio(...)) — for .encoded_audio(...) calls, packets you push through encoded_tx are still forwarded to the engine as-is, so silence your own source by pausing your encoder or withholding packets. But set_muted also announces the new state to the peer (await-ed, returns Result<(), CallError>) regardless of audio path, so encoded-audio callers should still call it alongside gating their encoder — otherwise the peer never learns the mic went quiet.
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

RTP payload type 120 carries either codec. It’s shared by MLow and native Opus, so payload type alone never tells you which one a packet is. Codec choice is read from the peer’s negotiated capability instead. MLOW capability index 31 (use_mlow_codec_v1) drives the decision, read through wacore::stanza::call::capability_bit. It returns one of three CapabilityBit states for a peer’s <capability> blob — Set, Clear, or Unknown — but Clear covers two different peer conditions below, so the table has four rows for three states. Set and Clear are handled in opposite directions: Applying the decision needs no mutable AudioFormat. The callee learns the peer’s capability from the <offer>, before the engine exists, so the call simply starts on the right codec. The caller learns it later, from <preaccept>/<accept>, riding the same channel that carries the answering device’s LID. Both are meant to land before the first inbound packet, but that’s not guaranteed on the caller side. See Content as a corroborator below for what covers the race being lost. Mid-call, MLOW_16KHZ_60MS and OPUS_16KHZ_60MS agree on payload type, clock rate, timestamp step, sample rate, channels and samples per frame, so switching between them changes no RTP header byte.
Group calls don’t implement the mutual-AND walk yet — the engine reads one participant’s capability, not every participant’s like the 1:1 path does. A group call currently stays on MLow regardless of whether a participant is outside the rollout. If that participant actually speaks native Opus, this is the same silent-audio failure PR #1111 fixes for 1:1 calls, just unaddressed for groups so far. GroupCallDevice::capability() is public for when that gets implemented.
A native-Opus answer clears capability bit 31 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.

Content as a corroborator

Negotiation has two blind spots on receive. The capability can be missing from signaling entirely (our own video <accept> omits it). Or, on the caller side, it can lose the race with the first inbound packet. codec_probe covers both cases, and only those cases — it never reads the payload alone, and it never runs when negotiation already answered the question. It checks two things together, and both have to hold:
  1. Does the peer’s Opus header agree with its own RTP timestamps? Specifically, whether the duration the header declares — read structurally per RFC 6716, with no libopus, so this also works on wasm32/ESP32 — matches the step the timestamps actually advance by.
  2. Does that step match the call’s negotiated cadence? This second check isn’t redundant: MLow and Opus SILK read the same TOC bits as different duration sets at 10 ms/20 ms, so the two would agree by construction at those durations even for a genuine MLow stream. Requiring the negotiated cadence too is what keeps a real MLow call from being misread as Opus.
Three consecutive agreements switch the decoder once, and emit CallEvent::AudioCodecSwitched { source: CodecDecisionSource::Content, .. }. Worth acting on: it means what this call believed about the peer’s codec — from a real capability read, or from the default assumed when one of the two blind spots above applied — didn’t match what’s actually arriving, not just that one call got rescued.

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:

Media stats and silence detection

PR #1111 added a per-call counters surface because every discard on the audio receive path used to be silent — a wrong payload type, an SRTP tag that didn’t verify, a frame the decoder refused, a jitter buffer trimming its own head all returned with nothing logged, so a call carrying no audio and a call where nobody was talking looked identical. handle.media_stats() returns a wacore::voip::CallMediaStats (#[non_exhaustive]) snapshot of named counters — rtp_received, srtp_unprotect_failed, audio_frames_decoded, mlow_off_point_dropped, playout_trimmed_samples, and others, one per discard reason. CallMediaStats::audio_produced() sums the three “a consumer actually got a frame” counters (audio_frames_decoded, audio_frames_delivered, foreign_frames_decoded) regardless of which audio path the call uses. This is a separate, per-call surface from the app-wide wa_* metrics and tracing spans — it dies with the call and isn’t in Client::stats(). PR #1385 added the video-side counterparts: video_sink_dropped (a reassembled access unit your VideoSink refused, mirroring audio_sink_dropped) and peer_keyframe_requests (RTCP PLIs that reached the outbox after the throttle — the engine sends these on its own initiative as well as yours, so a count is the only way to tell a recovering call from one asking in a loop). Two events on handle.events() turn the counters into an alarm: They’re deliberately distinct. AudioSilent means audio RTP is still arriving but nothing is becoming sound. dominant_reason names why, and it isn’t only a codec problem: CodecRejectingFrames, CodecFlapping, and NoDecoderForNegotiatedCodec are codec issues, AuthenticationFailing is an SRTP/keying issue, and UnexpectedPayloadType is neither — it means packets are arriving on a payload type outside the negotiated profile, a signaling/profile mismatch. AudioReceptionStalled means audio RTP has stopped arriving at all — that one’s always transport. Conflating “nothing arriving” with “arriving but not decoding” is exactly how issue #1105 stayed open for months before this PR. Neither is terminal — both are diagnostic, so treat them as something to log or alert on, not to react to inside the call. wacore::voip::PacketTap — a trait that observes every relay datagram in both directions — is now public, and TappedFactory decorates a RelayTransportFactory with one. The runtime doesn’t yet expose a factory-injection point for a live CallHandle, so it’s reachable today from a shell building its own transport, not from the builder API above.

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>).
PR #1355 added IncomingCall::video_orientation (see Events). A video-from-start party announces its rotation once, on whichever call-signaling stanza it sent. As a callee, that’s the peer’s Offer, which arrives before any media plane exists to race it. As a caller, it’s the peer’s Accept, riding the same signaling channel as the codec capability in Negotiation below — meant to land before the first inbound packet, but not guaranteed to on the caller side. Either way, read the value off that Event::IncomingCall as soon as it arrives, rather than waiting for the first frame.
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:

Call Termination

handle.terminate().await returns CallTermination (#[non_exhaustive]) instead of a Result — the local side is always down by the time it resolves, so the variants only describe how much the peer learned: outcome.peer_notified() returns true only for PeerNotified; match explicitly on PartlyNotified and LocalOnly to tell a partial fan-out apart from a fully unconfirmed termination — see the table above for what to do with each.

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.

Group calls

whatsapp-rust supports two ways to place a group call, both returning the same CallHandle as a 1:1 call and taking the same .audio(...) / .encoded_audio(...) / .video(...) builder methods described above:
A group call’s connected roster is capped at GROUP_CALL_MAX_PARTICIPANTS (32, including yourself) — group_call/group_call_by_id reject a remote-target count outside 2..=31 with CallError::Setup. Targets must be unique and cannot include yourself.

Receiving a group call invite

Event::IncomingCall fires the same way as for 1:1 calls (see Answering an Incoming Call), but a group offer’s CallAction::Offer carries group_jid: Some(...), and IncomingCall::group carries the roster snapshot embedded in the invitation. Signaling and media attach are two independent steps for a group call:

Promoting a 1:1 call to a group call

There’s no separate “promote” call — invite or ring another user directly on an active CallHandle from a 1:1 call, and it becomes an ad-hoc group call in place. handle.group_state() starts returning Some(...) once the promotion completes:

Reading group state

handle.group_state() -> Option<GroupCallState> returns the latest transaction-ordered roster/relay snapshot (None on a plain 1:1 call):
Call links are reusable, shareable URLs (https://call.whatsapp.com/<audio|video>/<token>) that don’t require a prior invite. Creating and previewing a link are direct calls on client.voip(); joining one uses the same builder shape as group_call/group_call_by_id:
preview_call_link, call_link(...), and join_call_link all take a token_or_url: &str — the bare link.token and the full link.url() are interchangeable.
call_link(...)’s media must match how you attach: a CallLinkMedia::Video link requires .video(...), and a CallLinkMedia::Audio link rejects .video(...) with CallError::Media. The signaling rate for .audio(...)/.encoded_audio(...) must be 16000 Hz — see Audio codecs.
client.voip().join_call_link(...) is the lower-level counterpart of call_link(...).start() — it performs admission (or enters the waiting room) and returns a CallLinkJoin without attaching media. Most applications want the builder instead.

Waiting rooms

A call link can require approval before letting someone in. handle.group_state().and_then(|s| s.waiting_room()) returns the current waiting room — is_admin, whether approval is enabled, and the pending users (each with a jid and a state string) — and CallEvent::WaitingRoomUpdated fires whenever it changes. If you’re the link’s admin, CallHandle exposes:
All three return CallError::Media("waiting-room control requires an administrator") if you’re not the admin for the call’s current generation.
A call link’s waiting room can hold up to 128 pending users — a separate, larger cap from the 32-participant connected roster above.

Reactions, hand raising and screen sharing

Available on any group-call CallHandle (ad-hoc, group-bound, or call-link):
start_screen_share reuses your call’s active local video plane — it requires a video group call that’s already sending video, and fails with CallError::Media("screen sharing requires an active local video plane") otherwise.
Other participants’ state surfaces on handle.events(), alongside the 1:1 CallEvent variants already covered in Call Handle (RelayAllocated, VideoStateChanged, RTCP, failures, …), which fire the same way in a group call:
Group calls and call links don’t yet expose a mute-participant, remove-participant, or end-call-for-everyone API — waiting-room admission above is the only administrator control shipped so far.

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.
PR #1364 split what used to be one voip-runtime feature in two. Call signaling, the facade, and CallEngine orchestration were portable all along — only the native UDP/DTLS/SCTP relay dialer actually needed a socket, and it is voip-relay-native now. Everything else builds on wasm32 directly from whatsapp-rust, not only from wacore’s sans-IO core — see Custom relay transport below for what such a build supplies in place of the native dialer.
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, no relay transport. 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-runtime"] / ["voip-encoded"] / ["voip-mlow"] / ["voip-libopus"] — call signaling, the facade, CallHandle, and (for the codec features) a codec adapter, with no relay transport linked. Builds on wasm32/espidf as well as Tokio targets. A call needs a RelayTransportProvider installed to reach the network on any of these — without one it fails at setup with CallEvent::MediaSetupFailed, which is the expected state until you install one, not a crash.
  • whatsapp-rust with features = ["voip-relay-native"] (pulled in by voip, or added explicitly alongside one of the features above) — adds the Tokio driver task built on the sans-IO rtc-dtls/rtc-sctp/rtc-datachannel crates (DTLS/SCTP) plus the default UDP relay dialer. Does not compile on wasm32 or espidf — a compile_error! enforces this at build time.
  • whatsapp-rust with features = ["voip"]voip-mlow + voip-libopus + voip-relay-native, unchanged from before PR #1364.
Building whatsapp-rust itself (not just wacore) for wasm32 also needs default-features = false: the crate’s defaults (tokio-native, sqlite-storage, tokio-transport, ureq-client, signal) assume a native multi-threaded runtime, SQLite storage, and a native HTTP client, none of which build on wasm32-unknown-unknown. cargo check -p whatsapp-rust --lib --target wasm32-unknown-unknown --no-default-features --features voip-mlow is exactly the check this PR added to CI — mirror both flags in your own Cargo.toml, e.g. whatsapp-rust = { version = "0.7", default-features = false, features = ["voip-mlow"] }, and supply your own storage/transport/HTTP implementations in their place.

Custom relay transport

A relay endpoint is a SocketAddr the server names per call — dialing it is the platform’s job, not the engine’s. On a voip-relay-native build that job is this crate’s own UDP/DTLS/SCTP/DataChannel dialer, and you never need to think about it. Everywhere else — a browser above all, where there is no UDP socket to open and ICE is not optional — install a RelayTransportProvider with Client::set_relay_transport_provider before placing or answering a call:
RelayEndpointParams carries the address plus the two ICE fields a synthetic SDP answer has to name: ice_ufrag (the endpoint’s own) and ice_pwd (the relay’s own key, in the ASCII form it arrived in). Its Debug redacts ice_pwd. Neither is the STUN allocation token the engine sends on the wire as RELAY-TOKEN — that one is never a ufrag, and a ufrag built from it fails a browser’s first connectivity check silently, surfacing as a call that won’t connect rather than as a recognizable bad-credential error.
A native build with no provider installed falls back to its own dialer exactly as before — installing one always takes priority over the default. A build with neither a provider nor voip-relay-native fails each call at setup rather than failing to compile. Because handle.wait_ended() resolving means the call is over, not that it never started, that failure surfaces as CallEvent::MediaSetupFailed(reason) on handle.events() — distinct from CallEvent::RelayAllocateFailed, which carries a STUN error code from a relay that did answer. The provider call itself is bounded at 15 seconds, so a provider that never answers fails the call instead of leaving its setup parked forever.

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

Analysis FFT

The encoder runs two perceptual/LPC-analysis FFTs: a 512-point transform and a 576-point transform. Both take real-valued input on the forward pass and Hermitian input on the inverse pass. Each now packs its own input into its own half-length complex transform, instead of running a full-length complex transform on data that’s half redundant. This roughly halves the butterflies each FFT needs. It also needs no separate encoder-side heap arena to hold a second, full-length plan. In PR #1330’s own benchmark — five runs pinned to a P-core over 486 s of audio — this cut whole-encode CPU by about 21%. This is the encoder’s only analysis-FFT path in current source. There is no feature flag and no full-length fallback to opt into. PR #1330 removed the earlier opt-in mlow-fast-fft feature that gated this transform. That change established perceptual equivalence with the reference encoder it replaced — measured with PESQ-WB, ESTOI, segSNR, and LSD over 991 s of clean and noisy speech. It also confirmed that every one of the 8,106 frames in that validation corpus keeps the same TOC byte and packet-size envelope as before. The half-length technique turns out to be what the smpl C reference implementation this codec was validated against already uses.
This landed after the published 0.7.0 release, so it needs a git dependency until a later version ships it:
Cargo.toml
The published 0.7.0 crate still runs the previous full-length transform unconditionally — there was never a feature flag to select between the two in a release.

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.
  • Requesting recovery: the engine can also ask the peer for a keyframe, by RTCP PLI, when it’s our decoder that lost an access unit — see PR #1385 below.
  • Backpressure: a complete access unit is the unit of backpressure, so overload can’t leave half an IDR on the wire.
PR #1355 added CallEvent::VideoKeyframeNeeded. The engine can withhold non-IDR access units at four moments: a video-from-start ungate, a group epoch commit, a source switch, or the peer’s own RTCP keyframe request. It never touches pixels, so only your encoder can produce the IDR that lifts the gate. This event tells you when that’s needed, and it fires once per requirement rather than once per dropped frame. Without reacting to it, video stays dark until your encoder’s own keyframe interval comes round on its own — which can be most of a short call.
PR #1385 added the other direction: until now the engine read the peer’s PLI/FIR to drive our own encoder but had no way to send one, so a single lost inbound access unit stayed dark until the peer’s own keyframe interval came round. Call handle.request_peer_keyframe(urgency) (see Call Handle above) whenever your decoder loses or discards an access unit — it’s throttled in the engine (1000ms, or 200ms under KeyframeUrgency::Immediate for a decoder that has already failed and reset), so calling it on every dropped unit is the intended usage. It’s fire-and-forget: the engine decides whether a request actually goes out and doesn’t report the outcome back. The bundled facade already calls it on your behalf whenever the sink you handed to .video()/start_video()/accept_video() is too slow to take a reassembled frame.Does nothing in a group call — group video is routed through the participant registry, so a group PLI would need to name which participant’s stream was lost, which is a different, not-yet-implemented request.
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/video and group calls/call links (see above) are the foundation. Natural follow-ups tracked upstream:
  • Participant moderation — no mute-participant, remove-participant, or end-call-for-everyone API yet; see the note at the end of Reactions, hand raising and screen sharing.
  • 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