@voltkit-rtc/client
v0.9.3
Published
Browser SDK for VoltKit RTC: realtime audio/video rooms, chat and live broadcast publishing.
Maintainers
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/clientWorks 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'); // CommonJSRooms
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 stateA 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 continueOnly 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.wasmandrnnoise_simd.wasmfromnode_modules/@sapphi-red/web-noise-suppressor/distto 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
errorsays 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-silentis not reported while a processor is on: a quiet room is near-silence after denoising. Your own level inaudioLevelsis 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; itsnoiseSuppressioncan 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'(blur1-10, default 5) or{ image }(a URL, Blob, image, canvas or ImageBitmap), fitted likeobject-fit: cover.beauty:true, orsmooth,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
localis 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), copynode_modules/@mediapipe/tasks-vision/wasmto your site and passwasmPath, andmodelUrlfor 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
errorsays why, so watcherrorwhen 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).
onOverloadtells 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 -- withmedia-disabled; andsetAudioQuality('hd')without HD audio, withaudio-quality-not-allowed. - Capped (the call goes on with the most the project allows, and
errorcarries a warning): HD audio asked for inpublish()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
maxVideoSubscriptionsvideos at once (25 by default). Further videos wait, with onevideo-subscription-limitwarning 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 backcpuConstrained 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 againLayer 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 ceilingsOffer 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 continueScreenPublishOptions 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 onlyModeration
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 metadataTokens
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)); // optionalBrowsers 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 keepLike 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 firstaudioLevels 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 metadataDraw 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 releasedThe 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 retriedA 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 closeStorage 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.
