npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zapo-js/voip-media

v0.1.2

Published

WhatsApp call media plane for zapo-js – relay transport, STUN, SRTP, RTP/RTCP and the MLow codec, with a Node host and a browser host

Readme

@zapo-js/voip-media

The media plane of a WhatsApp call: the relay transport, STUN, SRTP, RTP/RTCP, H.264 packetization, the app-data stream and the MLow codec.

It has no dependency on zapo-js. The signaling of a call stays on the server with @zapo-js/voip; only the media needs to live wherever the audio is, and a browser that carries a call's media installs this package and the codec, without zapo-js.

| Entry | Runs in | Holds | | -------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------- | | @zapo-js/voip-media | Node, browser | the media plane itself; everything host-specific is injected | | @zapo-js/voip-media/node | Node | node:crypto, RTCPeerConnection on @roamhq/wrtc, raw UDP legs | | @zapo-js/voip-media/web | browser | the primitives in plain JavaScript, the browser's RTCPeerConnection, audio on an AudioWorklet, video on WebCodecs |

The core never imports either host, and the two hosts never import each other.

Install

npm install @zapo-js/voip-media libmlow-wasm-fork

libmlow-wasm-fork is the codec, a fork of libmlow-wasm whose browser build carries no Node-only import, so a bundler takes it as it is. The WASM is embedded in the module, so there is no separate .wasm file to serve, and it is only downloaded when a call starts. It is an optional peer, so @zapo-js/voip installed for signaling alone does not pull it in.

Carrying a call's media

WaCallMediaPlane is the media of one call. It knows nothing of signaling: what it needs from there arrives as a plan, and what signaling needs from here leaves as events.

import { WaCallMediaPlane } from '@zapo-js/voip-media'
import { webMediaHost } from '@zapo-js/voip-media/web' // or nodeMediaHost from '/node'

const plane = new WaCallMediaPlane({
    ...webMediaHost,
    onActive: () => {
        // media is flowing: accepted, and a relay leg is up
    },
    onRelayLost: (reason) => {
        // no leg left - tell signaling, which ends the call
    },
    onReaction: (reaction) => {
        // an in-band emoji reaction from the peer
    }
})

await plane.start() // loads the codec
await plane.apply(plan) // the plan signaling sent, and every update after it

// On the host's own audio clock, 16 kHz mono:
plane.pushCapture(microphoneSamples)
plane.pullPlayout(speakerBuffer)

plane.stop()

The plan (WaCallMediaPlan) carries the relays, the SSRCs and the SRTP keys already derived, the settings the server tuned, whether the call is accepted or muted, and whether video may flow. It holds no jid and no call key. Every section is replaced whole when it changes, and updates apply in the order they are handed in.

The audio pace belongs to the host. Capture is pushed in at any length and framed internally; playout is pulled out of a jitter buffer that pads with silence when it runs short. In Node that pace is a timer; in a browser, the audio device.

The timestamps belong to the plane: audio and video are stamped on one media clock per call, the way the receiver needs them to pace the video against the audio. pushCapture(samples, capturedAtMs) takes the performance.now() instant the first sample was captured, and without it the block is taken as captured as it arrives. Consecutive frames keep consecutive timestamps whatever jitter those instants carry, a pause in the capture goes out as a marked jump, and capture delivered ahead of the clock is shed. sendVideoFrame takes the host's own capture timestamp in any epoch and maps it onto the same clock.

Audio in a browser

WaWebCallAudio carries that clock for you: it opens the microphone and the speaker and drives pushCapture and pullPlayout from one AudioWorklet, with no timers, so a background tab does not starve the call.

import { WaWebCallAudio } from '@zapo-js/voip-media/web'

// From the click that answers or dials: it asks for the microphone.
const audio = await WaWebCallAudio.start(plane)

await audio.stop()
plane.stop()

The worklet posts 20 ms of captured audio at a time, and each block is answered with the same amount of playout, so the audio device paces both directions. It prefers a 16 kHz AudioContext and falls back to the device rate, converting in the worklet, where the browser refuses one. A microphone or audioContext passed in stays the caller's to stop or close. Under a CSP that forbids blob: scripts, serve WA_CALL_AUDIO_WORKLET_SOURCE as a file and pass its URL as workletUrl.

Video in a browser

The plane carries H.264 access units; the browser's WebCodecs makes and shows them. WaWebCallVideoSender encodes a video track and hands each access unit to the plane, and WaWebCallVideoReceiver decodes the peer's frames as the plane reassembles them.

import { WaWebCallVideoReceiver, WaWebCallVideoSender } from '@zapo-js/voip-media/web'

const video = new WaWebCallVideoReceiver({
    onFrame: (frame, ssrc) => {
        context2d.drawImage(frame, 0, 0)
        frame.close()
    }
})
const plane = new WaCallMediaPlane({
    ...webMediaHost,
    onInboundVideo: (frame) => video.push(frame)
    // ...
})

const [camera] = (await navigator.mediaDevices.getUserMedia({ video: true })).getVideoTracks()
const sender = await WaWebCallVideoSender.start(plane, camera)

// A screen share replaces the camera on the same stream:
await sender.replaceTrack(screenTrack)

await sender.stop()
video.close()

The sender can start with the call: the plane drops every frame until media flows - the call accepted, with a leg up - and the plan opens video, on a video call or once an upgrade is agreed, and for as long as the plan holds our video back until the peer can take it (video.sendHeld). The stream then opens on a key frame - deltas before the first one are refused too, since nothing could decode them - and while the plane refuses, the sender offers a key frame every half second, so the stream opens soon after it may.

It encodes Constrained Baseline at up to 1280x720 and 15 fps, scaling a larger source down, with a key frame at least every two seconds - the plane does not act on the peer's key-frame requests, so that interval is what bounds how long a lost picture takes to come back. It reads the track through Chrome's MediaStreamTrackProcessor, or, where there is none, by sampling a video element on a timer that a background tab throttles. Tracks stay the caller's to stop.

The receiver keeps one decoder per stream, configured from each key frame's own parameter set, and starts a stream, or restarts it after a failure or a backlog, at its next key frame.

What the host provides

A host is a WaMediaHost: the three synchronous primitives SRTP and STUN run on, and a way to build the peer connection of each relay leg. nodeMediaHost and webMediaHost are the two ready-made ones.

| Piece | /web | /node | | ---------------------- | ------------------------------------ | --------------------------------------- | | crypto | AES-128-CTR, HMAC-SHA1 in JavaScript | node:crypto | | createPeerConnection | the browser's RTCPeerConnection | @roamhq/wrtc, loaded on the first leg | | createRawUdpLeg | not available | WaRawUdpLeg on node:dgram, opt-in | | logger | any object with the methods | a zapo-js Logger as it is |

A browser gets the primitives in JavaScript because WebCrypto only offers them as promises, and SRTP protects and verifies packet by packet on a path that cannot wait for one. The tests hold both implementations to the published vectors (FIPS-197, SP 800-38A, RFC 3711, RFC 2202) and to each other. Randomness comes from crypto.getRandomValues, which both hosts have.