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

@voltkit-rtc/client

v0.9.3

Published

Browser SDK for VoltKit RTC: realtime audio/video rooms, chat and live broadcast publishing.

Readme

@voltkit-rtc/client

Browser SDK for VoltKit RTC: realtime audio and video rooms, chat, and live broadcast publishing.

| | | | --- | --- | | Rooms | StreamClient — join, publish camera and mic, receive everyone else, moderate | | Chat | on the room's own connection, or ChatClient for chat with no media | | Broadcast | BroadcastPublisher — go live to an audience of any size over WHIP |

Using React or Next.js? @voltkit-rtc/react wraps this client in a provider, hooks and track components.

Your backend mints a short-lived token with @voltkit-rtc/server and hands the page { token, wsUrl }. This package turns that into a live session. The API secret never reaches the browser.

npm install @voltkit-rtc/client

Works from JavaScript and TypeScript, with ES modules or CommonJS, and with any bundler (Vite, webpack, Next.js, …). Types are included. Nothing here is tied to a framework.

import { StreamClient } from '@voltkit-rtc/client';        // ESM or TypeScript
// const { StreamClient } = require('@voltkit-rtc/client'); // CommonJS

Rooms

import { StreamClient } from '@voltkit-rtc/client';

const { token, wsUrl } = await fetch(`/api/shows/${id}/join`, { method: 'POST' }).then((r) => r.json());
const client = new StreamClient(wsUrl, token);

// Wire the events BEFORE connecting: connect() subscribes to everyone already
// publishing, and those tracks arrive during the call.
client.on('track', ({ identity, kind, track }) => attach(identity, kind, track));
client.on('trackEnded', ({ identity }) => detach(identity));
client.on('participantJoined', (p) => addToRoster(p));
client.on('participantLeft', (p) => removeFromRoster(p.identity));
client.on('streamInterrupted', () => showSpinner());      // the publisher is reconnecting; stay put
client.on('closed', (reason) => leaveScreen(reason));     // terminal; nothing follows

const joined = await client.connect();

if (joined.role === 'host' || joined.role === 'cohost') {
  const media = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 1280, height: 720 } });
  await client.publish(media);                            // 720p: 3 simulcast layers from a computer, 2 from a phone
}

Always call client.close() when leaving. A client that is dropped without closing keeps its reconnect loop running.

new StreamClient(wsUrl, token, options?) takes ClientOptions, all optional and none needed in a browser: handlerName names the mediasoup-client handler to build the WebRTC side with ('Chrome111', 'Chrome74', 'Firefox120', 'Safari12' or 'ReactNative106') instead of detecting the browser. It is reserved for future native hosts, which have no browser to detect; leave it unset.

Publishing

await client.publish(media);
await client.setMicEnabled(false);
await client.setCamEnabled(false);
await client.unpublish();

client.publishing; client.micEnabled; client.camEnabled; client.local;   // current state

A viewer cannot publish. That is enforced at the media server, not in this package — a tampered client gets an error, not a stream.

Calling publish() again adds to what is published (a microphone stream first, a camera stream later): local becomes one stream carrying both (localStreamChanged says so), and unpublish(), close() and a reconnect handle every track. A switch still waiting when unpublish() or close() runs resolves without doing more. setMicEnabled(true) / setCamEnabled(true) rejects with force-muted while a moderator holds that device off, including when the moderator decided while the call was waiting; it then stays off.

publish(media, constraints, options) takes these options. The defaults suit phones and computers alike; they are kept for a reconnect's republish.

| Option | Default | What it does | | --- | --- | --- | | audioQuality | 'std' | the microphone: 'std' (mono, up to 64 kbps) or 'hd' (stereo, up to 128 kbps); see Audio quality | | quality | the highest the project allows | '1080p', '720p' or '480p'; see Video quality | | frameRate | 30 | 30 or 60; only the top layer runs at 60 fps; capture at 60 too | | layers | 2 on phones and tablets, 3 elsewhere | the most simulcast layers to send. When a phone is too busy, the browser shrinks the picture and may then send fewer layers, dropping the top one: asked for two, it keeps the full picture; asked for three, viewers were left with a quarter of it | | codec | 'auto' | 'auto' sends H.264 on phones and tablets that encode it in hardware, VP8 elsewhere. 'vp8' or 'h264' forces one; a forced H.264 the browser cannot send is VP8, and fires error | | degradationPreference | the browser's | what the encoder gives up first on a busy device: 'maintain-resolution' (frame rate goes first), 'maintain-framerate' or 'balanced' |

await client.publish(media, undefined, { quality: '720p', layers: 2, codec: 'auto', degradationPreference: 'maintain-resolution' });

Chrome also reads the trade-off from the track itself: track.contentHint = 'detail' keeps the resolution, 'motion' the frame rate. A viewer whose browser cannot decode the codec cannot watch, so force 'vp8' if your audience uses browsers without H.264 (rare).

To send another video track while live, such as a canvas with effects drawn on it or the raw camera again:

await client.replaceVideoTrack(canvas.captureStream(30).getVideoTracks()[0]);

When the new track has the same dimensions and layers as the old one, viewers see no pause; otherwise the camera's producer is replaced and they see a brief one. A paused camera stays paused. Your own stream is not modified: the client keeps one of its own (client.local, announced by localStreamChanged) with the new track, and a reconnect republishes that. The track you replaced is yours again: it is not stopped, and if pausing the camera had switched it off, it is switched back on.

Cameras and microphones (0.8.0)

const cameras = await client.listDevices('videoinput');      // also 'audioinput', 'audiooutput'
await client.switchDevice('videoinput', cameras[1].deviceId);
await client.switchDevice('audioinput', headsetId);

client.on('devicesChanged', ({ added, removed }) => refreshDevicePicker());
client.on('localTrackEnded', ({ kind, recovered }) => {
  if (kind === 'video') showCameraPicker();   // the camera was switched off: offer another, then setCamEnabled(true)
});

switchDevice captures the chosen device with the constraints publish was given (size, frame rate, echo cancellation), swaps it in (viewers see no pause for a camera of the same size) and stops the one it replaces. A reconnect captures the chosen device again. If you publish a canvas or another processed track, capture the new camera yourself and pass your track to replaceVideoTrack; switchDevice refuses rather than replace your picture with the raw camera. A swap that fails releases the new device and keeps the old one on air, put back through noise suppression or video effects if they are on (0.9.3).

When a published device stops from outside (unplugged, a Bluetooth headset switched off, the permission revoked), as Amazon Chime and LiveKit do: the microphone is captured again from the system default and keeps publishing (recovered: true), or is switched off if there is none; the camera is switched off, and never swapped for another camera on its own. Labels in listDevices are empty until the page has camera or microphone access.

Video quality

Choose 1080p, 720p or 480p when publishing, or change it while live:

const media = await navigator.mediaDevices.getUserMedia({ audio: true, video: { width: 1920, height: 1080 } });
await client.publish(media, undefined, { quality: '1080p' });
await client.setVideoQuality('480p');      // resolves to the quality actually used

client.videoPolicy;     // { maxQuality: '480p' | '720p' | '1080p', maxFrameRate, maxBitrate } for this project
client.videoQuality;    // what is being sent now
client.videoFrameRate;  // 30 or 60, what the camera is being sent at

| Quality | Layers from a computer at 30 fps | From a phone or tablet | Video upload | |---|---|---|---| | 1080p | 480×270 @ 0.4 Mbps · 960×540 @ 2 Mbps · 1920×1080 @ 6 Mbps | the top two | about 8.4 Mbps (phone 8) | | 720p | 320×180 @ 0.2 Mbps · 640×360 @ 0.8 Mbps · 1280×720 @ 3 Mbps | the top two | about 4 Mbps (phone 3.8) | | 480p | 427×240 @ 0.4 Mbps · 854×480 @ 1.2 Mbps | the same two | about 1.6 Mbps |

All qualities are included on every plan. The default policy allows 1080p and 60 fps, with a 25 Mbps incoming safety cap per publisher. A project can have an explicit administrative restriction; videoPolicy describes it. The picture is scaled down to the chosen quality, never up. Portrait phones keep their shape, for example 360×640 and 720×1280. Adaptive reception is the default: viewers receive the best layer their connection can carry. Viewers start with a 4 Mbps estimate, enough for 720p; 1080p ramps up as bandwidth allows.

30 fps is the default. For 60 fps, capture at 60 and opt in:

await client.publish(media, undefined, { quality: '1080p', frameRate: 60 });
const effectiveFps = await client.setVideoFrameRate(30); // camera only, other tracks continue

Only the top layer uses 60 fps: 1080p is 9 Mbps, 720p 4.5 Mbps, and 480p 1.8 Mbps; smaller layers stay at 30. This means roughly 1.5× the top layer's data and twice the encoding work. Phones can heat up and use more battery, and many front cameras capture only 30. When 60 cannot be used the SDK sends 30 and says why on error, as a warning that stops nothing:

| code | Why | | --- | --- | | frame-rate-not-allowed | the project does not allow 60 fps (its settings, or no 60 fps mode is enabled) | | video-quality-not-allowed | 60 fps is allowed, but not at the size you asked for: the largest size that is goes out at 60 | | frame-rate-fallback | the camera captures below 50 fps, or the device or upload cannot keep 60 up |

setVideoFrameRate(60) retries the preference while preserving a paused camera; in a project without 60 fps it changes nothing and warns again. (Before 0.7.0 the project case was also reported as frame-rate-fallback.)

60 fps is delivered only when the capture source provides it. Phone makers usually keep 60 fps for their own camera app, so browsers and most apps get 30 fps. Check a camera's supported rates in chrome://media-internals → Video Capture. Billing follows the frame rate actually delivered. In our tests a laptop webcam gave Chrome 20 fps at 1080p, and a phone whose own camera app records 1080p60 gave 25–30 fps (14 fps in a dim room). Read what you got, not what you asked for: track.getSettings().frameRate is the rate the camera was opened at, and getVideoStats() reports the rate captured (source.fps, where the browser supplies it) and sent (fps). For 60 fps, use a webcam or capture card listed at 60 fps, or a virtual camera such as OBS Virtual Camera set to 60 fps.

The full guide, with data use: voltkit.in/docs/video-quality.

Audio quality

The microphone goes out as standard audio unless you ask for HD:

| audioQuality | Sent as | Billed as (wallet billing) | | --- | --- | --- | | 'std' (default) | mono Opus, maxaveragebitrate 64000, the sender held at 60 kbps, DTX and FEC | standard audio | | 'hd' | stereo Opus, maxaveragebitrate 128000, DTX and FEC | HD audio |

await client.publish(media, undefined, { audioQuality: 'hd' });
await client.setAudioQuality('std');   // while live; resolves to the quality in use
client.audioQuality;                    // 'std' | 'hd'

The server bills what it can verify, never what a client claims: HD only when the microphone declares more than 64 kbps or is measured above 72 kbps, so a standard microphone really stays at or under 64 kbps: its sender is held at 60 kbps, a little under the declared rate, because Opus VBR around a 64 kbps target measured up to 65 kbps in a 5 s window. Switching re-creates the microphone's producer (its codec settings are fixed per producer): listeners hear a gap of a fraction of a second, and a muted microphone stays muted.

For HD, capture with { audio: { channelCount: { ideal: 2 }, echoCancellation: true, noiseSuppression: true, autoGainControl: true } }; turn the processing off only for headphones or line-in music, or listeners hear their own echo. Music (publishMusic) is a separate 128 kbps stereo track and is not affected: it is HD and free whatever the microphone's quality (see Music).

Noise suppression (0.9.0)

AI noise suppression for the microphone, as Agora's AI Denoiser, LiveKit's Krisp filter and ZEGOCLOUD's AI noise suppression do: RNNoise, a small neural network, runs in the browser's audio thread and takes fans, traffic, air conditioning and room rumble out of the voice. It is a separate import, so apps that do not use it never download it:

import { createNoiseSuppressor } from '@voltkit-rtc/client/noise-suppression';

await client.setAudioProcessor(createNoiseSuppressor());   // before or after publish
client.audioProcessorInUse;                                 // the processor, or null
await client.setAudioProcessor(null);                       // send the microphone as it is again
  • It applies to the microphone published now (swapped in place: listeners hear no gap) and to every one after it: switchDevice('audioinput', …), a microphone recovered after unplugging, a reconnect, setAudioQuality. Music and screen-share audio are not processed.
  • The worklet (64 kB) and the model (150 kB) load the first time it runs, from jsDelivr at the exact version this SDK was tested with. Under a strict Content-Security-Policy, copy rnnoise/workletProcessor.js, rnnoise.wasm and rnnoise_simd.wasm from node_modules/@sapphi-red/web-noise-suppressor/dist to your site and pass their URLs: createNoiseSuppressor({ workletUrl, wasmUrl, simdWasmUrl }).
  • If it cannot start (the files are blocked, an old browser), the microphone still goes out, unprocessed, and error says why. The next microphone tries again.
  • Turn it on from a click (a toggle, the join button): browsers start audio processing only after the page has been touched, and the SDK resumes it on the first tap or key press.
  • mic-silent is not reported while a processor is on: a quiet room is near-silence after denoising. Your own level in audioLevels is what listeners hear.
  • Measured in Chromium: rumble-like noise (fans, traffic, AC) went down by more than 60 dB; steady white hiss, which it was not trained on, by 7-12 dB. Keep the browser's own echoCancellation; its noiseSuppression can stay on too.

Your own processor works the same way: anything with name, process(track) → Promise<MediaStreamTrack> and destroy() (AudioTrackProcessor), such as a licensed Krisp build.

Virtual background and beauty (0.9.0)

A blurred or replaced background and beauty for the camera, as LiveKit's track processors, Agora's and ZEGOCLOUD's virtual background and beauty, and TRTC's video effects do: in the browser, on the GPU. A separate import, so apps that do not use it never download it:

import { createVideoEffects, isVideoEffectsSupported } from '@voltkit-rtc/client/video-effects';

if (isVideoEffectsSupported()) {
  const effects = createVideoEffects({ background: 'blur', beauty: true });
  await client.setVideoProcessor(effects);                           // before or after publish
  client.videoProcessorInUse;                                        // the processor, or null

  await effects.update({ background: { image: '/office.jpg' } });   // live: no pause for viewers
  await effects.update({ blur: 8, beauty: { smooth: 0.7, brighten: 0.4 } });
  await client.setVideoProcessor(null);                              // the camera as it is again
}
  • background: 'none', 'blur' (blur 1-10, default 5) or { image } (a URL, Blob, image, canvas or ImageBitmap), fitted like object-fit: cover.
  • beauty: true, or smooth, brighten, redness, sharpen (0-1; defaults 0.5, 0.3, 0.1, 0.3). Skin is smoothed with an edge-preserving filter (eyes, lips and teeth stay sharp) and lightly brightened.
  • What you see in local is what viewers get. Switching cameras (switchDevice), replaceVideoTrack, reconnects and quality changes keep the effects without restarting them. A paused camera costs no processing.
  • Background effects need MediaPipe: npm install @mediapipe/[email protected] (exactly that version: later ones report usage to Google). Its wasm (3 MB compressed) and the person model (250 kB) load the first time a background is used, from jsDelivr and Google. To serve them yourself (a strict CSP), copy node_modules/@mediapipe/tasks-vision/wasm to your site and pass wasmPath, and modelUrl for the model. Beauty alone needs none of this.
  • Nothing behind you shows while the model loads: the background is fully blurred or replaced until you are found. If the effects cannot start, the camera goes out as it is and error says why, so watch error when privacy matters.
  • Webcams (wide pictures) use MediaPipe's landscape model and phones (tall pictures) the square one, which is what each traces best.
  • A slow device finds you only every few frames and reuses the outline (the picture keeps the camera's frame rate). onOverload tells you when even that is not enough: turn the background off or lower the camera's size.
  • Chrome and Edge process each frame as it arrives and keep going in a background tab. Firefox and Safari draw from a hidden video on a timer, which a background tab can slow down.

Your own processor works the same way: anything with name, process(track) → Promise<MediaStreamTrack> (same size and frame rate) and destroy() (VideoTrackProcessor). The SDK stops a picture it no longer sends; stop drawing it once its readyState is 'ended'.

Project media settings

A project's owner chooses what it may publish: audio and video on or off, the largest video quality, 30 or 60 fps, standard or HD audio. Allowing a mode allows every mode below it (1080p at 60 fps opens 720p and 480p at 60 and every 30 fps mode). The join result carries them, and the client follows them:

client.mediaPermissions;
// { audio: true, video: true, maxVideoQuality: '1080p', maxFrameRate: 60,
//   maxVideoQuality60: '720p', audioQualityMax: 'hd', maxVideoSubscriptions: 25, source: 'media' }
client.billingMode;   // 'wallet' | 'legacy' | null (older servers)

Offer only these in your UI. The server enforces them whatever a client sends; the SDK never sends what it would refuse, and tells you with the server's own codes:

  • Refused (the call throws, nothing is produced): a kind the project switched off -- publish() with such a track, publishScreen() without video, publishMusic() without audio -- with media-disabled; and setAudioQuality('hd') without HD audio, with audio-quality-not-allowed.
  • Capped (the call goes on with the most the project allows, and error carries a warning): HD audio asked for in publish() goes out standard (audio-quality-not-allowed); 60 fps without 60 fps modes goes out at 30 (frame-rate-not-allowed); a 60 fps size that is not enabled goes out at the largest that is (video-quality-not-allowed). A 30 fps quality above the maximum is capped silently, as the server does.
  • Fan-out: a session billed from the wallet receives at most maxVideoSubscriptions videos at once (25 by default). Further videos wait, with one video-subscription-limit warning each, and are received as others end, fail to subscribe, or end while their subscription is being set up.

With an older server (no media in the join result) the videoPolicy alone applies, as in 0.6.

When the picture is soft

A soft or stuttering picture usually comes from the publisher's device (too busy to encode) or their upload (too slow for the top layer), not from the service. The client can tell you which.

const stats = await client.getVideoStats();   // null when no camera is published
// { codec: 'H264', quality: '720p', width: 720, height: 1280, fps: 30, bitrate: 2400000,
//   qualityLimitation: 'none', availableOutgoingBitrate: 3900000, layers: [ ... ] }

The top-level fields describe the biggest layer going out; layers lists each one (active, size, frame rate, bitrate). qualityLimitation is the browser's reason for sending less than asked: cpu is the device, bandwidth the upload, none neither (then look at the camera and the light). Bitrates are averaged since your previous call, so call it every second or two; the first call reports 0.

videoLimited tells you when that changes, so you can tell the publisher:

client.on('videoLimited', ({ reason }) => {
  if (reason === 'cpu') showNotice('Your device is busy, so the stream is softer. Turn off effects or switch to 480p.');
  else if (reason === 'bandwidth') showNotice('Your upload is slow, so viewers get a lighter picture.');
  else hideNotice();                                   // 'none': cleared; 'other': rare
});

It fires once a new reason has held for about 4 seconds, so brief spikes stay quiet, and never fires none first. The client checks every 2 seconds, and while a camera is published and something listens, or while 60 fps needs fallback monitoring. unpublish() fires none if a limitation was showing.

On a viewer, getReceiveStats(producerId) (the producerId from the track event) says what arrives: size, frame rate, bitrate, packetsLost, jitter and, where the browser counts them, freezeCount. A viewer receiving a smaller picture than the publisher's biggest layer is on a lower one, picked for their own connection; if every viewer is, look at the publisher's getVideoStats().

The troubleshooting guide lists the usual causes and what to do about each.

Smooth on busy devices (0.8.0)

A room with several cameras, effects and reactions can overload a phone or a modest laptop: every viewer decoding every co-host's full-size picture, every publisher encoding layers nobody watches. Three things keep it smooth (the ideas behind LiveKit's adaptive stream and dynacast, Tencent TRTC's small stream with receiveWhenViewVisible, and Agora's automatic low stream):

Adaptive stream: each video at the size it is shown. Attach the element that shows a remote video:

client.on('track', ({ kind, producerId, track }) => {
  if (kind !== 'video') return;
  const video = showVideo(producerId, track);            // your <video> element
  const detach = client.attachVideo(producerId, video);  // call detach() when the element goes away
});

The SDK then receives the smallest layer that fills the element without stretching it (its size on screen × devicePixelRatio, at most 2) and pauses the video at the server while the element cannot be seen (scrolled away, display: none, the tab in the background): no bandwidth, no decoding. Bigger is applied at once, smaller after about a second, and a hidden video pauses after 1.5 s, so scrolling past a tile does not stop it. From 0.9.2 a quality picked with setReceiveQuality replaces the size (the video still pauses while hidden); setPreferredLayers still applies as a ceiling.

Dynacast: publishers stop encoding layers nobody receives. Nothing to call: the server tells the publishing client which of its camera layers any viewer needs, and the SDK switches the others off (the smallest always stays on, so a new viewer starts at once). With adaptive stream on the viewers, a host shown in small tiles stops spending its CPU on its largest layer.

When the publisher's device itself is overloaded:

client.on('cpuConstrained', ({ constrained }) => {
  if (constrained) {
    void client.prioritizePerformance();  // the camera's largest layer stops being encoded
    turnOffEffects();                     // and your own savings: effects off, smaller remote videos
  }
});
await client.prioritizePerformance(false); // later, when the user wants full quality back

cpuConstrained fires when the browser reports the camera limited by the CPU for about 4 seconds (videoLimited with cpu), and again when that clears. performancePrioritized tells whether the lighter mode is on.

Small tiles

Each viewer gets the biggest layer their connection carries, which a thumbnail or a row of co-host tiles does not need. attachVideo (above) chooses for you; to choose yourself, ask for a smaller one with the producerId from the track event:

client.on('track', ({ kind, producerId, track }) => {
  if (kind !== 'video') return;
  showVideo(producerId, track);
  if (isSmallTile(producerId)) client.setPreferredLayers(producerId, 0).catch(console.warn);
});

await client.setPreferredLayers(producerId, 2);   // the tile grew: the biggest layer again

Layer 0 is the smallest copy the publisher sends. At 720p a computer sends three (320×180, 640×360 and 1280×720) and a phone two (640×360 and 1280×720); a layer above the biggest means the biggest. The 640×360 copy needs about 0.9 Mbps including audio, the full picture about 3.1. An optional third argument, the temporal layer, lowers the frame rate as well; without it the full frame rate is kept. It is the most this viewer is sent: a connection that cannot carry it still gets less. The choice is kept across reconnects and forgotten when the track ends, and setPreferredLayers rejects for a video this client is not receiving.

Receive quality

await client.setReceiveQuality(producerId, '480p');
await client.setReceiveQuality(producerId, 'auto'); // recommended; clears spatial AND temporal ceilings

Offer Auto (recommended) / 1080p / 720p / 480p. The ReceiveQuality helper maps the request to the producer's actual videoLayers dimensions, including portrait phones, two-layer cameras and screen tracks. It chooses the largest available copy at or below the choice, or the smallest available if none fits. From 0.9.2 that copy is what the viewer gets while the video is shown, even in a tile smaller than it; up to 0.9.1, with attachVideo, it was only a ceiling. Auto sizes it to the element. A call that fails (refused, or the connection lost before the answer) leaves the previous choice in place (0.9.3). It never creates a missing resolution: a 1080p screen with 540p and 1080p copies uses 540p for the 480p choice. Connection adaptation still applies below the ceiling. The preference is remembered across reconnects and ends with that producer. Without layer metadata, explicit choices reject quality-unavailable; Auto and the numeric setPreferredLayers API still work.

Screen sharing

Hosts and cohosts can share a screen beside their camera in SDK 0.5.0:

client.on('screenShareStarted', ({ stream, mode }) => showScreenPreview(stream, mode));
client.on('screenShareEnded', ({ reason }) => hideScreenPreview(reason));
await client.publishScreen({ mode: 'detail', audio: true }); // from a button click
await client.unpublishScreen(); // camera, mic and music continue

ScreenPublishOptions accepts mode: 'detail' | 'motion' (default detail), audio?: boolean and stream?: MediaStream. Without stream, the SDK opens getDisplayMedia and returns its MediaStream; capture audio defaults to false. An existing stream's audio is used unless audio: false is supplied. The SDK stops the supplied screen tracks when sharing ends.

Detail sends up to native 1920×1080 at 15 fps and 2.5 Mbps plus a half-size 0.6 Mbps copy. Motion uses 30 fps, 6 Mbps plus 0.6 Mbps. Capture is never upscaled. Optional tab/system audio is separate, source: 'screen-audio', 128 kbps stereo. Viewers play it like music; screen video arrives with source: 'screen'. A browser may grant video without granting audio.

The browser's Stop sharing automatically unpublishes and emits screenShareEnded({ reason: 'track-ended' }). Calling unpublishScreen emits reason: 'stopped'. A reconnect republishes a still-live screen without another picker. unpublish() and close() stop all local media. Unsupported capture (most mobile browsers) rejects screen-share-unsupported; overlapping shares reject screen-share-active; missing live video rejects screen-share-no-video. Browser cancellation retains its NotAllowedError. Capture requires HTTPS, a user gesture and permission; in iframes, also allow display-capture.

Setup, modes, native platforms and troubleshooting.

Music

A host can play music to the room from inside the page. It does not go through the microphone: it is taken straight from the element that is playing it and published as a track of its own, so the audience hears it clean.

const song = document.querySelector('audio')!;      // <audio src="/song.mp3" crossorigin="anonymous">

await client.publishMusic(song, { volume: 0.8 });   // call it from a click
await song.play();

client.setMusicVolume(0.4);                         // for the audience
await client.setMusicEnabled(false);                // music off, host still talking
await client.setMicEnabled(false);                  // mic off, music still playing
await client.stopMusic();

setMicEnabled and setMusicEnabled are independent, in both directions. publishMusic also takes a MediaStream or a MediaStreamTrack. client.musicPublishing is whether music is published at all, and client.musicEnabled whether the audience hears it now.

stopMusic() also stops a publishMusic() that is still under way: that call rejects with unpublished and nothing goes out. Calls to publishMusic() take effect in the order they are made: a newer one replaces music still being published, and the replaced call rejects with unpublished.

Music is HD and free: it always goes out at 128 kbps stereo, also in a project limited to standard audio, and the service never bills it. Sending or hearing music never changes anyone's rate; the microphone and video alone decide it.

The host keeps hearing the music (monitor, on by default for an element). A file from another origin must be served with CORS headers and loaded with crossorigin="anonymous", or the browser hands the page silence.

Viewers receive it as a second audio track, and source says which is which:

client.on('track', ({ identity, kind, source, track }) => {
  if (source === 'music') musicAudio.srcObject = new MediaStream([track]);   // its own element = its own volume
  else attach(identity, kind, track);
});
client.on('trackMuted', ({ source, paused }) => {
  if (source === 'music') showMusicPaused(paused);
});

musicAudio.muted = true;      // "mute the music" for this viewer only

Moderation

await client.raiseHand();
await client.lowerHand();
await client.raiseHand({ requestRole: 'cohost' }); // metadata also supports 'moderator'
await client.lowerHand(identity); // host/moderator: decline that raised hand
await client.muteParticipant(identity);        // moderating roles: asks them to mute
await client.forceMute(identity, 'video');     // moderating roles: camera off, locked until released
await client.forceMute(identity, 'video', false); // release
await client.promote(identity, 'cohost');      // the promoted client reconnects by itself
await client.kick(identity, 'spam');
await client.endRoom();                        // host only
await client.getRoomState();

Events

| Event | Fires when | | --- | --- | | track / trackEnded | someone's audio or video is ready to render, or went away; source is mic, music, camera, screen or screen-audio | | trackMuted | someone paused or resumed a track — muted their mic, switched their music off | | participantJoined / participantLeft | roster changes | | participantMuted | moderation lock changed: { identity, kind, paused }; reconnect roster muted / videoBlocked seed the locks | | roleChanged | someone was promoted or demoted | | handRaised | a hand went up or down, with optional metadata and reason; reconnect rosters include handMetadata | | screenShareStarted / screenShareEnded | local screen capture began (stream, mode) or stopped (reason) | | forceMuted | a moderator switched your mic or camera off | | streamInterrupted | media stopped but the connection is fine — the publisher is reconnecting | | localMediaChanged / localStreamChanged | your own mic, camera or stream changed | | connected / disconnected | the socket came up or dropped; reconnection is automatic | | kicked / roomClosed | you were removed, or the host ended the room | | mustReconnect | the server moved this session (a promotion, a node lost); the client reconnects by itself, with the new token when one is given | | error | a failure or recoverable warning such as frame-rate-fallback; inspect its code | | usageLimit | your project ran out of minutes or hit a usage limit; the session ends at endsAt unless it is topped up | | videoLimited | your camera video is held back by this device (cpu) or the upload (bandwidth), or that cleared (none) | | closed | terminal — replaced, kicked, room-closed or gave-up, or left when your own close() ends the session. Whatever the reason, the camera, microphone, screen share, music and recording are released with it, as close() does | | connectionStateChanged | { state, previous }: idle → connecting → connected ⇄ reconnecting → closed | | participantUpdated | someone changed their display metadata (setMetadata) | | claimChanged | a slot was taken, went away, came back, was locked or freed: { key, identity, state, reason } | | roomMetadataChanged | a moderator changed the room metadata: { metadata, by } | | walletLow | wallet billing: what the project may spend covers less than the grace period ({ remainingSeconds }), or that is over ({ remainingSeconds, cleared: true }) | | tokenExpiring | the token this client would reconnect with expires in about a minute and nobody refreshed it: { expiresAt, secondsLeft } | | networkQuality | about every 3 s while listened to: { level, rttMs, lossPct, availableKbps } |

on() returns a function that removes the listener. CLIENT_EVENTS lists every event name.

Connection state

client.connectionState;   // 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'closed' (CONNECTION_STATES)
client.on('connectionStateChanged', ({ state, previous }) => render(state));
client.participants;      // who is in the room, kept current from the roster events
client.remoteTracks;      // [{ producerId, identity, kind, source, track, paused }] being received
client.info;              // the join result, kept current: roster, slots, room metadata

Tokens

A room token authorises the connection, not the session: a live session stays up past its expiry, and the server pushes a fresh token before the one the client holds lapses. Your backend can refresh it as well:

client.on('tokenExpiring', async ({ secondsLeft }) => {
  const { token } = await fetch('/api/rtc/token', { method: 'POST' }).then((r) => r.json());
  await client.updateToken(token);   // { expiresAt } (ms)
});

updateToken sends the token on the live connection (refreshToken), where the server checks it like a join token: bad-token for one it would refuse, token-mismatch for another room or identity; the session then keeps its token. While reconnecting, or before connect(), the token is kept for the next join. An older server without refreshToken keeps it for the next reconnect. tokenExpiring fires only when nobody refreshed the token: the server stops when the account may no longer be used, and a dropped socket cannot receive one.

A newer token always wins over an updateToken still waiting for its answer: one the server hands over meanwhile (its own refresh, or a promotion's, which carries the new role) or one from a later updateToken call. The waiting call then resolves to the expiry of the token now held.

Wallet and usage notices

client.on('walletLow', ({ remainingSeconds, cleared }) => {
  if (cleared) hideBanner();
  else showBanner(`Credits run out in about ${remainingSeconds} s. Recharge to keep the call going.`);
});
client.on('usageLimit', ({ reason, endsAt }) => showCountdown(endsAt));

With wallet billing, walletLow warns while the funds the project may spend (its own allocation, then the RTC allocation, then unallocated) cover less than the grace period at the current burn; usageLimit is sent beside it for older apps. The room closes with roomClosed { reason: 'quota-exhausted' } when the funds reach zero unless the wallet is recharged first; a recharge sends walletLow { cleared: true }, which also cancels the usageLimit countdown.

Media and socket recovery (0.8.0)

Recovery runs automatically. A send or receive transport that fails, or stays disconnected for 3 seconds, requests new ICE credentials and restarts on the same transport. A recovery still unfinished after 10 seconds (including the request and browser call), or an older server without restartIce, uses the normal reconnect and republish path.

mediaReconnecting carries { transportId, reconnecting }: true when ICE recovery starts and false when that recovery ends or its transport is discarded. Use connectionStateChanged to distinguish recovery from a full reconnect.

After 15 seconds without any socket frame the SDK sends ping. If no frame arrives for 8 seconds after it, the next 5-second check reconnects. Any reply counts, including an older server's unknown-method. Background tabs may delay timers. A browser online event wakes a pending reconnect immediately; while connected it probes the socket. Closing releases the timers and listener.

Diagnostics for support (0.8.0)

const report = await client.getDiagnostics();
sendToSupport(JSON.stringify(report));

What Agora's log upload and Twilio's Insights are used for, kept on the device: the SDK version (SDK_VERSION) and browser, the room, identity, role and connection state, how each media transport is connected (UDP, TCP or relayed), the round trip and upload now, what is published and received, and the last 200 notable events (state changes, errors, recoveries, fallbacks, warnings) with their times. It holds no token and no media.

Sound that browsers block until a tap (0.8.0)

client.on('track', ({ kind, track }) => {
  const el = kind === 'audio' ? new Audio() : videoFor(track);
  el.srcObject = new MediaStream([track]);
  void client.play(el);                       // instead of el.play()
});
client.on('autoplayFailed', ({ resume }) => showTapToUnmute(resume)); // optional

Browsers often refuse sound until the viewer interacts with the page. play() reports that (autoplayFailed, like Agora's and Tencent TRTC's), and plays every refused element by itself on the viewer's next tap, click or key press, so a viewer who touches anything hears the room.

Smoother watching on shaky networks (0.8.0)

client.setPlayoutDelay(400); // an audience watching a stream: a little later, far fewer freezes
client.setPlayoutDelay(null); // people talking with each other: as little delay as the browser can keep

Like Tencent TRTC's playoutDelay: every remote track is buffered about that long before it plays, which rides out a mobile network's jitter. It applies to what is received now and later; 0 to 4000 ms. Browsers before Chrome 114 keep their own buffer.

Faster joins (0.8.0)

Everything already live in the room is subscribed to at once when the client joins (it used to be one after another), so a room with six cameras shows them together. Warm the connection while the user is still on the way in, as LiveKit's prepareConnection does:

StreamClient.prepareConnection(wsUrl); // on the room screen; never rejects
// ... later
await client.connect();

From 0.9.0 the first picture comes sooner, with nothing to change in your app: the join brings the media transports with it (controllers from 2026-10-20), and neither the DTLS handshake nor a video's first layer waits for an acknowledgement any more. Each is a round trip saved, 50-150 ms on a mobile network, for the first publish and the first video alike.

Check before going live (0.8.0)

// Your backend mints a host token for a test room nobody else is in.
const { wsUrl, token } = await fetch('/api/precall-token').then((r) => r.json());
const report = await StreamClient.preflight(wsUrl, token);           // or { media: myStream, durationMs: 5000 }
if (!report.ok) showProblem(report.steps.find((s) => !s.ok)?.error);
else if (report.path?.relayed || report.path?.protocol === 'tcp') showHint('A firewall slows your connection');
else if (report.warnings.includes('mic-silent')) showHint('Your microphone sends no sound');

Like Twilio's runPreflight and LiveKit's ConnectionCheck, it joins the room, publishes for a few seconds (a tone and a moving picture it makes, or your media, cloned so your tracks keep running) and leaves. The report has each step (join, media) with its time or error, how media travelled (path: UDP is best; TCP or a relay means a firewall in the way), rttMs, lossPct, uploadKbps, level, and any camera or microphone warnings. It never rejects. The session is real and billed like any other for its few seconds.

Camera and microphone warnings (0.8.0)

client.on('mediaWarning', ({ code, active }) => {
  if (code === 'mic-silent') showBanner(active, 'Your microphone sends no sound: check its mute switch');
  if (code === 'camera-fps-low') showBanner(active, 'Your camera is dark: add light in front of you');
});

Like Agora's exception events, each warning comes once when it starts (active: true) and once when it clears:

| code | When | | --- | --- | | mic-silent | the microphone is on but sends exact silence for about 6 s: a mute switch, a broken or wrong device (a quiet room still has a noise floor) | | camera-fps-low | the camera delivers under 15 fps for about 6 s when 24 or more were asked: usually a dark room, where cameras expose each frame longer. 20 fps is not a warning | | camera-interrupted, mic-interrupted | the system took the device for more than 2 s: a phone call, another app |

Checked every 2 s while listened to.

Who is speaking (0.8.0)

client.on('audioLevels', (levels) => {
  for (const { identity, speaking } of levels) tileFor(identity).ring(speaking);
});
client.on('activeSpeakers', ({ speakers }) => showLarge(speakers[0])); // most active first

audioLevels comes about four times a second while listened to, for every remote microphone, music and shared audio this client receives and its own microphone while on: { identity, producerId, source, level, speaking, local }. level is WebRTC's audio level (0 silence, 1 full scale); speaking is above -35 dBov (SPEAKING_LEVEL). activeSpeakers ranks microphones the way Amazon Chime does, so a short cough does not steal the large tile and the person who keeps talking does; music never counts. Both are read in the browser from what it receives, and nothing is read while nobody listens.

Poor connections: audio keeps going (0.8.0)

When a viewer's connection cannot carry video, the audio comes first, as in Agora's audio-only stream fallback:

  • The media server cuts a video this viewer's bandwidth cannot carry even at its smallest size, while the sound keeps flowing, and says so.
  • The SDK pauses remote cameras when the received sound itself breaks up badly (a quarter of it lost over about 15 seconds), so what bandwidth there is goes to the audio. Screen shares stay. The cameras come back once the connection has recovered for 20 seconds, longer if it falls back again soon.

Either way the app is told, per video, so it can show the person's name instead of a frozen picture:

client.on('streamFallback', ({ producerId, identity, fallback, reason }) => {
  tileFor(producerId).showPoorConnection(fallback); // reason: 'bandwidth' or 'audio-protection'
});

The pause is on by default. Turn it off with new StreamClient(url, token, { audioOnlyFallback: false }) or client.setAudioOnlyFallback(false), which also brings back any camera held at that moment.

The host's side (opt-in). client.setPublishFallback(true) does the same for what this client sends (Agora's setLocalPublishFallbackOption): when the server reports this client's own sound breaking up on the way in, its camera is paused (viewers see it paused) so the upload carries the voice, and resumed once it recovers. publishFallback { fallback } says when. A camera the user or a moderator switches off meanwhile stays off. audioOnlyFallbackActive says whether it is holding cameras now. The server's bandwidth reports need the controller and media node from 2026-10-20; older ones never send them.

Network quality

client.on('networkQuality', ({ level, rttMs, lossPct, availableKbps }) => showBars(level));

Sampled from the media transports' statistics about every 3 s, only while something listens and the session is connected. rttMs is the round trip to the media server, lossPct the packets lost over the last interval in the worse direction (sending or receiving), availableKbps the browser's bandwidth estimate (upload while publishing). Each is null until known. The level:

| level | Round trip | and loss | | --- | --- | --- | | excellent | under 150 ms | under 1 % | | good | under 300 ms | under 3 % | | poor | under 500 ms | under 8 % | | bad | anything worse | |

An unknown value does not lower the level. Bandwidth is reported, not graded: videoLimited says when the camera is held back by the device or the upload.

Slots and room metadata

A slot is a named thing in the room that one participant holds at a time: a seat on a board, a guest spot. Keys are yours.

await client.claim('seat-3');                 // take it; claim('seat-3', identity) grants it (moderators)
await client.release('seat-3');
await client.blockSlot('seat-9');             // moderators: lock it; refused with slot-occupied while held
await client.unblockSlot('seat-9');
await client.setMetadata({ name: 'Ana' });   // your display metadata; the room hears participantUpdated
await client.setRoomMetadata({ seats: 10 }, { requireFree: ['seat-11', 'seat-12'] }); // moderators; persisted

client.info.claims;        // [{ key, identity, state: 'held' | 'away' }]
client.info.blockedSlots;  // ['seat-9']
client.info.metadata;      // the room metadata

Draw away as taken (greyed), never as free: the holder's connection dropped and the slot is kept for them for about a minute. A refused setRoomMetadata names the slots in the way in error.data.slots.

Reconnection

Handled for you. A dropped socket is retried with backoff (each attempt's socket must open, and its join be answered, within 15 s; a stalled attempt is closed and the next one follows), the session is rejoined, and if you were publishing, your media is published again (with the same quality, frame rate and audio quality). closed with gave-up means it stopped trying: after eight attempts, or at once for a join refused with a code retrying cannot fix (isTerminalError: the room ended, the token is wrong, you were removed, the account ran out).

replaced means the same identity joined the same room somewhere else, and this connection lost. Do not reconnect on it: the two would take turns evicting each other.

You do not need to call connect() again yourself. If you do, the newer call wins: the older connection is closed, requests still waiting on it fail at once with not-connected (they are never sent again: the server may already have acted on them), and an older connect() still under way fails without touching the new one. ChatClient behaves the same.

Chat

Every room has a chat channel of the same name. StreamClient carries it on the connection it already holds:

client.on('message', (m) => append(m));
client.on('messageDeleted', ({ id }) => remove(id));
client.on('typing', ({ identity, typing }) => showTyping(identity, typing));
client.on('chatMuted', ({ identity, muted }) => lockComposer(identity, muted));

await client.sendMessage('hello everyone');
await client.sendMessage('psst', { to: 'user-42' });                       // direct: only the two of you see it
await client.sendMessage('', { type: 'reaction', metadata: { emoji: 'heart' }, ephemeral: true });   // not stored

const older = await client.getMessages({ limit: 50, before: oldest.id }); // page backwards
await client.setTyping(true);                                              // safe on every keystroke
const { members, muted } = await client.getChatState();

Your own message comes back on message like everyone else's. Pass clientMsgId to match it to one you rendered optimistically.

text is delivered exactly as it was sent. Put it in the page with textContent, or let your framework escape it. Never with innerHTML.

The host and moderators can also do this (a co-host is refused with forbidden):

await client.deleteMessage(messageId);     // anyone's; everyone else, only their own
await client.muteChat('user-42');          // survives that user reconnecting
await client.muteChat('user-42', false);

A muted sender is refused with the code chat-muted; a client sending too fast with rate-limited. Show both to the user rather than retrying.

Chat without media

An audience watching a broadcast has no reason to join a room. ChatClient opens a connection that carries chat and nothing else, into the same conversation:

import { ChatClient } from '@voltkit-rtc/client';

// { token, wsUrl } from your backend: createChatToken()
const chat = new ChatClient(wsUrl, token);
chat.on('message', append);
chat.on('closed', (reason) => showEnded(reason));

const joined = await chat.connect();       // joined.messages: recent history, oldest first
await chat.sendMessage('first!');
// ...
chat.close();

It has the same methods and events as the chat half of StreamClient.

Broadcast

A broadcast is one publisher and an audience of any size, served as LL-HLS through a CDN. Use it when the audience watches and does not talk back; use a room when they do.

import { BroadcastPublisher } from '@voltkit-rtc/client';

// publish.whipUrl from your backend: createStream()
const publisher = new BroadcastPublisher(whipUrl);
publisher.on('state', (s) => setState(s));         // connecting, live, interrupted, failed, stopped
publisher.on('warning', console.warn);

preview.srcObject = await publisher.start();       // camera + mic; or start({ stream: yours })

publisher.setMicEnabled(false);
publisher.setCamEnabled(false);
await publisher.stop();                            // off air, camera released

The whipUrl carries the publish token. Treat it like a password: fetch it when the user clicks "go live", and keep it out of logs and analytics.

start() rejects with WhipError if the media server refuses. isRefused means the URL itself was not accepted — expired, for another stream, or the account is out of minutes — and a retry with the same URL is refused again. A media server that cannot be reached, does not answer within 15 s, or drops the connection while answering is a WhipError too, with status 0.

stop() wins over a start() still under way, such as one waiting for the camera permission or for the media server's answer. That start() rejects with an AbortError (err.name === 'AbortError'), nothing goes on air for it, a camera it captured afterwards is released, and a session the server opened for it is ended. You can call start() again straight after stop(). A second start() while one is under way is refused: call stop() first.

Video is offered as H.264 only. The media server repackages H.264 into HLS untouched; any other codec makes it transcode every frame. A browser with no H.264 encoder still publishes, and warning says so.

To publish something other than the camera — a screen share, a canvas — pass your own stream: publisher.start({ stream }). It stays yours; stop() does not end its tracks.

Music on a broadcast

A broadcast carries one audio track, so music is mixed with the microphone in the browser before it is sent. It still never passes through the microphone.

await publisher.setMusic(song, { volume: 0.8 });   // while live, or start({ music: song })
publisher.setMusicVolume(0.4);
publisher.setMusicEnabled(false);                  // music off, mic untouched
publisher.setMicEnabled(false);                    // mic off, music keeps playing
publisher.clearMusic();

Adding music to a broadcast that is already live does not interrupt it.

Watching

Viewers need nothing from this package. Safari plays the llhls URL natively; everywhere else, use the hls.js package:

import Hls from 'hls.js';

if (video.canPlayType('application/vnd.apple.mpegurl')) {
  video.src = llhlsUrl;
} else {
  const hls = new Hls({ lowLatencyMode: true });
  hls.loadSource(llhlsUrl);
  hls.attachMedia(video);
}

Errors

Server refusals reject with RpcError (rooms) or ChatRpcError (ChatClient), both carrying a machine-readable code; the SDK's own failures and warnings are RpcErrors too. Switch on code, never on the message. RpcError.data is what the server attached to act on a refusal ({ slots } for slot-occupied).

Every code is in one registry shared by all VoltKit SDKs:

import { ERROR_CODES, errorInfo, isTerminalError } from '@voltkit-rtc/client';

errorInfo('quota-exhausted');   // { origin: 'server', terminal: true, retryable: false }
isTerminalError(code);          // a join refused with it is not retried

A newer server may send a code this release does not know: it is passed through unchanged, and treated as not terminal. The registry with every meaning is in the repository's docs/SDK-CONTRACT.md, the wire codes in the API reference.

Help

Documentation: https://voltkit.in/docs. Questions and bug reports: [email protected].

Licence

MIT

Private camera recording (0.6.0)

startRecording({ apiUrl, chunkMiB?, onState?, onRecordingReady? }) copies camera and available microphone into the funded recording policy without changing live RTC tracks. The project owner must enable recording and purchase its separate storage quota first. Only camera-on hosts/cohosts are eligible. The default is 360p / 15 fps / 400 kbps video / 48 kbps audio; recording quality is independent of live quality and cannot be overridden by SDK developers.

Upload parts go device → private Cloudflare R2, never through VoltKit's VPS. Default 10 MiB chunks, two concurrent uploads and a 40 MiB buffer ceiling; 5 MiB is optional when allowed by admin. Files rotate after 300 seconds by default. Backpressure pauses recording only (show gaps to the user); persistent failures stop recording and clean up instead of filling RAM. Validation is Cloudflare-side.

await client.startRecording({
  apiUrl: 'https://api.voltkit.in/api/v1',
  onRecordingReady: file => {
    // Save file.id, file.playbackUrl and file.retainUntil in your app database.
  },
});
const files = await client.stopRecording(); // await before camera-off/room close

Storage quota covers validated uploaded bytes with 90-day retention; it is not refilled by deleting files. Repeat purchases add capacity; replay uses a separate delivery quota. Completed-file metadata contains a stable authenticated owner-console link, ID and expiry. For in-app playback your authenticated backend mints a 10-minute media URL using its RTC project secret through /recording/upload/server/playback. No secret/R2 key belongs in browser code. Media goes R2 → Cloudflare Worker → viewer. Access expires at the saved 90-day boundary; cleanup and a bucket lifecycle rule delete media automatically.

See recording integration and the repository's docs/RECORDING-R2-SETUP.md. Prepared release is unpublished; real Cloudflare integration and physical-device acceptance have not been claimed.